faceless-photolib

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.

StateWhat the UI shows
idleThe default Open/Save control, enabled.
loadingA visible progress indicator (driven by streamed WorkerProgress events) and the control disabled — no double-submission.
readyAn 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."
errorA 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.