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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
target/
painter-js/node_modules/
painter-js/dist/
painter-js/.env
snapshots/
.git/
.claude/
.env
*.DS_Store

View File

@@ -1,3 +1,13 @@
ADDRESS=0.0.0.0
PORT=3000
ENABLE_CORS=true
CANVAS_WIDTH=500
CANVAS_HEIGHT=500
COOLDOWN_SECS=10
RATE_LIMIT_BURST=10
RATE_LIMIT_PER_SECOND=10
BROADCAST_CAPACITY=1024
SNAPSHOT_ENABLED=true
SNAPSHOT_INTERVAL_SECS=300
SNAPSHOT_MAX=5
SNAPSHOT_DIR=snapshots/

40
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,40 @@
name: CI
on:
push:
branches: ["*"]
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: oven-sh/setup-bun@v2
- name: Build frontend
run: cd painter-js && bun install --frozen-lockfile && bun run build
- name: Check formatting
run: |
cargo fmt --all -- --check
cd painter-js && bun run fmt:check
- name: Clippy
run: cargo clippy --workspace -- -D warnings
- name: Tests
run: cargo test --workspace

74
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: CI
on:
push:
branches: ["*"]
tags: ["v*"]
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: oven-sh/setup-bun@v2
- name: Build frontend
run: cd painter-js && bun install --frozen-lockfile && bun run build
- name: Check formatting
run: |
cargo fmt --all -- --check
cd painter-js && bun run fmt:check
- name: Clippy
run: cargo clippy --workspace -- -D warnings
- name: Tests
run: cargo test --workspace
docker:
needs: ci
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/target
.env
/snapshots
*.DS_Store

1307
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,60 @@
[package]
name = "painter"
version = "0.1.0"
edition = "2021"
[workspace]
members = [
"crates/config",
"crates/domain",
"crates/application",
"crates/api-types",
"crates/adapters/canvas-file",
"crates/adapters/config-env",
"crates/adapters/http-axum",
"crates/adapters/socketio",
"crates/adapters/websocket",
"crates/server",
]
default-members = [
"crates/config",
"crates/domain",
"crates/application",
"crates/api-types",
"crates/adapters/canvas-file",
"crates/adapters/config-env",
"crates/adapters/http-axum",
"crates/server",
]
resolver = "2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[workspace.package]
version = "1.0.0"
edition = "2024"
[dependencies]
axum = "0.7.5"
bincode = "1.3.3"
bytes = "1.6.0"
chrono = "0.4.38"
[workspace.dependencies]
config = { path = "crates/config" }
domain = { path = "crates/domain" }
application = { path = "crates/application" }
api-types = { path = "crates/api-types" }
canvas-file = { path = "crates/adapters/canvas-file" }
config-env = { path = "crates/adapters/config-env" }
http-axum = { path = "crates/adapters/http-axum" }
socketio = { path = "crates/adapters/socketio" }
websocket = { path = "crates/adapters/websocket" }
futures = "0.3"
axum = "0.8.9"
dotenv = "0.15.0"
memory-stats = "1.1.0"
serde = { version = "1.0.201", features = ["derive"] }
serde_json = "1.0.117"
socketioxide = "0.13.1"
tokio = { version = "1.37.0", features = ["full"] }
tower-http = { version = "0.5.2", features = ["cors", "fs"] }
tower_governor = "0.4.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
socketioxide = "0.18.6"
thiserror = "2"
tokio = { version = "1.37", features = ["full"] }
rust-embed = { version = "8", features = ["interpolate-folder-path", "mime-guess"] }
tower-http = { version = "0.7.0", features = ["cors"] }
tower_governor = "0.8.0"
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
[profile.release]
lto = true
opt-level = "z"
codegen-units = 1
panic = "abort"
strip = true

View File

@@ -1,19 +1,24 @@
FROM rust:1.76 as builder
FROM oven/bun:1 AS frontend
WORKDIR /app/painter-js
COPY painter-js/package.json painter-js/bun.lock ./
RUN bun install --frozen-lockfile
COPY painter-js/ .
RUN bun run build
FROM rust:1-alpine AS builder
WORKDIR /app
RUN apk add --no-cache musl-dev
COPY Cargo.toml Cargo.lock ./
COPY crates/ ./crates/
COPY src ./src
RUN mkdir -p painter-js/dist && echo '<html></html>' > painter-js/dist/index.html
RUN cargo build --release --bin server 2>&1 || true
RUN cargo build --release
FROM rust:1.76
WORKDIR /app
COPY --from=builder /app/target/release/painter .
COPY painter-js/dist ./dist
COPY .env .env
COPY --from=frontend /app/painter-js/dist ./painter-js/dist
RUN touch crates/adapters/http-axum/src/routes.rs && cargo build --release --bin server
FROM scratch
COPY --from=builder /app/target/release/server /painter
EXPOSE 3000
CMD ["./painter"]
ENTRYPOINT ["/painter"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

33
Makefile Normal file
View File

@@ -0,0 +1,33 @@
.PHONY: build dev check check-all test fmt run clean fix
build:
cargo build --release
dev:
RUST_LOG=debug cargo run
check:
cargo fmt --all -- --check
cargo clippy -- -D warnings
cargo test
check-all:
cargo fmt --all -- --check
cargo clippy --workspace -- -D warnings
cargo test --workspace
test:
cargo test
fmt:
cargo fmt --all
run:
cargo run --release
fix:
cargo fmt --all
cargo clippy --fix --allow-dirty --allow-staged
clean:
cargo clean

122
README.md
View File

@@ -1,3 +1,121 @@
# painter
# Painter
Web app inspired by r/place
A collaborative pixel canvas inspired by r/place. Users connect in real-time and place colored pixels on a shared 500x500 canvas.
## Quick Start
```bash
# Install frontend dependencies and build
cd painter-js && bun install && bun run build && cd ..
# Run the server (serves embedded frontend)
cargo run --release
```
Open `http://localhost:3000` in your browser.
## Configuration
All configuration is via environment variables (or `.env` file):
| Variable | Default | Description |
|---|---|---|
| `ADDRESS` | `0.0.0.0` | Bind address |
| `PORT` | `3000` | Bind port |
| `ENABLE_CORS` | `true` | Enable CORS headers |
| `CANVAS_WIDTH` | `500` | Canvas width in pixels |
| `CANVAS_HEIGHT` | `500` | Canvas height in pixels |
| `COOLDOWN_SECS` | `10` | Seconds between pixel placements per user |
| `RATE_LIMIT_BURST` | `10` | HTTP rate limit burst size |
| `RATE_LIMIT_PER_SECOND` | `10` | HTTP rate limit per second |
| `BROADCAST_CAPACITY` | `1024` | Broadcast channel buffer size |
| `SNAPSHOT_ENABLED` | `true` | Enable periodic canvas snapshots |
| `SNAPSHOT_INTERVAL_SECS` | `300` | Seconds between snapshots |
| `SNAPSHOT_MAX` | `5` | Maximum snapshot files to keep |
| `SNAPSHOT_DIR` | `snapshots/` | Snapshot storage directory |
## Transport Adapters
The server supports two real-time transport protocols, selected at compile time:
```bash
# Socket.IO (default)
cargo run --release
# Native WebSocket
cargo run --release --no-default-features --features websocket
```
The frontend auto-detects via `VITE_TRANSPORT` environment variable (`socketio` or `websocket`).
## Docker
```bash
# Build
docker build -t painter .
# Run
docker run -p 3000:3000 painter
# With custom config
docker run -p 3000:3000 \
-e CANVAS_WIDTH=1000 \
-e CANVAS_HEIGHT=1000 \
-e COOLDOWN_SECS=30 \
-v ./snapshots:/app/snapshots \
painter
```
The Docker image is a single statically-linked binary on `scratch`**~3MB** total, no OS, no runtime dependencies. The frontend is embedded at compile time.
The server shuts down gracefully on SIGTERM/SIGINT (Ctrl+C) — in-flight connections are drained and a final canvas snapshot is saved before exit.
## Development
```bash
# Backend (watches for changes)
RUST_LOG=debug cargo run
# Frontend (Vite dev server with HMR)
cd painter-js
VITE_IS_DEBUG=true bun run dev
# Run all checks (fmt + clippy + tests)
make check
# Format
make fmt
cd painter-js && bun run fmt
```
## Architecture
Hexagonal architecture with clean dependency boundaries. See [architecture.mmd](architecture.mmd) for the full diagram.
```
crates/
config/ Config structs, ConfigSource trait
domain/ Canvas, value objects, port traits, events
application/ Use cases, AppState, InProcessBroadcaster
api-types/ Shared DTOs, event constants
adapters/
config-env/ Environment variable config loader
canvas-file/ File-based snapshot persistence
http-axum/ HTTP routes, rate limiting, embedded static files
socketio/ Socket.IO transport adapter
websocket/ Native WebSocket transport adapter
server/ Composition root
```
All infrastructure is behind port traits:
| Port | Adapter | Swappable to |
|---|---|---|
| `CanvasStore` | In-memory | Redis, mmap'd file |
| `CanvasPersistence` | File system | S3, SQLite |
| `EventBroadcaster` | tokio broadcast | NATS, Redis pub/sub |
| `ConfigSource` | Env vars | TOML, JSON, remote API |
## License
[MIT](LICENSE)

83
architecture.mmd Normal file
View File

@@ -0,0 +1,83 @@
graph TB
subgraph Binary ["Server (Composition Root)"]
server["server<br/><i>wires adapters, starts Axum</i>"]
end
subgraph Application ["Application Layer"]
AppState["AppState"]
PlacePixel["canvas::place_pixel"]
GetState["canvas::get_state"]
SaveSnapshot["canvas::save_snapshot"]
RestoreSnapshot["canvas::restore_snapshot"]
Connect["soldiers::connect"]
Disconnect["soldiers::disconnect"]
InMemoryCanvas["InMemoryCanvasStore"]
InProcessBroadcast["InProcessBroadcaster"]
end
subgraph Domain ["Domain Layer (0 infra deps)"]
Canvas["Canvas"]
ValueObjects["Color | Position | PixelUpdate"]
BroadcastEvent["BroadcastEvent"]
DomainError["DomainError"]
subgraph Ports ["Port Traits"]
CanvasStore["CanvasStore"]
CanvasPersistence["CanvasPersistence"]
EventBroadcaster["EventBroadcaster"]
end
end
subgraph Shared ["Shared Types"]
Config["config<br/><i>AppConfig, ConfigSource trait</i>"]
ApiTypes["api-types<br/><i>PixelUpdatePayload, event constants</i>"]
end
subgraph Adapters ["Adapters"]
subgraph Transport ["Transport (driving)"]
SocketIO["socketio<br/><i>Socket.IO protocol</i>"]
WebSocket["websocket<br/><i>native WebSocket + JSON</i>"]
end
subgraph Infrastructure ["Infrastructure (driven)"]
ConfigEnv["config-env<br/><i>env var loader</i>"]
CanvasFile["canvas-file<br/><i>file snapshots</i>"]
HttpAxum["http-axum<br/><i>HTTP routes, rate limit,<br/>embedded static files</i>"]
end
end
server --> Application
server --> Transport
server --> Infrastructure
server --> Config
PlacePixel --> CanvasStore
PlacePixel --> EventBroadcaster
SaveSnapshot --> CanvasStore
SaveSnapshot --> CanvasPersistence
RestoreSnapshot --> CanvasStore
RestoreSnapshot --> CanvasPersistence
Connect --> EventBroadcaster
Disconnect --> EventBroadcaster
InMemoryCanvas -.->|implements| CanvasStore
InProcessBroadcast -.->|implements| EventBroadcaster
CanvasFile -.->|implements| CanvasPersistence
ConfigEnv -.->|implements| Config
SocketIO --> Application
WebSocket --> Application
SocketIO --> ApiTypes
HttpAxum --> Config
classDef domain fill:#e8f5e9,stroke:#2e7d32
classDef app fill:#e3f2fd,stroke:#1565c0
classDef adapter fill:#fff3e0,stroke:#e65100
classDef shared fill:#f3e5f5,stroke:#6a1b9a
classDef binary fill:#fce4ec,stroke:#c62828
class Canvas,ValueObjects,BroadcastEvent,DomainError,CanvasStore,CanvasPersistence,EventBroadcaster domain
class AppState,PlacePixel,GetState,SaveSnapshot,RestoreSnapshot,Connect,Disconnect,InMemoryCanvas,InProcessBroadcast app
class SocketIO,WebSocket,ConfigEnv,CanvasFile,HttpAxum adapter
class Config,ApiTypes shared
class server binary

View 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 }

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

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

