Files
improvise/web/shell.js
T
Edward Langley 12714c7527 feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.

A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.

- improvise-1wft  the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9  read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw  start() rejecting for any reason now posts BootFailed
- improvise-9cma  boot::document_name -- and a/b/c.improv was ACCEPTED as
                  c.improv, not rejected, which is worse than the report
- improvise-iobc  vfs::Origin: a seed error is a notice only when the
                  document really had to be created without it
- improvise-vep8  the watchdog arms BEFORE transport.ready; its message
                  stopped claiming the connection was accepted
- improvise-7gip  DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1  a persist that fails raises a notice; the trigger is the
                  dirty flag surviving, observed rather than predicted
- improvise-7gf8  the render breaker trips once instead of retrying forever
- improvise-ociy  non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k  the three ws-server drops are logged
- improvise-r4nf  serde failures throw instead of reading as "nothing to send"

Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:51:34 -07:00

201 lines
7.8 KiB
JavaScript

// Everything `web/app.js` decides that is not painting: no DOM, no browser
// API, one `node --test` away. What belongs here: context/code/clients.md.
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
export function socketUrl(peer, pageProtocol) {
const secure = pageProtocol === "https:";
if (/^wss:\/\//i.test(peer)) return peer;
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
return `${secure ? "wss" : "ws"}://${peer}`;
}
/** `#status` carries two independent facts, composed rather than overwritten:
* a repaint must not quietly erase a connection error. */
export function composeStatus(shell, model) {
return [shell, model].filter(Boolean).join(" · ");
}
/** The title bar's one line, in the order and spelling `src/draw.rs::draw_title`
* uses. The three facts are the host's; only the joining is here (clients.md). */
export function documentTitle(name, dirty, storedAs) {
return `${name}${storedAs ? ` (${storedAs})` : ""}${dirty ? " [+]" : ""}`;
}
/** The collapse glyph. `collapsed` is the fact, decided by the view and
* carried on the frame; only this spelling is the page's (gotcha 26). */
export function indicator(collapsed) {
return collapsed ? "▶" : "▼";
}
// Rough character metrics, used only to turn the window size into an extent.
// Being wrong here costs a row of over- or under-fetch, nothing more.
export const ROW_PX = 26;
export const COL_PX = 110;
/** How long a peer has to answer the handshake. Exported because the harness
* must jump *past* it: a spec with its own copy can understate it silently
* and every watchdog assertion then passes vacuously (clients.md). */
export const HANDSHAKE_TIMEOUT_MS = 8000;
/** The extent a box of this size can show, in whole DATA cells: the frame is
* charged for first, and at least one cell is always asked for. */
export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_PX }) {
return {
rows: Math.max(1, Math.floor(box.height / metrics.rowPx) - frameCost.rows),
cols: Math.max(1, Math.floor(box.width / metrics.colPx) - frameCost.cols),
};
}
/** What the grid's own frame costs, in whole cells — measured off the painted
* model, never predicted. Inline group ROWS are deliberately not counted. */
export function frameCostOf(model) {
return {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
cols: model.row_levels,
};
}
/** The grid, as a description: every decision made, nothing created. The page
* decides nothing here and neither does this — which labels repeat, how many
* header levels there are and which columns share a group all arrive
* answered, by the same `AxisFrame` the TUI widget reads (improvise-anr9). */
export function gridPlan(model) {
const cornerSpan = model.row_levels;
const groups = model.col_groups.map((group) => ({
span: group.span,
// `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.
text: group.label === null ? "" : `${indicator(group.collapsed)} ${group.label}`,
}));
// 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 the row's position.
let dataRow = -1;
const body = model.rows.map((row) => {
if (row.kind === "group") {
return {
kind: "group",
colSpan: model.row_levels + model.col_levels[0].length,
text: `${indicator(row.collapsed)} ${row.label}`,
};
}
dataRow += 1;
return {
kind: "data",
row: dataRow,
labels: row.labels,
cells: row.cells.map((cell, col) => {
// 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";
return {
row: dataRow,
col,
text: typing ? `${model.editing}▏` : cell.text,
editText: cell.edit_text,
editable: cell.editable,
classes: {
numeric: cell.numeric && !typing,
error: cell.error,
selected: cell.selected,
typing,
// `editable` is the server's answer relayed through Rust: a stored
// cell. Drill is not on the wire, so an aggregate is not offered.
editable: cell.editable,
},
};
}),
};
});
return { cornerSpan, groups, levels: model.col_levels, body };
}
/** What the worker just posted. A protocol frame is a STRING and a shell
* notice is an OBJECT; nothing else crosses that port (clients.md). */
export function classifyWorkerMessage(data) {
if (typeof data === "string") return { kind: "frame", text: data };
if (data && typeof data === "object" && typeof data.kind === "string") {
return { kind: "notice", notice: data };
}
return { kind: "unknown", data };
}
/** What a boot or save notice READS AS. The kinds are decided in Rust
* (`improvise_worker_server::boot`); only the wording is the page's. */
export function noticeText(notice) {
const where = notice.document ? ` — ${notice.document}` : "";
const why = notice.detail ? ` (${notice.detail})` : "";
switch (notice.kind) {
case "ReadOnly":
return `read-only${where}: another tab or window has it open. Close it and reload to edit.`;
case "BootFailed":
return `the in-page worker could not start${where}${why}`;
case "SeedMissing":
return `started an EMPTY document${where}: its starting copy could not be fetched${why}`;
case "SaveFailed":
return `NOT SAVED${where}${why}`;
// A kind this page does not know is still a failure, and saying so beats
// saying nothing — the reason it carries is the point (improvise-1wft).
default:
return `the in-page worker reported ${notice.kind}${where}${why}`;
}
}
/** Whether a notice means the session is unusable, as opposed to degraded.
* Only `BootFailed` is fatal: read-only, empty and unsaved all still paint. */
export function isFatal(notice) {
return notice.kind === "BootFailed";
}
/** The page's two status facts and the handshake watchdog, which share a home
* because the one rule between them is that a *failure disarms the timer*. */
export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) {
let shell = "";
let model = "";
let watchdog = null;
let arms = 0;
const paint = () => onStatus(composeStatus(shell, model));
return {
setShell(text) {
shell = text;
paint();
},
setModel(text) {
model = text;
paint();
},
/** A peer that never answers the handshake. Deliberately about the *first
* frame*, not either peer's own failure modes — clients.md. */
arm(message) {
arms += 1;
clearTimer(watchdog);
watchdog = setTimer(() => {
watchdog = null;
this.setShell(message);
}, timeoutMs);
},
/** A frame arrived, so the peer answered: the watchdog has no question
* left to ask. */
sawFrame() {
clearTimer(watchdog);
watchdog = null;
},
/** A peer that failed *loudly* has already said something more precise, so
* the watchdog must be DISARMED and not merely pre-empted (PR #13). */
fail(text) {
this.sawFrame();
this.setShell(text);
},
armed: () => watchdog !== null,
/** The window this shell was built with. Exposed so a spec can compare the
* RUNNING page's value against the one it imported — improvise-669b. */
timeoutMs: () => timeoutMs,
/** How many times the watchdog has been armed. A test denying its message
* needs this to be nonzero, or it is denying an unreachable event. */
timesArmed: () => arms,
};
}