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