View 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 }

View File

@@ -0,0 +1,3 @@
mod routes;
pub use routes::build_router;

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

View File

@@ -0,0 +1,13 @@
[package]
name = "socketio"
version.workspace = true
edition.workspace = true
[dependencies]
application = { workspace = true }
api-types = { workspace = true }
domain = { workspace = true }
serde_json = { workspace = true }
socketioxide = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,109 @@
use std::sync::Arc;
use api_types::{self, PixelUpdatePayload};
use application::AppState;
use application::canvas::place_pixel;
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position};
use socketioxide::extract::{Data, SocketRef};
use tracing::info;
pub async fn on_connect(socket: SocketRef, state: Arc<AppState>) {
info!("Socket connected: {:?} {:?}", socket.ns(), socket.id);
let subscription = state.broadcaster().subscribe();
send_canvas_state(&socket, &state);
register_soldier(&state, &socket);
spawn_broadcast_forwarder(socket.clone(), subscription);
register_place_pixel_handler(&socket, state.clone());
register_disconnect_handler(&socket, state);
}
fn send_canvas_state(socket: &SocketRef, state: &AppState) {
let canvas_pixels = application::canvas::get_state::execute(state);
let pixel_values = Color::collect_as_u32(&canvas_pixels);
socket
.emit(api_types::events::CANVAS_STATE, &pixel_values)
.ok();
}
fn register_soldier(state: &AppState, socket: &SocketRef) {
let socket_id = socket.id.to_string();
application::soldiers::connect::execute(state, socket_id);
}
fn spawn_broadcast_forwarder(socket: SocketRef, mut subscription: BroadcastSubscription) {
tokio::spawn(async move {
while let Some(event) = subscription.recv().await {
if forward_broadcast_event(&socket, &event).is_err() {
break;
}
}
});
}
fn forward_broadcast_event(socket: &SocketRef, event: &BroadcastEvent) -> Result<(), ()> {
match event {
BroadcastEvent::PixelUpdated(update) => {
let payload = PixelUpdatePayload::from(*update);
socket
.emit(api_types::events::PIXEL_UPDATED, &payload)
.map_err(|_| ())
}
BroadcastEvent::SoldierCountChanged(count) => socket
.emit(api_types::events::CURRENT_SOLDIERS, count)
.map_err(|_| ()),
}
}
fn register_place_pixel_handler(socket: &SocketRef, state: Arc<AppState>) {
socket.on(
api_types::events::PLACE_PIXEL,
move |socket: SocketRef, Data::<PixelUpdatePayload>(payload)| async move {
handle_place_pixel(&socket, &state, payload);
},
);
}
fn handle_place_pixel(socket: &SocketRef, state: &AppState, payload: PixelUpdatePayload) {
let position = Position::new(payload.x, payload.y);
let color = Color::new(payload.color);
info!("Received pixel update: {position} color={}", color.as_u32());
let socket_id = socket.id.to_string();
let command = place_pixel::Command {
user_id: &socket_id,
position,
color,
};
match place_pixel::execute(state, command) {
Ok(place_pixel::Outcome::Placed(_)) => {}
Ok(place_pixel::Outcome::CooldownActive) => {
emit_error(socket, domain::COOLDOWN_MESSAGE);
}
Err(err) => {
emit_error(socket, &err.to_string());
}
}
}
fn register_disconnect_handler(socket: &SocketRef, state: Arc<AppState>) {
socket.on_disconnect(move |socket: SocketRef| async move {
handle_disconnect(&socket, &state);
});
}
fn handle_disconnect(socket: &SocketRef, state: &AppState) {
info!("Socket disconnected: {:?}", socket.id);
let socket_id = socket.id.to_string();
application::soldiers::disconnect::execute(state, &socket_id);
}
fn emit_error(socket: &SocketRef, message: &str) {
let _ = socket.emit(
api_types::events::ERROR,
&serde_json::Value::String(message.to_string()),
);
}

View File

@@ -0,0 +1,12 @@
mod handlers;
use std::sync::Arc;
use application::AppState;
use socketioxide::{SocketIo, extract::SocketRef};
pub fn setup_namespaces(io: &SocketIo, state: Arc<AppState>) {
io.ns("/", move |socket: SocketRef| async move {
handlers::on_connect(socket, state).await;
});
}

View File

@@ -0,0 +1,14 @@
[package]
name = "websocket"
version.workspace = true
edition.workspace = true
[dependencies]
application = { workspace = true }
domain = { workspace = true }
axum = { workspace = true, features = ["ws"] }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,163 @@
use std::sync::Arc;
use std::sync::atomic::Ordering;
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::IntoResponse;
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position};
use futures::{SinkExt, StreamExt, stream::SplitSink};
use tokio::sync::mpsc;
use tracing::{error, info};
use crate::WsState;
use crate::messages::{ClientMessage, ServerMessage};
use application::AppState;
use application::canvas::place_pixel;
type WsSender = SplitSink<WebSocket, Message>;
pub async fn ws_upgrade(
ws: WebSocketUpgrade,
State(state): State<Arc<WsState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_connection(socket, state))
}
async fn handle_connection(socket: WebSocket, state: Arc<WsState>) {
let (mut sender, mut receiver) = socket.split();
let connection_id = state
.connection_counter
.fetch_add(1, Ordering::Relaxed)
.to_string();
info!("WebSocket connected: {connection_id}");
// Subscribe before snapshotting to avoid missing updates
let subscription = state.app_state.broadcaster().subscribe();
if !send_canvas_snapshot(&mut sender, &state.app_state).await {
return;
}
application::soldiers::connect::execute(&state.app_state, connection_id.clone());
let (error_sender, error_receiver) = mpsc::unbounded_channel::<String>();
let mut send_task = tokio::spawn(run_send_loop(sender, subscription, error_receiver));
let app_state = state.app_state.clone();
let recv_connection_id = connection_id.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().await {
if let Message::Text(text) = message {
handle_client_message(&app_state, &error_sender, &recv_connection_id, &text);
} else if let Message::Close(_) = message {
break;
}
}
});
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
}
info!("WebSocket disconnected: {connection_id}");
application::soldiers::disconnect::execute(&state.app_state, &connection_id);
}
async fn send_canvas_snapshot(sender: &mut WsSender, state: &AppState) -> bool {
let pixels = application::canvas::get_state::execute(state);
let bytes = Color::collect_as_bytes(&pixels);
sender.send(Message::Binary(bytes.into())).await.is_ok()
}
async fn run_send_loop(
mut sender: WsSender,
mut subscription: BroadcastSubscription,
mut error_receiver: mpsc::UnboundedReceiver<String>,
) {
loop {
tokio::select! {
Some(event) = subscription.recv() => {
let Some(json) = serialize_broadcast_event(&event) else { continue };
if sender.send(Message::Text(json.into())).await.is_err() {
break;
}
}
Some(error_json) = error_receiver.recv() => {
if sender.send(Message::Text(error_json.into())).await.is_err() {
break;
}
}
else => break,
}
}
}
fn serialize_broadcast_event(event: &BroadcastEvent) -> Option<String> {
let message = match event {
BroadcastEvent::PixelUpdated(update) => ServerMessage::from(*update),
BroadcastEvent::SoldierCountChanged(count) => {
ServerMessage::CurrentSoldiers { count: *count }
}
};
serde_json::to_string(&message)
.inspect_err(|err| error!("Failed to serialize broadcast event: {err}"))
.ok()
}
fn handle_client_message(
state: &AppState,
error_sender: &mpsc::UnboundedSender<String>,
connection_id: &str,
text: &str,
) {
let Ok(message) = serde_json::from_str::<ClientMessage>(text) else {
return;
};
match message {
ClientMessage::PlacePixel { x, y, color } => {
handle_place_pixel(state, error_sender, connection_id, x, y, color);
}
}
}
fn handle_place_pixel(
state: &AppState,
error_sender: &mpsc::UnboundedSender<String>,
connection_id: &str,
x: u32,
y: u32,
color: u32,
) {
let command = place_pixel::Command {
user_id: connection_id,
position: Position::new(x, y),
color: Color::new(color),
};
match place_pixel::execute(state, command) {
Ok(place_pixel::Outcome::Placed(_)) => {}
Ok(place_pixel::Outcome::CooldownActive) => {
send_error(error_sender, domain::COOLDOWN_MESSAGE);
}
Err(err) => {
send_error(error_sender, &err.to_string());
}
}
}
fn send_error(error_sender: &mpsc::UnboundedSender<String>, message: &str) {
let error_message = ServerMessage::Error {
message: message.to_string(),
};
match serde_json::to_string(&error_message) {
Ok(json) => {
let _ = error_sender.send(json);
}
Err(err) => error!("Failed to serialize error message: {err}"),
}
}

View File

@@ -0,0 +1,24 @@
mod handler;
mod messages;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use application::AppState;
use axum::{Router, routing::get};
pub(crate) struct WsState {
app_state: Arc<AppState>,
connection_counter: AtomicU64,
}
pub fn build_router(state: Arc<AppState>) -> Router {
let ws_state = Arc::new(WsState {
app_state: state,
connection_counter: AtomicU64::new(0),
});
Router::new()
.route("/ws", get(handler::ws_upgrade))
.with_state(ws_state)
}

View File

@@ -0,0 +1,30 @@
use domain::PixelUpdate;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
#[serde(tag = "type")]
pub enum ClientMessage {
#[serde(rename = "place-pixel")]
PlacePixel { x: u32, y: u32, color: u32 },
}
#[derive(Serialize)]
#[serde(tag = "type")]
pub enum ServerMessage {
#[serde(rename = "pixel-updated")]
PixelUpdated { x: u32, y: u32, color: u32 },
#[serde(rename = "current_soldiers")]
CurrentSoldiers { count: usize },
#[serde(rename = "error")]
Error { message: String },
}
impl From<PixelUpdate> for ServerMessage {
fn from(update: PixelUpdate) -> Self {
Self::PixelUpdated {
x: update.position().x(),
y: update.position().y(),
color: update.color().as_u32(),
}
}
}

View File

@@ -0,0 +1,8 @@
[package]
name = "api-types"
version.workspace = true
edition.workspace = true
[dependencies]
domain = { workspace = true }
serde = { workspace = true }

View File

