v1.0.0 — Hexagonal architecture rewrite
All checks were successful
CI / ci (push) Successful in 7m16s

Restructure the monolithic 252-line main.rs into a 10-crate workspace
with clean hexagonal architecture, swappable adapters, and a production-
ready deployment pipeline.

Backend architecture:
- domain: Canvas, Color/Position/PixelUpdate value objects, port traits
  (CanvasStore, CanvasPersistence, EventBroadcaster), BroadcastEvent
- application: use cases (place_pixel, get_state, save/restore snapshot,
  connect/disconnect), AppState with Arc snapshot cache
- config: AppConfig structs + ConfigSource trait
- api-types: shared DTOs, event name constants
- adapters: config-env, canvas-file, http-axum (rust-embed), socketio,
  websocket — all behind port traits, swappable via feature flags
- server: composition root with graceful shutdown (SIGTERM/SIGINT)

Frontend:
- Transport abstraction: Socket.IO and native WebSocket via VITE_TRANSPORT
- Canvas zoom/pan with mouse wheel, pinch-to-zoom, and +/- buttons
- ImageData rendering (~50x faster than fillRect loop)
- Touch support, responsive CSS scaling, mobile-friendly layout
- OG/Twitter Card meta tags for rich link previews

Production:
- Docker: musl static build on scratch — 2.73MB image
- CI workflows for Gitea and GitHub Actions (fmt, clippy, test, Docker push)
- deploy.sh for private registry
- 39 unit tests across domain and application
- Zero unwraps, zero unsafe, graceful error handling with tracing
- Periodic canvas snapshots with rotation, restored on startup
- All config via environment variables with typed defaults
This commit is contained in:
2026-08-18 01:41:40 +02:00
parent e9bea5e1e5
commit f652785acb
85 changed files with 4240 additions and 3736 deletions

View File

@@ -0,0 +1,7 @@
[package]
name = "config-env"
version.workspace = true
edition.workspace = true
[dependencies]
config = { workspace = true }

View File

@@ -0,0 +1,70 @@
use config::{
AppConfig, BroadcastConfig, CanvasConfig, ConfigError, ConfigSource, CooldownConfig,
RateLimitConfig, ServerConfig, SnapshotConfig,
};
const DEFAULT_ADDRESS: &str = "0.0.0.0";
const DEFAULT_PORT: u16 = 3000;
const DEFAULT_CANVAS_WIDTH: u32 = 500;
const DEFAULT_CANVAS_HEIGHT: u32 = 500;
const DEFAULT_COOLDOWN_SECS: u64 = 10;
const DEFAULT_RATE_LIMIT_BURST: u32 = 10;
const DEFAULT_RATE_LIMIT_PER_SECOND: u64 = 10;
const DEFAULT_BROADCAST_CAPACITY: usize = 1024;
const DEFAULT_SNAPSHOT_INTERVAL_SECS: u64 = 300;
const DEFAULT_SNAPSHOT_MAX: usize = 5;
const DEFAULT_SNAPSHOT_DIR: &str = "snapshots/";
pub struct EnvConfigSource;
impl ConfigSource for EnvConfigSource {
fn load(&self) -> Result<AppConfig, ConfigError> {
Ok(AppConfig {
server: ServerConfig {
address: env_or("ADDRESS", DEFAULT_ADDRESS),
port: parse_env("PORT", DEFAULT_PORT)?,
enable_cors: parse_bool_env("ENABLE_CORS", true),
},
canvas: CanvasConfig {
width: parse_env("CANVAS_WIDTH", DEFAULT_CANVAS_WIDTH)?,
height: parse_env("CANVAS_HEIGHT", DEFAULT_CANVAS_HEIGHT)?,
},
cooldown: CooldownConfig {
placement_secs: parse_env("COOLDOWN_SECS", DEFAULT_COOLDOWN_SECS)?,
},
rate_limit: RateLimitConfig {
burst_size: parse_env("RATE_LIMIT_BURST", DEFAULT_RATE_LIMIT_BURST)?,
per_second: parse_env("RATE_LIMIT_PER_SECOND", DEFAULT_RATE_LIMIT_PER_SECOND)?,
},
broadcast: BroadcastConfig {
channel_capacity: parse_env("BROADCAST_CAPACITY", DEFAULT_BROADCAST_CAPACITY)?,
},
snapshot: SnapshotConfig {
enabled: parse_bool_env("SNAPSHOT_ENABLED", true),
interval_secs: parse_env("SNAPSHOT_INTERVAL_SECS", DEFAULT_SNAPSHOT_INTERVAL_SECS)?,
max_snapshots: parse_env("SNAPSHOT_MAX", DEFAULT_SNAPSHOT_MAX)?,
directory: env_or("SNAPSHOT_DIR", DEFAULT_SNAPSHOT_DIR),
},
})
}
}
fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
fn parse_bool_env(key: &str, default: bool) -> bool {
std::env::var(key)
.map(|value| value == "true")
.unwrap_or(default)
}
fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T, ConfigError> {
match std::env::var(key) {
Ok(value) => value.parse().map_err(|_| ConfigError::InvalidValue {
field: key.to_string(),
reason: format!("'{value}' is not a valid {}", std::any::type_name::<T>()),
}),
Err(_) => Ok(default),
}
}