The JS client
A notebook built for the browser (--target=wasm) is not a page you can only
look at — it is a component a host page can drive. Everything a stranger’s
page needs is already one plain-data object, globalThis.notebook, published by
the WASM build. The JS client is the optional, typed way to hold that object.
You never have to import it. globalThis.notebook is callable directly. Import
the client and you trade a few lines of hand-rolled glue for editor autocomplete,
a structural view of the notebook’s shape, and — the reason it exists — typed
value events: a program computes on the numbers a cell produces, not on the
text they render to.
There is no build step and no toolchain. client/notebook.js is pure ESM
with JSDoc types; it runs unmodified in a browser (<script type=module>) and in
Node, and a hand-written client/notebook.d.ts gives TypeScript first-class
types. Nothing here is compiled, bundled, or published to npm — the same
importless ethos as the notebooks themselves.
See it work
The page below is not the notebook’s built-in UI. It loads the capacity
notebook’s WASM, then draws its own two panels and speaks to the notebook only
through the client. The left control is one this host page rendered itself; the
right panel is a second computation running on the notebook’s typed output
stream. Drag the slider — the fleet’s hourly cost arrives as a number, and
the host annualizes it.
Connect
<script type="module">
import { connect } from "./notebook.js";
const nb = connect(); // wraps globalThis.notebook; pass a port to override
nb.leaves(); // [{ symbol, cell, label, kind, columns, type }, ...] — the settable inputs
nb.graph(); // { cell -> [upstream producer cells] } — the dependency edges
nb.cells(); // every cell's metadata (the raw port objects, PascalCase — see "A note on casing")
nb.start(); // run the first wave, so cells paint their defaults
nb.set("c", 40); // edit a leaf by its symbol; downstream recomputes
nb.setMany({ c: 40, price: 3.5 }); // several leaves as ONE atomic edit
</script>
connect() throws if no port is found (the script ran before the WASM published
globalThis.notebook, or you passed something that is not a notebook port).
loadNotebook — fetch, start, and connect in one call
connect() assumes the WASM is already running. loadNotebook does the whole
boilerplate — instantiate the .wasm (with a buffered fallback for a host that
doesn’t serve application/wasm), run it, and wait for the port — and resolves to
a connected client. Crucially it has a timeout: a broken artifact or a build
that never publishes the port rejects rather than polling forever.
<script src="wasm_exec.js"></script> <!-- defines globalThis.Go -->
<script type="module">
import { loadNotebook } from "./notebook.js";
const nb = await loadNotebook({ wasm: "./notebook.wasm", timeout: 10_000 });
nb.subscribeEpoch(({ values }) => render(values));
nb.start();
</script>
It owns the MIME fallback, go.run, the readiness poll, and the timeout — the
error-prone parts a host would otherwise hand-roll (the manual sequence in
The complete host page is what this replaces).
Feature detection
A notebook’s port grows over time (typed values, per-wave epochs, leaf types were
all added after the first release). Rather than call a method and catch an
exception to find out what an older .wasm supports, ask:
nb.capabilities(); // e.g. ["typed-events", "wave-settled", "epoch-events", "leaf-types", "atomic-set"]
nb.can("atomic-set"); // true — branch on this instead of try/catch
The list is derived from the port itself, never hand-maintained — so it can’t
claim more than the port delivers. typed-events is present when subscribeValues
is; leaf-types when at least one leaf carries its Go type; atomic-set when the
port has setMany. The behavioral pair, wave-settled and epoch-events (below),
can’t be probed from a static object, so they are reported together with
typed-events — they shipped in the same release, so a port that has one has all
three, and an older port truthfully reports none.
Atomic multi-leaf edit
setMany({ ... }) applies several leaves as one edit — a single epoch, one
recompute wave — so a host changing related inputs together never lands an
intermediate combination a subscriber could observe:
nb.setMany({ principal: 250000, rate: 0.065, term: 30 });
Prefer it over several set() calls for a form or a parameter-sweep step. It
throws on a port that predates it; guard with nb.can("atomic-set"). (The HTTP
equivalent is POST /set {"values": {…}}, which also returns the committed epoch
in X-Notebook-Epoch — see ports.)
There is deliberately no version number: a capability set that is always true
of the port in hand beats a version a client has to map to features.
Typed values out — the point
subscribeValues delivers each cell’s value as a JavaScript value, not as the
text it renders to. A scalar that a human sees as "40.24" arrives at a program
as the number 40.24:
nb.subscribeValues((ev) => {
// ev.cell is the cell id; ev.value is a real JS value (number, bool, object)
if (ev.cell === "hourlyCost") {
const annual = ev.value * 24 * 365; // arithmetic, not string concatenation
console.log("annualized:", annual);
}
});
An event is one of two shapes — a per-cell value, or the terminal wave-settled marker (see Coherent per-wave snapshots):
type ValueEvent =
| { epoch: number; cell: string; value: unknown } // one cell's typed value
| { epoch: number; settled: true }; // the wave is complete
This is the capability the pipe adds. Before it, a host could only subscribe to
rendered events — { mime, data }, where data is the string a human reads.
Multiplying "40.24" by a number is a bug waiting to happen; multiplying 40.24
is arithmetic. Use subscribe when you want exactly what the UI shows (a chart’s
SVG, a table’s HTML); use subscribeValues when a program needs the value.
// Rendered events — what a human reads (mime + data string):
nb.subscribe((ev) => { if (ev.mime === "image/svg+xml") draw(ev.data); });
// A synchronous snapshot of every leaf's current value, instead of subscribing:
nb.values();
subscribeValues throws on a notebook whose port predates it (an older .wasm).
Catch it and fall back to subscribe if you must support both.
Coherent per-wave snapshots
Each value event carries the epoch of the wave it belongs to, and every wave
ends with one terminal marker { epoch, settled: true } (no cell/value). So a
host combining several derived values from one edit can tell that its set is from
one wave, not a mix of an old and a new one:
nb.subscribeValues((ev) => {
if (ev.settled) { /* every value for ev.epoch has now arrived */ }
else { /* ev.epoch, ev.cell, ev.value */ }
});
subscribeEpoch is the convenience for exactly this — it buffers a wave’s values
and calls you once, when the wave settles, with { epoch, values }:
nb.subscribeEpoch(({ epoch, values }) => {
// values.hourlyCost and values.carbon are from the SAME wave — safe to combine
render(values.hourlyCost, values.carbon);
});
A superseded wave never settles (the engine only emits the marker for the wave
that actually completed), so subscribeEpoch silently drops the abandoned buffer
and only ever hands you a coherent set. It is built entirely on the one value
stream; delete it and subscribeValues still works. (Value events key by cell
id — ev.cell, values.hourlyCost — while set() and values() key by leaf
symbol; see A note on casing.)
Each leaf carries its type
Every leaf reports its Go result type, so a program can validate a value’s
shape before it sets it — without knowing Go. leaves()[i].type is
{ Name, Underlying }: the type as declared ("PerHour", "int") and the basic
kind it resolves to ("float64", "int", "bool", "string"). Underlying is
absent for a composite or interface leaf (a table row, a multi-selection), where
no single scalar kind describes the value.
for (const leaf of nb.leaves()) {
console.log(leaf.symbol, "→", leaf.type?.Name, `(${leaf.type?.Underlying})`);
// e.g. c → int (int) lambda → PerHour (float64)
}
// A shape check the host can run itself, before set():
function okForLeaf(leaf, v) {
switch (leaf.type?.Underlying) {
case "int": return Number.isInteger(v);
case "float64": case "float32": return typeof v === "number";
case "bool": return typeof v === "boolean";
case "string": return typeof v === "string";
default: return true; // composite, or an older port with no type — defer to the coercer
}
}
The type is readable, not compile-time enforced: set("c", "nope") is still
not rejected by tsc — the port’s coercer rejects it at runtime, on the far side.
You get the schema to check against, not a generated per-notebook type that fails
your build. (A generated typed set() is a separately-gated feature; the shape
you can read today covers the common case of validating input before sending it.)
One more shape worth knowing: values() reports leaf cells only — a derived
cell like hourlyCost is absent there, so use subscribeValues to observe it.
A note on casing
There are two naming conventions in play, and it is worth being explicit about
which you are touching. The raw port exposes Go wire fields in PascalCase; the
client’s leaves() normalizes the top level to camelCase for ergonomics — but
the nested type and columns objects are still the raw PascalCase shapes. The
event streams have their own fixed lowercase schemas.
| Surface | Naming | Example |
|---|---|---|
Raw port — notebook.meta[i], cells()[i] |
PascalCase Go fields | meta[0].Widget.Kind, meta[0].Type.Name, meta[0].In |
Raw port — notebook.values() |
keyed by leaf symbol | values().c |
leaves()[i] (client-normalized) |
camelCase top level | leaves()[0].symbol, .kind, .label |
leaves()[i].type (nested, raw) |
PascalCase | leaves()[0].type.Name, .type.Underlying |
leaves()[i].columns[j] (nested, raw) |
PascalCase | columns[0].Name, columns[0].Type |
Rendered event — subscribe |
fixed lowercase | { epoch, cell, state, mime, data, err } |
Typed event — subscribeValues |
fixed lowercase | { epoch, cell, value } or { epoch, settled: true } |
Rule of thumb: if you read it off notebook. directly, it is PascalCase; if you
read it off a leaves() row’s top level, it is camelCase; the nested type and
columns are the raw PascalCase objects either way. When in doubt, cells()
returns the port objects verbatim, so their keys are always the raw Go names.
The complete host page
The demo above is a full, deployable example — not a snippet. It is four files:
component/
├── index.html your page: its own layout, its own controls
├── app.js your logic: connect() → subscribe → start
├── notebook.js the client (copied from client/notebook.js)
├── wasm_exec.js Go's WASM support shim (copied from the toolchain)
└── notebook.wasm the built notebook (notebook build --target=wasm)
index.html and app.js are yours; the other three are produced by the build.
Get the runtime files this way — the .wasm name is content-addressed, so copy
the one the build emits to a stable name your page fetches:
notebook build --target=wasm -o ./out examples/capacity
cp ./out/notebook-*.wasm component/notebook.wasm
cp ./out/wasm_exec.js component/wasm_exec.js
cp client/notebook.js component/notebook.js
The load-and-drive sequence, in app.js, has a specific order that matters:
import { connect } from "./notebook.js";
const go = new Go();
// 1. Load the WASM yourself — a host page owns its own page lifecycle.
const src = await WebAssembly.instantiateStreaming(fetch("notebook.wasm"), go.importObject)
.catch(async () => WebAssembly.instantiate( // fallback for hosts without the wasm MIME type
await (await fetch("notebook.wasm")).arrayBuffer(), go.importObject));
go.run(src.instance); // publishes globalThis.notebook synchronously as it starts
// 2. Wait for the port to appear (go.run publishes it as it spins up).
const port = await new Promise((resolve) => {
const t = setInterval(() => globalThis.notebook && (clearInterval(t), resolve(globalThis.notebook)), 5);
});
const nb = connect(port);
// 3. Subscribe BEFORE start(), so you don't miss the first wave's values.
let unsub;
try {
unsub = nb.subscribeValues((ev) => { if (ev.cell === "hourlyCost") render(ev.value); });
} catch {
status("this notebook predates subscribeValues — rebuild with a newer go-notebook");
}
// 4. Wire your own control to a leaf, then run the first wave.
myServersSlider.addEventListener("input", (e) => nb.set("c", Number(e.target.value)));
nb.start();
// 5. Clean up on navigation.
addEventListener("pagehide", () => unsub?.());
Deploy it like any static site — it is HTML + JS + a .wasm. Serve it over HTTP
(WASM will not load from file://), set the application/wasm MIME type, and
cache the content-addressed .wasm forever. See publish & deploy
for the MIME type, caching, and a GitHub Pages / S3 recipe.
The full source of the working demo is in the repo:
site/component/index.html
and site/component/app.js
— it draws its own two-panel layout, checks that the value arrived as a number
(not the string "40.24"), and never touches the notebook’s built-in UI.
Verify it yourself
client/example.mjs
drives the client end-to-end against a real built notebook, with zero
dependencies:
notebook build --target=wasm -o /tmp/nb examples/capacity
cp client/notebook.js /tmp/nb/notebook-client.js
GOROOT=$(go env GOROOT)
PATH="$GOROOT/lib/wasm:$PATH" node client/example.mjs /tmp/nb
It enumerates the leaves, subscribes to typed values, runs the first wave, sets a leaf, and prints the recomputed typed values.