@@ -0,0 +1,27 @@
use domain::PixelUpdate;
use serde::{Deserialize, Serialize};
pub mod events {
pub const CANVAS_STATE: &str = "canvas_state";
pub const PIXEL_UPDATED: &str = "pixel-updated";
pub const PLACE_PIXEL: &str = "place-pixel";
pub const CURRENT_SOLDIERS: &str = "current_soldiers";
pub const ERROR: &str = "error";
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PixelUpdatePayload {
pub x: u32,
pub y: u32,
pub color: u32,
}
impl From<PixelUpdate> for PixelUpdatePayload {
fn from(update: PixelUpdate) -> Self {
Self {
x: update.position().x(),
y: update.position().y(),
color: update.color().as_u32(),
}
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "application"
version.workspace = true
edition.workspace = true
[dependencies]
domain = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,9 @@
use std::sync::Arc;
use domain::Color;
use crate::AppState;
pub fn execute(state: &AppState) -> Arc<[Color]> {
state.canvas().pixels()
}

View File

@@ -0,0 +1,4 @@
pub mod get_state;
pub mod place_pixel;
pub mod restore_snapshot;
pub mod save_snapshot;

View File

@@ -0,0 +1,29 @@
use domain::{BroadcastEvent, Color, PixelUpdate, Position};
use crate::{AppState, ApplicationError};
pub struct Command<'a> {
pub user_id: &'a str,
pub position: Position,
pub color: Color,
}
pub enum Outcome {
Placed(PixelUpdate),
CooldownActive,
}
pub fn execute(state: &AppState, command: Command<'_>) -> Result<Outcome, ApplicationError> {
if state.cooldowns().is_on_cooldown(command.user_id) {
return Ok(Outcome::CooldownActive);
}
state
.canvas()
.place_pixel(command.position, command.color)?;
state.cooldowns().record(command.user_id);
let update = PixelUpdate::new(command.position, command.color);
state
.broadcaster()
.publish(BroadcastEvent::PixelUpdated(update));
Ok(Outcome::Placed(update))
}

View File

@@ -0,0 +1,14 @@
use crate::{AppState, ApplicationError};
pub fn execute(state: &AppState) -> Result<bool, ApplicationError> {
let Some(persistence) = state.persistence() else {
return Ok(false);
};
match persistence.load_latest()? {
Some(pixels) => {
state.canvas().restore(pixels)?;
Ok(true)
}
None => Ok(false),
}
}

View File

@@ -0,0 +1,10 @@
use crate::{AppState, ApplicationError};
pub fn execute(state: &AppState) -> Result<(), ApplicationError> {
let Some(persistence) = state.persistence() else {
return Ok(());
};
let pixels = state.canvas().pixels();
persistence.save(&pixels)?;
Ok(())
}

View File

@@ -0,0 +1,7 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApplicationError {
#[error(transparent)]
Domain(#[from] domain::DomainError),
}

View File

@@ -0,0 +1,8 @@
mod errors;
mod state;
pub mod canvas;
pub mod soldiers;
pub use errors::ApplicationError;
pub use state::{AppState, InMemoryCanvasStore, InProcessBroadcaster};

View File

@@ -0,0 +1,11 @@
use domain::BroadcastEvent;
use crate::AppState;
pub fn execute(state: &AppState, user_id: String) -> usize {
let count = state.soldiers().add(user_id);
state
.broadcaster()
.publish(BroadcastEvent::SoldierCountChanged(count));
count
}

View File

@@ -0,0 +1,12 @@
use domain::BroadcastEvent;
use crate::AppState;
pub fn execute(state: &AppState, user_id: &str) -> usize {
state.cooldowns().remove(user_id);
let count = state.soldiers().remove(user_id);
state
.broadcaster()
.publish(BroadcastEvent::SoldierCountChanged(count));
count
}

View File

@@ -0,0 +1,2 @@
pub mod connect;
pub mod disconnect;

View File

@@ -0,0 +1,199 @@
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use domain::ports::{
BroadcastReceiverInner, BroadcastSubscription, CanvasPersistence, CanvasStore, EventBroadcaster,
};
use domain::{BroadcastEvent, Canvas, Color, DomainError, Position};
use tokio::sync::broadcast;
use tracing::warn;
const INITIAL_CONNECTION_CAPACITY: usize = 128;
pub struct AppState {
canvas: Box<dyn CanvasStore>,
broadcaster: Box<dyn EventBroadcaster>,
persistence: Option<Box<dyn CanvasPersistence>>,
cooldowns: CooldownTracker,
soldiers: SoldierTracker,
}
impl AppState {
pub fn new(
canvas: Box<dyn CanvasStore>,
broadcaster: Box<dyn EventBroadcaster>,
cooldown: Duration,
) -> Self {
Self {
canvas,
broadcaster,
persistence: None,
cooldowns: CooldownTracker::new(cooldown),
soldiers: SoldierTracker::new(),
}
}
pub fn with_persistence(mut self, persistence: Box<dyn CanvasPersistence>) -> Self {
self.persistence = Some(persistence);
self
}
pub fn canvas(&self) -> &dyn CanvasStore {
&*self.canvas
}
pub fn broadcaster(&self) -> &dyn EventBroadcaster {
&*self.broadcaster
}
pub fn persistence(&self) -> Option<&dyn CanvasPersistence> {
self.persistence.as_deref()
}
pub fn cooldowns(&self) -> &CooldownTracker {
&self.cooldowns
}
pub fn soldiers(&self) -> &SoldierTracker {
&self.soldiers
}
}
pub struct InProcessBroadcaster {
sender: broadcast::Sender<BroadcastEvent>,
}
impl InProcessBroadcaster {
pub fn new(sender: broadcast::Sender<BroadcastEvent>) -> Self {
Self { sender }
}
}
impl EventBroadcaster for InProcessBroadcaster {
fn publish(&self, event: BroadcastEvent) {
let _ = self.sender.send(event);
}
fn subscribe(&self) -> BroadcastSubscription {
BroadcastSubscription::new(Box::new(TokioBroadcastReceiver(self.sender.subscribe())))
}
}
struct TokioBroadcastReceiver(broadcast::Receiver<BroadcastEvent>);
impl BroadcastReceiverInner for TokioBroadcastReceiver {
fn recv_boxed(&mut self) -> Pin<Box<dyn Future<Output = Option<BroadcastEvent>> + Send + '_>> {
Box::pin(async { self.0.recv().await.ok() })
}
}
fn acquire_lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|poisoned| {
warn!("Recovered from poisoned mutex");
poisoned.into_inner()
})
}
struct CanvasState {
canvas: Canvas,
snapshot: Option<Arc<[Color]>>,
}
pub struct InMemoryCanvasStore {
state: Mutex<CanvasState>,
}
impl InMemoryCanvasStore {
pub fn new(width: u32, height: u32) -> Self {
Self {
state: Mutex::new(CanvasState {
canvas: Canvas::new(width, height),
snapshot: None,
}),
}
}
}
impl CanvasStore for InMemoryCanvasStore {
fn pixels(&self) -> Arc<[Color]> {
let mut state = acquire_lock(&self.state);
if let Some(ref cached) = state.snapshot {
return cached.clone();
}
let new_snapshot: Arc<[Color]> = Arc::from(state.canvas.pixels());
state.snapshot = Some(new_snapshot.clone());
new_snapshot
}
fn place_pixel(&self, position: Position, color: Color) -> Result<(), DomainError> {
let mut state = acquire_lock(&self.state);
state.canvas.place_pixel(position, color)?;
state.snapshot = None;
Ok(())
}
fn restore(&self, pixels: Vec<Color>) -> Result<(), DomainError> {
let mut state = acquire_lock(&self.state);
let new_canvas = Canvas::from_pixels(state.canvas.width(), state.canvas.height(), pixels)?;
state.canvas = new_canvas;
state.snapshot = None;
Ok(())
}
}
pub struct CooldownTracker {
entries: Mutex<HashMap<String, Instant>>,
cooldown: Duration,
}
impl CooldownTracker {
pub fn new(cooldown: Duration) -> Self {
Self {
entries: Mutex::new(HashMap::with_capacity(INITIAL_CONNECTION_CAPACITY)),
cooldown,
}
}
pub fn is_on_cooldown(&self, user_id: &str) -> bool {
let entries = acquire_lock(&self.entries);
entries
.get(user_id)
.map(|last| last.elapsed() < self.cooldown)
.unwrap_or(false)
}
pub fn record(&self, user_id: &str) {
acquire_lock(&self.entries).insert(user_id.to_string(), Instant::now());
}
pub fn remove(&self, user_id: &str) {
acquire_lock(&self.entries).remove(user_id);
}
}
pub struct SoldierTracker {
connected: Mutex<HashSet<String>>,
}
impl SoldierTracker {
pub fn new() -> Self {
Self {
connected: Mutex::new(HashSet::with_capacity(INITIAL_CONNECTION_CAPACITY)),
}
}
pub fn add(&self, user_id: String) -> usize {
let mut connected = acquire_lock(&self.connected);
connected.insert(user_id);
connected.len()
}
pub fn remove(&self, user_id: &str) -> usize {
let mut connected = acquire_lock(&self.connected);
connected.remove(user_id);
connected.len()
}
}

View File

@@ -0,0 +1,109 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use application::{AppState, InMemoryCanvasStore};
use domain::ports::{
BroadcastReceiverInner, BroadcastSubscription, CanvasPersistence, EventBroadcaster,
};
use domain::{BroadcastEvent, Color, DomainError};
#[derive(Clone)]
pub struct SpyBroadcaster {
events: Arc<Mutex<Vec<BroadcastEvent>>>,
}
impl SpyBroadcaster {
pub fn new() -> Self {
Self {
events: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn events(&self) -> Vec<BroadcastEvent> {
self.events.lock().unwrap().clone()
}
pub fn event_count(&self) -> usize {
self.events.lock().unwrap().len()
}
}
impl EventBroadcaster for SpyBroadcaster {
fn publish(&self, event: BroadcastEvent) {
self.events.lock().unwrap().push(event);
}
fn subscribe(&self) -> BroadcastSubscription {
BroadcastSubscription::new(Box::new(NoopReceiver))
}
}
struct NoopReceiver;
impl BroadcastReceiverInner for NoopReceiver {
fn recv_boxed(&mut self) -> Pin<Box<dyn Future<Output = Option<BroadcastEvent>> + Send + '_>> {
Box::pin(async { None })
}
}
pub struct FakePersistence {
saved: Arc<Mutex<Vec<Vec<Color>>>>,
to_load: Mutex<Option<Vec<Color>>>,
}
impl FakePersistence {
pub fn empty() -> Self {
Self {
saved: Arc::new(Mutex::new(Vec::new())),
to_load: Mutex::new(None),
}
}
pub fn with_snapshot(pixels: Vec<Color>) -> Self {
Self {
saved: Arc::new(Mutex::new(Vec::new())),
to_load: Mutex::new(Some(pixels)),
}
}
pub fn saved_ref(&self) -> Arc<Mutex<Vec<Vec<Color>>>> {
self.saved.clone()
}
}
impl CanvasPersistence for FakePersistence {
fn save(&self, pixels: &[Color]) -> Result<(), DomainError> {
self.saved.lock().unwrap().push(pixels.to_vec());
Ok(())
}
fn load_latest(&self) -> Result<Option<Vec<Color>>, DomainError> {
Ok(self.to_load.lock().unwrap().clone())
}
}
pub fn test_state() -> (Arc<AppState>, SpyBroadcaster) {
test_state_sized(10, 10)
}
pub fn test_state_sized(width: u32, height: u32) -> (Arc<AppState>, SpyBroadcaster) {
let spy = SpyBroadcaster::new();
let state = AppState::new(
Box::new(InMemoryCanvasStore::new(width, height)),
Box::new(spy.clone()),
Duration::from_secs(10),
);
(Arc::new(state), spy)
}
pub fn test_state_no_cooldown() -> (Arc<AppState>, SpyBroadcaster) {
let spy = SpyBroadcaster::new();
let state = AppState::new(
Box::new(InMemoryCanvasStore::new(10, 10)),
Box::new(spy.clone()),
Duration::ZERO,
);
(Arc::new(state), spy)
}

View File

@@ -0,0 +1,91 @@
mod common;
use application::canvas::place_pixel::{Command, Outcome};
use domain::{BroadcastEvent, Color, Position};
macro_rules! place {
($state:expr, $user:expr, $x:expr, $y:expr, $color:expr) => {
application::canvas::place_pixel::execute(
&$state,
Command {
user_id: $user,
position: Position::new($x, $y),
color: Color::new($color),
},
)
};
}
#[test]
fn successful_placement_returns_update() {
let (state, _) = common::test_state();
let result = place!(state, "user-1", 3, 4, 0xFF0000).unwrap();
let Outcome::Placed(update) = result else {
panic!("expected Placed outcome");
};
assert_eq!(update.position(), Position::new(3, 4));
assert_eq!(update.color(), Color::new(0xFF0000));
}
#[test]
fn placement_updates_canvas() {
let (state, _) = common::test_state();
place!(state, "user-1", 5, 5, 0xAA).unwrap();
let pixels = application::canvas::get_state::execute(&state);
let idx = 5 * 10 + 5;
assert_eq!(pixels[idx], Color::new(0xAA));
}
#[test]
fn placement_publishes_broadcast_event() {
let (state, spy) = common::test_state();
place!(state, "user-1", 0, 0, 0xFF).unwrap();
let events = spy.events();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], BroadcastEvent::PixelUpdated(_)));
}
#[test]
fn cooldown_blocks_rapid_placement() {
let (state, _) = common::test_state();
place!(state, "user-1", 0, 0, 0xFF).unwrap();
let result = place!(state, "user-1", 1, 1, 0xAA).unwrap();
assert!(matches!(result, Outcome::CooldownActive));
}
#[test]
fn cooldown_is_per_user() {
let (state, _) = common::test_state();
place!(state, "user-1", 0, 0, 0xFF).unwrap();
let result = place!(state, "user-2", 1, 1, 0xAA).unwrap();
assert!(matches!(result, Outcome::Placed(_)));
}
#[test]
fn zero_cooldown_allows_rapid_placement() {
let (state, _) = common::test_state_no_cooldown();
place!(state, "user-1", 0, 0, 0xFF).unwrap();
let result = place!(state, "user-1", 1, 1, 0xAA).unwrap();
assert!(matches!(result, Outcome::Placed(_)));
}
#[test]
fn out_of_bounds_returns_error() {
let (state, _) = common::test_state();
assert!(place!(state, "user-1", 99, 99, 0xFF).is_err());
}
#[test]
fn failed_placement_does_not_trigger_cooldown() {
let (state, _) = common::test_state();
let _ = place!(state, "user-1", 99, 99, 0xFF);
let result = place!(state, "user-1", 0, 0, 0xFF).unwrap();
assert!(matches!(result, Outcome::Placed(_)));
}

