Early docs These pages are very early. Structure and tone are still being worked out, so anything here may change drastically.
Skip to content
Rendered from 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 ​

  1. 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.
  2. All bytes go via HTTP. Mesh uploads are multipart POSTs; G-code downloads are streamed responses. The WebSocket carries only JSON.
  3. Workplate UUID and file UUID are distinct. POST /api/upload returns { ruuid, ofids: [file_uuid, ...] }. The workplate ruuid is the slicing job key; each file_uuid in ofids is one uploaded model the user can place in the scene. Slicing references files by file_uuid exclusively — there is no "request UUID is also the file ID" shortcut.
  4. 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 no format field.
  5. Every log line and phase timing is mirrored to the WS client. The WsLogger plugs into the same ProcessLogger interface the CLI uses, so verbose output is identical across both transports.
  6. The protocol is the source of truth, not the transport. Message shapes live in crate::ws_protocol with JsonSchema derives so the UI generates types from them.
  7. 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_uuid is immutable. A second upload of the same model is a second file_uuid, so the id is the validator: the response carries an ETag, private, max-age=1y, immutable, and answers If-None-Match with a 304. This is what makes switching back to a plate instant rather than a fresh download of every model on it. private because these are one person's models and a shared proxy has no business holding them.
  • Everything describing a plate is no-store — /api/workplates/:uuid and /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 workplate already promised.
  • Nobody hears their own writes. A client identifies itself with an opaque, self-assigned X-Client-Id header 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 ​

RouteMethodPurpose
/api/uploadPOSTMultipart STL / OBJ / 3MF upload → { ruuid, ofids: [file_uuid, ...] }
/api/file/:file_uuidGETStream an uploaded model back (used by the viewer on cold reload)
/api/request/:ruuidGETWorkplate metadata: { ruuid, status, has_gcode, ofids: [{file_uuid, name}] }
/api/download/:ruuidGETStream the G-code produced for that workplate
/api/configGETReturn the fully-merged AppConfig
/api/configPATCHUpdate one config key, persist to slicer.toml
/api/profilesGETReturn the user-owned profile library (printers/filaments/processes/labels)
/api/profiles/:kindPUTReplace one category from a JSON array, persist to profiles.toml
/api/profiles/exportGETDownload the library as TOML (?format=bundle ZIP, ?format=toml single file)
/* (anything else)GETServe 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):

typePurpose
SliceSlice a workplate: request_uuid + scene: [SceneObjectSliceDto] + settings
ListSessionsReplay history from the SQLite database
ResetAcknowledge / clear current state
SceneApply a list of SceneOpDtos to the session's SceneState
SceneSnapshotRequest the current scene state

ServerMessage (server → browser):

typePurpose
ConnectedSent once after handshake; carries CARGO_PKG_VERSION
LogMirrored info / debug / warn log line
PhaseMarkerPipeline phase start / end with elapsed time
Progresscurrent_layer / total_layers during slicing
SliceCompleteLayer count + download_url
SessionsListResult of ListSessions
SceneStateSnapshot reply for Scene / SceneSnapshot
ProfilesChangedSomeone wrote the profile library; refetch that category
WorkplateChangedSomeone wrote a plate; the client offers a refresh
ErrorFatal 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):

  1. the resolved SlicingParams, via SlicingParams::cache_fingerprint,
  2. the ordered scene DTOs — file id, part index, transform, support paint —
  3. 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_index is part of the key, because a file id alone is ambiguous for a multi-part 3MF — see scene.
  • support_paint is 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_fingerprint drops thumbnail_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_slice dispatches the actual work to tokio::task::spawn_blocking. WsLogger::send_log uses Sender::blocking_send for that reason — it would deadlock on an async runtime thread.
  • Database access also goes through spawn_blocking because rusqlite is synchronous. The Mutex<Connection> inside Database serialises all writes.

Configuration ​

The serve command's flags map onto [server] keys in slicer.toml:

FlagTOML keyDefault
--portserver.port5201
--hostserver.host0.0.0.0
--ui-dirserver.ui_dir./ui/dist/slicer-ui/browser
--work-dirserver.work_dirsystem 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.0 is 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_mesh and scene::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, and routes_match_router fails 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 ​