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:
12
crates/adapters/http-axum/Cargo.toml
Normal file
12
crates/adapters/http-axum/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "http-axum"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
config = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
rust-embed = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tower_governor = { workspace = true }
|
||||
3
crates/adapters/http-axum/src/lib.rs
Normal file
3
crates/adapters/http-axum/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod routes;
|
||||
|
||||
pub use routes::build_router;
|
||||
80
crates/adapters/http-axum/src/routes.rs
Normal file
80
crates/adapters/http-axum/src/routes.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Router, routing::get};
|
||||
use config::RateLimitConfig;
|
||||
use rust_embed::Embed;
|
||||
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
const RATE_LIMIT_CLEANUP_INTERVAL_SECS: u64 = 1;
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "$CARGO_MANIFEST_DIR/../../../painter-js/dist/"]
|
||||
struct StaticAssets;
|
||||
|
||||
async fn serve_static(path: axum::extract::Path<String>) -> Response {
|
||||
let path = path.0;
|
||||
serve_embedded_file(&path)
|
||||
}
|
||||
|
||||
async fn serve_index() -> Response {
|
||||
serve_embedded_file("index.html")
|
||||
}
|
||||
|
||||
fn serve_embedded_file(path: &str) -> Response {
|
||||
match StaticAssets::get(path) {
|
||||
Some(file) => {
|
||||
let content_type = file.metadata.mimetype();
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, content_type.to_string())],
|
||||
file.data,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
None => serve_embedded_file("index.html"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(
|
||||
enable_cors: bool,
|
||||
rate_limit_config: &RateLimitConfig,
|
||||
) -> Result<Router, String> {
|
||||
let rate_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.burst_size(rate_limit_config.burst_size)
|
||||
.per_second(rate_limit_config.per_second)
|
||||
.finish()
|
||||
.ok_or("Invalid rate limit configuration")?,
|
||||
);
|
||||
|
||||
let governor = rate_governor.limiter().clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(
|
||||
RATE_LIMIT_CLEANUP_INTERVAL_SECS,
|
||||
))
|
||||
.await;
|
||||
governor.retain_recent();
|
||||
}
|
||||
});
|
||||
|
||||
let router = Router::new()
|
||||
.route("/check/", get(|| async { "OK" }))
|
||||
.route("/{*path}", get(serve_static))
|
||||
.fallback(get(serve_index))
|
||||
.layer(GovernorLayer::new(rate_governor));
|
||||
|
||||
Ok(if enable_cors {
|
||||
router.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
)
|
||||
} else {
|
||||
router
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user