View File

@@ -0,0 +1,75 @@
mod common;
use std::sync::Arc;
use application::AppState;
use domain::Color;
fn state_with_persistence(persistence: common::FakePersistence) -> Arc<AppState> {
let spy = common::SpyBroadcaster::new();
let state = AppState::new(
Box::new(application::InMemoryCanvasStore::new(10, 10)),
Box::new(spy),
std::time::Duration::from_secs(10),
)
.with_persistence(Box::new(persistence));
Arc::new(state)
}
#[test]
fn save_snapshot_persists_current_canvas() {
let persistence = common::FakePersistence::empty();
let saved_ref = persistence.saved_ref();
let state = state_with_persistence(persistence);
application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user",
position: domain::Position::new(0, 0),
color: Color::new(0xFF),
},
)
.unwrap();
application::canvas::save_snapshot::execute(&state).unwrap();
let snapshots = saved_ref.lock().unwrap();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0][0], Color::new(0xFF));
assert_eq!(snapshots[0].len(), 100);
}
#[test]
fn restore_snapshot_loads_canvas() {
let mut snapshot = vec![Color::white(); 100];
snapshot[0] = Color::new(0xDEAD);
snapshot[99] = Color::new(0xBEEF);
let state = state_with_persistence(common::FakePersistence::with_snapshot(snapshot));
let restored = application::canvas::restore_snapshot::execute(&state).unwrap();
assert!(restored);
let pixels = application::canvas::get_state::execute(&state);
assert_eq!(pixels[0], Color::new(0xDEAD));
assert_eq!(pixels[99], Color::new(0xBEEF));
}
#[test]
fn restore_returns_false_when_no_snapshot() {
let state = state_with_persistence(common::FakePersistence::empty());
assert!(!application::canvas::restore_snapshot::execute(&state).unwrap());
}
#[test]
fn save_without_persistence_is_noop() {
let (state, _) = common::test_state();
assert!(application::canvas::save_snapshot::execute(&state).is_ok());
}
#[test]
fn restore_without_persistence_returns_false() {
let (state, _) = common::test_state();
assert!(!application::canvas::restore_snapshot::execute(&state).unwrap());
}

View File

@@ -0,0 +1,96 @@
mod common;
use domain::BroadcastEvent;
#[test]
fn connect_increments_count() {
let (state, _) = common::test_state();
assert_eq!(
application::soldiers::connect::execute(&state, "a".into()),
1
);
assert_eq!(
application::soldiers::connect::execute(&state, "b".into()),
2
);
assert_eq!(
application::soldiers::connect::execute(&state, "c".into()),
3
);
}
#[test]
fn disconnect_decrements_count() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::connect::execute(&state, "b".into());
assert_eq!(application::soldiers::disconnect::execute(&state, "a"), 1);
assert_eq!(application::soldiers::disconnect::execute(&state, "b"), 0);
}
#[test]
fn disconnect_unknown_user_is_harmless() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
assert_eq!(
application::soldiers::disconnect::execute(&state, "unknown"),
1
);
}
#[test]
fn connect_publishes_soldier_count() {
let (state, spy) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
let events = spy.events();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], BroadcastEvent::SoldierCountChanged(1)));
}
#[test]
fn disconnect_publishes_soldier_count() {
let (state, spy) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::disconnect::execute(&state, "a");
let events = spy.events();
assert_eq!(events.len(), 2);
assert!(matches!(events[1], BroadcastEvent::SoldierCountChanged(0)));
}
#[test]
fn disconnect_clears_cooldown() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "user-1".into());
application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user-1",
position: domain::Position::new(0, 0),
color: domain::Color::new(0xFF),
},
)
.unwrap();
application::soldiers::disconnect::execute(&state, "user-1");
// Reconnect with same ID — cooldown should be gone
application::soldiers::connect::execute(&state, "user-1".into());
let result = application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user-1",
position: domain::Position::new(1, 1),
color: domain::Color::new(0xAA),
},
)
.unwrap();
assert!(matches!(
result,
application::canvas::place_pixel::Outcome::Placed(_)
));
}

7
crates/config/Cargo.toml Normal file
View File

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

61
crates/config/src/lib.rs Normal file
View File

@@ -0,0 +1,61 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("invalid value for '{field}': {reason}")]
InvalidValue { field: String, reason: String },
#[error("failed to load config: {0}")]
LoadFailed(String),
}
#[derive(Debug, Clone)]
pub struct AppConfig {
pub server: ServerConfig,
pub canvas: CanvasConfig,
pub cooldown: CooldownConfig,
pub rate_limit: RateLimitConfig,
pub broadcast: BroadcastConfig,
pub snapshot: SnapshotConfig,
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub address: String,
pub port: u16,
pub enable_cors: bool,
}
#[derive(Debug, Clone)]
pub struct CanvasConfig {
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone)]
pub struct CooldownConfig {
pub placement_secs: u64,
}
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub burst_size: u32,
pub per_second: u64,
}
#[derive(Debug, Clone)]
pub struct BroadcastConfig {
pub channel_capacity: usize,
}
#[derive(Debug, Clone)]
pub struct SnapshotConfig {
pub enabled: bool,
pub interval_secs: u64,
pub max_snapshots: usize,
pub directory: String,
}
pub trait ConfigSource {
fn load(&self) -> Result<AppConfig, ConfigError>;
}

7
crates/domain/Cargo.toml Normal file
View File

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

View File

@@ -0,0 +1,57 @@
use crate::{Color, DomainError, PixelUpdate, Position};
pub struct Canvas {
pixels: Vec<Color>,
width: u32,
height: u32,
}
impl Canvas {
pub fn new(width: u32, height: u32) -> Self {
Self {
pixels: vec![Color::white(); (width * height) as usize],
width,
height,
}
}
pub fn from_pixels(width: u32, height: u32, pixels: Vec<Color>) -> Result<Self, DomainError> {
let expected = (width * height) as usize;
if pixels.len() != expected {
return Err(DomainError::InvalidCanvasData {
expected_width: width,
expected_height: height,
actual: pixels.len(),
});
}
Ok(Self {
pixels,
width,
height,
})
}
pub fn place_pixel(
&mut self,
position: Position,
color: Color,
) -> Result<PixelUpdate, DomainError> {
if position.x() >= self.width || position.y() >= self.height {
return Err(DomainError::PixelOutOfBounds(position));
}
self.pixels[(position.y() * self.width + position.x()) as usize] = color;
Ok(PixelUpdate::new(position, color))
}
pub fn pixels(&self) -> &[Color] {
&self.pixels
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
}

View File

@@ -0,0 +1,21 @@
use thiserror::Error;
use crate::Position;
#[derive(Debug, Error)]
pub enum DomainError {
#[error("pixel position {0} is out of bounds")]
PixelOutOfBounds(Position),
#[error(
"invalid canvas dimensions: expected {expected_width}x{expected_height}, got {actual} pixels"
)]
InvalidCanvasData {
expected_width: u32,
expected_height: u32,
actual: usize,
},
#[error("persistence error: {0}")]
Persistence(String),
}

View File

@@ -0,0 +1,7 @@
use crate::PixelUpdate;
#[derive(Debug, Clone, Copy)]
pub enum BroadcastEvent {
PixelUpdated(PixelUpdate),
SoldierCountChanged(usize),
}

13
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,13 @@
pub mod canvas;
pub mod errors;
pub mod events;
pub mod ports;
pub mod value_objects;
pub use canvas::Canvas;
pub use errors::DomainError;
pub use events::BroadcastEvent;
pub use ports::BroadcastSubscription;
pub use value_objects::{Color, PixelUpdate, Position};
pub const COOLDOWN_MESSAGE: &str = "You can only place one pixel per minute";

View File

@@ -0,0 +1,39 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::{BroadcastEvent, Color, DomainError, Position};
pub trait CanvasStore: Send + Sync {
fn pixels(&self) -> Arc<[Color]>;
fn place_pixel(&self, position: Position, color: Color) -> Result<(), DomainError>;
fn restore(&self, pixels: Vec<Color>) -> Result<(), DomainError>;
}
pub trait CanvasPersistence: Send + Sync {
fn save(&self, pixels: &[Color]) -> Result<(), DomainError>;
fn load_latest(&self) -> Result<Option<Vec<Color>>, DomainError>;
}
pub trait EventBroadcaster: Send + Sync {
fn publish(&self, event: BroadcastEvent);
fn subscribe(&self) -> BroadcastSubscription;
}
pub trait BroadcastReceiverInner: Send {
fn recv_boxed(&mut self) -> Pin<Box<dyn Future<Output = Option<BroadcastEvent>> + Send + '_>>;
}
pub struct BroadcastSubscription {
inner: Box<dyn BroadcastReceiverInner>,
}
impl BroadcastSubscription {
pub fn new(inner: Box<dyn BroadcastReceiverInner>) -> Self {
Self { inner }
}
pub async fn recv(&mut self) -> Option<BroadcastEvent> {
self.inner.recv_boxed().await
}
}

View File

