mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
The pure swap is gone: a reload restarts every live object of a changed plugin (documents and tables kept). Calls running at the commit and every call they cause (the ctx call chain; an old flag on call from trusted links) run on the open old attempt, which seals once its work drained and its successor's recorded requests are ready, bounded by reloadDrainTimeout (5 s); then the successor's factory runs. New work waits at the gate; same-host consumers see the draining object live; a reload cycle fails at once with unavailable/reload_cycle. Example 64, design section 21 rewritten.
353 lines
18 KiB
TypeScript
353 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 request's trace across 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 } 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 restarts: running calls finish on the old code, new calls
|
|
// wait for the new code, documents and tables stay (the store's live query runs again on its new attempt). Ada's
|
|
// reference stays live while the old code drains, then shows `loading` briefly and the new code's snapshot.
|
|
// 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. The server records to the store in the data directory (step 1 opened it) and writes what every list's
|
|
// worker sends it. A command-line client adds honey and a blank todo: each request is a `chord.http` trace of the
|
|
// server, the call into the list's worker under it, and the store's `add` checks the text in a span of its own
|
|
// (`ctx.trace`). A page's click, recorded by the page itself, is `screenshots.ts`'s (it needs Chromium).
|
|
for (const text of ["honey", " "]) {
|
|
await fetch(`${url}/api/lists/groceries`, {
|
|
method: "POST",
|
|
headers: { ...cli, "content-type": "application/json" },
|
|
body: JSON.stringify(text),
|
|
});
|
|
}
|
|
// The worker sends every second, the server writes every second: read until the worker's spans of both are there.
|
|
const [refusedTrace, honeyTrace] = await written(telemetry.store, "chord.http", 2, "todos.validate");
|
|
console.log("11. adding honey from the command line:");
|
|
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 chord.http --principal owner:");
|
|
printTelemetry(await read(dataDir, "--name", "chord.http", "--principal", "owner"));
|
|
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;
|
|
}
|
|
}
|