pi-mono/packages/chord/test/examples/61-tutorial/main.ts

364 lines
18 KiB
TypeScript

// The tutorial's app (`docs/tutorial/book.md`): a shared todo app built with the app kit, one worker per list. Each
// numbered step is the tutorial's chapter of that number: the server and the owner's sign-in, a list on its worker,
// routes, the page, the terminal, a theme plugin added while the app runs, a second user, languages, an edit applied in
// place, a list exported and imported, and a trace across the page, the server and a worker, read back from the app's
// telemetry store. The lists plugin's own test is `plugins/lists/worker.test.ts`; `screenshots.ts` shows the page and
// the trace viewer in Chromium; `trace.ts` reads a data directory's traces.
// Run from packages/chord (with Node 26.5.1 or later the terminal client runs too; `--serve` keeps the app running):
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts
import { execFile } from "node:child_process";
import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { StoredTrace } from "@earendil-works/pi-telemetry";
import { renderTrace } from "@earendil-works/pi-telemetry/render";
import { List } from "@todo/app/v1";
import { WebSocket } from "ws";
import { BACKGROUND_CONTEXT as context, withTelemetry } from "../../../src/context/index.ts";
import {
type ChordError,
createHost,
LocaleChoice,
WebScripts,
type WebSocketLike,
webSocketLink,
} from "../../../src/index.ts";
import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts";
import { type Item, Todos } from "./plugins/lists/contract.ts";
import { messages } from "./plugins/lists/messages.ts";
import type { TodoPrincipal } from "./src/principals.ts";
import { openTelemetry, type TodoTelemetry, telemetryFile } from "./src/telemetry.ts";
import { startTodo, todoDirectories } from "./start.ts";
const { base, dataDir, root } = await todoDirectories();
// 1. Start the app: the kit makes the data directory, the server's store and host, HTTP and the owner link. Nobody is
// signed in yet, so the page answers 401. The owner opens the link: its page posts the token from the URL's fragment
// and gets a session cookie.
const telemetry = await openTelemetry(telemetryFile(dataDir));
const app = await startTodo(dataDir, root, telemetry, context);
const link = new URL(app.ownerLink());
const url = link.origin;
console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`);
console.log(" data directory:", (await readdir(dataDir)).sort().join(", "));
console.log(" GET / signed out:", (await fetch(url)).status);
const signIn = await fetch(`${url}/_chord/owner`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: link.hash.slice(1) }),
});
const cookie = signIn.headers.get("set-cookie")!.split(";")[0]!;
console.log(" the owner signs in:", signIn.status, cookie.replace(/=.*/, "=…"));
console.log(" GET / signed in:", (await fetch(url, { headers: { cookie } })).status);
// 2. A list on its own worker. Code acting as the owner gets the groceries list's `Todos`: the request goes to the
// list's address, and the first one starts its worker. The same call in a root of the server acts as the server.
const OWNER = { kind: "owner" } as const;
await app.host.root(
async (ctx) => {
const todos = await ctx.get(Todos, { list: "groceries" });
await todos.add("milk", ctx);
await todos.add("eggs", ctx);
await todos.tick({ id: 1, done: true }, ctx);
await until(() => todos.items.value[0]?.done === true);
console.log("2. as the owner:", todos.variant, "|", show(todos.items.value));
},
context,
{ principal: OWNER },
);
await app.host.root(async (ctx) => {
const todos = await ctx.get(Todos, { list: "groceries" });
await todos.add("bread", ctx);
await until(() => todos.items.value.length === 3);
console.log(" as the server:", todos.variant, "|", show(todos.items.value.slice(-1)));
}, context);
const workers = join(dataDir, "workers", "list");
const [groceriesDir] = await readdir(workers);
console.log(" workers:", Object.keys(app.workers.get("list")!.state.value).join(", "));
console.log(` workers/list/${groceriesDir}:`, (await readdir(join(workers, groceriesDir!))).sort().join(", "));
// 3. Routes. A command-line client on this machine sends the owner token file as a bearer token; the route's
// `ctx.get` acts as the request's principal, so the todo is the owner's.
const token = (await readFile(app.ownerTokenFile, "utf8")).trim();
const cli = { authorization: `Bearer ${token}` };
const posted = await fetch(`${url}/api/lists/groceries`, {
method: "POST",
headers: { ...cli, "content-type": "application/json" },
body: JSON.stringify("butter"),
});
console.log("3. POST /api/lists/groceries:", await posted.json());
const listed = (await (await fetch(`${url}/api/lists/groceries`, { headers: cli })).json()) as Item[];
console.log(" GET /api/lists/groceries:", show(listed));
console.log(" GET without a token:", (await fetch(`${url}/api/lists/groceries`)).status);
// 4. The page: the app's template with the base bundle, the plugins' combined script and their stylesheets. A watch
// starts at the current value; its listener gets only what changes after.
const html = await (await fetch(url, { headers: cli })).text();
console.log("4. the page loads:");
for (const [, asset] of html.matchAll(/(?:src|href)="([^"]+)"/g)) console.log(` ${hashless(asset!)}`);
const groceries = await app.host.get(Todos, { list: "groceries" }, context);
const watch = await groceries.items.watch(context);
console.log(" watch.value:", done(watch.value), "done");
const changed = Promise.withResolvers<readonly Item[]>();
watch.start(async (call) => {
if (call.kind === "value") changed.resolve(call.value);
});
await groceries.tick({ id: 2, done: true }, context);
console.log(" the listener's first call:", done(await changed.promise), "done");
await watch.stop();
// 5. The terminal client (Node 26.5.1 or later): the launcher signs in with the owner token file, downloads the
// terminal's base bundle and the plugins' terminal scripts and runs them in its sandbox, here on a scripted terminal.
const frames: string[] = [];
let terminal: RunningTerminal | undefined;
if (sandboxAvailable()) {
const output = Object.assign(new PassThrough(), { columns: 60, rows: 12 });
output.on("data", (chunk: Buffer) => frames.push(chunk.toString()));
terminal = await startTerminal({
url,
credentials: { kind: "file", path: app.ownerTokenFile },
input: new PassThrough(),
output,
colors: "truecolor",
// The machine's languages (its system locale by default), here fixed for the book's output.
locales: ["en-US"],
});
await until(() => screen(frames).includes("butter"));
console.log("5. the terminal shows:");
for (const line of screen(frames).trim().split("\r\n")) console.log(` ${line}`);
} else console.log("5. (the terminal client needs Node 26.5.1 or later for its sandbox)");
// 6. A theme plugin added while the app runs: a new directory in the plugins root marks the set changed, and applying
// the changes builds it and rolls it out. Pages and terminals restyle in place.
await cp(fileURLToPath(new URL("./later/harbor/", import.meta.url)), join(root, "harbor"), { recursive: true });
await until(() => app.plugins.state.value.set);
const added = await app.applyChanges(context);
console.log("6. harbor added, rolled out to:", added.steps.map(({ name }) => name).join(", "));
const scripts = await app.host.get(WebScripts, {}, context);
console.log(" the page's plugins:", scripts.set.value.plugins.map(({ name }) => name).join(", "));
scripts.release();
if (terminal !== undefined) {
await until(() => frames.join("").includes("\x1b[38;2;242;166;90mGroceries"));
console.log(
" the terminal redraws its title in Harbor's accent:",
JSON.stringify("\x1b[38;2;242;166;90mGroceries"),
);
}
// 7. A second user. Ada picks a name (the kit's ephemeral login) and connects a client host of her own over the links
// endpoint, as a page does. A host serves nothing before `load`: a client with nothing to load calls `load([])`.
const login = await fetch(`${url}/_chord/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "ada" }),
});
const adaCookie = login.headers.get("set-cookie")!.split(";")[0]!;
const ada = createHost<TodoPrincipal>({ onError: (error) => console.error("ada:", error.kind) });
try {
ada.hold(Todos, { list: "groceries" }, context);
} catch (error) {
console.log("7. ada's hold before load:", (error as Error).message);
}
await ada.load([], context);
const socket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie: adaCookie } });
ada.connect(webSocketLink(socket as unknown as WebSocketLike), { principal: { kind: "server" } }, context);
const adas = await ada.get(Todos, { list: "groceries" }, context);
await adas.add("coffee", context);
console.log(" ada's variant:", adas.variant);
if (adas.variant === "member") {
// @ts-expect-error `clear` is the owner's: TypeScript refuses it, and so does the worker.
const clearing: Promise<null> = adas.clear(context);
await clearing.catch((error: ChordError) => console.log(" ada's clear:", `${error.code}/${error.reason}`));
}
await until(() => groceries.items.value.some(({ by }) => by === "ada"));
console.log(" the server's reference sees:", show(groceries.items.value.slice(-1)));
// 8. Languages. The owner chooses German: a preference the server keeps per principal, which every client of the owner
// follows, so the terminal redraws in German without restarting. Ada's pick is her own. Clearing the done todos
// leaves a notice as data, a key and its parameters: the server's plain render is the English catalog, the
// terminal renders the same state in German.
const ownerChooses = (locale: string) =>
app.host.root(async (ctx) => (await ctx.get(LocaleChoice, {})).choose(locale, ctx), context, { principal: OWNER });
await ownerChooses("de");
console.log("8. the owner chooses German");
if (terminal !== undefined) {
await until(() => screen(frames).includes("Einkäufe"));
console.log(" the terminal shows:");
for (const line of screen(frames).trim().split("\r\n")) console.log(` ${line}`);
}
const adasChoice = await ada.get(LocaleChoice, {}, context);
await adasChoice.choose("fr", context);
console.log(" ada's pick:", adasChoice.pick.value);
adasChoice.release();
await app.host.root(
async (ctx) => {
const todos = await ctx.get(Todos, { list: "groceries" });
if (todos.variant === "owner") await todos.clear(ctx);
},
context,
{ principal: OWNER },
);
await until(() => groceries.notice.value !== null);
console.log(" the notice:", groceries.notice.value);
console.log(" the server renders it:", messages.render(groceries.notice.value!));
if (terminal !== undefined) {
await until(() => screen(frames).includes("entfernt"));
console.log(" the terminal renders it:", screen(frames).trim().split("\r\n").at(-1));
}
// 9. An edit applied in place: the lists plugin's worker facet trims a todo's text, so a blank one is empty and the
// store refuses it with Chord's `invalid/args`, which reaches Ada as it would a refused argument. The
// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and
// the terminal. On the worker each object of the plugin takes its fate, dependents first: each view swaps in place
// while the store is still live, then the store, which opens no native, swaps in place too (its live query runs
// again); the views read the store's old values until its new attempt is live, and their calls wait for it. Ada's
// reference follows them and never shows `loading`.
// The edit: `add` in the `Todos` view, before and after.
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
const workerFile = join(root, "lists", "worker.ts");
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
const applied = await app.applyChanges(context);
console.log("9. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", "));
for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
const fates = "report" in host ? host.report.objects.map(({ object, kind }) => `${object} ${kind}`) : [];
console.log(` on ${host.host}:`, [...new Set(fates)].join(", "));
}
await adas.add(" tea ", context);
await until(() => groceries.items.value.at(-1)?.text === "tea");
console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1)));
const refused = await adas.add(" ", context).then(
() => "added",
(error: ChordError) => ({ code: error.code, reason: error.reason }),
);
console.log(" ada adds ' ':", refused);
// 10. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing
// the archive makes a new list with the same todos. An address that has data is never overwritten.
const archive = join(base, "groceries.tar");
await app.exportData(List({ id: "groceries" }), archive, context);
console.log("10. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing");
await app.importData(archive, List({ id: "picnic" }), context);
const picnic = await app.host.get(Todos, { list: "picnic" }, context);
console.log(" imported as picnic:", show(picnic.items.value));
picnic.release();
await app.importData(archive, List({ id: "groceries" }), context).catch((error: Error) => {
console.log(" importing over groceries:", error.message.replace(base, "…"));
});
// 11. Telemetry. Every process of the app records to the store in the data directory (step 1 opened it for the server,
// `worker.ts` opens it in each list's worker). A page stands in here as a client host of this process with a
// recorder of its own on the same file, signed in with the owner's cookie of step 1. A click is one span of the
// page; the call under it goes through the server's relay into the list's worker, and the carriers on the wire make
// one trace of the three processes' spans. The store's `add` checks the text in a span of its own (`ctx.trace`).
const pageTelemetry = await openTelemetry(telemetryFile(dataDir));
const page = createHost<TodoPrincipal>({
telemetry: pageTelemetry.store,
onError: (error) => console.error("page:", error.kind),
});
await page.load([], context);
const pageSocket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie } });
page.connect(webSocketLink(pageSocket as unknown as WebSocketLike), { principal: { kind: "server" } }, context);
const pageTodos = await page.get(Todos, { list: "groceries" }, context);
const click = (text: string) =>
pageTelemetry.store.startSpan({ name: "page.add" }, (span) => pageTodos.add(text, withTelemetry(span, context)));
await click("honey");
await click(" ").catch(() => "refused");
pageTodos.release();
await page.close(context);
await pageTelemetry.close();
// Each recorder writes what settled once a second: read until the worker's spans of both clicks are there.
const [refusedTrace, honeyTrace] = await written(telemetry.store, "page.add", 2, "todos.validate");
console.log("11. adding honey from the page:");
printTelemetry(renderTrace(honeyTrace!.spans));
// `trace.ts` reads the same file from a process of its own: the newest traces, then one by its id.
const traceScript = fileURLToPath(new URL("./trace.ts", import.meta.url));
const read = async (...args: string[]) =>
(await promisify(execFile)(process.execPath, [...process.execArgv, traceScript, ...args])).stdout;
console.log(" trace.ts <data directory> --name page.add:");
printTelemetry(await read(dataDir, "--name", "page.add"));
console.log(" trace.ts <data directory> <the refused one's id>:");
printTelemetry(await read(dataDir, refusedTrace!.traceId));
if (process.argv.includes("--serve")) {
console.log(`Open ${app.ownerLink()} (Ctrl-C to stop); terminal: 57-tui's terminal.ts with ${app.ownerTokenFile}`);
console.log(`Traces: ${url}/traces, or trace.ts ${dataDir}`);
} else {
await terminal?.stop();
groceries.release();
adas.release();
await ada.close(context);
await app.close(context);
await telemetry.close();
await rm(base, { recursive: true, force: true });
console.log("12. closed");
}
/** Todos as one line: `[x] milk (owner), [ ] eggs (owner)`. */
function show(items: readonly Item[]): string {
return items.map((item) => `${item.done ? "[x]" : "[ ]"} ${item.text} (${item.by})`).join(", ");
}
/** How many of `items` are done. */
function done(items: readonly Item[]): number {
return items.filter((item) => item.done).length;
}
/** Waits until `check` holds. */
async function until(check: () => boolean): Promise<void> {
while (!check()) await new Promise((resolve) => setTimeout(resolve, 10));
}
/** An asset's URL with its content hash masked, so the output is the same at every run. */
function hashless(asset: string): string {
return asset.replace(/\.[0-9a-f]{8,}\./, ".<hash>.");
}
/** The terminal's last frame as text (its colours stripped). */
function screen(written: readonly string[]): string {
return written
.join("")
.split("\x1b[2J\x1b[H")
.at(-1)!
.replace(/\x1b\[[0-9;]*m/g, "");
}
/** The newest `count` traces whose first span is `name`, once each has a span named `last` (the server's flushed). */
async function written(
store: TodoTelemetry["store"],
name: string,
count: number,
last: string,
): Promise<readonly StoredTrace[]> {
for (;;) {
await store.flush();
const { traces } = await store.findTraces({ name, limit: count });
const complete = traces.filter(({ spans }) => spans.some((span) => span.name === last));
if (complete.length === count) return traces;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/** Prints telemetry indented, as the same text at every run: span ids, lengths of time and byte counts masked. */
function printTelemetry(text: string): void {
const steady = text
.replaceAll(/[0-9a-f-]{36}:\d+/g, "<id>")
.replaceAll(/ \(\+?[\d.]+ms\)/g, "")
.replaceAll(/duration=[\d.]+/g, "duration=<ms>")
.replaceAll(/ [\d.]+ ms/g, " <ms>")
.replaceAll(/\d+ bytes in \d+ messages/g, "<n> bytes in <n> messages");
for (const line of steady.trim().split("\n")) console.log(` ${line}`);
}
/** Whether this Node can run the terminal's sandbox. */
function sandboxAvailable(): boolean {
try {
requireSandboxNode();
return true;
} catch {
return false;
}
}