@@ -0,0 +1,76 @@
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
pub struct Color(u32);
impl Color {
pub fn new(value: u32) -> Self {
Self(value)
}
pub fn as_u32(self) -> u32 {
self.0
}
pub fn white() -> Self {
Self(0xFFFFFFFF)
}
pub fn collect_as_u32(colors: &[Color]) -> Vec<u32> {
colors.iter().map(|color| color.as_u32()).collect()
}
pub fn collect_as_bytes(colors: &[Color]) -> Vec<u8> {
colors
.iter()
.flat_map(|color| color.as_u32().to_ne_bytes())
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
x: u32,
y: u32,
}
impl Position {
pub fn new(x: u32, y: u32) -> Self {
Self { x, y }
}
pub fn x(self) -> u32 {
self.x
}
pub fn y(self) -> u32 {
self.y
}
}
impl fmt::Display for Position {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
#[derive(Debug, Clone, Copy)]
pub struct PixelUpdate {
position: Position,
color: Color,
}
impl PixelUpdate {
pub fn new(position: Position, color: Color) -> Self {
Self { position, color }
}
pub fn position(self) -> Position {
self.position
}
pub fn color(self) -> Color {
self.color
}
}

View File

@@ -0,0 +1,125 @@
use domain::{Canvas, Color, DomainError, Position};
macro_rules! pos {
($x:expr, $y:expr) => {
Position::new($x, $y)
};
}
macro_rules! color {
($v:expr) => {
Color::new($v)
};
}
macro_rules! assert_pixel {
($canvas:expr, $x:expr, $y:expr, $expected:expr) => {{
let (x, y): (u32, u32) = ($x, $y);
let idx = y as usize * $canvas.width() as usize + x as usize;
let expected = color!($expected);
assert_eq!($canvas.pixels()[idx], expected, "pixel at ({x}, {y})");
}};
}
fn small_canvas() -> Canvas {
Canvas::new(10, 10)
}
#[test]
fn new_canvas_is_all_white() {
let canvas = small_canvas();
assert_eq!(canvas.pixels().len(), 100);
assert!(canvas.pixels().iter().all(|&c| c == Color::white()));
}
#[test]
fn dimensions_match_construction() {
let canvas = Canvas::new(42, 17);
assert_eq!(canvas.width(), 42);
assert_eq!(canvas.height(), 17);
assert_eq!(canvas.pixels().len(), 42 * 17);
}
#[test]
fn place_pixel_updates_correct_position() {
let mut canvas = small_canvas();
let update = canvas.place_pixel(pos!(3, 4), color!(0xFF0000)).unwrap();
assert_pixel!(canvas, 3, 4, 0xFF0000);
assert_eq!(update.position(), pos!(3, 4));
assert_eq!(update.color(), color!(0xFF0000));
}
#[test]
fn place_pixel_does_not_affect_neighbors() {
let mut canvas = small_canvas();
canvas.place_pixel(pos!(5, 5), color!(0xFF)).unwrap();
for (x, y) in [(4, 5), (6, 5), (5, 4), (5, 6)] {
assert_pixel!(canvas, x, y, 0xFFFFFFFF);
}
}
#[test]
fn place_pixel_overwrites_previous() {
let mut canvas = small_canvas();
canvas.place_pixel(pos!(0, 0), color!(0xAA)).unwrap();
canvas.place_pixel(pos!(0, 0), color!(0xBB)).unwrap();
assert_pixel!(canvas, 0, 0, 0xBB);
}
#[test]
fn place_pixel_at_boundary() {
let mut canvas = small_canvas();
assert!(canvas.place_pixel(pos!(9, 9), color!(0xFF)).is_ok());
assert!(canvas.place_pixel(pos!(0, 0), color!(0xFF)).is_ok());
assert!(canvas.place_pixel(pos!(9, 0), color!(0xFF)).is_ok());
assert!(canvas.place_pixel(pos!(0, 9), color!(0xFF)).is_ok());
}
#[test]
fn place_pixel_out_of_bounds() {
let mut canvas = small_canvas();
for (x, y) in [(10, 0), (0, 10), (10, 10), (100, 100)] {
let result = canvas.place_pixel(pos!(x, y), color!(0xFF));
assert!(
matches!(result, Err(DomainError::PixelOutOfBounds(_))),
"({x}, {y}) should be out of bounds"
);
}
}
#[test]
fn from_pixels_with_correct_size() {
let pixels = vec![color!(0xAA); 25];
let canvas = Canvas::from_pixels(5, 5, pixels).unwrap();
assert_eq!(canvas.width(), 5);
assert_eq!(canvas.height(), 5);
assert!(canvas.pixels().iter().all(|&c| c == color!(0xAA)));
}
#[test]
fn from_pixels_with_wrong_size() {
let too_few = vec![color!(0); 10];
let too_many = vec![color!(0); 30];
for pixels in [too_few, too_many] {
assert!(
matches!(
Canvas::from_pixels(5, 5, pixels),
Err(DomainError::InvalidCanvasData { .. })
),
"should reject pixel vec that doesn't match dimensions"
);
}
}
#[test]
fn from_pixels_preserves_content() {
let mut pixels = vec![Color::white(); 9];
pixels[4] = color!(0xFF0000); // center pixel of 3x3
let canvas = Canvas::from_pixels(3, 3, pixels).unwrap();
assert_pixel!(canvas, 1, 1, 0xFF0000);
assert_pixel!(canvas, 0, 0, 0xFFFFFFFF);
}

View File

@@ -0,0 +1,69 @@
use domain::{Color, PixelUpdate, Position};
#[test]
fn color_roundtrips_through_u32() {
for value in [0, 0xFF, 0xFF0000, 0xFFFFFFFF, 0xDEADBEEF] {
assert_eq!(Color::new(value).as_u32(), value);
}
}
#[test]
fn color_white_is_full_alpha() {
assert_eq!(Color::white().as_u32(), 0xFFFFFFFF);
}
#[test]
fn color_equality() {
assert_eq!(Color::new(42), Color::new(42));
assert_ne!(Color::new(1), Color::new(2));
}
#[test]
fn collect_as_u32_preserves_values() {
let colors = [Color::new(1), Color::new(2), Color::new(3)];
assert_eq!(Color::collect_as_u32(&colors), vec![1, 2, 3]);
}
#[test]
fn collect_as_bytes_length() {
let colors = vec![Color::white(); 10];
assert_eq!(Color::collect_as_bytes(&colors).len(), 40);
}
#[test]
fn collect_as_bytes_roundtrips() {
let original = vec![Color::new(0x01020304), Color::new(0xAABBCCDD)];
let bytes = Color::collect_as_bytes(&original);
let restored: Vec<Color> = bytes
.chunks_exact(4)
.map(|c| Color::new(u32::from_ne_bytes([c[0], c[1], c[2], c[3]])))
.collect();
assert_eq!(original, restored);
}
#[test]
fn position_accessors() {
let pos = Position::new(42, 17);
assert_eq!(pos.x(), 42);
assert_eq!(pos.y(), 17);
}
#[test]
fn position_display() {
assert_eq!(Position::new(3, 7).to_string(), "(3, 7)");
}
#[test]
fn position_equality() {
assert_eq!(Position::new(1, 2), Position::new(1, 2));
assert_ne!(Position::new(1, 2), Position::new(2, 1));
}
#[test]
fn pixel_update_carries_position_and_color() {
let pos = Position::new(5, 10);
let color = Color::new(0xFF00FF);
let update = PixelUpdate::new(pos, color);
assert_eq!(update.position(), pos);
assert_eq!(update.color(), color);
}

26
crates/server/Cargo.toml Normal file
View 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
View 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))
}

21
deploy.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
REGISTRY="registry.gabrielkaszewski.dev"
REPO="painter"
tag="latest"
while [[ $# -gt 0 ]]; do
case $1 in
--tag) tag="$2"; shift 2 ;;
*) echo "usage: $0 [--tag T]" >&2; exit 1 ;;
esac
done
image="${REGISTRY}/${REPO}:${tag}"
echo "building ${image}"
docker buildx build --platform linux/amd64 \
-t "$image" --push .
echo "pushed $image"

315
painter-js/bun.lock Normal file
View File

