feat(worker-server): the whole App in a Web Worker, over postMessage + OPFS

improvise-9x6: the server half of the standalone static-web deployment
(epic improvise-tm6). `improvise-worker-server` hosts the authoritative
`App`, the command pipeline, the projection layer and VFS persistence behind
`self.onmessage`/`self.postMessage`.

It is small because everything it needs already existed: the frames are
`improvise_protocol::frame`'s (improvise-xks6), the session loop is
`Host` (previous commit), the basis check is `Sessions::apply`/`Checked`
(improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq,
i34). What is genuinely new is three things.

`vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is
unchanged: it is `persistence::load_from` on a `Storage`, the same call the
native binary makes. Seeding is the new problem, and it is a
`Storage::write`, so a fetched demo, an upload and a saved document take the
same parse path and a seed never clobbers an edit. `list` is the document
picker a browser has no OS file manager for.

`worker.rs` — the loop: one port, one client, the `Viewport` handshake
(dropped rather than closed, since there is no connection to close), and the
filter from `Addressed`. Every decision is here and every test points at it.

`bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here
can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and
forwards strings.

Two gaps found and reported rather than papered over. No client on any
transport can ask its host to save — `effect::Save` is `ViewOnly`, so the
wire vocabulary derived from the model effects cannot name it — and a wire
`ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is
raised by a separate `MarkDirty` effect the key path emits. So the worker
persists on its own behalf, keyed off the command variant rather than off a
flag that is never set; `touches_document` carries the argument.

`ClientFrame::Key` is still answered with nothing. A worker CAN call
`reduce_key` — it holds the `App` — but nothing in the projection stream
carries the host's own `ViewState`, so a half-wired arm diverges rather than
under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear`
demonstrates it against a real `App` and `Sessions` (improvise-bpq2).

22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`,
which drives the worker with nothing but the JSON a `MessagePort` carries.
Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Edward Langley
2026-09-19 19:08:19 -07:00
parent ae362acc78
commit bdce1636bb
12 changed files with 1561 additions and 10 deletions
+12 -3
View File
@@ -49,6 +49,11 @@ jobs:
# (improvise-gsw).
- name: improvise-wasm-client
run: cargo check -p improvise-wasm-client --target wasm32-unknown-unknown
# The worker half of the standalone deployment (improvise-9x6). Same
# terms as the client: a wasm build is the point, so a failure here is a
# broken deliverable rather than a portability regression.
- name: improvise-worker-server
run: cargo check -p improvise-worker-server --target wasm32-unknown-unknown
# Not in the browser bundle yet — nothing depends on improvise-gp. Gated
# anyway because it is a candidate (fitting a tree in the browser) and
# staying wasm-clean is free now, expensive to retrofit (improvise-z5eg).
@@ -79,8 +84,10 @@ jobs:
# wasm32 gate can reach the published site. The deploy depends on those checks
# rather than repeating them (improvise-d31 AC 2).
#
# Not path-filtered, deliberately. The bundle is `improvise-wasm-client` plus
# improvise-protocol plus improvise-core plus improvise-formula, so a filter
# Not path-filtered, deliberately. The bundles are `improvise-wasm-client` and
# `improvise-worker-server` plus, between them, most of the workspace — the
# worker bundle is the whole App: core, formula, io, protocol and command. A
# filter
# that meant "skip when the bundle cannot have changed" would have to name
# most of the workspace, and the failure mode of getting it wrong is a stale
# site with no signal that it is stale. A redundant build is the cheaper error.
@@ -122,9 +129,11 @@ jobs:
run: |
set -eu
mkdir -p site
cp web/index.html web/app.js web/style.css site/
cp web/index.html web/app.js web/style.css web/worker.js site/
cp -rf web/pkg site/pkg
cp -rf web/worker-pkg site/worker-pkg
rm -f site/pkg/.gitignore site/pkg/package.json
rm -f site/worker-pkg/.gitignore site/worker-pkg/package.json
find site -type f | sort
du -sh site