v1.0.0 — Hexagonal architecture rewrite
All checks were successful
CI / ci (push) Successful in 7m16s
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:
26
crates/server/Cargo.toml
Normal file
26
crates/server/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["socketio"]
|
||||
socketio = ["dep:socketio", "dep:socketioxide"]
|
||||
websocket = ["dep:websocket"]
|
||||
|
||||
[dependencies]
|
||||
application = { workspace = true }
|
||||
canvas-file = { workspace = true }
|
||||
config = { workspace = true }
|
||||
config-env = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
http-axum = { workspace = true }
|
||||
dotenv = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
socketio = { workspace = true, optional = true }
|
||||
socketioxide = { workspace = true, optional = true }
|
||||
websocket = { workspace = true, optional = true }
|
||||
133
crates/server/src/main.rs
Normal file
133
crates/server/src/main.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
#[cfg(not(any(feature = "socketio", feature = "websocket")))]
|
||||
compile_error!("Enable either the `socketio` or `websocket` feature");
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use application::{AppState, InMemoryCanvasStore, InProcessBroadcaster};
|
||||
use canvas_file::FileCanvasPersistence;
|
||||
use config::{AppConfig, ConfigSource};
|
||||
use config_env::EnvConfigSource;
|
||||
use domain::BroadcastEvent;
|
||||
use tokio::signal;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenv::dotenv().ok();
|
||||
tracing::subscriber::set_global_default(FmtSubscriber::new())?;
|
||||
|
||||
let config = EnvConfigSource.load()?;
|
||||
let state = build_state(&config)?;
|
||||
|
||||
if config.snapshot.enabled {
|
||||
if let Err(err) = application::canvas::restore_snapshot::execute(&state) {
|
||||
warn!("Failed to restore canvas snapshot: {err}");
|
||||
}
|
||||
spawn_snapshot_scheduler(state.clone(), config.snapshot.interval_secs);
|
||||
}
|
||||
|
||||
let app = build_app(state.clone(), &config)?;
|
||||
|
||||
let server_address = format!("{}:{}", config.server.address, config.server.port);
|
||||
info!("Starting server on {server_address}");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(server_address).await?;
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
info!("Shutting down gracefully...");
|
||||
|
||||
if config.snapshot.enabled {
|
||||
info!("Saving final canvas snapshot...");
|
||||
if let Err(err) = application::canvas::save_snapshot::execute(&state) {
|
||||
error!("Failed to save final snapshot: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
info!("Server stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("Failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
() = ctrl_c => info!("Received Ctrl+C"),
|
||||
() = terminate => info!("Received SIGTERM"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_state(config: &AppConfig) -> Result<Arc<AppState>, Box<dyn std::error::Error>> {
|
||||
let (broadcast_tx, _) = broadcast::channel::<BroadcastEvent>(config.broadcast.channel_capacity);
|
||||
|
||||
let canvas_store = InMemoryCanvasStore::new(config.canvas.width, config.canvas.height);
|
||||
let broadcaster = InProcessBroadcaster::new(broadcast_tx);
|
||||
let cooldown = Duration::from_secs(config.cooldown.placement_secs);
|
||||
|
||||
let mut state = AppState::new(Box::new(canvas_store), Box::new(broadcaster), cooldown);
|
||||
|
||||
if config.snapshot.enabled {
|
||||
let persistence = FileCanvasPersistence::new(&config.snapshot)?;
|
||||
state = state.with_persistence(Box::new(persistence));
|
||||
}
|
||||
|
||||
Ok(Arc::new(state))
|
||||
}
|
||||
|
||||
fn spawn_snapshot_scheduler(state: Arc<AppState>, interval_secs: u64) {
|
||||
let interval = Duration::from_secs(interval_secs);
|
||||
info!("Snapshot scheduler started (every {interval_secs}s)");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
if let Err(err) = application::canvas::save_snapshot::execute(&state) {
|
||||
error!("Failed to save canvas snapshot: {err}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "socketio")]
|
||||
fn build_app(
|
||||
state: Arc<AppState>,
|
||||
config: &AppConfig,
|
||||
) -> Result<axum::Router, Box<dyn std::error::Error>> {
|
||||
let (layer, io) = socketioxide::SocketIo::new_layer();
|
||||
socketio::setup_namespaces(&io, state);
|
||||
let router = http_axum::build_router(config.server.enable_cors, &config.rate_limit)?;
|
||||
Ok(router.layer(layer))
|
||||
}
|
||||
|
||||
#[cfg(feature = "websocket")]
|
||||
fn build_app(
|
||||
state: Arc<AppState>,
|
||||
config: &AppConfig,
|
||||
) -> Result<axum::Router, Box<dyn std::error::Error>> {
|
||||
let ws_router = websocket::build_router(state);
|
||||
let http_router = http_axum::build_router(config.server.enable_cors, &config.rate_limit)?;
|
||||
Ok(ws_router.merge(http_router))
|
||||
}
|
||||
Reference in New Issue
Block a user