@@ -0,0 +1,315 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "painter-js",
"dependencies": {
"socket.io-client": "^4.7.5",
},
"devDependencies": {
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"prettier": "^3.9.6",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"autoprefixer": ["autoprefixer@10.5.4", "", { "dependencies": { "browserslist": "^4.28.6", "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.408", "", {}, "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw=="],
"engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.7", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
}
}

View File

@@ -1,44 +1,126 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/pixel_war_icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pixel War</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Painter</title>
<meta name="description" content="Place pixels on a shared canvas with other players in real-time. Inspired by r/place." />
<meta property="og:type" content="website" />
<meta property="og:title" content="Painter" />
<meta property="og:description" content="Place pixels on a shared canvas with other players in real-time." />
<meta property="og:image" content="/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Painter" />
<meta name="twitter:description" content="Place pixels on a shared canvas with other players in real-time." />
<meta name="twitter:image" content="/og-image.png" />
<meta name="theme-color" content="#f1f5f9" />
<link rel="stylesheet" href="/src/style.css" />
</head>
<body class="relative flex flex-col items-center justify-center min-h-screen bg-slate-100">
<div id="challenge" class="absolute z-10 w-full min-h-screen"></div>
<div class="z-20 flex flex-wrap gap-2 p-2">
<button id="red" class="w-8 h-8 bg-red-500 border border-black shadow"></button>
<button id="green" class="w-8 h-8 bg-green-500 border border-black shadow"></button>
<button id="blue" class="w-8 h-8 bg-blue-500 border border-black shadow"></button>
<button id="cyan" class="w-8 h-8 border border-black shadow bg-cyan-500"></button>
<button id="yellow" class="w-8 h-8 bg-yellow-500 border border-black shadow"></button>
<button id="black" class="w-8 h-8 bg-black border border-black shadow"></button>
<button id="white" class="w-8 h-8 bg-white border border-black shadow"></button>
<button id="pink" class="w-8 h-8 bg-pink-500 border border-black shadow"></button>
<button id="purple" class="w-8 h-8 bg-purple-500 border border-black shadow"></button>
<button id="orange" class="w-8 h-8 bg-orange-500 border border-black shadow"></button>
<button id="brown" class="w-8 h-8 bg-orange-800 border border-black shadow"></button>
<input class="w-8 h-8 border border-black shadow" type="color" id="color-picker" value="#000000" />
<body
class="relative flex flex-col items-center justify-center min-h-screen bg-slate-100 gap-1"
>
<div id="connection-status" class="text-sm"></div>
<div class="z-20 flex flex-wrap justify-center gap-2 p-1">
<button
id="red"
class="w-8 h-8 bg-red-500 border border-black shadow"
></button>
<button
id="green"
class="w-8 h-8 bg-green-500 border border-black shadow"
></button>
<button
id="blue"
class="w-8 h-8 bg-blue-500 border border-black shadow"
></button>
<button
id="cyan"
class="w-8 h-8 border border-black shadow bg-cyan-500"
></button>
<button
id="yellow"
class="w-8 h-8 bg-yellow-500 border border-black shadow"
></button>
<button
id="black"
class="w-8 h-8 bg-black border border-black shadow"
></button>
<button
id="white"
class="w-8 h-8 bg-white border border-black shadow"
></button>
<button
id="pink"
class="w-8 h-8 bg-pink-500 border border-black shadow"
></button>
<button
id="purple"
class="w-8 h-8 bg-purple-500 border border-black shadow"
></button>
<button
id="orange"
class="w-8 h-8 bg-orange-500 border border-black shadow"
></button>
<button
id="brown"
class="w-8 h-8 bg-orange-800 border border-black shadow"
></button>
<input
class="w-8 h-8 border border-black shadow"
type="color"
id="color-picker"
value="#000000"
/>
</div>
<p class="z-20">Your selected color <span id="current-color-span" class="text-white">COLOR</span></p>
<div id="countdown" class="z-20">You can place a pixel now</div>
<div class="z-20 flex items-center gap-2 mx-1 my-4">
<label for="toggle-grid">Toggle Grid</label>
<input id="toggle-grid" type="checkbox" />
<button id="place-pixel" class="px-2 py-1 rounded-md shadow-lg bg-cyan-400 hover:bg-cyan-500 active:bg-cyan-600">Place Pixel</button>
<p class="z-20 text-sm">
Your selected color
<span id="current-color-span" class="px-2 rounded text-white">COLOR</span>
</p>
<div id="countdown" class="z-20 text-xs">You can place a pixel now</div>
<div class="z-20 flex items-center gap-2 mx-1">
<button
id="zoom-out"
class="w-8 h-8 text-lg font-bold rounded shadow bg-slate-200 hover:bg-slate-300"
>
-
</button>
<span id="zoom-level" class="text-xs font-mono w-10 text-center"
>1.0x</span
>
<button
id="zoom-in"
class="w-8 h-8 text-lg font-bold rounded shadow bg-slate-200 hover:bg-slate-300"
>
+
</button>
<button
id="place-pixel"
class="px-3 py-1 text-sm rounded-md shadow-lg bg-cyan-400 hover:bg-cyan-500 active:bg-cyan-600"
>
Place Pixel
</button>
<p id="coords" class="text-xs font-mono"></p>
</div>
<div class="z-20 flex gap-1">
<p id="coords"></p>
<div class="canvas-viewport" id="canvas-viewport">
<canvas class="bg-white" id="canvas" width="500" height="500"></canvas>
</div>
<div class="relative z-20 p-1 rainbow-border">
<canvas class="bg-white rounded-md" id="canvas" width="500" height="500"></canvas>
</div>
<div class="z-20 flex flex-col gap-2 p-2">
<p>Current soldiers: <span id="current-soldiers" class="font-bold">0</span></p>
<button id="save-canvas" class="px-2 py-1 bg-green-400 rounded-md shadow-lg hover:bg-green-500 active:bg-green-600">Save Canvas</button>
<div class="z-20 flex items-center gap-4 p-1">
<p class="text-sm">
Soldiers: <span id="current-soldiers" class="font-bold">0</span>
</p>
<button
id="save-canvas"
class="px-3 py-1 text-sm bg-green-400 rounded-md shadow-lg hover:bg-green-500 active:bg-green-600"
>
Save Canvas
</button>
</div>
<script type="module" src="/src/main.js"></script>
</body>

View File

@@ -1,917 +0,0 @@
{
"name": "painter-js",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "painter-js",
"version": "0.0.0",
"dependencies": {
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz",
"integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz",
"integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz",
"integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz",
"integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz",
"integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz",
"integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz",
"integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz",
"integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz",
"integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz",
"integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz",
"integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz",
"integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz",
"integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz",
"integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz",
"integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz",
"integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz",
"integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.11.0",
"xmlhttprequest-ssl": "~2.0.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz",
"integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"dev": true
},
"node_modules/postcss": {
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz",
"integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==",
"dev": true,
"dependencies": {
"@types/estree": "1.0.5"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.17.2",
"@rollup/rollup-android-arm64": "4.17.2",
"@rollup/rollup-darwin-arm64": "4.17.2",
"@rollup/rollup-darwin-x64": "4.17.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.17.2",
"@rollup/rollup-linux-arm-musleabihf": "4.17.2",
"@rollup/rollup-linux-arm64-gnu": "4.17.2",
"@rollup/rollup-linux-arm64-musl": "4.17.2",
"@rollup/rollup-linux-powerpc64le-gnu": "4.17.2",
"@rollup/rollup-linux-riscv64-gnu": "4.17.2",
"@rollup/rollup-linux-s390x-gnu": "4.17.2",
"@rollup/rollup-linux-x64-gnu": "4.17.2",
"@rollup/rollup-linux-x64-musl": "4.17.2",
"@rollup/rollup-win32-arm64-msvc": "4.17.2",
"@rollup/rollup-win32-ia32-msvc": "4.17.2",
"@rollup/rollup-win32-x64-msvc": "4.17.2",
"fsevents": "~2.3.2"
}
},
"node_modules/socket.io-client": {
"version": "4.7.5",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
"integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz",
"integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==",
"dev": true,
"dependencies": {
"esbuild": "^0.20.1",
"postcss": "^8.4.38",
"rollup": "^4.13.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/ws": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
"integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
"engines": {
"node": ">=0.4.0"
}
}
}
}

View File

@@ -6,17 +6,19 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"fmt": "prettier --write 'src/**/*.{js,ts,css,html}'",
"fmt:check": "prettier --check 'src/**/*.{js,ts,css,html}'"
},
"devDependencies": {
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"prettier": "^3.9.6",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
"dependencies": {
"socket.io-client": "^4.7.5",
"three": "^0.164.1"
"socket.io-client": "^4.7.5"
}
}

1466
painter-js/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

View File

@@ -1,267 +0,0 @@
import {
hexToU32,
u32ToHex,
getColorFromElementCSS,
rgbToHex,
} from "./utils.js";
import {
pixelSize,
pixelCooldown,
canvasEndpoint,
WIDTH,
HEIGHT,
} from "./constants.js";
let socket = null;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const countdownDiv = document.getElementById("countdown");
let lastPixelTime = parseInt(localStorage.getItem("lastPixelTime") || "0");
const colorPicker = document.getElementById("color-picker");
const redButton = document.getElementById("red");
const greenButton = document.getElementById("green");
const blueButton = document.getElementById("blue");
const yellowButton = document.getElementById("yellow");
const purpleButton = document.getElementById("purple");
const pinkButton = document.getElementById("pink");
const cyanButton = document.getElementById("cyan");
const whiteButton = document.getElementById("white");
const blackButton = document.getElementById("black");
const orangeButton = document.getElementById("orange");
const brownButton = document.getElementById("brown");
const currentColorSpan = document.getElementById("current-color-span");
const toggleGridToggle = document.getElementById("toggle-grid");
const placePixelButton = document.getElementById("place-pixel");
const saveCanvasButton = document.getElementById("save-canvas");
colorPicker.value = localStorage.getItem("currentColor") || "#000000";
let currentColor = colorPicker.value;
let showGrid = toggleGridToggle.checked;
currentColorSpan.style.backgroundColor = currentColor;
let canvasState = [];
let confirmPlacePixel = false;
let previewPixel = null;
const setCurrentColor = (color, isColorPicker = false) => {
const hexColor = isColorPicker ? color : rgbToHex(color);
currentColor = hexColor;
currentColorSpan.style.backgroundColor = hexColor;
localStorage.setItem("currentColor", hexColor);
};
const handleColorPicker = () => {
colorPicker.addEventListener("input", (event) => {
setCurrentColor(event.target.value, true);
});
redButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(redButton));
});
greenButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(greenButton));
});
blueButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(blueButton));
});
yellowButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(yellowButton));
});
purpleButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(purpleButton));
});
pinkButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(pinkButton));
});
cyanButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(cyanButton));
});
whiteButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(whiteButton));
});
blackButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(blackButton));
});
orangeButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(orangeButton));
});
brownButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(brownButton));
});
};
const fetchCanvasState = async () => {
fetch(canvasEndpoint)
.then((response) => response.json())
.then((data) => {
canvasState = data;
drawCanvasState(data);
})
.catch((error) => {
alert("Error fetching canvas state from server. Please try again later.");
});
};
const drawCanvasState = (canvasState) => {
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const index = y * WIDTH + x;
const color = u32ToHex(canvasState[index]);
ctx.fillStyle = color;
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
};
const checkIfCanPlacePixel = () => {
const now = Date.now();
return now - lastPixelTime >= pixelCooldown;
};
const setLastPixelTime = () => {
lastPixelTime = Date.now();
localStorage.setItem("lastPixelTime", lastPixelTime.toString());
};
const handlePlacePixel = (pixelData) => {
if (!checkIfCanPlacePixel()) {
alert("You can't place a pixel yet");
return;
}
socket.emit("place-pixel", pixelData);
const index = pixelData.y * WIDTH + pixelData.x;
canvasState[index] = pixelData.color;
setLastPixelTime();
pixelData = null;
};
canvas.addEventListener("click", (event) => {
const rect = canvas.getBoundingClientRect();
const x = Math.floor((event.clientX - rect.left) / pixelSize);
const y = Math.floor((event.clientY - rect.top) / pixelSize);
const color = hexToU32(currentColor);
const oldPreviewPixel = previewPixel;
const update = { x, y, color };
if (confirmPlacePixel) {
handlePlacePixel(update);
} else {
previewPixel = update;
}
if (previewPixel) {
if (oldPreviewPixel) {
ctx.clearRect(
oldPreviewPixel.x * pixelSize,
oldPreviewPixel.y * pixelSize,
pixelSize,
pixelSize
);
}
ctx.fillStyle = `rgba(${(color >> 16) & 0xff}, ${(color >> 8) & 0xff}, ${
color & 0xff
}, 0.5)`;
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
});
const removePreviewPixel = () => {
previewPixel = null;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCanvasState(canvasState);
};
const drawGrid = () => {
ctx.strokeStyle = "#000";
for (let x = 0; x < canvas.width; x += pixelSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (let y = 0; y < canvas.height; y += pixelSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
};
const handleToggleGrid = () => {
toggleGridToggle.addEventListener("change", (event) => {
showGrid = event.target.checked;
localStorage.setItem("showGrid", showGrid);
if (showGrid) {
drawGrid();
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCanvasState(canvasState);
}
});
};
window.onkeydown = (event) => {
// on enter (keycode 13 is enter)
if (event.keyCode === 13) {
if (previewPixel) {
handlePlacePixel(previewPixel);
removePreviewPixel();
}
}
};
placePixelButton.addEventListener("click", () => {
if (previewPixel) {
handlePlacePixel(previewPixel);
removePreviewPixel();
}
});
saveCanvasButton.addEventListener("click", () => {
const a = document.createElement("a");
a.href = canvas.toDataURL();
a.download = "canvas.png";
a.click();
});
handleColorPicker();
handleToggleGrid();
export const handleSocketEvents = (_socket) => {
socket = _socket;
socket.on("connect", () => {
fetchCanvasState();
});
socket.on("pixel-updated", (update) => {
const color = u32ToHex(update.color);
ctx.fillStyle = color;
ctx.fillRect(
update.x * pixelSize,
update.y * pixelSize,
pixelSize,
pixelSize
);
const index = update.y * WIDTH + update.x;
canvasState[index] = update.color;
});
};

View File

@@ -1,139 +0,0 @@
import * as three from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
let scene, camera, renderer, character, mixer, clock;
let walkAction, idleAction;
let moving = false;
const keys = {}
let boxes = [];
const container = document.getElementById('challenge');
const onWindowResize = () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
const onKeyDown = (e) => {
keys[e.code] = true;
}
const onKeyUp = (e) => {
keys[e.code] = false;
}
const init = () => {
scene = new three.Scene();
camera = new three.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
renderer = new three.WebGLRenderer({ antialias: true});
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
clock = new three.Clock();
// Adjust light positions and intensity
const light = new three.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7.5);
light.castShadow = true;
scene.add(light);
for (let i = 0; i < 10; i++) {
const box = new three.Mesh(
new three.BoxGeometry(1, 1, 1),
new three.MeshStandardMaterial({ color: 0x00ff00 })
);
box.position.set(Math.random() * 10 - 5, 0.5, Math.random() * 10 - 5);
box.castShadow = true;
scene.add(box);
boxes.push(box);
}
const loader = new GLTFLoader();
loader.load('src/Astronaut.glb',(gltf) => {
character = gltf.scene;
scene.add(character);
mixer = new three.AnimationMixer(character);
gltf.animations.forEach((clip) => {
if (clip.name === 'CharacterArmature|Walk') {
walkAction = mixer.clipAction(clip);
}
if (clip.name === 'CharacterArmature|Idle') {
idleAction = mixer.clipAction(clip);
}
});
if (walkAction) walkAction.play();
if (idleAction) idleAction.play();
character.position.set(-10, 0, -60);
character.rotation.y = Math.PI;
})
//blue sky
scene.background = new three.Color(0x87ceeb);
camera.position.set(20, 10, 10)
camera.lookAt(0, 10, 0)
window.addEventListener('resize', onWindowResize, false);
document.addEventListener('keydown', onKeyDown, false);
document.addEventListener('keyup', onKeyUp, false);
}
const animate = () => {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (mixer) mixer.update(delta);
if (character) {
if (keys['KeyW']) {
character.position.z -= 0.1;
character.rotation.y = Math.PI; // North
}
if (keys['KeyS']) {
character.position.z += 0.1;
character.rotation.y = 0; // South
}
if (keys['KeyA']) {
character.position.x -= 0.1;
character.rotation.y = Math.PI / 2; // West
}
if (keys['KeyD']) {
character.position.x += 0.1;
character.rotation.y = -Math.PI / 2; // East
}
// check if any key is pressed
if (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']) {
moving = true;
} else {
moving = false;
}
// Play walking animation if moving
if (mixer) {
if (walkAction && idleAction) {
walkAction.enabled = moving;
idleAction.enabled = !moving;
}
}
}
renderer.render(scene, camera);
}
const threejs = import.meta.env.VITE_THREE_JS === "true";
if (threejs) {
init();
animate();
}

View File

@@ -1,12 +0,0 @@
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
export const pixelSize = 10;
export const pixelCooldown = 10 * 1000; // 10 seconds
export const canvasEndpoint = isDebug
? "http://localhost:3000/canvas/"
: "/canvas/";
export const checkEndpoint = isDebug
? "http://localhost:3000/check/"
: "/check/";
export const WIDTH = 500;
export const HEIGHT = 500;

View File

@@ -1,20 +0,0 @@
import { pixelCooldown } from "./constants.js";
const countdownDiv = document.getElementById("countdown");
export const updateCountdown = () => {
setInterval(() => {
const lastPixelTime = parseInt(
localStorage.getItem("lastPixelTime") || "0"
);
const now = Date.now();
const timeLeft = Math.max(0, pixelCooldown - (now - lastPixelTime));
if (timeLeft > 0) {
countdownDiv.textContent = `You can place a pixel in ${Math.ceil(
timeLeft / 1000
)} seconds`;
} else {
countdownDiv.textContent = "You can place a pixel now";
}
}, 1000);
};

View File

@@ -0,0 +1,11 @@
import { WIDTH } from "./constants.js";
export const getPixelIndex = (x, y) => y * WIDTH + x;
export const setPixel = (state, x, y, color) => {
state[getPixelIndex(x, y)] = color;
};
export const getPixel = (state, x, y) => {
return state[getPixelIndex(x, y)];
};

View File

@@ -6,13 +6,16 @@ export const hexToU32 = (color) => {
return parseInt(color.slice(1), 16);
};
export const getColorFromElementCSS = (element) => {
return window.getComputedStyle(element).backgroundColor;
};
export const rgbToHex = (rgbProperty) => {
const rgb = rgbProperty.match(/\d+/g);
return `#${rgb
.map((x) => parseInt(x).toString(16).padStart(2, "0"))
.join("")}`;
};
export const u32ToRGBA = (color, alpha) => {
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};

View File

@@ -0,0 +1,4 @@
export const PIXEL_SIZE = 1;
export const PIXEL_COOLDOWN = 10 * 1000;
export const WIDTH = 500;
export const HEIGHT = 500;

View File

@@ -0,0 +1,9 @@
import { PIXEL_COOLDOWN } from "./constants.js";
export const canPlacePixel = (lastPlacementTime) => {
return Date.now() - lastPlacementTime >= PIXEL_COOLDOWN;
};
export const timeRemaining = (lastPlacementTime) => {
return Math.max(0, PIXEL_COOLDOWN - (Date.now() - lastPlacementTime));
};

View File

@@ -0,0 +1,19 @@
import { WIDTH, HEIGHT } from "./constants.js";
export const getCanvasCoords = (event, canvas) => {
const rect = canvas.getBoundingClientRect();
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.min(
Math.max(Math.floor((clientX - rect.left) * scaleX), 0),
WIDTH - 1,
),
y: Math.min(
Math.max(Math.floor((clientY - rect.top) * scaleY), 0),
HEIGHT - 1,
),
};
};

View File

@@ -0,0 +1,5 @@
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
const CHECK_ENDPOINT = isDebug ? "http://localhost:3000/check/" : "/check/";
export const checkServer = () => fetch(CHECK_ENDPOINT);

View File

@@ -0,0 +1,72 @@
import io from "socket.io-client";
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
const transport = import.meta.env.VITE_TRANSPORT || "socketio";
const createSocketIoTransport = () => {
const url = isDebug ? "ws://localhost:3000" : undefined;
const socket = url ? io(url) : io({ transports: ["websocket"] });
return {
on: (event, handler) => socket.on(event, handler),
emit: (event, data) => socket.emit(event, data),
};
};
const createWebSocketTransport = () => {
const handlers = {};
const wsUrl = isDebug
? "ws://localhost:3000/ws"
: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
const on = (event, handler) => {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(handler);
};
const emit = (event, data) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: event, ...data }));
};
const dispatch = (event, ...args) => {
(handlers[event] || []).forEach((handler) => handler(...args));
};
ws.addEventListener("open", () => dispatch("connect"));
ws.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
const pixels = new Uint32Array(event.data);
dispatch("canvas_state", Array.from(pixels));
return;
}
const message = JSON.parse(event.data);
switch (message.type) {
case "pixel-updated":
dispatch("pixel-updated", message);
break;
case "current_soldiers":
dispatch("current_soldiers", message.count);
break;
case "error":
dispatch("error", message.message);
break;
}
});
ws.addEventListener("close", () => dispatch("disconnect"));
return { on, emit };
};
export const createSocketConnection = () => {
if (transport === "websocket") {
return createWebSocketTransport();
}
return createSocketIoTransport();
};

