Worker API reference
The format-worker-runtime message protocol — request/response/progress shapes, transferables, and the four async UI states.
Status
apps/faceless-photo-site's worker implementation (workers/protocol.ts
and its zod schemas) is being built in a parallel track of this same
change. This page documents the message shapes the frozen
format-worker-runtime spec
commits every implementation to, so importer/UI work can proceed against a
stable contract. Once that module merges, the tables below are meant to be
regenerated straight from its Zod schemas (the same "generated so docs can't
drift from code" treatment the migration guides'
fidelity tables get) rather than hand-maintained.
Why a worker at all
Opening, saving, validating, and generating a preview for a package all
involve parsing zip bytes and hashing blobs — potentially hundreds of
megabytes of them. None of that may run on the main thread: the spec requires
that parsing and hashing happen in a dedicated Web Worker, with progress
events streaming to the UI, so the tab stays interactive on a large .fpd.
Message discipline
Communication is a hand-rolled, Zod-validated postMessage protocol —
deliberately not Comlink or any RPC-transparency library, per design
decision D6 of the native-file-format change. Three message families, each
a discriminated union matched exhaustively with ts-pattern on both sides:
// Main thread → worker. `requestId` correlates responses/progress back to
// the call site; `type` is the command.
type WorkerRequest =
| { type: "open"; requestId: string; source: PackageSource }
| { type: "save"; requestId: string; doc: DocV2; heads: HeadsSnapshot }
| { type: "validate"; requestId: string; source: PackageSource }
| { type: "generate-preview"; requestId: string; doc: DocV2 };
// Worker → main thread, terminal per request.
type WorkerResponse =
| { type: "open-result"; requestId: string; result: OpenOutcome }
| { type: "save-result"; requestId: string; result: SaveOutcome }
| { type: "validate-result"; requestId: string; result: ValidationOutcome }
| { type: "preview-result"; requestId: string; result: PreviewOutcome }
// Never a silent drop, never an unhandled rejection: a message that fails
// schema validation on EITHER side becomes this, always.
| { type: "protocol-error"; requestId: string | null; reason: string };
// Worker → main thread, zero or more per request before its terminal response.
type WorkerProgress =
| { type: "progress"; requestId: string; phase: string; fraction: number };Every terminal *Outcome is itself a discriminated union with explicit
success and failure variants (an open that hits a corrupt zip, a failed
validation, or an unsupported container version each get their own named
variant — see the container gate and the
validator's finding kinds) — never a boolean ok flag
plus a loosely-typed error?: string.
Malformed messages never crash a side
Per the spec's "Malformed message rejected" scenario: if a message arrives on
either side and fails its Zod schema, the receiver produces a typed
protocol-error response — not a thrown exception that could take down the
worker, and not a silently ignored message that leaves the caller hanging
forever.
Pixel data crosses as transferables
Any raw pixel buffer crossing the worker boundary (a decoded layer, a
generated preview) is transferred, not structurally cloned — postMessage's
second-argument transfer list, so a multi-megapixel buffer moves at
constant cost instead of being copied.
OPFS-backed workspace persistence
The worker maintains a content store backed by the browser's Origin Private
File System (OPFS) — the same ContentStore port
content-store defines, with an OPFS-backed
implementation alongside the in-memory and Node/filesystem ones. Reopening a
document already seen in the same browser profile reuses blobs already
present in OPFS rather than rewriting them, so a second open of the same
package is measurably faster than the first. The UI is expected to surface
storage usage (navigator.storage.estimate()) and offer garbage collection
when unreachable blobs accumulate — the same mark-sweep GC
content-store implements for any backend.
Editor integration: Open/Save render all four async states
The editor's Open (.fpd file or directory picker) and Save/Save-As actions
each render every Resource<T> state exhaustively — this repo's house rule
for any async-triggering control, and this is where it's most visible: a
multi-hundred-MB package is exactly the case where "it looks like nothing
happened" would be the worst possible UX.
| State | What the UI shows |
|---|---|
idle | The default Open/Save control, enabled. |
loading | A visible progress indicator (driven by streamed WorkerProgress events) and the control disabled — no double-submission. |
ready | An explicit success affordance: for Open, the document is visibly loaded; for Save, the browser has received a real downloadable .fpd and the UI confirms it, not just "the promise resolved." |
error | A banner naming the typed failure reason (corrupt zip, failed validation, unsupported container version, …) plus an inline retry/clear — and critically, the previous editor state is preserved, an open failure never leaves the editor blank. |
Saving always validates the assembled package (via
@faceless-photolib/package-format's validatePackage) before handing it
to the browser as a download — an invalid .fpd is never a state this
protocol can produce.