From bdce1636bb9ae16eba2100b3943aa883c0313eca Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 19 Sep 2026 19:08:19 -0700 Subject: [PATCH] feat(worker-server): the whole App in a Web Worker, over postMessage + OPFS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH --- .github/workflows/ci.yml | 15 +- Cargo.lock | 14 + Cargo.toml | 2 +- crates/improvise-worker-server/Cargo.toml | 38 ++ .../improvise-worker-server/src/bindings.rs | 187 ++++++ crates/improvise-worker-server/src/lib.rs | 55 ++ crates/improvise-worker-server/src/vfs.rs | 243 ++++++++ crates/improvise-worker-server/src/worker.rs | 582 ++++++++++++++++++ .../tests/post_message.rs | 322 ++++++++++ web/.gitignore | 1 + web/build.sh | 27 +- web/worker.js | 85 +++ 12 files changed, 1561 insertions(+), 10 deletions(-) create mode 100644 crates/improvise-worker-server/Cargo.toml create mode 100644 crates/improvise-worker-server/src/bindings.rs create mode 100644 crates/improvise-worker-server/src/lib.rs create mode 100644 crates/improvise-worker-server/src/vfs.rs create mode 100644 crates/improvise-worker-server/src/worker.rs create mode 100644 crates/improvise-worker-server/tests/post_message.rs create mode 100644 web/worker.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50716f0a..27c2e378 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 6ae4a8c8..18ddea3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -952,6 +952,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "improvise-worker-server" +version = "0.1.0-rc2" +dependencies = [ + "anyhow", + "improvise-command", + "improvise-core", + "improvise-io", + "improvise-protocol", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "improvise-ws-server" version = "0.1.0-rc2" diff --git a/Cargo.toml b/Cargo.toml index 6d47ff6a..cce4cd64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/improvise-ws-server", "crates/gp-tree", "crates/improvise-gp"] +members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/improvise-ws-server", "crates/improvise-worker-server", "crates/gp-tree", "crates/improvise-gp"] [package] name = "improvise" diff --git a/crates/improvise-worker-server/Cargo.toml b/crates/improvise-worker-server/Cargo.toml new file mode 100644 index 00000000..7b8d9774 --- /dev/null +++ b/crates/improvise-worker-server/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "improvise-worker-server" +version = "0.1.0-rc2" +edition = "2024" +description = "Worker-hosted improvise server: the authoritative App in a browser Web Worker, over postMessage + OPFS" +license = "Apache-2.0" +repository = "https://github.com/fiddlerwoaroof/improvise" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +improvise-core = { path = "../improvise-core" } +improvise-io = { path = "../improvise-io" } +improvise-protocol = { path = "../improvise-protocol" } +improvise-command = { path = "../improvise-command" } +anyhow = "1" +serde_json = "1" + +# The browser half. Only the wasm build pulls these in, so `cargo test` on the +# host builds and drives `worker.rs`/`vfs.rs` with no JS anywhere near them — +# which is the whole reason the decisions live there and not in `bindings.rs`. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +# `#[wasm_bindgen] pub async fn` expands to a `future_to_promise`, so the +# generated code names this crate even though nothing here does. +wasm-bindgen-futures = "0.4" + +# The bundled `wasm-opt` predates two features being enabled by default, while +# LLVM emits both regardless, so the validator rejects the module unless each +# is named. `bulk-memory` is the one improvise-wasm-client already needs +# (`memory.copy`). This bundle needs a second: it carries the whole `App`, and +# so chrono's date arithmetic, which compiles to `i64.trunc_sat_f64_u` — +# `nontrapping-float-to-int`. Naming them keeps the optimizer in the loop +# rather than turning it off, which matters here: it is 22% of this bundle. +[package.metadata.wasm-pack.profile.release] +wasm-opt = ["-O", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"] diff --git a/crates/improvise-worker-server/src/bindings.rs b/crates/improvise-worker-server/src/bindings.rs new file mode 100644 index 00000000..a6c4df43 --- /dev/null +++ b/crates/improvise-worker-server/src/bindings.rs @@ -0,0 +1,187 @@ +//! The browser shim: `self.onmessage`, `self.postMessage`, and OPFS. +//! +//! Deliberately decision-free, for the reason `improvise-ws-server`'s +//! `transport` is: everything that could be wrong about *what* the main thread +//! is told lives in [`crate::worker`], and everything that could be wrong +//! about *which document* is open lives in [`crate::vfs`]. Both are driven by +//! host tests. What is left here is awaiting four promises and moving strings, +//! and it is the only part of this crate that no test in the repository can +//! reach — there is no browser here and `createSyncAccessHandle` exists in no +//! harness. +//! +//! # The async seam, and the `Rc` it forces +//! +//! `Storage` is synchronous and must stay so (gotcha 28): OPFS's +//! `FileSystemSyncAccessHandle` really does block, which is why the whole app +//! is in a worker in the first place. What is still async is exactly four +//! calls — `open`, `attach`, `refresh`, `commit_deletes` — and they are on +//! `OpfsFs`, not on the trait. +//! +//! That creates one problem worth naming, because every worker will hit it: +//! `App::storage` is a `Box`, and the trait has none of those four +//! methods, so **handing `OpfsFs` to the `App` puts the async seam out of +//! reach**. A boxed backend can be read and written and never attached again. +//! The fix is local and small — keep an `Rc` for the async half and +//! give the `App` a newtype over a clone of it — but it cannot live in +//! `improvise-io`'s callers generically, because `impl Storage for Rc` +//! is two foreign types and the orphan rule refuses it. Hence [`Shared`]. +//! Reported as a VFS gap rather than routed around silently. +//! +//! # `attach` before anything +//! +//! Acquiring a handle is async even though using it is not, so +//! [`start`] awaits `refresh()` (to populate what `list` answers from) and +//! `attach(path)` (to get the handle `read`/`write` need) **before** building +//! the `App`. A `Storage::read` on an unattached name is an error, by design: +//! that is the seam being explicit rather than a lazy `attach` hidden inside a +//! synchronous call, which could not await anything anyway. + +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use anyhow::Result; +use improvise_command::ui::app::App; +use improvise_io::opfs::OpfsFs; +use improvise_io::storage::Storage; +use wasm_bindgen::prelude::*; + +use crate::vfs::{self, Opening}; +use crate::worker::Worker; + +/// An `OpfsFs` the worker and the `App` both hold. +/// +/// The `App` needs a `Box` while the message loop needs the +/// backend's four async methods, and the trait carries neither the methods nor +/// a way to downcast back. A local newtype over the shared pointer is the +/// smallest thing that gives both; `Rc` rather than `Arc` because a worker is +/// one thread and `wasm32-unknown-unknown` has no others. +struct Shared(Rc); + +impl Storage for Shared { + fn read(&self, path: &Path) -> Result> { + self.0.read(path) + } + fn write(&self, path: &Path, bytes: &[u8]) -> Result<()> { + self.0.write(path, bytes) + } + fn list(&self, dir: &Path) -> Result> { + self.0.list(dir) + } + fn delete(&self, path: &Path) -> Result<()> { + self.0.delete(path) + } +} + +/// The worker's handle for the lifetime of the page. +#[wasm_bindgen] +pub struct WorkerServer { + worker: Worker, + storage: Rc, +} + +/// Boot the worker over OPFS and open `document`. +/// +/// `seed` is the bytes to write if the origin has never held that document — +/// a demo `.improv` the page fetched, or a file the user dropped. It goes in +/// through [`vfs::open`], which is a `Storage::write` like any other, so the +/// browser's first run and its thousandth take the same path. +/// +/// The document is named **out of band**, not in a frame. That is the same +/// answer the socket gives (`ws://host/quarterly` names it in the URL path, +/// improvise-j31x): a document is a resource, and `ClientFrame` stays exactly +/// as it is for both deployments. In a worker the out-of-band channel is the +/// worker's own URL — `new Worker('worker.js?doc=demo.improv')` — which is why +/// this takes the name as an argument rather than adding a fourth +/// `ClientFrame`. Reading it off `self.location.search` is left to the JS +/// bootstrap: URL parsing is one line there and a web-sys feature plus an +/// untestable function here. +#[wasm_bindgen] +pub async fn start(document: &str, seed: Option>) -> Result { + boot(document, seed.as_deref()) + .await + .map_err(|e| JsError::new(&e.to_string())) +} + +async fn boot(document: &str, seed: Option<&[u8]>) -> Result { + let storage = Rc::new(OpfsFs::open().await?); + // `list` answers from this, and `vfs::open` asks it whether to seed. + storage.refresh().await?; + let path = PathBuf::from(document); + // Acquiring a handle is the async half; reading and writing through it are + // not. Nothing may touch this document before the await completes. + storage.attach(&path).await?; + + let opening = match seed { + Some(bytes) => Opening::named(path.clone()).with_seed(bytes), + None => Opening::named(path.clone()), + }; + let workbook = vfs::open(&Shared(Rc::clone(&storage)), &opening)?; + + let mut app = App::with_storage(workbook, Some(path), Box::new(Shared(Rc::clone(&storage)))); + app.rebuild_layout(); + Ok(WorkerServer { + worker: Worker::new(app), + storage, + }) +} + +#[wasm_bindgen] +impl WorkerServer { + /// One frame in, the frames to post back, in order. + /// + /// The caller must post **every** returned string, in this order, and must + /// never merge two calls' results or split one: a `GridUpdate::Cells` that + /// overtakes the labels framing it is dropped, not replayed (gotcha 33). + #[wasm_bindgen] + pub fn on_message(&mut self, json: &str) -> Result, JsError> { + self.worker + .on_message(json) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Flush the removals `Storage::delete` queued. + /// + /// `delete` is visible to `read` and `list` immediately and lands in OPFS + /// here, at the end of a batch — the price of keeping the trait + /// synchronous, and invisible to a single worker (gotcha 28). The JS loop + /// awaits this after each message. + #[wasm_bindgen] + pub async fn flush(&self) -> Result<(), JsError> { + self.storage + .commit_deletes() + .await + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Every document in the origin's storage, for a picker on the main + /// thread. `Storage::list`, which is why a browser needed `list` at all + /// (improvise-i34): there is no OS file manager behind this. + #[wasm_bindgen] + pub fn documents(&self) -> Result, JsError> { + vfs::documents(&Shared(Rc::clone(&self.storage))) + .map(|paths| { + paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + }) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Re-read the directory listing `documents` answers from. + #[wasm_bindgen] + pub async fn refresh(&self) -> Result<(), JsError> { + self.storage + .refresh() + .await + .map_err(|e| JsError::new(&e.to_string())) + } +} + +/// Assert the shim is reachable at all. Not a test of behaviour — there is +/// nothing here to test without a browser — but it does keep the module from +/// rotting out of the wasm build unnoticed. +#[allow(dead_code)] +fn _shim_is_a_storage(fs: Rc) -> Box { + Box::new(Shared(fs)) +} diff --git a/crates/improvise-worker-server/src/lib.rs b/crates/improvise-worker-server/src/lib.rs new file mode 100644 index 00000000..684d10c4 --- /dev/null +++ b/crates/improvise-worker-server/src/lib.rs @@ -0,0 +1,55 @@ +//! The improvise server, hosted in a browser Web Worker (improvise-9x6). +//! +//! The second half of the standalone static-web deployment (epic +//! improvise-tm6): `improvise-wasm-client` runs on the main thread and this +//! runs in a dedicated worker, with a `MessagePort` between them carrying the +//! *same* frames a WebSocket carries. Neither end knows which it is talking +//! to. That is the whole architectural claim of the epic, and it is cheap only +//! because everything it needs already existed: +//! +//! | piece | comes from | new here | +//! |---|---|---| +//! | the frames | `improvise_protocol::frame` (improvise-xks6) | nothing | +//! | the session loop | `improvise_command::ui::host::Host` | nothing | +//! | the projection layer | `improvise_command::ui::project` | nothing | +//! | the basis check | `Sessions::apply` / `Checked` (improvise-ondj) | nothing | +//! | persistence | `Storage` + `OpfsFs` (improvise-6mq/i34) | nothing | +//! +//! So this crate is three small things and no fourth: +//! +//! - [`worker`] — the loop: one port, one client, the handshake, and the +//! filter from `Addressed` to "post this". Every decision is here and every +//! test points at it. +//! - [`vfs`] — how a document gets *into* a fresh browser's empty filesystem, +//! which is the one genuinely new problem. It is a `Storage::write`. +//! - [`bindings`] — `self.onmessage`, `self.postMessage`, and the four async +//! `OpfsFs` calls. `cfg(target_arch = "wasm32")`, decision-free, and the +//! only part of this crate no test in the repository can reach. +//! +//! # Where the tested core ends +//! +//! [`worker`] and [`vfs`] are ordinary Rust, driven on the host by +//! `tests/post_message.rs` over exactly the JSON a `MessagePort` carries. +//! [`bindings`] is the shim, and it is deliberately thin enough to read: it +//! awaits `OpfsFs::open`/`attach`/`refresh`, builds an `App` through +//! [`vfs::open`], and forwards strings. It is compile-checked for wasm32 and +//! nothing more — `createSyncAccessHandle` exists in no test harness here, and +//! browser verification (improvise-xf1) is deferred by decision, so the +//! `storage_contract` that `OpfsFs` owes has still never been run against it +//! (gotcha 28). **If persistence misbehaves in a browser, `OpfsFs` is the +//! first place to look, not the last.** +//! +//! # Nothing depends on this crate +//! +//! It is a leaf, like the binary it replaces. It may use core, formula, io, +//! protocol and command; no member of the workspace may use it. + +pub mod vfs; +pub mod worker; + +#[cfg(target_arch = "wasm32")] +pub mod bindings; + +pub use improvise_protocol::{ClientFrame, ServerFrame}; +pub use vfs::{Opening, documents}; +pub use worker::Worker; diff --git a/crates/improvise-worker-server/src/vfs.rs b/crates/improvise-worker-server/src/vfs.rs new file mode 100644 index 00000000..3d986f77 --- /dev/null +++ b/crates/improvise-worker-server/src/vfs.rs @@ -0,0 +1,243 @@ +//! How a document gets *into* the browser's filesystem, and out of it again. +//! +//! There is no browser-specific document path. Opening a document in a worker +//! is [`persistence::load_from`] on a [`Storage`], which is the same call the +//! native binary makes through [`PhysicalFs`](improvise_io::storage::PhysicalFs); +//! the only difference is which backend the `App` was handed. That is the whole +//! point of improvise-6mq, and it is why nothing here takes bytes and builds a +//! `Workbook` out of them behind the VFS's back: a bespoke byte-injection entry +//! point would make the browser the one deployment where a document is not a +//! document. +//! +//! # Seeding is a write, not an exception +//! +//! What *is* genuinely new in a browser is that a fresh origin has an **empty** +//! OPFS, so on first run there is nothing to `read`. Loading is unchanged; +//! **seeding** is the new problem. [`open`] answers it in the only way that +//! keeps the VFS the single door: whatever supplies the first document — a +//! fetched demo `.improv`, a file the user dropped on the page, or nothing at +//! all — the document arrives by [`Storage::write`] and is then `read` back +//! like any other. Three consequences worth stating: +//! +//! - A seeded document and a stored one take the **same parse path**, so a +//! corrupt demo fails exactly as a corrupt saved document does, at the same +//! line of the same grammar. Handing `parse_md` the bytes directly and only +//! *then* writing them would have had two paths and one of them untested. +//! - Seeding is **not** clobbering. `open` writes the seed only when the name +//! is absent from [`documents`]; a user who has edited the demo keeps their +//! edits across a reload, which is the entire reason OPFS is here. +//! - "No document at all" is a document too: an empty [`Workbook`] is *saved* +//! before it is served, so the second run finds it via `list` like any other. +//! A worker that held an unsaved model would have a state the VFS cannot see. +//! +//! # Where `list` fits +//! +//! [`documents`] is how a browser user picks what to open — the analogue of an +//! OS file manager, which a browser does not have (improvise-i34 added `list` +//! and `delete` for exactly this). A worker that only ever opens one hard-coded +//! name is not using the VFS, it is using a constant. +//! +//! # The root is flat, and that is load-bearing for OPFS +//! +//! [`root`] is `Path::new("")`, so a document name is a bare `demo.improv` +//! whose `parent()` is the root. `OpfsFs` flattens by construction — it stores +//! one entry per document in the origin's root directory and its `list` +//! returns bare names — so the flat root is the one `dir` at which `OpfsFs` +//! and `MemoryFs` answer `list` identically, and therefore the one at which +//! `storage_contract` describes both. See `the_flat_root_is_where_the_backends +//! _agree`, which is the closest a host-side test can get to exercising the +//! browser backend (improvise-xf1 is deferred, so `OpfsFs` remains +//! compile-checked and not browser-tested — gotcha 28). + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use improvise_core::workbook::Workbook; +use improvise_io::persistence; +use improvise_io::storage::Storage; + +/// The directory documents live in. Flat: OPFS has directories, but improvise +/// stores documents side by side and a browser has no directory UI to drive +/// them with. +pub fn root() -> &'static Path { + Path::new("") +} + +/// Every document this backend holds, sorted. +/// +/// What a document picker on the main thread reads. On `OpfsFs` this answers +/// from the index `refresh()` populated, so the shim must have awaited it. +pub fn documents(storage: &dyn Storage) -> Result> { + storage.list(root()) +} + +/// What to open, and what to do when it is not there. +/// +/// Both fields together are the worker's analogue of the socket server's URL +/// path plus its CLI file argument: which document, and where the first copy +/// comes from. Neither is a frame — see [`crate::worker`] on why this stays +/// out of `ClientFrame`. +pub struct Opening<'a> { + /// The document name, relative to [`root`]. + pub name: PathBuf, + /// Bytes to write if `name` is absent. `None` means "create an empty + /// model under that name instead". + pub seed: Option<&'a [u8]>, +} + +impl<'a> Opening<'a> { + pub fn named(name: impl Into) -> Self { + Self { + name: name.into(), + seed: None, + } + } + + pub fn with_seed(mut self, bytes: &'a [u8]) -> Self { + self.seed = Some(bytes); + self + } +} + +/// Open `opening.name`, creating it from the seed (or as an empty model) if +/// the backend does not have it yet. +/// +/// Always ends in a `read`, never in a value carried over from the write, so +/// there is one path from bytes to `Workbook` regardless of how the bytes got +/// there. +pub fn open(storage: &dyn Storage, opening: &Opening<'_>) -> Result { + let path = opening.name.as_path(); + if !documents(storage)?.iter().any(|p| p == path) { + create(storage, opening)?; + } + persistence::load_from(storage, path).with_context(|| format!("Cannot open {}", path.display())) +} + +/// Put a first copy of `opening.name` into the backend. +fn create(storage: &dyn Storage, opening: &Opening<'_>) -> Result<()> { + match opening.seed { + Some(bytes) => storage.write(&opening.name, bytes), + None => persistence::save_to( + storage, + &Workbook::new(model_name(&opening.name)), + &opening.name, + ), + } +} + +/// A document's name as a model is called: the file stem, or `Untitled` for a +/// name that has none. +fn model_name(path: &Path) -> String { + path.file_stem() + .and_then(|s| s.to_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("Untitled") + .to_string() +} + +#[cfg(test)] +mod tests { + use improvise_io::storage::MemoryFs; + + use super::*; + + const DEMO: &str = "v2025-04-09\n# Demo\nInitial View: Default\n\n\ + ## View: Default\nRegion: row\n_Measure: column\n\n\ + ## Category: Region\n- North, South\n\n\ + ## Data\nRegion=North, _Measure=Revenue = 10\n"; + + #[test] + fn an_absent_document_is_seeded_then_read_back() { + let fs = MemoryFs::new(); + let wb = open( + &fs, + &Opening::named("demo.improv").with_seed(DEMO.as_bytes()), + ) + .expect("seeds and opens"); + assert_eq!(wb.model.name, "Demo"); + assert_eq!( + documents(&fs).unwrap(), + vec![PathBuf::from("demo.improv")], + "the seed landed in the VFS, so `list` can offer it next time" + ); + } + + /// The point of OPFS: a reload must not throw the user's work away. A seed + /// is a *first* copy, so the second `open` ignores it entirely. + #[test] + fn a_seed_never_overwrites_a_document_that_is_already_there() { + let fs = MemoryFs::new(); + open( + &fs, + &Opening::named("demo.improv").with_seed(DEMO.as_bytes()), + ) + .unwrap(); + + let mut edited = persistence::load_from(&fs, Path::new("demo.improv")).unwrap(); + edited.add_item("Region", "East").unwrap(); + persistence::save_to(&fs, &edited, Path::new("demo.improv")).unwrap(); + + let reopened = open( + &fs, + &Opening::named("demo.improv").with_seed(DEMO.as_bytes()), + ) + .expect("opens the stored copy"); + assert!( + reopened + .model + .category("Region") + .unwrap() + .items + .contains_key("East"), + "re-seeding destroyed an edit the user had saved" + ); + } + + #[test] + fn with_no_seed_an_absent_document_becomes_an_empty_model_that_is_saved() { + let fs = MemoryFs::new(); + let wb = open(&fs, &Opening::named("scratch.improv")).expect("creates"); + assert_eq!(wb.model.name, "scratch"); + assert_eq!( + documents(&fs).unwrap(), + vec![PathBuf::from("scratch.improv")], + "a worker must not hold a model the VFS cannot see" + ); + } + + /// A seed that is not a `.improv` fails in the parser, not in a + /// browser-only path: the write happened, and the `read` that follows it + /// is the same `read` a stored document gets. + #[test] + fn a_corrupt_seed_fails_where_a_corrupt_document_fails() { + let fs = MemoryFs::new(); + let err = open(&fs, &Opening::named("bad.improv").with_seed(b"not a model")) + .expect_err("must not yield a workbook"); + assert!( + err.to_string().contains("bad.improv"), + "the error must name the document: {err}" + ); + } + + /// `documents` is `Storage::list` at [`root`], and the flat root is the + /// one `dir` at which `OpfsFs` — which stores bare names in the origin + /// root — and the host backends answer alike. Nothing here can reach a + /// browser (improvise-xf1 is deferred), so this pins the *shape* the + /// worker relies on rather than the backend. + #[test] + fn the_flat_root_is_where_the_backends_agree() { + let fs = MemoryFs::new(); + fs.write(Path::new("b.improv"), b"b").unwrap(); + fs.write(Path::new("a.improv"), b"a").unwrap(); + assert_eq!( + documents(&fs).unwrap(), + vec![PathBuf::from("a.improv"), PathBuf::from("b.improv")], + "sorted bare names, exactly what OpfsFs::list returns" + ); + assert_eq!( + Path::new("a.improv").parent(), + Some(root()), + "a document name's parent IS the root, or MemoryFs would filter it out" + ); + } +} diff --git a/crates/improvise-worker-server/src/worker.rs b/crates/improvise-worker-server/src/worker.rs new file mode 100644 index 00000000..36d24e16 --- /dev/null +++ b/crates/improvise-worker-server/src/worker.rs @@ -0,0 +1,582 @@ +//! The message loop: one `MessagePort`, one client, one authoritative `App`. +//! +//! This is the whole worker minus the two things a browser has to supply — +//! `self.onmessage`/`self.postMessage`, and OPFS. Both are in +//! [`crate::bindings`], which is `cfg(target_arch = "wasm32")` and has no +//! decisions in it. Everything that could be *wrong* about what the main +//! thread is told is here, where `cargo test` can drive it. +//! +//! # It is the socket's [`Host`], not a second one +//! +//! `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec`. +//! Nothing in it is a socket, and improvise-9x6 is what made that load-bearing +//! rather than tidy: the worker needs the same session loop and cannot depend +//! on `improvise-ws-server`, which is tokio and tungstenite. So `Host` moved +//! down to `improvise_command::ui::host` and **both deployments name the one +//! type**. Re-implementing it here would have been a mirror, and improvise-xks6 +//! had just finished deleting the last one (gotcha 42): a second copy of a +//! decision layer drifts, and the drift is invisible until a client sees it. +//! +//! What this adds on top is only what a `MessagePort` differs in: +//! +//! - **One client, by construction.** A `MessagePort` is point to point, so +//! the `SessionId` is not an address the transport has to carry — it is a +//! field. `Addressed` is still honoured rather than assumed away (see +//! [`Worker::frames_for`]), because a `SharedWorker` would make it matter +//! again and the honest filter costs one line. +//! - **A handshake with no close.** [`ClientFrame::Viewport`] must come first, +//! for the reason it must on the socket: a `Snapshot` seeds the *visible* +//! cells, so one built at extent zero is an empty grid. A socket enforces +//! that by closing the connection; a worker has nothing to close, so a frame +//! that arrives before the handshake is **dropped**, which is the same +//! outcome without a mechanism that does not exist here. +//! +//! # `ClientFrame::Key` is answered with nothing, and that is not laziness +//! +//! A worker holds the authoritative `App`, so `ui::reduce::reduce_key` really +//! is callable here where it is not on a socket. Calling it would still be +//! wrong, and the reason is not about transports at all: **nothing in the +//! projection stream carries the host's own `ViewState`.** A client's view +//! slice moves only by folding `Command`s (gotcha 36), and a keypress resolved +//! against the `App` moves the *`App`'s* slice — its mode, its cursor, its +//! minibuffer — with no `Command` produced that any client could fold. +//! `ClientSession::project` emits document projections, a grid diff and a +//! basis; none of the three is a mode change. +//! +//! So a half-wired `Key` arm does not under-deliver, it **diverges**: the host +//! would believe it is in `AppMode::Editing` while the client draws Normal, +//! and `ClientSession::viewport()` reads the *mirror's* cursor, so the grid +//! would not even scroll to follow. `a_key_resolved_on_the_host_moves_state_no_ +//! client_can_hear` demonstrates exactly that against a real `App` and a real +//! `Sessions`, so the gap is recorded as a failing property rather than as a +//! comment. Closing it means deriving `Command`s from a `ViewState` delta, +//! which is improvise-bpq2 and is a ticket, not plumbing. +//! +//! # Persistence is the worker's, because saving is not on the wire +//! +//! There is no `Command::Save` and no `ModelCommand::Save`, and that is not an +//! oversight: `effect::Save` is `EffectKind::ViewOnly` (gotcha 3 — it writes +//! the document to *this host's* file path and clears *this host's* dirty +//! flag; it mutates no document state), so the derivation that produces +//! `ModelCommand` from the model effects cannot reach it +//! (`decision(improvise-cqi, command_set_derived_not_invented)`, gotcha 38). +//! **Consequently no wire client — browser or socket — can ask its host to +//! save.** That is a real gap and it is reported rather than patched: adding a +//! wire variant for it is a protocol decision, not a worker one. +//! +//! It does not block the browser, because in a browser it is the **wrong +//! question**. There is no file the user owns and no Ctrl-S semantics to +//! honour: the document *is* the OPFS entry, and a page that loses an edit +//! because nobody pressed a key has simply lost it. So the worker persists on +//! its own behalf: [`Worker::persist`] runs the one existing `Save` effect +//! through `App::apply_effects`, so effects remain the only mutation site and +//! the save path is not duplicated, after any frame [`touches_document`] +//! accepts and the host did not refuse. Two costs, both stated rather than +//! hidden: `Storage` is whole-document by construction (gotcha 41), so OPFS +//! rewrites the file per accepted model command — correct, not yet cheap; and +//! the trigger is the command *variant* rather than an observation of the +//! document, because the flag that would be the observation is never set on +//! this path. [`touches_document`] carries that argument. +//! +//! # Ordering +//! +//! [`Worker::on_frame`] returns the frames for one inbound frame, in order, +//! and a `ServerFrame::Commands` batch must reach the client **whole and in +//! sequence**: a `GridUpdate::Cells` that overtakes the labels framing it is +//! dropped, not replayed (gotcha 33). That is a property of the fold, so a +//! `postMessage` transport owes it exactly as a socket does — never coalesce +//! two batches, never split one. + +use improvise_command::ui::app::App; +use improvise_command::ui::effect; +use improvise_command::ui::host::{Addressed, Host}; +use improvise_command::ui::project::SessionId; +use improvise_protocol::{ClientFrame, Command, ServerFrame}; + +/// Whether a frame can have changed the document, and so whether the worker +/// owes it a write. +/// +/// The two variants `Sessions::apply` routes to the document (gotchas 36, 38): +/// a `Model` command is a document mutation by construction, and a +/// `SwitchView` moves `Workbook::active_view`, which persists as the +/// `Initial View` line. Everything else in `Command` is the asking client's +/// own view slice and belongs to nobody else, let alone to storage. +/// +/// **It is a per-variant question, not a per-batch observation, and that is a +/// deliberate over-approximation.** The obvious trigger — "save if +/// `model_state.dirty`" — does not work, and the reason is worth knowing: a +/// wire `ModelCommand::SetCell` goes through `reduce::model_effects` to +/// `effect::SetCell`, which writes the cell and **does not** set the flag. +/// Dirtiness is raised by a separate `MarkDirty` effect that the *command* +/// layer emits on the key path, and no wire command carries one. So the +/// authoritative document is never marked dirty by a client's edit on any +/// transport — reported as a gap rather than papered over here. Over-saving +/// costs one redundant write; keying off a flag that is never set would have +/// lost every client edit, silently. +fn touches_document(frame: &ClientFrame) -> bool { + match frame { + ClientFrame::Command(cmd) => { + matches!(cmd, Command::Model { .. } | Command::SwitchView(_)) + } + ClientFrame::Viewport { .. } | ClientFrame::Key { .. } => false, + } +} + +/// One document, one port, one client. +/// +/// Not `Debug`, for the reason `Host` is not: it owns the whole document. +pub struct Worker { + host: Host, + /// The main thread, once it has reported its extent. `None` until then. + client: Option, +} + +impl Worker { + /// Take an `App` that is already pointed at its document and storage + /// backend. Building that is [`crate::vfs`]'s job and the shim's, not + /// this loop's — a session loop that also knew how to open files would be + /// two things. + pub fn new(app: App) -> Self { + Self { + host: Host::new(app), + client: None, + } + } + + pub fn app(&self) -> &App { + self.host.app() + } + + /// One frame from the main thread; the frames to post back, in order. + pub fn on_frame(&mut self, frame: ClientFrame) -> Vec { + match (self.client, frame) { + // The handshake. A worker is connected by its first `Viewport`, + // exactly as a socket is. + (None, ClientFrame::Viewport { rows, cols }) => { + let (id, bootstrap) = self.host.connect(rows, cols); + self.client = Some(id); + vec![bootstrap.msg] + } + // Anything else before the handshake. The client cannot have + // composed it against a document it has not been sent, so there is + // nothing to apply and nothing to answer. + (None, _) => Vec::new(), + (Some(id), frame) => { + let touched = touches_document(&frame); + let out = self.frames_for(id, frame); + let refused = out.iter().any(|f| matches!(f, ServerFrame::Rejected(_))); + if touched && !refused { + self.persist(); + } + out + } + } + } + + /// Write the document back. + /// + /// Runs the existing `Save` effect rather than calling `persistence` here, + /// so there is one save path with one set of failure semantics (a failed + /// save leaves its message in the status and aborts the batch — gotcha 21) + /// and effects stay the only mutation site. + /// + /// A worker with no `file_path` silently does nothing, which is what + /// `Save` already does. + fn persist(&mut self) { + self.host + .app_mut() + .apply_effects(vec![Box::new(effect::Save)]); + } + + /// The same, over the JSON that actually crosses a `MessagePort`. + /// + /// Returns `Err` on a frame that will not parse rather than swallowing it. + /// A worker that silently ignores a malformed frame leaves the client + /// waiting forever for an answer that is never coming, and the browser + /// half is the one place a `Result` can still be turned into something a + /// developer sees (gotcha 42 records the client-side version of this as a + /// bug, improvise-hwpi; there is no reason to repeat it here). + pub fn on_message(&mut self, json: &str) -> anyhow::Result> { + let frame: ClientFrame = serde_json::from_str(json)?; + self.on_frame(frame) + .iter() + .map(|f| serde_json::to_string(f).map_err(anyhow::Error::from)) + .collect() + } + + /// Run one frame through the host and keep what is addressed to our one + /// client. + /// + /// The filter is honoured rather than assumed: `Host::relay` addresses a + /// broadcast to every session it knows, and with one session that is + /// always this one — but a `SharedWorker` hosting several ports would make + /// the difference real, and a loop that had assumed it away would be + /// silently wrong at that point rather than obviously incomplete. + fn frames_for(&mut self, id: SessionId, frame: ClientFrame) -> Vec { + self.host + .handle(id, frame) + .into_iter() + .filter(|addressed: &Addressed| addressed.to == id) + .map(|addressed| addressed.msg) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use improvise_command::ui::project::Sessions; + use improvise_command::ui::reduce; + use improvise_core::model::cell::{CellKey, CellValue}; + use improvise_core::view::Axis; + use improvise_core::workbook::Workbook; + use improvise_io::storage::MemoryFs; + use improvise_protocol::{ + CellId, Command, GridCache, GridUpdate, KeyCode, KeyEvent, KeyModifiers, ModelCommand, + Projection, RecordCell, WidgetCache, + }; + + use super::*; + use crate::vfs::{self, Opening}; + + /// The seed bytes, written by the real writer rather than by hand. + /// + /// A hand-rolled `.improv` gets the virtual category's on-disk spelling + /// wrong — the format writes `Measure`, not `_Measure` — and the result is + /// a document that parses into an empty grid, which reads exactly like a + /// projection bug. Formatting a real `Workbook` cannot drift from the + /// parser. + fn demo_bytes() -> Vec { + improvise_io::persistence::format_md(&populated()).into_bytes() + } + + fn populated() -> Workbook { + let mut wb = workbook(); + for (region, value) in [("North", 10.0), ("South", 20.0)] { + wb.model.data.set( + CellKey::new(vec![ + ("Region".to_string(), region.to_string()), + ("_Measure".to_string(), "Revenue".to_string()), + ]), + CellValue::Number(value), + ); + } + wb + } + + fn workbook() -> Workbook { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + for item in ["North", "South"] { + wb.add_item("Region", item).unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb + } + + fn worker() -> Worker { + let mut app = App::new(workbook(), None); + app.rebuild_layout(); + Worker::new(app) + } + + /// A worker over a real VFS, opened exactly as the browser opens one. + fn worker_over_storage() -> (Worker, PathBuf) { + let fs = MemoryFs::new(); + let path = PathBuf::from("demo.improv"); + let seed = demo_bytes(); + let wb = vfs::open(&fs, &Opening::named(&path).with_seed(&seed)).expect("the demo seeds"); + let mut app = App::with_storage(wb, Some(path.clone()), Box::new(fs)); + app.rebuild_layout(); + (Worker::new(app), path) + } + + fn connect(worker: &mut Worker) -> GridCache { + let out = worker.on_frame(ClientFrame::Viewport { rows: 8, cols: 4 }); + folded(&mut GridCache::default(), &out) + } + + /// The grid a client that folded `frames` in order would hold. + fn folded(grid: &mut GridCache, frames: &[ServerFrame]) -> GridCache { + for frame in frames { + match frame { + ServerFrame::Snapshot(snapshot) => { + *grid = GridCache::default(); + for projection in &snapshot.seed { + if let Projection::Grid(update) = projection { + grid.apply(update); + } + } + } + ServerFrame::Commands(cmds) => { + for cmd in cmds { + if let Command::Project(Projection::Grid(update)) = cmd { + grid.apply(update); + } + } + } + ServerFrame::Rejected(_) => {} + } + } + grid.clone() + } + + /// The surrogate a client addresses a write to: the one the server put on + /// `CellDisplay::id` (ADR 002). A `CellKey` is not constructible from a + /// client, so a test that builds one is not testing the client's path. + fn addressed(grid: &GridCache, row: usize, col: usize) -> RecordCell { + match grid.cells[row][col].id.as_ref().expect("a projected cell") { + CellId::Record(rc) => rc.clone(), + CellId::Aggregate(_) => panic!("row {row} col {col} is an aggregate, not writable"), + } + } + + #[test] + fn the_first_viewport_is_the_handshake_and_answers_a_snapshot() { + let mut worker = worker(); + let out = worker.on_frame(ClientFrame::Viewport { rows: 8, cols: 4 }); + assert!( + matches!(out.as_slice(), [ServerFrame::Snapshot(_)]), + "expected exactly one bootstrap, got {out:?}" + ); + } + + /// The reason the handshake carries the extent at all: a `Snapshot` seeds + /// the *visible* cells, so one built at extent zero is an empty grid. + #[test] + fn the_bootstrap_seeds_the_cells_the_reported_extent_can_show() { + let mut worker = worker(); + let grid = connect(&mut worker); + assert_eq!(grid.row_labels, vec!["North", "South"]); + assert_eq!(grid.col_labels, vec!["Revenue"]); + } + + /// There is no connection to close, so the rule is enforced by dropping. + #[test] + fn a_frame_before_the_handshake_is_dropped() { + let mut worker = worker(); + let out = worker.on_frame(ClientFrame::Command(Command::SetSelected { + row: 1, + col: 0, + })); + assert!( + out.is_empty(), + "answered a client that has not connected: {out:?}" + ); + assert_eq!( + worker.app().cursor().selected, + (0, 0), + "and it must not have reached the document either" + ); + } + + /// End to end over the VFS: open a seeded document, write a cell through + /// the wire, and find the new value in what the client is projected. + #[test] + fn a_wire_edit_reaches_the_document_and_comes_back_as_a_projection() { + let (mut worker, _) = worker_over_storage(); + let mut grid = connect(&mut worker); + assert_eq!(grid.cells[0][0].text, "10"); + + let cell = addressed(&grid, 0, 0); + let cmd = reduce::caught_up( + worker.app(), + ModelCommand::SetCell { + cell, + value: CellValue::Number(42.0), + }, + ); + let out = worker.on_frame(ClientFrame::Command(cmd)); + let grid = folded(&mut grid, &out); + assert_eq!( + grid.cells[0][0].text, "42", + "the edit did not come back through the projection stream" + ); + } + + /// The persistence half of acceptance criterion 2, and the reason the + /// worker saves on its own behalf: there is no `ModelCommand::Save` for a + /// client to send, because `effect::Save` is `ViewOnly` and the wire + /// vocabulary is derived from the *model* effects (gotcha 38). So an edit + /// must reach storage with the client having asked for nothing but the + /// edit. + #[test] + fn an_edit_is_persisted_without_the_client_asking_to_save() { + let (mut worker, path) = worker_over_storage(); + let grid = connect(&mut worker); + let cell = addressed(&grid, 0, 0); + + let wire = reduce::caught_up( + worker.app(), + ModelCommand::SetCell { + cell, + value: CellValue::Number(99.0), + }, + ); + worker.on_frame(ClientFrame::Command(wire)); + + let reopened = vfs::open(&*worker.app().storage, &Opening::named(&path)) + .expect("the saved document reopens through the same VFS"); + assert_eq!( + value_at(&reopened, 0, 0), + Some(CellValue::Number(99.0)), + "the edit never reached the backend" + ); + } + + /// The value at a grid position in a freshly loaded document. Compared as + /// a `CellValue` rather than as display text, which would make the + /// assertion depend on the view's number format. + fn value_at(wb: &Workbook, row: usize, col: usize) -> Option { + let layout = improvise_core::view::layout::GridLayout::new(&wb.model, wb.active_view()); + let key = layout.cell_key(row, col)?; + wb.model.evaluate_aggregated(&key, &layout.none_cats) + } + + /// `Storage::list` is how a browser offers a document picker, and it must + /// see what the worker saved. + #[test] + fn the_vfs_lists_the_document_the_worker_is_serving() { + let (worker, path) = worker_over_storage(); + assert_eq!(vfs::documents(&*worker.app().storage).unwrap(), vec![path]); + } + + /// ADR 001 is not socket-only. A batch composed against a document that + /// has moved is refused, the refusal is addressed to the asker, and + /// nothing is applied. + #[test] + fn a_stale_batch_is_rejected_over_post_message_too() { + let (mut worker, _) = worker_over_storage(); + let grid = connect(&mut worker); + let cell = addressed(&grid, 0, 0); + + // Compose against the document as it is now... + let stale = reduce::caught_up( + worker.app(), + ModelCommand::SetCell { + cell: cell.clone(), + value: CellValue::Number(1.0), + }, + ); + // ...then let somebody else move it. + let other = reduce::caught_up( + worker.app(), + ModelCommand::SetCell { + cell, + value: CellValue::Number(2.0), + }, + ); + worker.on_frame(ClientFrame::Command(other)); + + let out = worker.on_frame(ClientFrame::Command(stale)); + assert!( + matches!(out.as_slice(), [ServerFrame::Rejected(_)]), + "a stale batch must be refused, got {out:?}" + ); + assert_eq!( + value_at(&worker.app().model_state.workbook, 0, 0), + Some(CellValue::Number(2.0)), + "and refusing must leave the document untouched" + ); + } + + /// A resize changes no document state, but it moves this client's window, + /// so it must still be answered with the grid the new extent can show. + #[test] + fn a_resize_is_answered_although_nothing_is_dirty() { + let mut worker = worker(); + connect(&mut worker); + let out = worker.on_frame(ClientFrame::Viewport { rows: 1, cols: 1 }); + let cells: Vec<&GridUpdate> = out + .iter() + .filter_map(|f| match f { + ServerFrame::Commands(cmds) => Some(cmds), + _ => None, + }) + .flatten() + .filter_map(|c| match c { + Command::Project(Projection::Grid(u)) => Some(u), + _ => None, + }) + .collect(); + assert!( + !cells.is_empty(), + "a resize must re-project the grid: {out:?}" + ); + } + + /// The `Key` arm answers nothing. Asserted at the worker so that wiring it + /// halfway fails here rather than in a browser. + #[test] + fn a_key_frame_is_answered_with_nothing_and_moves_nothing() { + let mut worker = worker(); + connect(&mut worker); + let before = worker.app().cursor().selected; + let out = worker.on_frame(ClientFrame::key(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + assert!(out.is_empty(), "improvise-bpq2 is not built: {out:?}"); + assert_eq!(worker.app().cursor().selected, before); + } + + /// *Why* it answers nothing, demonstrated rather than asserted in prose. + /// + /// A worker can call `reduce_key` — it holds the authoritative `App`, + /// which is the thing a socket server lacks. What neither has is a way to + /// tell a client that the **host's own `ViewState`** moved: a client's + /// slice advances only by folding `Command`s, and resolving a keypress + /// produces none. So the two slices diverge, and `ClientSession::viewport` + /// goes on reading the mirror's cursor, which means the grid does not even + /// follow. That is improvise-bpq2's real cost, and it is a ticket rather + /// than plumbing. + #[test] + fn a_key_resolved_on_the_host_moves_state_no_client_can_hear() { + let mut app = App::new(workbook(), None); + app.rebuild_layout(); + let mut sessions = Sessions::default(); + let (id, _) = sessions.connect(&app, 8, 4); + + let emitted = reduce::reduce_key( + &mut app, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + + assert_eq!( + app.cursor().selected, + (1, 0), + "precondition: the key really did move the authoritative cursor" + ); + assert_eq!( + sessions.get_mut(id).unwrap().view().cursor().selected, + (0, 0), + "the client's mirror did not move, so the two have diverged" + ); + assert!( + !emitted + .iter() + .any(|c| matches!(c, Command::SetSelected { .. })), + "and nothing in what reduce_key emits would let a client catch up: {emitted:?}" + ); + } + + /// The JSON boundary a `MessagePort` actually carries, and the failure a + /// worker must not swallow. + #[test] + fn on_message_speaks_json_and_refuses_a_frame_it_cannot_parse() { + let mut worker = worker(); + let out = worker + .on_message(r#"{"Viewport":{"rows":8,"cols":4}}"#) + .expect("a well-formed frame"); + assert_eq!(out.len(), 1); + serde_json::from_str::(&out[0]).expect("what goes back is a ServerFrame"); + + assert!( + worker.on_message(r#"{"Nonsense":{}}"#).is_err(), + "a malformed frame must be loud, not dropped" + ); + } +} diff --git a/crates/improvise-worker-server/tests/post_message.rs b/crates/improvise-worker-server/tests/post_message.rs new file mode 100644 index 00000000..e2943d00 --- /dev/null +++ b/crates/improvise-worker-server/tests/post_message.rs @@ -0,0 +1,322 @@ +//! The main thread's traffic, simulated (improvise-9x6 AC 4). +//! +//! The counterpart of `improvise-ws-server`'s `tests/socket.rs` and the wasm +//! client's `tests/browser_round_trip.rs`: a real `Worker` over a real `App` +//! over a real `Storage`, driven with **nothing but strings** — exactly what +//! crosses a `MessagePort` — and asserted on the strings that come back. +//! +//! Two things this can see that a unit test cannot, and one it cannot. +//! +//! It sees the **JSON boundary**: every frame is serialized and deserialized, +//! so a `ClientFrame` the browser could compose but this crate could not parse +//! fails here. And it sees **arrival order**, because the harness folds the +//! frames in the order the worker returned them, which is the order a +//! `postMessage` loop must preserve (gotcha 33). +//! +//! What it cannot see is OPFS. `MemoryFs` stands in, so what is exercised is +//! the `Storage` *contract* rather than the browser's implementation of it — +//! `OpfsFs` owes `storage_contract` and has never been run (gotcha 28, +//! improvise-xf1 deferred). Everything above the trait is real. + +use std::path::{Path, PathBuf}; + +use improvise_command::ui::app::App; +use improvise_command::ui::reduce; +use improvise_core::model::cell::{CellKey, CellValue}; +use improvise_core::view::Axis; +use improvise_core::view::layout::GridLayout; +use improvise_core::workbook::Workbook; +use improvise_io::persistence; +use improvise_io::storage::MemoryFs; +use improvise_protocol::{ + CellId, ClientFrame, Command, GridCache, ModelCommand, Projection, RecordCell, ServerFrame, + WidgetCache, +}; +use improvise_worker_server::vfs::{self, Opening}; +use improvise_worker_server::worker::Worker; + +/// The document a fresh origin is seeded with, written by the real writer. +/// +/// Not a hand-rolled `.improv`: the virtual category is spelled `Measure` on +/// disk and `_Measure` in the model, and getting that wrong yields a document +/// that parses into an empty grid — which reads as a projection bug rather +/// than a bad fixture. +fn demo_bytes() -> Vec { + let mut wb = Workbook::new("Quarterly"); + wb.add_category("Region").unwrap(); + for item in ["North", "South", "East"] { + wb.add_item("Region", item).unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + for (region, value) in [("North", 10.0), ("South", 20.0), ("East", 30.0)] { + wb.model.data.set( + CellKey::new(vec![ + ("Region".to_string(), region.to_string()), + ("_Measure".to_string(), "Revenue".to_string()), + ]), + CellValue::Number(value), + ); + } + persistence::format_md(&wb).into_bytes() +} + +/// The main thread: it holds a port, a grid cache, and no protocol knowledge +/// beyond "post a string, fold what comes back, in order". +struct MainThread { + worker: Worker, + grid: GridCache, +} + +impl MainThread { + fn boot(storage: MemoryFs, path: &Path) -> Self { + let workbook = vfs::open(&storage, &Opening::named(path).with_seed(&demo_bytes())) + .expect("the demo seeds into an empty origin"); + let mut app = App::with_storage(workbook, Some(path.to_path_buf()), Box::new(storage)); + app.rebuild_layout(); + Self { + worker: Worker::new(app), + grid: GridCache::default(), + } + } + + /// Post one frame and fold everything that comes back. Panics on a frame + /// the worker refuses, which is what a `postMessage` handler should do + /// with an error it cannot route. + fn post(&mut self, frame: &ClientFrame) { + let json = serde_json::to_string(frame).expect("the client serializes its frame"); + let replies = self + .worker + .on_message(&json) + .expect("the worker accepts it"); + for reply in replies { + let frame: ServerFrame = serde_json::from_str(&reply).expect("a ServerFrame"); + self.fold(&frame); + } + } + + /// Post a frame and report the raw replies, for the tests whose subject is + /// what the worker *said* rather than what the client ends up holding. + fn ask(&mut self, frame: &ClientFrame) -> Vec { + let json = serde_json::to_string(frame).unwrap(); + self.worker + .on_message(&json) + .expect("the worker accepts it") + .iter() + .map(|r| serde_json::from_str(r).expect("a ServerFrame")) + .collect() + } + + fn fold(&mut self, frame: &ServerFrame) { + match frame { + ServerFrame::Snapshot(snapshot) => { + self.grid = GridCache::default(); + for projection in &snapshot.seed { + if let Projection::Grid(update) = projection { + self.grid.apply(update); + } + } + } + ServerFrame::Commands(cmds) => { + for cmd in cmds { + if let Command::Project(Projection::Grid(update)) = cmd { + self.grid.apply(update); + } + } + } + ServerFrame::Rejected(_) => {} + } + } + + fn text_at(&self, row: usize, col: usize) -> &str { + &self.grid.cells[row][col].text + } + + /// The surrogate the server projected for a cell — the only handle a + /// client has to name one (ADR 002). + fn cell(&self, row: usize, col: usize) -> RecordCell { + match self.grid.cells[row][col].id.as_ref().expect("a cell") { + CellId::Record(rc) => rc.clone(), + CellId::Aggregate(_) => panic!("({row},{col}) is an aggregate"), + } + } + + /// A model command composed against the document as it stands. A real + /// client builds its `Basis` from `Projection::Basis`; this shortcut takes + /// it off the `App`, which only a host can do — the staleness case is + /// covered by `a_batch_composed_against_a_moved_document_is_refused`. + fn caught_up(&self, cmd: ModelCommand) -> ClientFrame { + ClientFrame::Command(reduce::caught_up(self.worker.app(), cmd)) + } +} + +fn origin() -> (MemoryFs, PathBuf) { + (MemoryFs::new(), PathBuf::from("quarterly.improv")) +} + +/// The whole acceptance criterion in one run: a document is seeded into an +/// empty origin, opened, projected, edited over the wire, saved, and found +/// again on disk. +#[test] +fn the_worker_loads_projects_edits_and_persists_over_post_message() { + let (storage, path) = origin(); + let mut main = MainThread::boot(storage, &path); + + main.post(&ClientFrame::Viewport { rows: 10, cols: 4 }); + assert_eq!(main.grid.row_labels, vec!["North", "South", "East"]); + assert_eq!(main.text_at(0, 0), "10"); + + let cell = main.cell(2, 0); + main.post(&main.caught_up(ModelCommand::SetCell { + cell, + value: CellValue::Number(31.0), + })); + assert_eq!( + main.text_at(2, 0), + "31", + "the edit must come back through the projection stream, not be assumed" + ); + + // Nothing here asks to save. There is no `ModelCommand::Save` for a client + // to send — `effect::Save` is `ViewOnly`, so the wire vocabulary derived + // from the model effects cannot name it (gotcha 38) — so the worker + // persists on its own behalf after a batch dirties the document. + let saved = persistence::load_from(&*main.worker.app().storage, &path) + .expect("the document is in the VFS"); + assert_eq!( + value_at(&saved, 2, 0), + Some(CellValue::Number(31.0)), + "the edit never reached storage" + ); +} + +/// The value at a grid position in a document freshly loaded from the backend. +/// A `CellValue`, not display text, so the assertion does not depend on the +/// view's number format. +fn value_at(wb: &Workbook, row: usize, col: usize) -> Option { + let layout = GridLayout::new(&wb.model, wb.active_view()); + let key = layout.cell_key(row, col)?; + wb.model.evaluate_aggregated(&key, &layout.none_cats) +} + +/// A second worker over the same backend sees what the first one saved. This +/// is what a browser reload is, and the reason `Storage` is in the picture at +/// all rather than the document living in the page. +#[test] +fn a_reload_over_the_same_origin_reopens_what_was_saved() { + let (storage, path) = origin(); + let mut main = MainThread::boot(storage, &path); + main.post(&ClientFrame::Viewport { rows: 10, cols: 4 }); + let cell = main.cell(0, 0); + main.post(&main.caught_up(ModelCommand::SetCell { + cell, + value: CellValue::Number(77.0), + })); + // The "reload": a fresh worker over the backend the first one wrote to, + // offered the same seed it was originally given. + let workbook = vfs::open( + &*main.worker.app().storage, + &Opening::named(&path).with_seed(&demo_bytes()), + ) + .expect("reopens"); + let mut app = App::with_storage(workbook, Some(path.clone()), Box::new(MemoryFs::new())); + app.rebuild_layout(); + let mut reloaded = MainThread { + worker: Worker::new(app), + grid: GridCache::default(), + }; + reloaded.post(&ClientFrame::Viewport { rows: 10, cols: 4 }); + assert_eq!( + reloaded.text_at(0, 0), + "77", + "the reload re-seeded over the user's saved work" + ); +} + +/// ADR 001 over a `MessagePort`. `ServerFrame::Rejected` is not socket-only, +/// and a worker that never sent one would leave a client's basis claim +/// unanswerable. +#[test] +fn a_batch_composed_against_a_moved_document_is_refused() { + let (storage, path) = origin(); + let mut main = MainThread::boot(storage, &path); + main.post(&ClientFrame::Viewport { rows: 10, cols: 4 }); + + let cell = main.cell(0, 0); + let stale = main.caught_up(ModelCommand::SetCell { + cell: cell.clone(), + value: CellValue::Number(1.0), + }); + let winner = main.caught_up(ModelCommand::SetCell { + cell, + value: CellValue::Number(2.0), + }); + main.post(&winner); + + let replies = main.ask(&stale); + assert!( + matches!(replies.as_slice(), [ServerFrame::Rejected(_)]), + "expected a rejection, got {replies:?}" + ); + assert_eq!( + main.text_at(0, 0), + "2", + "a refused batch changed the document" + ); +} + +/// The handshake, enforced by dropping rather than by closing: a worker has no +/// connection to close, and the client has been sent no document to compose +/// against. +#[test] +fn a_command_before_the_first_viewport_is_dropped() { + let (storage, path) = origin(); + let mut main = MainThread::boot(storage, &path); + let replies = main.ask(&ClientFrame::Command(Command::SetSelected { + row: 2, + col: 0, + })); + assert!( + replies.is_empty(), + "answered before the handshake: {replies:?}" + ); + assert_eq!(main.worker.app().cursor().selected, (0, 0)); +} + +/// A resize is a fan-out with nothing dirty: the document did not move, but +/// the window did, so the grid must be re-projected to the new extent. +#[test] +fn a_resize_reprojects_the_grid_although_the_document_is_unchanged() { + let (storage, path) = origin(); + let mut main = MainThread::boot(storage, &path); + main.post(&ClientFrame::Viewport { rows: 1, cols: 4 }); + assert_eq!(main.grid.row_labels.len(), 1, "one row fits"); + + main.post(&ClientFrame::Viewport { rows: 10, cols: 4 }); + assert_eq!( + main.grid.row_labels.len(), + 3, + "growing the window must deliver the rows it can now show" + ); +} + +/// `Storage::list` is the document picker a browser has no OS file manager +/// for, and it must see documents the worker created as well as seeded ones. +#[test] +fn the_vfs_offers_every_document_the_origin_holds() { + let (storage, path) = origin(); + let main = MainThread::boot(storage, &path); + let fs = &*main.worker.app().storage; + + vfs::open(fs, &Opening::named("scratch.improv")).expect("a second, empty document"); + + assert_eq!( + vfs::documents(fs).unwrap(), + vec![ + PathBuf::from("quarterly.improv"), + PathBuf::from("scratch.improv") + ], + "a picker must see both the seeded document and the created one" + ); +} diff --git a/web/.gitignore b/web/.gitignore index 3cf32dc9..6f1a95d7 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,2 +1,3 @@ # wasm-pack output; built, never committed. pkg/ +worker-pkg/ diff --git a/web/build.sh b/web/build.sh index dffbf488..b4c0904a 100755 --- a/web/build.sh +++ b/web/build.sh @@ -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 diff --git a/web/worker.js b/web/worker.js new file mode 100644 index 00000000..d27b72cd --- /dev/null +++ b/web/worker.js @@ -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); +});