View File

@@ -1,58 +1,88 @@
import { connectToWS } from "./socket.js";
import "./canvas.js";
import "./counter.js";
import { updateCountdown } from "./counter.js";
import { checkEndpoint, pixelSize } from "./constants.js";
import { handleSocketEvents } from "./canvas.js";
import "./challenge.js"
import { createSocketConnection } from "./infrastructure/socket-client.js";
import { checkServer } from "./infrastructure/api.js";
import { createCanvasRenderer } from "./ui/canvas-renderer.js";
import { createColorPalette } from "./ui/color-palette.js";
import { createPixelPlacer } from "./ui/pixel-placer.js";
import { startCooldownDisplay } from "./ui/cooldown-display.js";
import { createCanvasViewport } from "./ui/canvas-viewport.js";
import { setPixel } from "./domain/canvas-state.js";
import { getCanvasCoords } from "./domain/coords.js";
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
const currentSoldiersSpan = document.getElementById("current-soldiers");
let coords = [];
const canvas = document.getElementById("canvas");
const canvasEl = document.getElementById("canvas");
const coordsText = document.getElementById("coords");
const ogCanvasStyle = canvas.style.display;
canvas.style.display = "none";
const currentSoldiersSpan = document.getElementById("current-soldiers");
const statusEl = document.getElementById("connection-status");
fetch(checkEndpoint)
.then((response) => {
if (response.ok) {
const socket = connectToWS();
const savedDisplay = canvasEl.style.display;
canvasEl.style.display = "none";
socket.on("connect", () => {
canvas.style.display = ogCanvasStyle;
console.log("connect");
});
let canvasState = [];
socket.on("error", (message) => {
alert(message);
});
const renderer = createCanvasRenderer(canvasEl);
const palette = createColorPalette();
socket.on("current_soldiers", (currentSoldiers) => {
currentSoldiersSpan.textContent = currentSoldiers;
});
handleSocketEvents(socket);
requestAnimationFrame(updateCountdown);
window.addEventListener("mousemove", (event) => {
// get coordinates of the mouse inside the canvas
const rect = canvas.getBoundingClientRect();
const x = Math.floor((event.clientX - rect.left) / pixelSize);
const y = Math.floor((event.clientY - rect.top) / pixelSize);
coords = [x, y];
startCooldownDisplay();
createCanvasViewport(canvasEl);
canvasEl.addEventListener("mousemove", (event) => {
const { x, y } = getCanvasCoords(event, canvasEl);
coordsText.textContent = `${x}, ${y}`;
});
} else {
throw new Error("Can't connect to the server");
}
})
.catch((error) => {
alert(
"You have already connected to the server from another tab or window. Please close the other tab or window and refresh this page."
);
document.getElementById("save-canvas").addEventListener("click", () => {
const a = document.createElement("a");
a.href = renderer.toDataURL();
a.download = "canvas.png";
a.click();
});
const showStatus = (message, isError) => {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = isError
? "text-red-500 text-sm"
: "text-green-500 text-sm";
};
checkServer()
.then((response) => {
if (!response.ok) throw new Error("Server unavailable");
const socket = createSocketConnection();
socket.on("connect", () => {
canvasEl.style.display = savedDisplay;
showStatus("Connected", false);
});
socket.on("canvas_state", (data) => {
canvasState = data;
renderer.drawState(data);
});
socket.on("error", (message) => showStatus(message, true));
socket.on("current_soldiers", (count) => {
currentSoldiersSpan.textContent = count;
});
socket.on("pixel-updated", (update) => {
renderer.drawPixel(update.x, update.y, update.color);
setPixel(canvasState, update.x, update.y, update.color);
});
socket.on("disconnect", () => {
showStatus("Disconnected — reconnecting...", true);
});
createPixelPlacer({
canvas: canvasEl,
renderer,
getColor: palette.getColor,
getState: () => canvasState,
socket,
});
})
.catch(() => {
showStatus("Cannot connect to server", true);
});

View File

@@ -1,21 +0,0 @@
import io from "socket.io-client";
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = window.location.host;
let socket;
export const connectToWS = () => {
if (isDebug) {
socket = io("ws://localhost:3000");
} else {
socket = io(`${wsProtocol}//${wsHost}`, {
transports: ["websocket"],
});
}
return socket;
};
export default socket;

View File

@@ -2,20 +2,17 @@
@tailwind components;
@tailwind utilities;
@layer base {
.rainbow-border {
position: relative;
display: inline-block;
background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet);
}
.rainbow-border::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: white;
z-index: -1; /* Place behind the content */
.canvas-viewport {
overflow: auto;
width: min(92vw, 70vh);
height: min(92vw, 70vh);
border: 2px solid #94a3b8;
border-radius: 4px;
cursor: crosshair;
}
#canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
display: block;
}

View File

@@ -0,0 +1,39 @@
import { u32ToHex, u32ToRGBA } from "../domain/color.js";
import { WIDTH, HEIGHT } from "../domain/constants.js";
export const createCanvasRenderer = (canvas) => {
const ctx = canvas.getContext("2d");
const drawState = (state) => {
const imageData = ctx.createImageData(WIDTH, HEIGHT);
const data = imageData.data;
for (let i = 0; i < state.length; i++) {
const color = state[i];
const offset = i * 4;
data[offset] = (color >> 16) & 0xff;
data[offset + 1] = (color >> 8) & 0xff;
data[offset + 2] = color & 0xff;
data[offset + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
};
const drawPixel = (x, y, colorU32) => {
ctx.fillStyle = u32ToHex(colorU32);
ctx.fillRect(x, y, 1, 1);
};
const drawPreview = (x, y, colorU32) => {
ctx.fillStyle = u32ToRGBA(colorU32, 0.5);
ctx.fillRect(x, y, 1, 1);
};
const restorePixel = (state, x, y) => {
ctx.fillStyle = u32ToHex(state[y * WIDTH + x]);
ctx.fillRect(x, y, 1, 1);
};
const toDataURL = () => canvas.toDataURL();
return { drawState, drawPixel, drawPreview, restorePixel, toDataURL };
};

View File

@@ -0,0 +1,78 @@
const MIN_ZOOM = 1;
const MAX_ZOOM = 40;
export const createCanvasViewport = (canvas) => {
const viewport = canvas.parentElement;
const zoomLabel = document.getElementById("zoom-level");
let zoom = 1;
let baseSize = viewport.clientWidth;
const applyZoom = () => {
const size = baseSize * zoom;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
if (zoomLabel) zoomLabel.textContent = `${zoom.toFixed(1)}x`;
};
const setZoom = (newZoom, centerX, centerY) => {
const oldZoom = zoom;
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom));
if (zoom === oldZoom) return;
if (centerX !== undefined && centerY !== undefined) {
const ratio = zoom / oldZoom;
viewport.scrollLeft = (viewport.scrollLeft + centerX) * ratio - centerX;
viewport.scrollTop = (viewport.scrollTop + centerY) * ratio - centerY;
}
applyZoom();
};
viewport.addEventListener("wheel", (event) => {
event.preventDefault();
const factor = event.deltaY > 0 ? 0.8 : 1.25;
const rect = viewport.getBoundingClientRect();
setZoom(zoom * factor, event.clientX - rect.left, event.clientY - rect.top);
});
let lastPinchDist = 0;
viewport.addEventListener("touchmove", (event) => {
if (event.touches.length !== 2) return;
event.preventDefault();
const dist = Math.hypot(
event.touches[0].clientX - event.touches[1].clientX,
event.touches[0].clientY - event.touches[1].clientY,
);
if (lastPinchDist > 0) {
const midX =
(event.touches[0].clientX + event.touches[1].clientX) / 2 -
viewport.getBoundingClientRect().left;
const midY =
(event.touches[0].clientY + event.touches[1].clientY) / 2 -
viewport.getBoundingClientRect().top;
setZoom(zoom * (dist / lastPinchDist), midX, midY);
}
lastPinchDist = dist;
});
viewport.addEventListener("touchend", () => {
lastPinchDist = 0;
});
document.getElementById("zoom-in")?.addEventListener("click", () => {
setZoom(zoom * 1.5);
applyZoom();
});
document.getElementById("zoom-out")?.addEventListener("click", () => {
setZoom(zoom / 1.5);
applyZoom();
});
window.addEventListener("resize", () => {
baseSize = viewport.clientWidth;
applyZoom();
});
applyZoom();
};

View File

@@ -0,0 +1,53 @@
import { rgbToHex } from "../domain/color.js";
const PALETTE_IDS = [
"red",
"green",
"blue",
"yellow",
"purple",
"pink",
"cyan",
"white",
"black",
"orange",
"brown",
];
export const createColorPalette = () => {
const colorPicker = document.getElementById("color-picker");
const currentColorSpan = document.getElementById("current-color-span");
let activeButton = null;
let currentColor = localStorage.getItem("currentColor") || "#000000";
colorPicker.value = currentColor;
currentColorSpan.style.backgroundColor = currentColor;
const setColor = (hex, button) => {
currentColor = hex;
currentColorSpan.textContent = hex;
currentColorSpan.style.backgroundColor = hex;
localStorage.setItem("currentColor", hex);
if (activeButton)
activeButton.classList.remove("ring-2", "ring-offset-2", "ring-cyan-400");
if (button) {
button.classList.add("ring-2", "ring-offset-2", "ring-cyan-400");
activeButton = button;
}
};
colorPicker.addEventListener("input", (e) => setColor(e.target.value, null));
for (const id of PALETTE_IDS) {
const button = document.getElementById(id);
button.addEventListener("click", () => {
setColor(
rgbToHex(window.getComputedStyle(button).backgroundColor),
button,
);
});
}
return { getColor: () => currentColor };
};

View File

@@ -0,0 +1,16 @@
import { timeRemaining } from "../domain/cooldown.js";
export const startCooldownDisplay = () => {
const countdownDiv = document.getElementById("countdown");
setInterval(() => {
const lastPlacementTime = parseInt(
localStorage.getItem("lastPixelTime") || "0",
);
const remaining = timeRemaining(lastPlacementTime);
countdownDiv.textContent =
remaining > 0
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
: "You can place a pixel now";
}, 1000);
};

View File

@@ -0,0 +1,57 @@
import { hexToU32 } from "../domain/color.js";
import { canPlacePixel } from "../domain/cooldown.js";
import { setPixel } from "../domain/canvas-state.js";
import { getCanvasCoords } from "../domain/coords.js";
export const createPixelPlacer = ({
canvas,
renderer,
getColor,
getState,
socket,
}) => {
let previewPixel = null;
const place = (update) => {
const lastPixelTime = parseInt(
localStorage.getItem("lastPixelTime") || "0",
);
if (!canPlacePixel(lastPixelTime)) return;
socket.emit("place-pixel", update);
setPixel(getState(), update.x, update.y, update.color);
localStorage.setItem("lastPixelTime", Date.now().toString());
};
const clearPreview = () => {
if (!previewPixel) return;
renderer.restorePixel(getState(), previewPixel.x, previewPixel.y);
previewPixel = null;
};
const confirmPlacement = () => {
if (!previewPixel) return;
place(previewPixel);
clearPreview();
};
const selectPixel = (event) => {
const { x, y } = getCanvasCoords(event, canvas);
const color = hexToU32(getColor());
clearPreview();
previewPixel = { x, y, color };
renderer.drawPreview(x, y, color);
};
canvas.addEventListener("click", selectPixel);
window.addEventListener("keydown", (event) => {
if (event.key === "Enter") confirmPlacement();
if (event.key === "Escape") clearPreview();
});
document
.getElementById("place-pixel")
.addEventListener("click", confirmPlacement);
};

View File

@@ -1,251 +0,0 @@
use std::{
collections::{HashMap, HashSet},
net::SocketAddr,
sync::{Arc, Mutex},
};
use axum::{routing::get, Extension, Json, Router};
use chrono::{DateTime, Duration, Utc};
use memory_stats::memory_stats;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use socketioxide::{
extract::{Bin, Data, SocketRef},
SocketIo,
};
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use tower_http::{
cors::{Any, CorsLayer},
services::ServeDir,
};
use tracing::info;
use tracing_subscriber::FmtSubscriber;
type Canvas = Arc<Mutex<Vec<u32>>>;
type LastUpdate = Arc<Mutex<HashMap<String, DateTime<Utc>>>>;
type Soldiers = Arc<Mutex<HashSet<String>>>;
#[derive(Serialize, Deserialize, Debug)]
struct PixelUpdate {
x: u32,
y: u32,
color: u32,
}
async fn on_connect(
socket: SocketRef,
canvas: Canvas,
last_update: LastUpdate,
soldiers: Soldiers,
) {
info!("Socket connected: {:?} {:?}", socket.ns(), socket.id,);
let socket_id = socket.id.to_string();
{
let mut soldiers = soldiers.lock().unwrap();
soldiers.insert(socket_id.clone());
let soldiers_num = soldiers.len();
socket.emit("current_soldiers", &soldiers_num).ok();
socket
.broadcast()
.emit("current_soldiers", &soldiers_num)
.ok();
}
info!("Memory usage after connection and before cloning state: ");
print_memory_usage();
let canvas_clone = canvas.clone();
let last_update_clone = last_update.clone();
info!("Memory usage after connection and after cloning state: ");
print_memory_usage();
socket.on(
"place-pixel",
move |socket: SocketRef, Data::<PixelUpdate>(update), Bin(_bin)| {
let socket_id = socket.id.to_string();
let last_update = last_update_clone.clone();
let canvas = canvas_clone.clone();
{
let now = Utc::now();
let mut last_update = last_update.lock().unwrap();
if let Some(&last_time) = last_update.get(&socket_id) {
if now < last_time + Duration::seconds(10) {
let _ = socket.emit(
"error",
Value::String("You can only place one pixel per minute".to_string()),
);
return;
}
}
last_update.insert(socket_id.clone(), now);
}
info!("Received pixel update: {:?}", update);
let mut canvas = canvas.lock().unwrap();
canvas[update.y as usize * 500 + update.x as usize] = update.color;
info!("Emitting pixel update");
socket.emit("pixel-updated", &update).ok(); // Send to the user who placed the pixel
socket.broadcast().emit("pixel-updated", &update).ok(); // Send to all other users
info!("Memory usage after pixel update: ");
print_memory_usage();
},
);
socket.on_disconnect(move |socket: SocketRef| {
info!("Socket disconnected: {:?}", socket.id);
info!("Memory usage after disconnection: ");
print_memory_usage();
let socket_id = socket.id.to_string();
{
let mut soldiers = soldiers.lock().unwrap();
soldiers.remove(&socket_id.to_string());
info!("Soldiers: {:?}", soldiers.len());
let soldiers_num = soldiers.len();
socket
.broadcast()
.emit("current_soldiers", &soldiers_num)
.ok();
}
{
let mut last_update = last_update.lock().unwrap();
last_update.remove(&socket_id);
info!("Last update: {:?}", last_update.len());
}
info!("Memory usage after disconnection and cleanup: ");
print_memory_usage();
});
}
fn print_memory_usage() {
if let Some(usage) = memory_stats() {
info!(
"Current physical memory usage: {} MB",
usage.physical_mem as f64 / 1024.0 / 1024.0
);
info!(
"Current virtual memory usage: {} MB",
usage.virtual_mem as f64 / 1024.0 / 1024.0
);
}
}
async fn get_canvas_state(Extension(canvas): Extension<Canvas>) -> Json<Vec<u32>> {
let canvas = canvas.lock().unwrap();
Json(canvas.clone())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv::dotenv().ok();
tracing::subscriber::set_global_default(FmtSubscriber::new())?;
let rate_governor = Arc::new(
GovernorConfigBuilder::default()
.burst_size(10)
.per_second(10)
.finish()
.unwrap(),
);
let governor = rate_governor.limiter().clone();
let interval = std::time::Duration::from_secs(1);
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
governor.retain_recent();
}
});
let (layer, io) = SocketIo::new_layer();
let canvas = Arc::new(Mutex::new(vec![0xFFFFFFFF; 500 * 500]));
let last_update = Arc::new(Mutex::new(HashMap::new()));
let soldiers = Arc::new(Mutex::new(HashSet::new()));
let used_memory = std::mem::size_of_val(&*canvas)
+ std::mem::size_of_val(&*last_update)
+ std::mem::size_of_val(&*soldiers);
info!(
"Used memory of state: {} bytes, {} KB, {} MB",
used_memory,
used_memory / 1024,
used_memory / 1024 / 1024
);
info!("Memory usage after state setup and before socket.io setup: ");
print_memory_usage();
let canvas_for_socket = canvas.clone();
io.ns("/", move |socket: SocketRef| {
tokio::spawn(on_connect(
socket,
canvas_for_socket.clone(),
last_update.clone(),
soldiers.clone(),
));
});
info!("Memory usage after socket.io setup: ");
print_memory_usage();
let address = std::env::var("ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string());
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("Invalid port");
let enable_cors = std::env::var("ENABLE_CORS").unwrap_or_else(|_| "true".to_string()) == "true";
let cors = if enable_cors {
Some(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
)
} else {
None
};
let app = Router::new()
.route("/canvas/", get(get_canvas_state))
.route("/check/", get(|| async { "OK" }))
.fallback_service(ServeDir::new("dist/"))
.layer(GovernorLayer {
config: rate_governor,
})
.layer(layer)
.layer(Extension(canvas.clone()));
let app = if let Some(cors) = cors {
app.layer(cors)
} else {
app
};
let server_address = format!("{}:{}", address, 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>(),
)
.await?;
Ok(())
}