Guide · @staticflux/worker · @staticflux/document · @staticflux/stage · @staticflux/blob · @staticflux/surface
Editors: pipeline, documents, stage, blobs & raster
The ports a local-first editor with a GPU canvas needs, as framework packages: a pipeline between editor and renderer, documents split into parts, a composed stage with per-actor undo, and a content-addressed store underneath. Instaflow drove them; none of them knows about it.
#The editor ↔ render pipeline
@staticflux/worker/pipeline is a set of topology-neutral pipes. Every pipe runs on three media with the same code: local (both ends in one worker), shared (SharedArrayBuffer + Atomics, isolated pages) and message (each side mirrors the region, changes are posted in order). selectMedium({ topology }) picks one, so one worker or two is a boot setting, not a code path.
Deltas are sequenced: each record carries base → version, and the reader applies them in order. A gap, an epoch change or a missing blob moves the reader to resyncing and asks the writer for a snapshot; the writer answers in onResnapshot. The renderer draws what has arrived — it never waits on, or guesses past, a hole.
import { acceptPeer, attachDeltaStream, createDeltaStream, createPeerBroker, defineDeltaStream, definePeer,
isRegionEndpoint, selectMedium, workerHost } from '@staticflux/worker/pipeline';
export const EditorRender = definePeer('app/editor-render');
export const Scene = defineDeltaStream({ id: 'app/scene', capacityBytes: 1 << 20 });
const SceneEndpoint = 'app/scene-endpoint' as const;
// editor worker: owns the stream, re-sends its endpoint on every (re)connection
const deltas = createDeltaStream(Scene, { medium: selectMedium({ topology: boot.topology }) });
if (!deltas.ok) return deltas;
deltas.value.onResnapshot(() => deltas.value.snapshot({ version, payload: encodeScene() }));
deltas.value.append({ version: ++version, payload: encodeChange(change) });
acceptPeer(runtime.link, EditorRender).onConnect((link) => {
const endpoint = deltas.value.endpoint();
if (endpoint.ok) link.send(SceneEndpoint, endpoint.value);
});
// render worker: attach, then apply up to the version the view-state frame shows
acceptPeer(runtime.link, EditorRender).on(SceneEndpoint, async (endpoint) => {
if (!isRegionEndpoint(endpoint)) return;
const attached = await attachDeltaStream(Scene, endpoint); // awaiting-snapshot → live ⇄ resyncing
if (attached.ok) reader = attached.value;
});
// every vsync: apply what arrived, up to the version the view-state frame shows
reader?.apply(applyToScene, { upTo: viewFrame.docVersion });
// main thread: the broker wires the two workers and re-wires after a respawn
createPeerBroker().connect(EditorRender, workerHost(editorWorker), workerHost(renderWorker));
- View-state frames:
defineCell/createCell/attachCell— a seqlock cell;read()returns a private stable copy, torn reads are detected and retried. - Input ring: lossless SPMC record rings (
defineRecordRing);@staticflux/surface/input-ringputs forwarded pointer, pen and key events on one (inputRingSink) and each reader coalesces on its own (drainInputRing({ coalesce })). - Blobs by digest:
createBlobSender/createBlobReceivermove large payloads once; a delta thatrefsa blob never applies before it. - Brokered peer channels:
createPeerBrokerconnects two workers directly;onConnectruns on every (re)connection, so a respawned reader gets a fresh endpoint and asks for a snapshot. - Handles (
createHandleLedger,Owned<T>) account for every transferred platform handle; leaks are reported and closed with the scope.
#Documents v2: one CRDT doc per page
The v1 engine — an ECS world over a Yjs-backed DocumentCRDT — still runs Studio. Large editors use v2 (@staticflux/document/v2): an asset is split into parts, one CRDT document per page plus a small document-level part, each behind the engine-independent DocumentCRDTv2 port. The Loro engine (@staticflux/document/loro-crdt) is the implementation, worker only, its Wasm in its own budget; adaptToV1 moves a v1 engine onto it.
createDocumentRepo opens assets once (single-flight), loads parts lazily, persists snapshots plus one append-only commit log through the DocumentStore port, and compacts incrementally. Edits go through the repo: commit(meta, { [part]: ops }), moveAcross for a cross-page move (one record, both parts), and one commit per gesture.
import { createDocumentRepo, defineComponent, field } from '@staticflux/document/v2';
import { createLoroEngine } from '@staticflux/document/loro-crdt';
// versioned components: ids and fields are persisted format — add, or bump with a migration
export const Spatial = defineComponent('spatial', { x: 'f64', y: 'f64', rotation: 'f64' }, { defaults: { rotation: 0 } });
export const Style = defineComponent('style', { fill: 'color', blend: field.enum(['normal', 'multiply']) });
const repo = createDocumentRepo({ store, engine: createLoroEngine() }); // store: any DocumentStore
const doc = await repo.open('as_42', signal);
if (!doc.ok) return doc;
const drag = doc.value.gesture(meta, 'page:p1'); // stage every frame …
if (!drag.ok) return drag;
for (const x of pointerXs) drag.value.stage([{ op: 'set', node: 'card', path: 'spatial.x', value: x }]);
drag.value.commit(); // … commit once: one record, one undo step
Two materialized views feed the pipeline. @staticflux/document/render-projection turns part changes into a compact binary scene (defineRenderProjection, createRenderProjector in the editor worker, createSceneStore in the render worker, both checking the projection hash). @staticflux/document/spatial-index keeps an rbush index in step with the part (bindSpatialIndex) for hit tests and marquee selection. Components generate JSON Schema (componentJsonSchema) and milestones are canonical parts (RFC 8785).
#The stage and per-actor undo
@staticflux/stage composes what an editor, renderer, inspector or agent reads: a layer stack (session strongest, then proposal layers, then local — the asset), variant, inherit and reference arcs, final paths and locks. read(node) returns values with provenance, and explain(node, path) says which opinion won and which were overridden or ignored. Locks are checked before a write, never after.
The undo service keeps one stack per actor — human, agent or flow, attributed from the capability run context. An undo over someone else’s later overwrite is not applied silently: it comes back as a typed conflict, and the user picks overwrite or keep-theirs.
import { createPartsDocument, createSessionLayer, createStage, createUndoService, stageWriteGuard } from '@staticflux/stage';
const root = createPartsDocument('as_42');
root.attach(page, { id: 'page:p1' }); // each loaded page part
const session = createSessionLayer();
const stage = createStage({ root, session, assets });
session.set('card', 'spatial.x', 50); // a session opinion: wins, never persisted
stage.explain('card', 'spatial.x'); // { value, winner, overridden, ignored }
const undo = createUndoService({ doc, guard: stageWriteGuard(stage) });
undo.commit(alice, 'Move card', { 'page:p1': [{ op: 'set', node: 'card', path: 'spatial.x', value: 10 }] });
const outcome = await undo.undo(alice, signal); // empty | conflict | applied
if (outcome.ok && outcome.value.kind === 'conflict') showConflicts(outcome.value.conflicts);
#Content-addressed blobs
@staticflux/blob is a local-first workspace store: immutable blobs by SHA-256 (whole up to 8 MiB, 4 MiB chunks above, deduped), a few small heads swapped atomically (temp file + move() under a Web Lock, compare-and-swap on a generation), an append-only journal that is the only ack point, and a leader tab that owns the writers. It knows nothing about documents; DocumentRepo stores its parts and records through it.
import { createWorkspaceStore, openOpfsDirectory } from '@staticflux/blob';
const dir = await openOpfsDirectory(['workspaces', workspaceId]); // SF-BLOB-009 without OPFS
if (!dir.ok) return dir;
const workspace = createWorkspaceStore(dir.value, { lockPrefix: workspaceId, peer });
if (!workspace.ok) return workspace; // Web Locks are required for heads
const ref = await workspace.value.blobs.put(file.stream(), { mime: file.type, signal });
if (!ref.ok) return ref; // SF-BLOB-001 on quota
const head = await workspace.value.blobs.get(ref.value, { range: { start: 0, end: 4096 }, signal });
createFolderMirrormirrors the workspace to a folder the user picked — batched, never on the ack path, read-only (not failing) when permission lapses.createZipWriter/openZipwrite and read bundles; deflate-raw blobs go in byte for byte, andputEncodedadopts them on import.collectGarbagesweeps from the catalog (never a directory listing) with a grace period;requestPersistenceandcheckQuotacover storage pressure.
#GPU raster, text input and images
@staticflux/surface/raster is the port for tiled GPU rendering in render and export workers. The app supplies a RasterBackend (it only draws); the framework decides which tiles and when: visible tiles first, a 2 ms in-frame raster budget, the rest on an idle lane, stale tiles and snapshots instead of holes, and no raster before warmUp. createGpuDevice recovers a lost device on a fresh adapter, and every tile cache reports its bytes to the memory ledger.
import { createTiledRenderer, workerSchedulerLane } from '@staticflux/surface/raster';
const renderer = createTiledRenderer({
backend, // any RasterBackend (passes runRasterBackendContract)
lane: workerSchedulerLane(scheduler), // tile prep at idle priority, sliced
requestFrame: () => requestAnimationFrame(draw),
memory, // @staticflux/runtime/memory ledger (kind 'gpu')
});
if (!renderer.ok) return renderer;
await renderer.value.warmUp(signal);
renderer.value.apply(delta); // tiles under the dirty rects go stale
const report = renderer.value.frame();
@staticflux/surface/text-input is the TextInput port for canvas text editing — insert, delete, composition and selection as TextIntents, with bounds for the IME. createEditContextTextInput and createHiddenTextareaTextInput both pass runTextInputContract; textInputBindings({ editContext: () => facts.apis.editContext }) picks one with a variant binding, never a browser check. @staticflux/surface/image scales images (scaleImage, fitWithin) and encodes deterministic PNGs (encodePng) in any worker, no canvas needed.
AGENTS.md on GitHub: @staticflux/worker · @staticflux/document · @staticflux/stage · @staticflux/blob · @staticflux/surface · @staticflux/testkit