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,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)
}