Backend Architecture
February 10, 2026 · View on GitHub
Intended audience: OSS developers working on the server.
This document does not cover: Frontend implementation or deployment.
Technology Stack
Framework: Fastify Language: TypeScript (compiled to JavaScript ES modules) Runtime: Node.js 18+ Package manager: npm
Rationale:
- Fastify: Fast, low-overhead HTTP framework with schema validation
- TypeScript: Type safety for config structures
- ES modules: Modern JavaScript, tree-shaking, better performance
Directory Structure
server/
├── package.json # Dependencies, scripts
├── tsconfig.json # TypeScript config (ES modules)
├── jest.config.js # Test configuration
│
├── data/ # Runtime data directory
│ ├── configs/ # Preset library (*.json)
│ └── latest_dsp_state.json # Recovery cache
│
└── src/
├── index.ts # Entry point
├── app.ts # Fastify app setup
├── logger.ts # Pino logger
├── configPaths.ts # Path resolution
│
├── routes/ # HTTP endpoint handlers
│ ├── health.ts # GET /health
│ ├── version.ts # GET /api/version
│ ├── config.ts # GET/PUT /api/state/latest
│ └── configs.ts # GET/PUT /api/configs/*
│
├── services/ # Business logic
│ ├── configStore.ts # Single-file persistence
│ ├── configsLibrary.ts # Preset library management
│ └── shellExec.ts # Safe shell execution (unused in prod)
│
├── types/
│ └── errors.ts # AppError class, error codes
│
└── __tests__/ # Integration tests
└── routes.test.ts
Application Lifecycle
Startup (server/src/index.ts)
1. Resolve data directory paths (configPaths.ts)
2. Create Fastify app (app.ts)
3. Register routes
4. Start HTTP server (port 3000 default)
5. Log "Server listening on http://0.0.0.0:3000"
Shutdown
1. Graceful shutdown (SIGTERM/SIGINT)
2. Close HTTP server (drain connections)
3. Exit process
HTTP Routes
Health Check
Endpoint: GET /health
Handler: server/src/routes/health.ts
Response:
{
"status": "ok"
}
Use case: Kubernetes liveness probe, systemd health check
Version
Endpoint: GET /api/version
Handler: server/src/routes/version.ts
Response:
{
"version": "1.0.0"
}
Source: package.json version field
Settings
Endpoint: GET /api/settings
Handler: server/src/routes/settings.ts
Response:
{
"camillaControlWsUrl": "ws://localhost:1234",
"camillaSpectrumWsUrl": "ws://localhost:1235"
}
Source: Environment variables CAMILLA_CONTROL_WS_URL and CAMILLA_SPECTRUM_WS_URL
Use case:
- Client fetches connection defaults on first load (when localStorage is empty)
- Enables production deployments to preconfigure CamillaDSP connection parameters
- Returns
nullfor URLs if environment variables are not set
Recovery Cache
Endpoint: GET /api/state/latest
Handler: server/src/routes/config.ts
Response: Full CamillaDSP config JSON
Use case:
- Client reconnects after crash/reload
- Retrieves last-applied DSP state
- Avoids starting from empty config
Endpoint: PUT /api/state/latest
Handler: server/src/routes/config.ts
Request body: Full CamillaDSP config JSON
Response:
{
"message": "Config saved successfully"
}
Use case:
- Client uploads config to DSP
- Writes-through to backend for recovery
- Non-fatal if fails (best-effort)
File: server/data/latest_dsp_state.json
Preset Library
Endpoint: GET /api/configs
Handler: server/src/routes/configs.ts
Response: Array of ConfigMetadata objects
[
{
"id": "autoeq--headphones--sennheiser-hd-6xx",
"configName": "Sennheiser HD 6XX",
"file": "autoeq/headphones/Sennheiser HD 6XX.json",
"mtimeMs": 1234567890123,
"size": 2048,
"presetType": "eq",
"source": "autoeq",
"readOnly": true,
"category": "headphones",
"manufacturer": "Sennheiser",
"model": "HD 6XX"
},
{
"id": "my-custom-eq",
"configName": "My Custom EQ",
"file": "My Custom EQ.json",
"mtimeMs": 1234567890456,
"size": 512,
"presetType": "pipeline",
"source": "user",
"readOnly": false
}
]
ID generation: Kebab-case from relative path + filename
Name extraction: From JSON name (EQ presets) or configName (pipeline configs)
Includes:
- User-created presets (top-level + subdirs)
- AutoEQ library presets (imported from AutoEQ database)
Endpoint: GET /api/configs/:id
Handler: server/src/routes/configs.ts
Response: Pipeline-config JSON (see pipelineConfigMapping.ts)
Use case: Load preset into UI
Note: AutoEQ presets (EqPresetV1 format) are converted on-the-fly to PipelineConfig format (legacy filterArray) for client compatibility.
Endpoint: PUT /api/configs/:id
Handler: server/src/routes/configs.ts
Request body: Pipeline-config JSON
Response:
{
"success": true
}
Use case: Save current EQ/pipeline as preset
Protection: Returns 403 if ID matches a read-only preset (e.g., AutoEQ library)
File: server/data/configs/<id>.json (converted from kebab-case ID)
Services
ConfigStore (configStore.ts)
Purpose: Atomic single-file persistence
Key functions:
readConfig(filepath)
- Reads JSON file
- Returns parsed object
- Throws if file missing or invalid JSON
writeConfig(filepath, data)
- Writes JSON file atomically
- Write to temp file → rename (atomic on POSIX)
- Creates parent directories if needed
- Size limit: 1MB
Atomic write flow:
1. Write to <filepath>.tmp
2. fsync() to flush kernel buffers
3. Rename <filepath>.tmp → <filepath>
4. Delete temp file on error
Error handling:
- Throws
AppErrorwith error code - Caller decides retry/fallback
ConfigsLibrary (configsLibrary.ts)
Purpose: Preset library management (user presets + AutoEQ library)
Key functions:
listConfigs()
- Returns metadata for all presets (user + AutoEQ)
- Performance optimization: Uses
autoeq/index.jsonmanifest for fast AutoEQ lookups (O(1) read vs recursive scan) - Falls back to full recursive scan if manifest missing
- Excludes
index.jsonfiles from scan results - Sorts by name
getConfig(id)
- Reads preset file by ID
- Returns
PipelineConfigformat - AutoEQ conversion: EqPresetV1 files are converted on-the-fly to PipelineConfig (legacy filterArray format)
saveConfig(id, data)
- Writes preset to
server/data/configs/<id>.json - Read-only enforcement: Returns 403 if ID matches a preset with
readOnly: true - Uses atomic write via
configStore.writeConfig()
ID normalization:
- Kebab-case from relative path + filename
- Subdirectories encoded:
/→-- - Example:
"autoeq/headphones/Sennheiser HD 6XX.json"→"autoeq--headphones--sennheiser-hd-6xx"
AutoEQ Library:
- Pre-imported headphone/IEM EQ presets from AutoEQ database
- Located in
server/data/configs/autoeq/<category>/ - Marked as
readOnly: true(cannot be overwritten) - Format: EqPresetV1 (converted to PipelineConfig on load)
- Manifest file (
autoeq/index.json) enables fast cold-start (no filesystem scan)
ShellExec (shellExec.ts)
Purpose: Safe shell command execution
Features:
- Timeout protection (default: 10s)
- Output size limits (default: 1MB)
- Command whitelist (optional)
- Abort on timeout/size exceeded
Status: Not used in production (included for future tooling)
Error Handling
AppError Class (types/errors.ts)
Purpose: Structured error responses
Shape:
class AppError extends Error {
code: ErrorCode;
statusCode: number;
details?: any;
}
Error codes:
ERR_CONFIG_NOT_FOUND(404)ERR_CONFIG_INVALID_JSON(400)ERR_CONFIG_READ_FAILED(500)ERR_CONFIG_WRITE_FAILED(500)ERR_CONFIG_TOO_LARGE(413)
Fastify error handler:
- Catches
AppErrorinstances - Returns JSON with
{ error, code, details? } - Logs stack trace to console
Configuration
Environment Variables
SERVER_PORT (default: 3000)
- HTTP server port
SERVER_HOST (default: 0.0.0.0)
- Network interface binding
- Use
127.0.0.1for localhost-only (behind reverse proxy)
CONFIG_DIR (default: ./data)
- Base data directory (relative to WorkingDirectory)
CONFIGS_DIR (optional, default: <CONFIG_DIR>/configs)
- Preset library directory
LOG_LEVEL (default: info)
- Pino log level (error, warn, info, debug, trace)
NODE_ENV (default: development)
production→ serves built frontenddevelopment→ API only (Vite serves frontend)- In development: server loads
.envfromserver/.env(preferred) or repo root
CAMILLA_CONTROL_WS_URL (optional)
- Default CamillaDSP control WebSocket URL
- Example:
ws://localhost:1234 - Returned via
GET /api/settingsfor client auto-configuration
CAMILLA_SPECTRUM_WS_URL (optional)
- Default CamillaDSP spectrum WebSocket URL
- Example:
ws://localhost:1235 - Returned via
GET /api/settingsfor client auto-configuration
SERVER_READ_ONLY (default: false)
- When
true, blocks write operations (PUT/POST/PATCH/DELETE) to/api/* - CamillaDSP control from browser remains fully functional (WebSocket bypass)
- Use for safer public exposure
Path Resolution (configPaths.ts)
Functions:
getConfigDir()
- Returns absolute path to config directory
- Checks
CONFIG_DIRenv, fallback to./data
getConfigsDir()
- Returns
<dataDir>/configs
getLatestStatePath()
- Returns
<dataDir>/latest_dsp_state.json
Ensures:
- Paths are absolute
- Directories exist (creates if missing)
Static File Serving
Production Mode (NODE_ENV=production)
Behavior:
- Serves
server/dist/client/*at/ - SPA fallback: All non-API routes →
index.html - API routes prioritized (
/api/*,/health)
Build artifact:
npm run buildcopiesclient/dist/→server/dist/client/
Development Mode
Behavior:
- Does NOT serve frontend (Vite dev server on 5173)
- API only on port 3000
- CORS not needed (Vite proxies
/api/*to 3000)
Logging
Logger: Pino (server/src/logger.ts)
Log levels:
error- Unrecoverable errorswarn- Recoverable errors (e.g., file read failures)info- Startup, shutdown, HTTP requestsdebug- Detailed operation traces
Output:
- Console (colorized with pino-pretty in dev)
- JSON structured logs in production
Request logging:
- Fastify plugin logs all requests
- Format:
GET /api/configs 200 12ms
Testing
Framework: Jest
Test types:
- Unit tests for services (
configStore,configsLibrary) - Integration tests for routes (
routes.test.ts)
Run tests: npm test (from server directory)
Coverage: npm run test:coverage
Security
Input Validation
JSON schema validation:
- Fastify validates request bodies against schemas
- Rejects invalid payloads with 400
File path sanitization:
- Config IDs must be alphanumeric + hyphens
- Prevents directory traversal (
../../etc/passwd)
Size limits:
- Request body: 1MB max (Fastify default)
- Config files: 1MB max (enforced by configStore)
No Authentication/Authorization
Current state: No auth layer
Rationale:
- Designed for trusted LAN deployment
- User responsible for network security
Future: Add auth middleware if needed (JWT, basic auth)
Performance
File I/O
- Async operations only (no blocking)
- Atomic writes prevent corruption
- No caching (configs small, infrequent access)
HTTP
- Keep-alive enabled (default)
- Compression (gzip/brotli) via Fastify plugin
Startup
- Lazy directory creation (on-demand)
- No config preloading (load on first request)
Failure Modes
Data Directory Missing
- Creates on startup (via
configPaths.ts) - Logs warning if creation fails
- Server starts anyway (degraded)
Config File Corrupted
- Read error logged
- Returns 500 to client
- Does not crash server
Disk Full
- Write fails with
ERR_CONFIG_WRITE_FAILED - Temp file cleaned up
- Returns 500 to client
Deployment
Production Build
npm run build
Output:
server/dist/- Compiled TypeScriptserver/dist/client/- Built frontend
Start Production Server
npm run start
Runs: node server/dist/index.js
Environment:
- Set
NODE_ENV=production - Set
SERVER_PORTif needed - Set
DATA_DIRfor custom path
Extension Points
Add New Endpoint
- Create route handler in
server/src/routes/ - Register in
server/src/app.ts - Add tests in
server/src/__tests__/
Add New Service
- Create service in
server/src/services/ - Export functions, document contracts
- Add unit tests
Add Persistence Layer
- Extend
configStore.tswith new file format - Or add new service (e.g.,
database.tsfor SQLite)
Next Steps
- Frontend - Client architecture
- State and Persistence - State ownership model
- Extension Points - Detailed extension guidance
- Runtime Topology - Process diagram