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:
@@ -1,2 +1,3 @@
|
||||
# wasm-pack output; built, never committed.
|
||||
pkg/
|
||||
worker-pkg/
|
||||
|
||||
+21
-6
@@ -1,20 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Build the wasm bundle this page loads, then say how to run it.
|
||||
# Build the two wasm bundles this site loads, then say how to run it.
|
||||
#
|
||||
# Needs wasm-pack (cargo install wasm-pack) and the wasm32-unknown-unknown
|
||||
# target. Nothing else in the repo depends on either — the `wasm` CI job only
|
||||
# runs `cargo check --target wasm32-unknown-unknown`, which does not need a
|
||||
# bindgen step.
|
||||
# Needs wasm-pack (cargo install wasm-pack, or `nix develop`) and the
|
||||
# wasm32-unknown-unknown target. Nothing else in the repo depends on either —
|
||||
# the `wasm` CI job only runs `cargo check --target wasm32-unknown-unknown`,
|
||||
# which does not need a bindgen step.
|
||||
#
|
||||
# Two bundles, because there are two sides and they are separate artifacts:
|
||||
#
|
||||
# pkg/ improvise-wasm-client — the main thread: fold, keys, render
|
||||
# worker-pkg/ improvise-worker-server — the worker: the whole App + OPFS
|
||||
#
|
||||
# The client bundle is the SAME artifact in both deployments (over a socket, or
|
||||
# over a MessageChannel to the worker); only the JS bootstrap differs. The
|
||||
# worker bundle is the standalone deployment's server half (improvise-9x6).
|
||||
set -eu
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
wasm-pack build "$root/crates/improvise-wasm-client" \
|
||||
--target web --out-dir "$root/web/pkg"
|
||||
wasm-pack build "$root/crates/improvise-worker-server" \
|
||||
--target web --out-dir "$root/web/worker-pkg"
|
||||
cat <<'MSG'
|
||||
|
||||
Built. To run it, in two terminals:
|
||||
Built. To run the thin client against a real server:
|
||||
|
||||
improvise-ws-server your-model.improv --port 8787
|
||||
python3 -m http.server 8080 --directory web
|
||||
|
||||
then open http://127.0.0.1:8080/ (override the socket with ?ws=ws://host:port).
|
||||
|
||||
worker-pkg/ is built and web/worker.js will load it, but nothing instantiates
|
||||
that worker yet: the main-thread bootstrap that spawns it and wires the
|
||||
MessageChannel is improvise-djm. Until then the worker bundle ships unused.
|
||||
MSG
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// The worker half of the standalone deployment (improvise-9x6).
|
||||
//
|
||||
// It hosts the authoritative App. Everything it decides is decided in Rust —
|
||||
// this file only moves strings between `self.onmessage`/`self.postMessage` and
|
||||
// `WorkerServer::on_message`, which is the same shape `web/app.js` has on the
|
||||
// main thread and for the same reason: JS here is untested (there is no
|
||||
// browser in this repository), so the less of it there is, the less can be
|
||||
// silently wrong.
|
||||
//
|
||||
// Two rules it must not break:
|
||||
//
|
||||
// 1. Post every reply, in the order the Rust returned them, one message per
|
||||
// frame. A `GridUpdate::Cells` that overtakes the labels framing it is
|
||||
// DROPPED by the client's fold, not replayed (gotcha 33). Never batch two
|
||||
// calls' replies together and never reorder them.
|
||||
// 2. Await `flush()` after each message. `Storage::delete` is visible to
|
||||
// `read`/`list` immediately but only reaches OPFS here (gotcha 28).
|
||||
//
|
||||
// The document is named in this worker's own URL — `new
|
||||
// Worker('worker.js?doc=demo.improv')` — the analogue of the socket server
|
||||
// naming it in the ws:// path. It is deliberately not a `ClientFrame`: the
|
||||
// envelope pair is shared with the socket deployment and stays exactly as it
|
||||
// is (improvise-xks6).
|
||||
|
||||
import init, { start } from "./worker-pkg/improvise_worker_server.js";
|
||||
|
||||
const params = new URLSearchParams(self.location.search);
|
||||
const DOC = params.get("doc") || "demo.improv";
|
||||
// Where the first copy of the document comes from on an origin that has never
|
||||
// seen one. It is fetched and handed to Rust, which WRITES it through the VFS
|
||||
// and then reads it back like any other document — not injected past storage.
|
||||
const SEED = params.get("seed") || "demo.improv";
|
||||
|
||||
// Fetch the seed if there is one to fetch. A missing seed is not an error:
|
||||
// with none, the worker creates an empty model under DOC instead.
|
||||
async function seedBytes() {
|
||||
try {
|
||||
const res = await fetch(SEED);
|
||||
if (!res.ok) return undefined;
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let server = null;
|
||||
// Frames that arrived before `start()` finished. The client sends its
|
||||
// `Viewport` as soon as it loads, and losing it would leave the page showing
|
||||
// an empty grid forever.
|
||||
let pending = [];
|
||||
|
||||
function post(replies) {
|
||||
for (const reply of replies) self.postMessage(reply);
|
||||
}
|
||||
|
||||
async function handle(data) {
|
||||
const replies = server.on_message(data);
|
||||
post(replies);
|
||||
await server.flush();
|
||||
}
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
if (!server) {
|
||||
pending.push(event.data);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handle(event.data);
|
||||
} catch (err) {
|
||||
// An unhandled rejection inside a `message` listener reaches the devtools
|
||||
// console and nothing else. Say which frame it was; the main thread cannot
|
||||
// see this and has no way to ask.
|
||||
console.error("improvise worker: frame rejected", event.data, err);
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
await init();
|
||||
server = await start(DOC, await seedBytes());
|
||||
const queued = pending;
|
||||
pending = [];
|
||||
for (const data of queued) await handle(data);
|
||||
})().catch((err) => {
|
||||
console.error("improvise worker: failed to start", err);
|
||||
});
|
||||
Reference in New Issue
Block a user