src/server/README.md in the repository — edit it there.Server — HTTP + WebSocket Host for the UI
The server is the network shell that lets the Angular UI use the slicer without bundling it into the browser. Two transports, one purpose.
HTTP for the bytes that don't fit in JSON; WebSocket for everything else.
Why two transports
A slicing job has two very different kinds of payload:
- The mesh. Tens of MB of binary data, occasional, streamable.
- The control flow. Small JSON messages (scene ops, slice requests, log lines, progress markers) — frequent, bidirectional, low-latency.
Trying to push 50 MB of STL through a WebSocket frame is fighting the medium. Trying to relay layer-by-layer progress over HTTP polling is fighting it the other way. The server uses each for what it's good at:
The contract
- One WebSocket per session, ephemeral state. The scene engine state lives in
ws_session::handle_ws_session's stack. Disconnect = scene gone. Persistence is the UI's problem. - All bytes go via HTTP. Mesh uploads are multipart POSTs; G-code downloads are streamed responses. The WebSocket carries only JSON.
- Workplate UUID and file UUID are distinct.
POST /api/uploadreturns{ ruuid, ofids: [file_uuid, ...] }. The workplateruuidis the slicing job key; eachfile_uuidinofidsis one uploaded model the user can place in the scene. Slicing references files byfile_uuidexclusively — there is no "request UUID is also the file ID" shortcut. - The on-disk extension is the format hint. Uploads are stored as
{file_uuid}.{ext}(e.g..stl,.obj,.3mf); the slicer dispatches to the right loader from the extension. The wire protocol carries noformatfield. - Every log line and phase timing is mirrored to the WS client. The
WsLoggerplugs into the sameProcessLoggerinterface the CLI uses, so verbose output is identical across both transports. - The protocol is the source of truth, not the transport. Message shapes live in
crate::ws_protocolwithJsonSchemaderives so the UI generates types from them. - An uploaded file is immutable; everything about a plate is not. See below — it is the whole of this server's caching policy.
What may be cached, and for how long
Two answers, and the line between them is whether the URL can ever return different bytes.
GET /api/file/:file_uuidis immutable. A second upload of the same model is a secondfile_uuid, so the id is the validator: the response carries anETag,private, max-age=1y, immutable, and answersIf-None-Matchwith a 304. This is what makes switching back to a plate instant rather than a fresh download of every model on it.privatebecause these are one person's models and a shared proxy has no business holding them.- Everything describing a plate is
no-store—/api/workplates/:uuidand/api/request/:uuid. Both are small, both change whenever anyone touches the plate, and a cached copy of either is how one person's arrangement quietly replaces another's.
The Angular bundle is served under the same rule, decided by static_cache_control: a filename carrying a build hash is kept for a year, and everything else revalidates. The two that must revalidate are index.html, which names the current build, and scene_engine_bg.wasm, which ships under a fixed name beside content-hashed glue — a browser reusing a stale copy of either pairs the wrong halves of the app together, and the failure surfaces deep inside generated code.
Two people on one plate
A hosted slicer has more than one client, and nothing stopped two of them editing the same plate with neither told. PUT /api/workplates/:uuid — and an upload that joins an existing plate — now broadcasts a WorkplateChange to every open WebSocket session, which relays it as ServerMessage::WorkplateChanged.
Two properties are the whole design:
- It is advisory. The client offers a refresh; it never takes the scene away from whoever typed second. Ignoring it leaves last-writer-wins exactly as it was — which is what
workplatealready promised. - Nobody hears their own writes. A client identifies itself with an opaque, self-assigned
X-Client-Idheader and repeats it as?client=on/ws; a session skips a change whose author matches. Without that, every save would bounce straight back as "someone changed this plate" and the prompt would mean nothing. A client that sends no id is simply told about everything.
Anatomy
Endpoint catalog
HTTP
| Route | Method | Purpose |
|---|---|---|
/api/upload | POST | Multipart STL / OBJ / 3MF upload → { ruuid, ofids: [file_uuid, ...] } |
/api/file/:file_uuid | GET | Stream an uploaded model back (used by the viewer on cold reload) |
/api/request/:ruuid | GET | Workplate metadata: { ruuid, status, has_gcode, ofids: [{file_uuid, name}] } |
/api/download/:ruuid | GET | Stream the G-code produced for that workplate |
/api/config | GET | Return the fully-merged AppConfig |
/api/config | PATCH | Update one config key, persist to slicer.toml |
/api/profiles | GET | Return the user-owned profile library (printers/filaments/processes/labels) |
/api/profiles/:kind | PUT | Replace one category from a JSON array, persist to profiles.toml |
/api/profiles/export | GET | Download the library as TOML (?format=bundle ZIP, ?format=toml single file) |
/* (anything else) | GET | Serve the Angular bundle from ui_dir |
POST /api/upload enforces a 500 MB file-size cap, only reads the field named "file" from the multipart payload, and preserves the original extension on disk so the slicer can pick the right loader without anyone having to re-encode the format hint.
WebSocket (/ws)
ClientMessage (browser → server):
type | Purpose |
|---|---|
Slice | Slice a workplate: request_uuid + scene: [SceneObjectSliceDto] + settings |
ListSessions | Replay history from the SQLite database |
Reset | Acknowledge / clear current state |
Scene | Apply a list of SceneOpDtos to the session's SceneState |
SceneSnapshot | Request the current scene state |
ServerMessage (server → browser):
type | Purpose |
|---|---|
Connected | Sent once after handshake; carries CARGO_PKG_VERSION |
Log | Mirrored info / debug / warn log line |
PhaseMarker | Pipeline phase start / end with elapsed time |
Progress | current_layer / total_layers during slicing |
SliceComplete | Layer count + download_url |
SessionsList | Result of ListSessions |
SceneState | Snapshot reply for Scene / SceneSnapshot |
ProfilesChanged | Someone wrote the profile library; refetch that category |
WorkplateChanged | Someone wrote a plate; the client offers a refresh |
Error | Fatal error during processing |
Full schemas: src/ws_protocol.rs.
Lifecycle of one slice job
The G-code result table — written on every slice, never read to skip one
handle_slice hashes three things into an FNV-1a key (compute_slice_cache_key):
- the resolved
SlicingParams, viaSlicingParams::cache_fingerprint, - the ordered scene DTOs — file id, part index, transform, support paint —
- and
crate::version::VERSION.
The freshly-generated .gcode is written into the gcode_cache table (migration m20250201_000002) under that key via Db::put_cached_gcode.
Every slice request runs the full pipeline. The table is written, never read back to decide whether to slice. There is an explicit comment at the would-be lookup site saying so.
Db::get_cached_gcode still exists and is still tested (history_tests.rs) — it is a working DB primitive, just not called from the slice path. Do not wire it back into handle_slice without discussing it first. The skip-on-hit behaviour it used to drive was deliberately removed, because a "cached" response could silently serve stale G-code for a scene the engine had since changed how it slices.
What the key still has to get right, for whatever does eventually read it:
- Object order is part of the key. It affects the merged mesh and therefore the output. Do not sort.
- The engine version is part of the key, so output changes across releases can never be confused for the same result.
part_indexis part of the key, because a file id alone is ambiguous for a multi-part 3MF — see scene.support_paintis part of the key for the same reason: a repaint has to produce a different key, or a reader would match G-code sliced before the stroke.- The embedded thumbnail PNG is excluded.
cache_fingerprintdropsthumbnail_png_base64— a camera-derived preview captured fresh from the viewer on every slice — so its volatile bytes never enter the key. The thumbnail settings (thumbnail_view/theme/size/ …) stay in. - FNV-1a, not
DefaultHasher. The latter's output is not stability-guaranteed across runs or platforms.
The desktop (Tauri) runtime keeps an in-memory mirror with the same key and the same write-only discipline, in ui-desktop/src-tauri/src/bridge/runtime_bridge.rs.
Threading model
- Actix-web drives the HTTP and WebSocket I/O on its async runtime.
- Slicing is CPU-bound, so
handle_slicedispatches the actual work totokio::task::spawn_blocking.WsLogger::send_logusesSender::blocking_sendfor that reason — it would deadlock on an async runtime thread. - Database access also goes through
spawn_blockingbecauserusqliteis synchronous. TheMutex<Connection>insideDatabaseserialises all writes.
Configuration
The serve command's flags map onto [server] keys in slicer.toml:
| Flag | TOML key | Default |
|---|---|---|
--port | server.port | 5201 |
--host | server.host | 0.0.0.0 |
--ui-dir | server.ui_dir | ./ui/dist/slicer-ui/browser |
--work-dir | server.work_dir | system temp directory |
The serve command writes a default slicer.toml to the user config directory if none exists, then loads it via config::load_and_merge_config.
What this module deliberately does not do
- No authentication. This is a localhost-only dev tool. Binding to
0.0.0.0is opt-in and intended for LAN testing. - No multi-tenant scenes. Each WS connection has its own scene state; there is no shared workspace.
- No persistence of scene state. The SQLite DB tracks completed jobs for re-download, not in-flight scenes.
- No slicing logic. The server is a transport adapter around
core::process_meshandscene::SceneState.
The API describes itself
GET /api/openapi.json is an OpenAPI 3.1 document built from the engine's own types on every request, and GET /api/docs renders it as a browsable reference. Two things about it are load-bearing:
- Bodies come from
schemars, only the route table is hand-written. (openapi.rs) A spec file maintained by hand is a second description of the API that nothing keeps honest — correct the day it is written, quietly wrong from the first handler change. The bodies are where the hundreds of fields are and where drift actually hurts; the routes are a dozen entries beside the router, androutes_match_routerfails when the two disagree. Field descriptions, defaults and ranges carry through from the Rust doc comments, which is why the reference reads like the settings panel. - The viewer is self-contained. (docs.html) No CDN, no bundled viewer library. A self-hosted slicer is routinely a box on a workshop network with no route out, and a reference page that goes blank without internet is worse than no page at all.
schemars spells its subschemas $defs; OpenAPI wants components/schemas. hoist moves them and rewrites every $ref, and a test asserts no $defs survives — one that did would render as a dead link.
Slicing is deliberately absent from the paths: it runs over /ws, because a slice streams progress for as long as it takes. The socket's vocabulary is published as the ClientMessage / ServerMessage schemas instead.
See also
- mod.rs —
ServeCommand, app wiring, route registration - handlers.rs — HTTP upload / download / config handlers
- ws_session.rs — WebSocket loop,
WsLogger, slice driver - ../ws_protocol.rs —
ClientMessage,ServerMessage,SceneOpDto, schema-derived UI types - ../db/README.md — SQLite session tracking
- ../scene/README.md —
SceneStatelifecycle - ../config/README.md — TOML config loading