11 KiB
Spacedrive Server Architecture
Overview
The Spacedrive Server is a production-ready HTTP server that embeds the Spacedrive daemon and serves the web interface. It's designed for headless deployments, NAS systems, and container environments.
Design Principles
- Embedded Daemon - No separate process management needed
- Single Binary - Web assets bundled via
include_dirwhen built with--features assets - Platform Abstraction - Uses same
@sd/interfaceas Tauri, with web-specific platform impl - Security First - HTTP Basic Auth for all endpoints (except health check)
- Container Native - Docker-first design with distroless runtime image
Components
1. HTTP Server (apps/server/src/main.rs)
Built with Axum, provides:
GET /health- Health check (no auth required)POST /rpc- JSON-RPC proxy to daemon Unix socketGET /*- Static asset serving (SPA fallback to index.html)
Flow:
Browser → HTTP Request → Axum Router → Basic Auth Middleware → Handler
↓
┌─────────────────────────┴────────┐
↓ ↓
Static Assets RPC Proxy
(serve from ↓
ASSETS_DIR) Unix Socket → Daemon
2. Embedded Daemon
Unlike Tauri (which spawns sd-daemon as a child process), the server runs the daemon in-process:
tokio::spawn(async move {
sd_core::infra::daemon::bootstrap::start_default_server(
socket_path,
data_dir,
enable_p2p,
).await
});
Benefits:
- Single container image
- Simplified lifecycle management
- Shared memory space (more efficient)
- Graceful shutdown via
tokio::select!
Daemon lifecycle:
- Check if socket already exists (reuse existing daemon)
- If not, spawn daemon in background task
- Wait for socket file to appear (max 3s)
- Return handle for graceful shutdown
3. Web Client (apps/web/)
Minimal React app using @sd/interface:
// apps/web/src/main.tsx
<PlatformProvider platform={webPlatform}>
<Explorer />
</PlatformProvider>
Platform implementation:
// apps/web/src/platform.ts
export const platform: Platform = {
platform: "web",
openLink(url) { window.open(url) },
confirm(msg, cb) { cb(window.confirm(msg)) },
// No native file pickers, daemon control, etc.
};
Build process:
- Vite bundles React app →
apps/web/dist/ build.rsrunspnpm buildbefore compiling serverinclude_dir!macro embedsdist/into binary at compile time- Axum serves embedded files from memory
4. RPC Proxy
Browsers can't connect to Unix sockets, so the server proxies:
Browser Server Daemon
│ │ │
│ POST /rpc │ │
├────────────────────>│ │
│ (JSON-RPC) │ Unix Socket Write │
│ ├────────────────────────>│
│ │ │
│ │ Unix Socket Read │
│ │<────────────────────────┤
│ 200 OK │ │
│<────────────────────┤ │
│ (JSON-RPC result) │ │
Implementation:
async fn daemon_rpc(
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut stream = UnixStream::connect(&state.socket_path).await?;
stream.write_all(format!("{}\n", serde_json::to_string(&payload)?).as_bytes()).await?;
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response).await?;
Ok(Json(serde_json::from_str(&response)?))
}
Comparison: Server vs Tauri vs CLI
| Aspect | Server | Tauri | CLI |
|---|---|---|---|
| Process Model | Embedded daemon | Spawned daemon | Connects to daemon |
| UI | Web (React in browser) | WebView (React) | Terminal (TUI) |
| Daemon Communication | Unix socket (proxied) | Unix socket (direct) | Unix socket (direct) |
| Platform Abstraction | platform: "web" |
platform: "tauri" |
N/A |
| Access Model | Remote (HTTP) | Local only | Local only |
| Auth | HTTP Basic Auth | Not needed | Not needed |
| Deployment | Docker, systemd | App bundle | Binary |
Authentication Flow
1. Browser makes request without auth
↓
2. basic_auth middleware checks state.auth
↓
3. If empty → allow (auth disabled)
If populated → require Basic Auth header
↓
4. Extract credentials from Authorization header
↓
5. Compare with state.auth HashMap
↓
6. Match → proceed to handler
No match → 401 Unauthorized
Security considerations:
- Credentials stored in memory as
SecStr(zeroed on drop) - Basic Auth over HTTPS recommended for production
- Socket file has filesystem permissions (only accessible to server user)
Docker Architecture
Multi-stage Build
# Stage 1: Builder (Debian + Rust + Node)
FROM debian:bookworm-slim AS builder
RUN install Rust, Node, pnpm
COPY workspace
RUN pnpm build (web)
RUN cargo build --release --features assets
# Stage 2: Runtime (Distroless)
FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=builder /build/target/release/sd-server
ENTRYPOINT ["/usr/bin/sd-server"]
Benefits:
- Small image - Distroless base (~50MB vs ~1GB for full Debian)
- Secure - No shell, no package manager, minimal attack surface
- Fast - Cached layers for dependencies
- Reproducible - Locked versions via
Cargo.lockandpnpm-lock.yaml
Volume Mounts
volumes:
- spacedrive-data:/data # Persistent library data
- /mnt/storage:/storage:ro # Optional: Read-only media access
Data layout:
/data/
├── daemon/
│ └── daemon.sock # Unix socket for RPC
├── libraries/
│ └── *.sdlibrary/ # SQLite databases
│ ├── library.db
│ └── sidecars/ # Thumbnails, previews
├── logs/
│ ├── daemon.log
│ └── indexing.log
└── current_library_id.txt
Development vs Production
Development Mode
# Terminal 1: Web dev server (hot reload)
cd apps/web
pnpm dev # → http://localhost:3000
# Terminal 2: API server
cargo run -p sd-server
# → http://localhost:8080
# Vite proxies /rpc to 8080
Workflow:
- Edit React components → Vite hot reloads
- Edit server code →
cargo runrebuilds - No need to rebuild web assets during development
Production Build
# Build with bundled assets
cargo build --release -p sd-server --features assets
# Single binary contains:
# - Axum HTTP server
# - Embedded daemon
# - Bundled web UI (React app)
Deployment:
./target/release/sd-server \
--data-dir /var/lib/spacedrive \
--port 8080
Platform Abstraction
Both Tauri and Web use @sd/interface, but with different platform implementations:
Tauri Platform (apps/tauri/src/platform.ts)
{
platform: "tauri",
openDirectoryPickerDialog: async () => open({ directory: true }),
revealFile: async (path) => invoke("reveal_file", { path }),
getCurrentLibraryId: async () => invoke("get_current_library_id"),
getDaemonStatus: async () => invoke("get_daemon_status"),
// Full native capabilities
}
Web Platform (apps/web/src/platform.ts)
{
platform: "web",
openLink: (url) => window.open(url),
confirm: (msg, cb) => cb(window.confirm(msg)),
// Minimal browser-only capabilities
}
Interface components adapt:
function FilePickerButton() {
const platform = usePlatform();
if (platform.platform === "tauri") {
// Show native picker button
return <button onClick={platform.openDirectoryPickerDialog}>Pick</button>;
} else {
// Web: no native picker, show manual path input
return <input type="text" placeholder="Enter path..." />;
}
}
Error Handling
HTTP Errors
async fn daemon_rpc(...) -> Result<Json<Value>, (StatusCode, String)> {
let stream = UnixStream::connect(&socket_path)
.await
.map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Daemon not available: {}", e)))?;
// ...
}
Responses:
503 Service Unavailable- Daemon not running400 Bad Request- Invalid JSON500 Internal Server Error- RPC failed401 Unauthorized- Auth failed
Daemon Errors
Daemon errors are passed through RPC response:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32603,
"message": "Library not found"
}
}
Performance Considerations
- Static Assets - Served from memory (embedded via
include_dir) - Socket Pooling - Each RPC request opens new socket (TODO: connection pool)
- Async I/O - Tokio runtime handles concurrent requests
- Graceful Shutdown - Waits for in-flight requests before terminating
Future Enhancements
- WebSocket Support - Real-time event streaming (vs polling)
- HTTPS - TLS termination (currently expects reverse proxy)
- Connection Pool - Reuse Unix sockets for RPC
- Multi-tenancy - Separate libraries per user
- SSE Events - Server-sent events for daemon notifications
Security Model
Trust Boundaries:
Internet ←[TLS]→ Reverse Proxy ←[HTTP+Auth]→ Server ←[Unix Socket]→ Daemon
() () () () ()
Assumptions:
- Server runs on trusted network OR behind reverse proxy with TLS
- Unix socket accessible only to server process (filesystem permissions)
- HTTP Basic Auth sufficient for home/NAS use
- For public internet: Use nginx/Caddy with Let's Encrypt
Monitoring
Health Check:
curl http://localhost:8080/health
# → "OK"
Logs:
# Docker
docker logs spacedrive -f
# Systemd
journalctl -u spacedrive -f
# Native
RUST_LOG=debug ./sd-server
Metrics: (TODO)
- Request count/latency
- Daemon socket errors
- Active connections
Related Documentation
- README.md - Setup and usage
- ../../docs/core/architecture.md - Core VDFS design
- ../tauri/DAEMON_SETUP.md - Tauri daemon integration