docs(web,harness): cap the JavaScript and the browser harness
web/app.js's transport/watchdog/frameCost arguments and web/worker.js's two ordering rules — including the one nothing here can test, that an `async` message listener does not serialize — are now prose in context/code/clients.md. The harness keeps its own section: why a worker, why a report, the engine coverage and what `wipe` deliberately cannot clear. harness/tests/*.spec.js's comments were the record of what each assertion proves; they are in context/test-evidence/clients.md under the exact test titles, including the `test.fail` measurement that could NOT become a CI guard because its trigger is the host's network stack, and the closed-invalid improvise-xps8 report that a simulated fix appeared to confirm. ws-server/src/main.rs's ```text fence was prose claiming the doctest exemption, not a runnable doctest; the synopsis is now two lines and the full form is in the prose file. No fences remain in this unit. Verified: node --check on every JS file touched; the non-comment diff for both '*.rs' and '*.js' is empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+41
-179
@@ -1,47 +1,5 @@
|
||||
// The browser half of improvise-cr3, kept as thin as it can be made, now
|
||||
// talking to either of two peers (improvise-djm).
|
||||
//
|
||||
// The boundary is JSON strings in and out, so this file holds no protocol
|
||||
// knowledge at all: it never inspects a Command, never builds a CellId, never
|
||||
// decides whether a cell is editable or whether a typed string is a number.
|
||||
// Five calls are the whole harness —
|
||||
//
|
||||
// client.set_viewport(rows, cols) -> the handshake frame, and every resize;
|
||||
// also records the window so keys resolve here
|
||||
// client.on_frame(json) -> fold one frame, in arrival order
|
||||
// client.render_model() -> everything to paint, already decided
|
||||
// client.typed_cell(r, c, s) -> the frame for "the user typed s here"
|
||||
// client.on_key_event(k,c,a,s,m) -> the frame for a keypress, folded locally
|
||||
// first where this client may answer it
|
||||
//
|
||||
// — and the two rules this file must not break are ordering and framing:
|
||||
// frames are folded one at a time in the order they arrive (a cell update
|
||||
// that overtakes its labels is dropped, not replayed), and the *first* frame
|
||||
// out must be the viewport, because the server builds the snapshot at it.
|
||||
//
|
||||
// # Two transports, one client
|
||||
//
|
||||
// The wasm bundle is the SAME artifact either way (improvise-xks6: one
|
||||
// envelope pair, two deployments). Only where the strings come from differs,
|
||||
// so the difference is confined to `openTransport` and nothing below it knows
|
||||
// which peer it has:
|
||||
//
|
||||
// default an in-page Web Worker hosting the whole App over OPFS
|
||||
// (improvise-9x6). No server process; this is what makes a
|
||||
// GitHub Pages copy of this directory a working application.
|
||||
// ?ws=host:port
|
||||
// a websocket to `improvise-ws-server`. That process is how a
|
||||
// document opened in the TUI is shared, so the path is kept; the
|
||||
// presence of the parameter is the whole selection mechanism,
|
||||
// because a second knob naming the same fact is a second thing to
|
||||
// get wrong. The scheme defaults to `wss://` — see `socketUrl`.
|
||||
//
|
||||
// Neither is reachable silently. Every transport writes what it is doing to
|
||||
// `#status` — "starting", "connected", "cannot reach …", "disconnected" — so
|
||||
// a peer that never arrives reads differently from one that went away.
|
||||
// "disconnected" means exactly the second: a socket that opened and then
|
||||
// closed. A socket that never opened keeps saying "cannot reach …", because
|
||||
// the close event there is the tail of the failure and not news.
|
||||
// The browser half of improvise-cr3, talking to either of two peers: five
|
||||
// string calls, no protocol knowledge. See context/code/clients.md.
|
||||
|
||||
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
||||
|
||||
@@ -50,23 +8,8 @@ const params = new URLSearchParams(location.search);
|
||||
// the scheme is NOT the caller's to get wrong: see `socketUrl`.
|
||||
const WS_PEER = params.get("ws");
|
||||
|
||||
/**
|
||||
* The websocket URL for `?ws=`, defaulting to TLS.
|
||||
*
|
||||
* `wss://` is the default and a bare `host:port` gets it, because the address
|
||||
* that works from where this page is actually served is the one it should be
|
||||
* easiest to write. This page ships to GitHub Pages, which is https, and an
|
||||
* https origin refuses a `ws://` address as mixed content — so a `ws://`
|
||||
* default would hand the browser a URL that cannot work and report it as a
|
||||
* connection failure, which reads like "the server is not running".
|
||||
*
|
||||
* An explicit `ws://` is honoured only where it can function: a page served
|
||||
* over plain http, which is the local `python3 -m http.server` case that pairs
|
||||
* with `improvise-ws-server` (which speaks plain `ws://` only — it has no TLS,
|
||||
* so reaching it from https means terminating TLS in front of it). From an
|
||||
* https page an explicit `ws://` is upgraded rather than passed through, since
|
||||
* passing it through can only produce a failure the user cannot act on.
|
||||
*/
|
||||
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
|
||||
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
|
||||
function socketUrl(peer) {
|
||||
const secure = location.protocol === "https:";
|
||||
if (/^wss:\/\//i.test(peer)) return peer;
|
||||
@@ -95,9 +38,8 @@ const el = {
|
||||
await init();
|
||||
const client = new Client();
|
||||
|
||||
// `#status` carries two independent facts — what the transport is doing, and
|
||||
// what the document last said — so it composes them rather than letting a
|
||||
// repaint quietly erase a connection error.
|
||||
// `#status` carries two independent facts, composed rather than overwritten:
|
||||
// a repaint must not quietly erase a connection error.
|
||||
let shellStatus = "";
|
||||
let modelStatus = "";
|
||||
function paintStatus() {
|
||||
@@ -108,19 +50,8 @@ function setShellStatus(text) {
|
||||
paintStatus();
|
||||
}
|
||||
|
||||
// What the grid's own frame costs, in whole cells, as of the last repaint.
|
||||
// One header row and one header column was right until improvise-anr9: the
|
||||
// column header is now one row PER LEVEL plus an optional group row, the row
|
||||
// header is `row_levels` columns wide, and inline group headers take a row
|
||||
// each. Reserving one of each announces a viewport the page cannot show, and
|
||||
// `#grid` has `overflow: auto`, so the DOM would acquire a scroll position of
|
||||
// its own while the client's cursor offsets were computed from the larger
|
||||
// window — putting the selected cell off screen.
|
||||
//
|
||||
// It is measured off the painted model rather than predicted, and defaults to
|
||||
// the old one-and-one before the first paint, which is the only moment there
|
||||
// is nothing to measure. What it counts, and the one thing it deliberately
|
||||
// does not, is at the tail of `repaint`.
|
||||
// What the grid's own frame costs, in whole cells, as of the last repaint —
|
||||
// measured off the painted model, never predicted. context/code/clients.md.
|
||||
let frameCost = { rows: 1, cols: 1 };
|
||||
|
||||
/** The extent this window can show, in whole DATA cells. */
|
||||
@@ -135,22 +66,12 @@ function extent() {
|
||||
function sendViewport() {
|
||||
const { rows, cols } = extent();
|
||||
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
|
||||
// frame, but it also RECORDS the window. A client learns its window only
|
||||
// from what it announced -- `row_frame.len()` is the window clipped to the
|
||||
// grid, so the two differ exactly at the bottom edge, which is where the
|
||||
// scroll arithmetic matters. Without this the client cannot clamp, so every
|
||||
// cursor key goes upstream into `Host::handle`'s empty vec and the arrow
|
||||
// keys silently do nothing (improvise-bpq2).
|
||||
// frame, but it also RECORDS the window. Why that matters: clients.md.
|
||||
transport.send(client.set_viewport(rows, cols));
|
||||
}
|
||||
|
||||
// A peer that never answers the handshake. The socket has an `error` event
|
||||
// for this; the worker has none that fires, because worker.js catches its own
|
||||
// `start()` rejection — so a browser with no OPFS, or a worker bundle that
|
||||
// failed to load, would leave the page saying "in-page worker" over an empty
|
||||
// grid forever. The watchdog is deliberately about the *first frame* rather
|
||||
// than about either peer's own failure modes: it is the one condition that
|
||||
// means the same thing in both deployments.
|
||||
// A peer that never answers the handshake. Deliberately about the *first
|
||||
// frame*, not either peer's own failure modes — context/code/clients.md.
|
||||
const HANDSHAKE_TIMEOUT_MS = 8000;
|
||||
let watchdog = null;
|
||||
function armWatchdog(message) {
|
||||
@@ -158,10 +79,8 @@ function armWatchdog(message) {
|
||||
watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// A peer that failed *loudly* has already said something more precise than
|
||||
// the watchdog's guess, so the watchdog must be disarmed and not merely
|
||||
// pre-empted: it fires later and would overwrite the better message with
|
||||
// "did not answer" (PR #13 review). The watchdog is for silence only.
|
||||
// A peer that failed *loudly* has already said something more precise, so the
|
||||
// watchdog must be DISARMED and not merely pre-empted (PR #13 review).
|
||||
function setShellFailure(text) {
|
||||
clearTimeout(watchdog);
|
||||
watchdog = null;
|
||||
@@ -174,9 +93,8 @@ function onFrame(text) {
|
||||
try {
|
||||
client.on_frame(text);
|
||||
} catch (err) {
|
||||
// `on_frame` returns a Result, which wasm-bindgen throws. Thrown in a bare
|
||||
// listener it reached the devtools console and nothing else, and the grid
|
||||
// simply stopped updating with nothing on screen saying why (improvise-hwpi).
|
||||
// `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare
|
||||
// listener it reached the console alone and the grid just stopped.
|
||||
setShellStatus("bad frame — the grid has stopped updating (see console)");
|
||||
console.error("improvise: frame rejected", text, err);
|
||||
return;
|
||||
@@ -184,7 +102,7 @@ function onFrame(text) {
|
||||
repaint();
|
||||
}
|
||||
|
||||
// ── transports ───────────────────────────────────────────────────────────
|
||||
// Transports.
|
||||
|
||||
/** The in-page worker: the standalone deployment, no server process. */
|
||||
function workerTransport() {
|
||||
@@ -202,7 +120,7 @@ function workerTransport() {
|
||||
});
|
||||
setShellStatus(`in-page worker · ${DOC}`);
|
||||
// Frames posted before the worker has finished `start()` are held by
|
||||
// worker.js and replayed in order, so the handshake can go out immediately.
|
||||
// worker.js in arrival order, so the handshake can go out immediately.
|
||||
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
|
||||
}
|
||||
|
||||
@@ -210,12 +128,8 @@ function workerTransport() {
|
||||
function socketTransport() {
|
||||
const socket = new WebSocket(WS_URL);
|
||||
const unreachable = `cannot reach ${WS_URL} — is improvise-ws-server running?`;
|
||||
// Did this socket ever open? The page has to record that itself, because
|
||||
// nothing on the close event carries it: `readyState` is already CLOSED by
|
||||
// the time a close listener runs, whichever happened, and a refused
|
||||
// connection and a mid-session network drop are BOTH code 1006 with
|
||||
// `wasClean: false`. So "never connected" and "was connected and went away"
|
||||
// are distinguishable only by remembering.
|
||||
// Did this socket ever open? The page must record it itself: nothing on the
|
||||
// close event can discriminate. See context/code/clients.md.
|
||||
let opened = false;
|
||||
const ready = new Promise((resolve) => {
|
||||
socket.addEventListener("open", () => {
|
||||
@@ -226,25 +140,10 @@ function socketTransport() {
|
||||
});
|
||||
socket.addEventListener("message", (event) => onFrame(event.data));
|
||||
// Without this, a connection that never opens left #status blank and
|
||||
// "server not running" read exactly like "still connecting" (improvise-hwpi).
|
||||
// "server not running" read exactly like "still connecting".
|
||||
socket.addEventListener("error", () => setShellFailure(unreachable));
|
||||
// A socket that never opened did not "disconnect" — it failed to connect,
|
||||
// and the close event is the tail of that failure rather than a second,
|
||||
// vaguer thing that happened (improvise-taxx). Writing "disconnected" here
|
||||
// unconditionally replaced the `error` listener's precise message with the
|
||||
// least informative of the two facts.
|
||||
//
|
||||
// Same family as the watchdog bug and NOT the same fix: the watchdog is
|
||||
// disarmed because it is for silence only, whereas suppressing `close`
|
||||
// outright would hide a genuine mid-session disconnect, which is the one
|
||||
// thing this listener is really for. Hence the split rather than a guard.
|
||||
// Both arms go through `setShellFailure`, which DISARMS the watchdog. That
|
||||
// matters on the opened path: the watchdog is armed after `transport.ready`
|
||||
// resolves — i.e. on `open` — so a socket that opened and then dropped
|
||||
// before its first frame reaches here with the timer still pending, and
|
||||
// "disconnected" is more precise than the guess it would otherwise fire 8s
|
||||
// later. On the never-opened path there is no timer yet and the clear is a
|
||||
// no-op. Neither arm leaves one armed.
|
||||
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
|
||||
// route through `setShellFailure`, which disarms the watchdog — clients.md.
|
||||
socket.addEventListener("close", () =>
|
||||
setShellFailure(opened ? "disconnected" : unreachable),
|
||||
);
|
||||
@@ -259,7 +158,7 @@ function socketTransport() {
|
||||
|
||||
const transport = WS_URL ? socketTransport() : workerTransport();
|
||||
|
||||
// ── painting ─────────────────────────────────────────────────────────────
|
||||
// Painting.
|
||||
|
||||
let painted = -1;
|
||||
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
|
||||
@@ -287,26 +186,8 @@ function repaint() {
|
||||
el.grid.replaceChildren(gridTable(model));
|
||||
el.categories.replaceChildren(categoryList(model));
|
||||
|
||||
// What the frame costs, so the NEXT viewport announcement is honest: the
|
||||
// header lines, a group line if there is one, the row header's width in
|
||||
// columns, and every inline group row.
|
||||
//
|
||||
// Inline group ROWS are NOT reserved, and that is a decision rather than
|
||||
// an oversight. They take real height, but how many are in view depends on
|
||||
// how many rows the window asked for, so every way of counting them is
|
||||
// either a feedback loop (count the current ones: a smaller window crosses
|
||||
// fewer groups and asks for a larger one) or a ratchet (keep the maximum:
|
||||
// a group-dense grid stores an oversized cost on its first oversized
|
||||
// projection and never lets it fall — one group header per row clamps the
|
||||
// grid to a single row, permanently). Both were tried on this PR.
|
||||
//
|
||||
// What is left is an over-request bounded by the groups in view. The cost
|
||||
// is that a grouped grid asks for a few rows more than the table shows,
|
||||
// which matters only for a selection near the bottom edge — and that gap
|
||||
// is not new and not specific to groups: nothing in this page calls
|
||||
// `scrollIntoView` at all, so a selection the client has not yet scrolled
|
||||
// to can already sit below the fold. Filed as its own ticket rather than
|
||||
// answered with a heuristic in a file no test in this repo can execute.
|
||||
// What the frame costs, so the NEXT viewport announcement is honest.
|
||||
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
||||
const next = {
|
||||
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
|
||||
cols: model.row_levels,
|
||||
@@ -338,9 +219,8 @@ function gridTable(model) {
|
||||
for (const group of model.col_groups) {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = group.span;
|
||||
// `label` is null for a run in no group. An empty STRING is a legal
|
||||
// group name and still gets its indicator, which is why this is a null
|
||||
// check and not a truthiness test.
|
||||
// `label` is null for a run in no group; an empty STRING is a legal group
|
||||
// name, so this is a null check and not a truthiness test.
|
||||
th.textContent =
|
||||
group.label === null ? "" : indicator(group.collapsed) + " " + group.label;
|
||||
th.classList.add("group");
|
||||
@@ -361,9 +241,8 @@ function gridTable(model) {
|
||||
}
|
||||
|
||||
const body = table.createTBody();
|
||||
// The cell coordinates a click sends are DATA row indices, and a group
|
||||
// header is not a data row -- so this counts rows rather than trusting the
|
||||
// position in `model.rows`.
|
||||
// The cell coordinates a click sends are DATA row indices and a group header
|
||||
// is not a data row, so this counts rather than trusting `model.rows`.
|
||||
let r = -1;
|
||||
for (const row of model.rows) {
|
||||
const tr = body.insertRow();
|
||||
@@ -389,21 +268,16 @@ function gridTable(model) {
|
||||
|
||||
row.cells.forEach((cell, c) => {
|
||||
const td = tr.insertCell();
|
||||
// A keyboard edit is open on the selected cell when `model.editing` is
|
||||
// a string; it holds what has been typed so far. Both facts are
|
||||
// decided in Rust (`RenderModel::editing`) — the page does not know
|
||||
// which modes are editing modes or which buffer they type into.
|
||||
// Without this the mode indicator would say INSERT while every
|
||||
// keystroke vanished into a buffer nobody drew.
|
||||
// A keyboard edit is open when `model.editing` is a string, and it holds
|
||||
// what has been typed. Both facts are decided in Rust.
|
||||
const typing = cell.selected && typeof model.editing === "string";
|
||||
td.textContent = typing ? model.editing + "\u258f" : cell.text;
|
||||
td.classList.toggle("numeric", cell.numeric && !typing);
|
||||
td.classList.toggle("error", cell.error);
|
||||
td.classList.toggle("selected", cell.selected);
|
||||
td.classList.toggle("typing", typing);
|
||||
// `editable` is the server's answer relayed through Rust: a stored
|
||||
// cell. An aggregate is a drill target and drill is not on the wire
|
||||
// yet, so it is simply not offered here.
|
||||
// `editable` is the server's answer relayed through Rust: a stored cell.
|
||||
// Drill is not on the wire yet, so an aggregate is not offered.
|
||||
td.classList.toggle("editable", cell.editable);
|
||||
if (cell.editable)
|
||||
td.addEventListener("click", () => edit(td, dataRow, c, cell.edit_text));
|
||||
@@ -423,9 +297,8 @@ function indicator(collapsed) {
|
||||
function edit(td, row, col, seed) {
|
||||
if (editing) return;
|
||||
editing = { row, col };
|
||||
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn
|
||||
// one. `td.textContent` has been through the number format, so a stored
|
||||
// 1200 reads "1,200" and committing that stores the string "1,200".
|
||||
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn one:
|
||||
// `td.textContent` has been through the number format.
|
||||
const original = seed;
|
||||
const input = document.createElement("input");
|
||||
input.value = original;
|
||||
@@ -434,7 +307,7 @@ function edit(td, row, col, seed) {
|
||||
input.select();
|
||||
|
||||
// Enter removes the focused <input>, which fires `blur`, which calls
|
||||
// `close` again. Harmless before, but only by accident (improvise-hwpi).
|
||||
// `close` again. Harmless before, but only by accident.
|
||||
let closed = false;
|
||||
const close = (commit) => {
|
||||
if (closed) return;
|
||||
@@ -475,16 +348,8 @@ function categoryList(model) {
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── the keyboard ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// `Client::on_key_event` classifies the event and folds it locally where this
|
||||
// client may answer it — text entry, and since improvise-bpq2 the grid, panel
|
||||
// and tile cursors too, which is why `sendViewport` must call `set_viewport`
|
||||
// and not `Client.viewport` (keys.rs carries the list, and what is still
|
||||
// upstream). Anything it cannot answer becomes a `ClientFrame::Key`, which
|
||||
// `Host::handle` answers with an empty vec: those keys are sent and do
|
||||
// nothing. That is settled rather than unfinished — the host must not know
|
||||
// the keymap — and closing one means teaching this client, not that arm.
|
||||
// The keyboard. `Client::on_key_event` folds locally where it can; what it
|
||||
// cannot answer is sent and does nothing, by design (context/code/clients.md).
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
// The inline editor owns the keyboard while it is open. It stops
|
||||
@@ -496,18 +361,15 @@ window.addEventListener("keydown", (e) => {
|
||||
|
||||
const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey);
|
||||
if (frame) {
|
||||
// A frame means the page claimed the key, so the browser must not also
|
||||
// act on it — an arrow that both moves the cursor and scrolls the page is
|
||||
// the worse of the two errors. Combinations under Ctrl or Alt are left
|
||||
// alone regardless: those are the browser's own shortcuts, and this
|
||||
// client resolves nothing under them anyway.
|
||||
// A frame means the page claimed the key, so the browser must not also act
|
||||
// on it. Ctrl/Alt combinations are left alone: they are the browser's.
|
||||
if (!e.ctrlKey && !e.altKey) e.preventDefault();
|
||||
transport.send(frame);
|
||||
}
|
||||
repaint();
|
||||
});
|
||||
|
||||
// ── start ────────────────────────────────────────────────────────────────
|
||||
// Start.
|
||||
|
||||
await transport.ready;
|
||||
sendViewport(); // the handshake: the snapshot is built at this extent
|
||||
|
||||
+9
-46
@@ -1,45 +1,12 @@
|
||||
// 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).
|
||||
//
|
||||
// Rule 1 is about frames within one call's replies AND about the calls
|
||||
// themselves, which is why every message is chained onto one tail promise
|
||||
// below rather than handled by an `async` listener: an `async` listener does
|
||||
// not serialize. The event loop is free to start the next `message` while an
|
||||
// earlier one is suspended at an `await`, so two `on_message`/`flush` pairs
|
||||
// interleave and a later frame's replies can be posted before an earlier
|
||||
// frame's. The chain is also what makes "frames that arrived before start()
|
||||
// finished" an ordinary case of the same rule instead of a second code path
|
||||
// with its own drain loop — and that loop had the race in it twice over,
|
||||
// since `server` was set before it ran.
|
||||
//
|
||||
// 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).
|
||||
// The worker half of the standalone deployment (improvise-9x6): strings only,
|
||||
// every decision in Rust. The two rules it must not break: context/code/clients.md.
|
||||
|
||||
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.
|
||||
// Where the first copy of the document comes from on a fresh origin: fetched
|
||||
// and WRITTEN through the VFS, never 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:
|
||||
@@ -66,9 +33,8 @@ async function handle(data) {
|
||||
await server.flush();
|
||||
}
|
||||
|
||||
// Startup, and the head of the chain. The client sends its `Viewport` as soon
|
||||
// as it loads, so frames arrive before this settles; chaining onto it is what
|
||||
// holds them, in arrival order, without a queue to drain.
|
||||
// Startup, and the head of the chain: frames arrive before this settles, and
|
||||
// chaining onto it holds them in arrival order with no queue to drain.
|
||||
const started = (async () => {
|
||||
await init();
|
||||
server = await start(DOC, await seedBytes());
|
||||
@@ -78,8 +44,7 @@ started.catch((err) => {
|
||||
});
|
||||
|
||||
// One frame at a time, in arrival order: the listener is deliberately NOT
|
||||
// `async`. It appends to the chain and returns, so the ordering does not
|
||||
// depend on when the event loop chooses to resume a suspended listener.
|
||||
// `async` — see context/code/clients.md.
|
||||
let tail = started.catch(() => {});
|
||||
|
||||
self.onmessage = (event) => {
|
||||
@@ -91,10 +56,8 @@ self.onmessage = (event) => {
|
||||
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. Caught here rather than
|
||||
// left on the chain so one bad frame does not poison every later one.
|
||||
// An unhandled rejection here reaches the devtools console and nothing
|
||||
// else. Caught, so one bad frame does not poison every later one.
|
||||
console.error("improvise worker: frame rejected", event.data, err);
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user