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
Generated
+14
View File
@@ -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"
+1 -1
View File
@@ -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"
+38
View File
@@ -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"]
@@ -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<dyn Storage>`, 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<OpfsFs>` 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<OpfsFs>`
//! 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<dyn Storage>` 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<OpfsFs>);
impl Storage for Shared {
fn read(&self, path: &Path) -> Result<Vec<u8>> {
self.0.read(path)
}
fn write(&self, path: &Path, bytes: &[u8]) -> Result<()> {
self.0.write(path, bytes)
}
fn list(&self, dir: &Path) -> Result<Vec<PathBuf>> {
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<OpfsFs>,
}
/// 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<Vec<u8>>) -> Result<WorkerServer, JsError> {
boot(document, seed.as_deref())
.await
.map_err(|e| JsError::new(&e.to_string()))
}
async fn boot(document: &str, seed: Option<&[u8]>) -> Result<WorkerServer> {
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<Vec<String>, 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<Vec<String>, JsError> {
vfs::documents(&Shared(Rc::clone(&self.storage)))
.map(|paths| {
paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
})
.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<OpfsFs>) -> Box<dyn Storage> {
Box::new(Shared(fs))
}
+55
View File
@@ -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;
+243
View File
@@ -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<Vec<PathBuf>> {
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<PathBuf>) -> 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<Workbook> {
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"
);
}
}
@@ -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<Addressed>`.
//! 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<SessionId>,
}
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<ServerFrame> {
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<Vec<String>> {
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<ServerFrame> {
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<u8> {
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<CellValue> {
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::<ServerFrame>(&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"
);
}
}
@@ -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<u8> {
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<ServerFrame> {
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<CellValue> {
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"
);
}
+1
View File
@@ -1,2 +1,3 @@
# wasm-pack output; built, never committed.
pkg/
worker-pkg/
+21 -6
View File
@@ -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
+85
View File
@@ -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);
});