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:
9
crates/adapters/canvas-file/Cargo.toml
Normal file
9
crates/adapters/canvas-file/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "canvas-file"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
config = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
158
crates/adapters/canvas-file/src/lib.rs
Normal file
158
crates/adapters/canvas-file/src/lib.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use std::fs;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use config::SnapshotConfig;
|
||||
use domain::ports::CanvasPersistence;
|
||||
use domain::{Color, DomainError};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub struct FileCanvasPersistence {
|
||||
directory: PathBuf,
|
||||
max_snapshots: usize,
|
||||
}
|
||||
|
||||
impl FileCanvasPersistence {
|
||||
pub fn new(config: &SnapshotConfig) -> Result<Self, DomainError> {
|
||||
let directory = PathBuf::from(&config.directory);
|
||||
fs::create_dir_all(&directory).map_err(|err| {
|
||||
DomainError::Persistence(format!(
|
||||
"Failed to create snapshot directory '{}': {err}",
|
||||
directory.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
directory,
|
||||
max_snapshots: config.max_snapshots,
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot_path(&self, index: usize) -> PathBuf {
|
||||
self.directory.join(format!("canvas_{index}.bin"))
|
||||
}
|
||||
|
||||
fn latest_index_path(&self) -> PathBuf {
|
||||
self.directory.join("latest")
|
||||
}
|
||||
|
||||
fn read_latest_index(&self) -> Option<usize> {
|
||||
let path = self.latest_index_path();
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|content| content.trim().parse().ok())
|
||||
}
|
||||
|
||||
fn write_latest_index(&self, index: usize) -> Result<(), DomainError> {
|
||||
let path = self.latest_index_path();
|
||||
fs::write(&path, index.to_string())
|
||||
.map_err(|err| DomainError::Persistence(format!("Failed to write latest index: {err}")))
|
||||
}
|
||||
|
||||
fn rotate_and_cleanup(&self, new_index: usize) {
|
||||
if self.max_snapshots == 0 {
|
||||
return;
|
||||
}
|
||||
let oldest_to_keep = new_index.saturating_sub(self.max_snapshots - 1);
|
||||
for stale_index in 0..oldest_to_keep {
|
||||
let stale_path = self.snapshot_path(stale_index);
|
||||
if stale_path.exists()
|
||||
&& let Err(err) = fs::remove_file(&stale_path)
|
||||
{
|
||||
error!(
|
||||
"Failed to remove old snapshot '{}': {err}",
|
||||
stale_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasPersistence for FileCanvasPersistence {
|
||||
fn save(&self, pixels: &[Color]) -> Result<(), DomainError> {
|
||||
let next_index = self.read_latest_index().map(|i| i + 1).unwrap_or(0);
|
||||
let path = self.snapshot_path(next_index);
|
||||
|
||||
write_pixels_to_file(&path, pixels)?;
|
||||
self.write_latest_index(next_index)?;
|
||||
self.rotate_and_cleanup(next_index);
|
||||
|
||||
info!(
|
||||
"Saved canvas snapshot #{next_index} to '{}'",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_latest(&self) -> Result<Option<Vec<Color>>, DomainError> {
|
||||
let Some(index) = self.read_latest_index() else {
|
||||
info!("No canvas snapshots found");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let path = self.snapshot_path(index);
|
||||
if !path.exists() {
|
||||
info!("Snapshot file '{}' not found", path.display());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let pixels = read_pixels_from_file(&path)?;
|
||||
info!(
|
||||
"Loaded canvas snapshot #{index} from '{}' ({} pixels)",
|
||||
path.display(),
|
||||
pixels.len()
|
||||
);
|
||||
Ok(Some(pixels))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_pixels_to_file(path: &Path, pixels: &[Color]) -> Result<(), DomainError> {
|
||||
let file = fs::File::create(path).map_err(|err| {
|
||||
DomainError::Persistence(format!(
|
||||
"Failed to create snapshot '{}': {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
for color in pixels {
|
||||
writer
|
||||
.write_all(&color.as_u32().to_ne_bytes())
|
||||
.map_err(|err| {
|
||||
DomainError::Persistence(format!(
|
||||
"Failed to write snapshot '{}': {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
writer.flush().map_err(|err| {
|
||||
DomainError::Persistence(format!(
|
||||
"Failed to flush snapshot '{}': {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_pixels_from_file(path: &Path) -> Result<Vec<Color>, DomainError> {
|
||||
let bytes = fs::read(path).map_err(|err| {
|
||||
DomainError::Persistence(format!(
|
||||
"Failed to read snapshot '{}': {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if bytes.len() % 4 != 0 {
|
||||
return Err(DomainError::Persistence(format!(
|
||||
"Snapshot '{}' has invalid size: {} bytes (not a multiple of 4)",
|
||||
path.display(),
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let pixels = bytes
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| Color::new(u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])))
|
||||
.collect();
|
||||
|
||||
Ok(pixels)
|
||||
}
|
||||
Reference in New Issue
Block a user