docs(chord): the Chord v2 tutorial, a todo app built with the app kit (example 61)

This commit is contained in:
Mario Zechner 2026-11-03 08:30:05 +00:00
parent 0c7c741988
commit 24d3993b20
31 changed files with 3768 additions and 0 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,5 @@
{
"name": "@todo/harbor",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,21 @@
// The Harbor theme's data, shared by its web and terminal facets: colours by role, a corner radius for the page, a
// 256-colour muted tone for terminals.
import type { Theme } from "@todo/app/v1";
export const harbor: Theme = {
id: "harbor",
name: "Harbor",
mode: "dark",
colors: {
accent: "#f2a65a",
bg: "#12232e",
fg: "#d8e2e8",
muted: "#6b8796",
error: "#e5676b",
warning: "#e9c46a",
success: "#7fb685",
},
web: { radius: "6px" },
tui: { muted: "66" },
};

View file

@ -0,0 +1,6 @@
// The Harbor theme in terminals.
import { defineFacet, Themes } from "@todo/app/v1";
import { harbor } from "./theme.ts";
export default defineFacet((_host, self) => self.contribute(Themes, harbor));

View file

@ -0,0 +1,6 @@
// The Harbor theme on pages.
import { defineFacet, Themes } from "@todo/app/v1";
import { harbor } from "./theme.ts";
export default defineFacet((_host, self) => self.contribute(Themes, harbor));

View file

@ -0,0 +1,249 @@
// 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, an edit applied in place,
// and a list exported and imported. The lists plugin's own test is `plugins/lists/worker.test.ts`; `screenshots.ts`
// shows the page in Chromium.
// 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 { 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 { WebSocket } from "ws";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { type ChordError, createHost, WebScripts, type WebSocketLike, webSocketLink } from "../../../src/index.ts";
import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts";
import { type Item, List, Todos } from "./src/api/v1/index.ts";
import type { TodoPrincipal } from "./src/principals.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 app = await startTodo(dataDir, root, 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",
});
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. An edit applied in place: the lists plugin's worker facet trims a todo's text and ignores an empty one. 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: the store opens a native, so it restarts
// (its todos are in the store, and it reads them again); the views wait for it, then go on with the new code. Ada's
// reference follows them.
// 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: async (text: string, ctx: Ctx) => {
if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
return null;
},`;
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("8. 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 count = groceries.items.value.length;
await adas.add(" ", context);
console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added");
// 9. 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("9. 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, "…"));
});
if (process.argv.includes("--serve")) {
console.log(`Open ${app.ownerLink()} (Ctrl-C to stop); terminal: 57-tui's terminal.ts with ${app.ownerTokenFile}`);
} else {
await terminal?.stop();
groceries.release();
adas.release();
await ada.close(context);
await app.close(context);
await rm(base, { recursive: true, force: true });
console.log("10. 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, "");
}
/** Whether this Node can run the terminal's sandbox. */
function sandboxAvailable(): boolean {
try {
requireSandboxNode();
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,9 @@
/* The lists panel: the active theme's colours (the kit writes them on :root), class names prefixed with the plugin's. */
.lists-panel ul { list-style: none; padding: 0; margin: 0 0 0.75rem; }
.lists-panel li { padding: 0.35rem 0; border-bottom: 1px solid var(--muted); cursor: pointer; }
.lists-done { color: var(--muted); text-decoration: line-through; }
.lists-by { float: right; color: var(--muted); font-size: 0.8em; }
.lists-panel input { width: 100%; box-sizing: border-box; padding: 0.4rem; background: var(--bg); color: var(--fg);
border: 1px solid var(--accent); border-radius: var(--radius, 0); }
.lists-panel button { margin-top: 0.5rem; background: none; color: var(--accent); border: 1px solid var(--accent);
border-radius: var(--radius, 0); padding: 0.3rem 0.6rem; }

View file

@ -0,0 +1,5 @@
{
"name": "@todo/lists",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,21 @@
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
import { defineFacet, Todos } from "@todo/app/v1";
import { Http } from "@todo/app/v1/server";
export default defineFacet((_host, self) => {
self.contribute(Http, {
routes: {
"GET /api/lists/:list": async (request, ctx) => {
const todos = await ctx.get(Todos, { list: request.params.list! });
return { kind: "json", value: todos.items.value };
},
"POST /api/lists/:list": async (request, ctx) => {
const todos = await ctx.get(Todos, { list: request.params.list! });
await todos.add(String(await request.json()), ctx);
return { kind: "json", value: "added" };
},
},
});
});

View file

@ -0,0 +1,17 @@
// The lists plugin in the terminal: the groceries list's todos, live; done ones muted.
import { defineFacet, Todos } from "@todo/app/v1";
import { Screens } from "@todo/app/v1/tui";
export default defineFacet((_host, self) =>
self.contribute(Screens, {
title: "Groceries",
open: (scope, theme) => {
const items = scope.watch(scope.hold(Todos, { list: "groceries" }).items);
return () =>
(items() ?? []).map((item) =>
item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`,
);
},
}),
);

View file

@ -0,0 +1,53 @@
// The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, and for
// the owner "Clear done". The list is the page's `#hash`, `groceries` without one.
import { BACKGROUND_CONTEXT as context, defineFacet, Todos } from "@todo/app/v1";
import { Panels, useHold, useWatch } from "@todo/app/v1/web";
import { createElement as h, useState } from "react";
import "./lists.css";
function TodoList() {
const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" });
const items = useWatch(todos?.items) ?? [];
const [text, setText] = useState("");
const add = (event: { preventDefault(): void }) => {
event.preventDefault();
if (todos === undefined || text.trim() === "") return;
void todos.add(text, context);
setText("");
};
return h(
"div",
{ className: "lists-panel" },
h(
"ul",
null,
...items.map((item) =>
h(
"li",
{
key: item.id,
className: item.done ? "lists-done" : undefined,
onClick: () => void todos?.tick({ id: item.id, done: !item.done }, context),
},
item.text,
h("span", { className: "lists-by" }, item.by),
),
),
),
h(
"form",
{ onSubmit: add },
h("input", {
value: text,
placeholder: "Add a todo",
onChange: (event: { target: { value: string } }) => setText(event.target.value),
}),
),
todos?.variant === "owner"
? h("button", { type: "button", onClick: () => void todos.clear(context) }, "Clear done")
: null,
);
}
export default defineFacet((_host, self) => self.contribute(Panels, { title: "Groceries", Component: TodoList }));

View file

@ -0,0 +1,62 @@
// The lists plugin's worker facet, tested alone with `testPlugin`: built as production builds it, evaluated with the
// app's real API module and loaded on a host of its own (an in-memory store) at a list's address. Each test runs code
// as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it.
// Run from packages/chord:
// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../../../../src/context/index.ts";
import { type TestedPlugin, testPlugin } from "../../../../../src/node/plugins/index.ts";
import * as v1 from "../../src/api/v1/index.ts";
import type { TodoPrincipal } from "../../src/principals.ts";
const ADA = { kind: "user", id: "u1", name: "ada" } as const;
const OWNER = { kind: "owner" } as const;
describe("the lists plugin on a list's worker", () => {
let t: TestedPlugin<TodoPrincipal>;
beforeEach(async () => {
t = await testPlugin<TodoPrincipal>(
fileURLToPath(new URL("./", import.meta.url)),
{
kind: "worker",
api: { name: "@todo/app", versions: ["v1"] },
modules: { "@todo/app/v1": v1 },
address: v1.List({ id: "groceries" }),
principal: { kind: "worker", list: "list|groceries" },
},
context,
);
});
afterEach(() => t.close(context));
it("adds a todo as the principal that called", async () => {
await t.root(
async (ctx) => {
const todos = await ctx.get(v1.Todos, { list: "groceries" });
expect(todos.variant).toBe("member");
await todos.add("coffee", ctx);
expect(todos.items.value.map(({ text, by }) => `${text} (${by})`)).toEqual(["coffee (ada)"]);
},
context,
{ principal: ADA },
);
});
it("lets the owner clear the done todos", async () => {
await t.root(
async (ctx) => {
const todos = await ctx.get(v1.Todos, { list: "groceries" });
if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
await todos.add("milk", ctx);
await todos.add("eggs", ctx);
await todos.tick({ id: 1, done: true }, ctx);
await todos.clear(ctx);
expect(todos.items.value.map(({ text }) => text)).toEqual(["eggs"]);
},
context,
{ principal: OWNER },
);
expect(t.unserved()).toEqual([]);
});
});

View file

@ -0,0 +1,96 @@
// The lists plugin on a list's worker. The todos live in a table of the worker's store (every one kept), written and
// read by one local object, `Store`. A table has no watch, so the store keeps a live window itself: the newest 50
// todos, read when it goes live and changed after each write (it is the only writer). Each principal's `Todos` view
// adds todos as that principal, and gives the owner `clear`.
import {
arr,
bool,
type Ctx,
defineFacet,
defineTable,
eq,
Item,
int,
local,
method,
num,
obj,
query,
str,
Todos,
} from "@todo/app/v1";
/** Every todo of the list, by its number. */
const Items = defineTable("todo.items", {
version: 1,
columns: { id: int(), text: str(), done: bool(), by: str(), at: num() },
key: ["id"],
});
/** The newest todos first. */
const NEWEST = query(Items).orderBy((item) => item.id, "desc");
/** How many todos the live window holds. */
const WINDOW = 50;
/** The list's todos, once per worker: empty params, so it lives as long as the worker and may keep data in its store. */
const Store = local("todo.store", {
version: 1,
params: {},
members: {
items: arr(Item),
add: method(obj({ text: str(), by: str() })),
tick: method(obj({ id: int(), done: bool() })),
clear: method(),
},
});
export default defineFacet((host) => {
host.serve(Store, (_params, self) => {
const items = self.table(Items);
// The newest todos, oldest first, and the next todo's number: read once when the store goes live, then kept here.
const window = self.writable<Item[]>([]);
let next = 1;
self.open(
async (ctx) => {
const { items: newest } = await items.page(NEWEST, WINDOW, ctx);
window.set([...newest].reverse());
next = (newest[0]?.id ?? 0) + 1;
},
() => {},
);
return {
items: self.derive([window], ([value]) => value),
add: async ({ text, by }, ctx) => {
const item = { id: next++, text, done: false, by, at: Date.now() };
await self.tx(async (tx) => tx.put(items, item), ctx);
// In number order: two adds may commit in either order.
window.set([...window.value, item].sort((a, b) => a.id - b.id).slice(-WINDOW));
return null;
},
tick: async ({ id, done }, ctx) => {
await self.tx(async (tx) => tx.updateWhere(items, (item) => eq(item.id, id), { done }), ctx);
window.set(window.value.map((item) => (item.id === id ? { ...item, done } : item)));
return null;
},
clear: async (ctx) => {
await self.tx(async (tx) => tx.deleteWhere(items, (item) => eq(item.done, true)), ctx);
window.set(window.value.filter((item) => !item.done));
return null;
},
};
});
host.serve(Todos, (_params, principal, self) => {
const store = self.get(Store, {});
// Who adds a todo comes from the principal the link stamped, never from the arguments.
const by = principal.kind === "user" ? principal.name : principal.kind;
const members = {
items: self.derive([store.items], ([items]) => items),
add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),
tick: (tick: { id: number; done: boolean }, ctx: Ctx) => store.tick(tick, ctx),
};
if (principal.kind !== "owner") return { variant: "member", members };
return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear(ctx) } };
});
});

View file

@ -0,0 +1,5 @@
// The kit's pick-a-name login, added to the app as a single-file plugin: a server facet, one line.
import { login } from "@todo/app/v1/server";
export default login;

View file

@ -0,0 +1,95 @@
// Screenshots of the tutorial's page (`docs/tutorial/screens/`), taken in headless Chromium: the page after the owner
// signed in with the owner link, the page in the Harbor theme added while it runs, and the page after an edit of the
// lists plugin's web facet applied in place.
// Run from packages/chord, with Chromium (`CHROMIUM_BIN`, else `chromium-docker` on the `PATH`):
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/screenshots.ts docs/tutorial/screens
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { Browser, CHROMIUM } from "../../node/web/browser.ts";
import { Todos } from "./src/api/v1/index.ts";
import { startTodo, todoDirectories } from "./start.ts";
const out = resolve(process.argv[2] ?? "screens");
if (CHROMIUM === undefined) throw new Error("no Chromium: set CHROMIUM_BIN");
await mkdir(out, { recursive: true });
const { base, dataDir, root } = await todoDirectories();
const app = await startTodo(dataDir, root, context);
// The list as the tutorial leaves it after chapter 7: the owner's todos, one by the server, one by Ada.
const as = async (principal: Parameters<typeof app.host.root>[2], texts: readonly string[]): Promise<void> => {
await app.host.root(
async (ctx) => {
const todos = await ctx.get(Todos, { list: "groceries" });
for (const text of texts) await todos.add(text, ctx);
},
context,
principal,
);
};
await as({ principal: { kind: "owner" } }, ["milk", "eggs"]);
await as(undefined, ["bread"]);
await as({ principal: { kind: "owner" } }, ["butter"]);
await as({ principal: { kind: "user", id: "u1", name: "ada" } }, ["coffee"]);
await app.host.root(
async (ctx) => (await ctx.get(Todos, { list: "groceries" })).tick({ id: 1, done: true }, ctx),
context,
{ principal: { kind: "owner" } },
);
/** The edit of step 3: a line counting the open todos above the list. */
const BEFORE = `h(
"ul",`;
const AFTER = `h("p", { className: "lists-open" }, \`\${items.filter((item) => !item.done).length} open\`),
h(
"ul",`;
const browser = await Browser.start();
const shoot = async (name: string): Promise<void> => {
await new Promise((done) => setTimeout(done, 300));
const { data } = (await browser.call("Page.captureScreenshot", { format: "png" })) as { readonly data: string };
await writeFile(join(out, name), Buffer.from(data, "base64"));
console.log(`wrote ${join(out, name)}`);
};
try {
await browser.call("Emulation.setDeviceMetricsOverride", {
width: 560,
height: 430,
deviceScaleFactor: 1,
mobile: false,
});
// 1. The owner opens the owner link: the kit's page signs them in and goes to the app's page.
await browser.open(app.ownerLink());
await browser.wait("document.querySelectorAll('.lists-panel li').length === 5");
await shoot("page.png");
// 2. The Harbor theme plugin added and applied; the browser prefers dark, so the page takes Harbor.
await cp(fileURLToPath(new URL("./later/harbor/", import.meta.url)), join(root, "harbor"), { recursive: true });
while (!app.plugins.state.value.set) await new Promise((done) => setTimeout(done, 10));
await app.applyChanges(context);
await browser.call("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: "dark" }] });
await browser.wait("getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#12232e'");
await shoot("harbor.png");
// 3. Half a todo typed, then an edit of the lists plugin's web facet applied: a count of the open todos.
await browser.evaluate("document.querySelector('.lists-panel input').focus()");
await browser.type("oat mi");
// A mark on the page's window: a page that reloaded would have lost it.
await browser.evaluate("window.marked = true");
const typed = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
const file = join(root, "lists", "web.ts");
await writeFile(file, (await readFile(file, "utf8")).replace(BEFORE, AFTER));
while (!app.plugins.state.value.dirty.includes("@todo/lists")) await new Promise((done) => setTimeout(done, 10));
await app.applyChanges(context);
await browser.wait("document.querySelector('.lists-open') !== null");
await shoot("edited.png");
const after = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`);
console.log("the page did not reload:", await browser.evaluate<boolean>("window.marked === true"));
} finally {
await browser.close();
await app.close(context);
await rm(base, { recursive: true, force: true });
}

View file

@ -0,0 +1,74 @@
// The todo app's API, version 1, for every host kind (`@todo/app/v1`): the part of Chord its plugins use, `defineFacet`
// typed with the app's principals, the app's host kind (a list, one worker each) and its contract for a list's todos.
// Plugins import this module, never Chord: their builds leave it out and each process passes its own instance in.
import {
arr,
bool,
defineFacet as chordFacet,
type FacetHost,
type FacetSelf,
hostKind,
int,
method,
num,
obj,
remote,
type Static,
str,
} from "../../../../../../src/index.ts";
import type { TodoPrincipal } from "../../principals.ts";
export { BACKGROUND_CONTEXT } from "../../../../../../src/context/index.ts";
export {
arr,
bool,
type Ctx,
defineTable,
eq,
int,
local,
method,
num,
obj,
PluginError,
query,
str,
type Theme,
Themes,
} from "../../../../../../src/index.ts";
export type { TodoPrincipal } from "../../principals.ts";
/** Defines a facet of the todo app: its factories get the app's principals. */
export function defineFacet(setup: (host: FacetHost<TodoPrincipal>, self: FacetSelf) => void) {
return chordFacet<TodoPrincipal>(setup);
}
/** A todo list: a worker process of its own, with its own store. */
export const List = hostKind("list", { id: str() });
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
/** A todo (see `Item`). */
export type Item = Static<typeof Item>;
/**
* A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one
* off (or on again); the owner also clears the done ones.
*/
export const Todos = remote("todo.todos", {
version: 1,
params: { list: str() },
hostAddress: ({ list }) => List({ id: list }),
variants: {
member: {
state: { items: arr(Item) },
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
},
owner: {
state: { items: arr(Item) },
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
},
},
});

View file

@ -0,0 +1,10 @@
// The todo app's API v1 for server facets (`@todo/app/v1/server`): the HTTP registry plugins add routes to, and the
// kit's pick-a-name login as a facet a one-line plugin exports.
import { ephemeralLoginFacet } from "../../../../../../src/node/app/index.ts";
import type { TodoPrincipal } from "../../principals.ts";
export { Http } from "../../../../../../src/node/http/index.ts";
/** The kit's ephemeral login: `POST /_chord/login { name }` signs in as `{ kind: "user", id, name }`. */
export const login = ephemeralLoginFacet<TodoPrincipal>();

View file

@ -0,0 +1,17 @@
// The todo app's API v1 for terminal facets (`@todo/app/v1/tui`): the `Screens` registry the terminal draws, one part
// per contributing plugin.
import { defineRegistry } from "../../../../../../src/index.ts";
import type { TerminalTheme, TuiScope } from "../../../../../../src/tui/index.ts";
/**
* A part of the terminal's screen: its title, and `open`, which holds what the part shows in `scope` (released when the
* terminal closes it) and returns a reader of its lines, coloured by role with `theme`.
*/
export type Screen = {
readonly title: string;
readonly open: (scope: TuiScope, theme: TerminalTheme) => () => readonly string[];
};
/** The terminal's parts, in plugin-name order. */
export const Screens = defineRegistry<Screen, readonly Screen[]>("todo.screens", { version: 1 });

View file

@ -0,0 +1,13 @@
// The todo app's API v1 for web facets (`@todo/app/v1/web`): the `Panels` registry the page shows, and Chord's React
// hooks (a plugin imports them from here: Chord itself is not a package plugins may name).
import type { ComponentType } from "react";
import { defineRegistry } from "../../../../../../src/index.ts";
export { useHold, useWatch } from "../../../../../../src/react/index.ts";
/** A panel of the page: its title and its React component. */
export type Panel = { readonly title: string; readonly Component: ComponentType };
/** The page's panels, one per contributing plugin, in plugin-name order. */
export const Panels = defineRegistry<Panel, readonly Panel[]>("todo.panels", { version: 1 });

View file

@ -0,0 +1,8 @@
// The todo app's principals: its server and list workers, the kit's owner, and the users who signed in with a name.
/** Who someone is in the todo app. */
export type TodoPrincipal =
| { readonly kind: "server" }
| { readonly kind: "worker"; readonly list: string }
| { readonly kind: "owner" }
| { readonly kind: "user"; readonly id: string; readonly name: string };

View file

@ -0,0 +1,36 @@
// The todo app's terminal entry, the base bundle the launcher's sandbox runs: `startTerminalRuntime` with the app's
// terminal modules, a core facet serving the `Screens` registry, and a redraw on every change. The screens are opened in
// a scope of their own, opened again when a reload changes them; `q` quits.
import { defineFacet } from "../../../../../src/index.ts";
import { startTerminalRuntime, TuiScope } from "../../../../../src/tui/index.ts";
import * as v1 from "../api/v1/index.ts";
import * as v1tui from "../api/v1/tui.ts";
/** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */
const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) }));
void startTerminalRuntime({
modules: { "@todo/app/v1": v1, "@todo/app/v1/tui": v1tui },
core: [{ name: "@todo/tui", facet: core }],
render: (runtime) => {
const { terminal, theme } = runtime;
const screens = new TuiScope(runtime.host, () => draw()).registry(v1tui.Screens);
let opened: { screens: readonly v1tui.Screen[]; scope: TuiScope; lines: (() => readonly string[])[] } | undefined;
const draw = (): void => {
const current = screens() ?? [];
if (opened?.screens !== current) {
opened?.scope.dispose();
const scope = new TuiScope(runtime.host, () => draw());
opened = { screens: current, scope, lines: current.map((screen) => screen.open(scope, theme)) };
}
const lines = current.flatMap((screen, n) => [theme.fg("accent", screen.title), ...opened!.lines[n]!()]);
terminal.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`);
};
theme.onChange(draw);
terminal.onInput((data) => {
if (data === "q") runtime.exit(0);
});
draw();
},
});

View file

@ -0,0 +1,33 @@
// The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and
// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute.
import * as React from "react";
import { createElement as h } from "react";
import * as jsx from "react/jsx-runtime";
import { createRoot } from "react-dom/client";
import { defineFacet } from "../../../../../src/index.ts";
import { HostProvider, useRegistry } from "../../../../../src/react/index.ts";
import { startPage } from "../../../../../src/web/index.ts";
import * as v1 from "../api/v1/index.ts";
import * as v1web from "../api/v1/web.ts";
import "./style.css";
/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */
const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) => table.map(({ value }) => value) }));
function Page() {
const panels = useRegistry(v1web.Panels) ?? [];
return h(
"main",
null,
h("h1", null, "Todo"),
...panels.map(({ title, Component }) => h("section", { key: title }, h("h2", null, title), h(Component))),
);
}
void startPage({
connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`),
modules: { "@todo/app/v1": v1, "@todo/app/v1/web": v1web, react: React, "react/jsx-runtime": jsx },
core: [{ name: "@todo/web", facet: core }],
render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))),
});

View file

@ -0,0 +1,5 @@
/* The page's look: the active theme's colours, which the kit writes on :root as CSS variables. */
body { margin: 0; font: 15px system-ui, sans-serif; background: var(--bg); color: var(--fg); }
main { max-width: 26rem; margin: 1.5rem auto; padding: 0 1rem; }
h1 { color: var(--accent); font-size: 1.4rem; margin: 0 0 1rem; }
h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }

View file

@ -0,0 +1,76 @@
// The todo app's start: what the app decides itself, given to the kit's `startServer`: its name and data directory,
// its API, its facet kinds, its principals, its one worker kind (a list), its page and its terminal client. The kit
// does the rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls
// it with the data directory from its command line and prints the owner link.
import { cp, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Context } from "../../../src/context/index.ts";
import { type App, startServer } from "../../../src/node/app/index.ts";
import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts";
import * as v1 from "./src/api/v1/index.ts";
import * as v1server from "./src/api/v1/server.ts";
import type { TodoPrincipal } from "./src/principals.ts";
/** The app's API: its name, its versions (newest first), and this process's instances of its modules. */
export const API = {
name: "@todo/app",
versions: ["v1"],
modules: { "@todo/app/v1": v1, "@todo/app/v1/server": v1server },
};
/** The app's facet kinds: a plugin has one file per kind it runs on (`server.ts`, `worker.ts`, `web.ts`, `tui.ts`). */
export const KINDS = {
server: { platform: "node" },
worker: { platform: "node" },
web: { platform: "browser", packages: { react: {}, "react/jsx-runtime": {} } },
tui: { platform: "node" },
} as const;
/** The page: the app's own HTML around the assets the kit made (the base bundle, the plugins' scripts and styles). */
function page(request: { readonly nonce: string }, assets: PageAssets): string {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Todo</title>${pageScripts(assets, request.nonce)}</head><body><div id="root"></div></body></html>`;
}
/** Starts the todo app on a free loopback port, with its data in `dataDir` and its built-in plugins in `root`. */
export function startTodo(dataDir: string, root: string, context: Context): Promise<App<TodoPrincipal>> {
return startServer<TodoPrincipal>(
{
name: "todo",
dataDir,
root,
listen: { host: "127.0.0.1", port: 0 },
api: API,
kinds: KINDS,
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) },
workers: [
{
kind: v1.List,
entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
start: () => ({}),
// Who may reach a list's worker: everyone signed in, and the server itself.
authorize: (principal) => principal.kind !== "worker",
},
],
web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page },
tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) },
onError: (error) => console.error("todo:", error.kind, error.error),
},
context,
);
}
/** A data directory and a copy of the app's plugins, in a new temporary directory. */
export async function todoDirectories(): Promise<{
readonly base: string;
readonly dataDir: string;
readonly root: string;
}> {
const base = await mkdtemp(join(tmpdir(), "todo-"));
const root = join(base, "plugins");
await cp(fileURLToPath(new URL("./plugins/", import.meta.url)), root, { recursive: true });
return { base, dataDir: join(base, "data"), root };
}

View file

@ -0,0 +1,116 @@
// The tutorial's checks (`docs/tutorial/book.md`): every TypeScript, JSON and CSS block of the book is quoted from this
// example's files (`<!-- quote: path -->` above it) and fails here when the file no longer has those lines; the
// example's steps run (`main.ts`, and `screenshots.ts` with Chromium), and every console output the book shows
// (`<!-- output: main -->`, `<!-- output: screenshots -->`) is among the lines they print, in order. The terminal's
// output (`<!-- output: tui -->`) is checked when a Node for its sandbox is there (`CHORD_TUI_NODE`, else `node26`).
// The p2 chapter's blocks (`<!-- p2: path -->`) quote p2's sources, which live outside this repository.
// Run from packages/chord:
// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/tutorial.test.ts
import { execFile } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { TUI_NODE } from "../../node/tui/support.ts";
import { CHROMIUM } from "../../node/web/browser.ts";
const HERE = fileURLToPath(new URL("./", import.meta.url));
const BOOK = readFileSync(new URL("../../../docs/tutorial/book.md", import.meta.url), "utf8");
/** A fenced block of the book: its language, the marker comment right above it (if any), its text and line. */
type Block = {
readonly lang: string;
readonly marker: string | undefined;
readonly text: string;
readonly line: number;
};
const BLOCKS = blocksOf(BOOK);
/** Every fenced block of `book`, with the `<!-- … -->` marker on the line before its opening fence. */
function blocksOf(book: string): Block[] {
const lines = book.split("\n");
const found: Block[] = [];
for (let n = 0; n < lines.length; n++) {
const open = /^```(\w*)$/.exec(lines[n]!);
if (open === null) continue;
const end = lines.indexOf("```", n + 1);
const marker = /^<!-- (.+) -->$/.exec(lines[n - 1] ?? "")?.[1];
found.push({ lang: open[1]!, marker, text: lines.slice(n + 1, end).join("\n"), line: n + 1 });
n = end;
}
return found;
}
/** The blocks whose marker is `<kind>: <value>`, with the value. */
function marked(kind: string): { readonly block: Block; readonly value: string }[] {
return BLOCKS.flatMap((block) => {
const value = block.marker?.startsWith(`${kind}: `) ? block.marker.slice(kind.length + 2) : undefined;
return value === undefined ? [] : [{ block, value }];
});
}
/** Fails unless every line of `block` is among `printed`'s lines, in order. */
function expectPrinted(block: Block, printed: string): void {
const lines = printed.split("\n").map((line) => line.trimEnd());
let at = 0;
for (const line of block.text.split("\n")) {
const found = lines.indexOf(line.trimEnd(), at);
expect(found, `book.md:${block.line}: not printed (in this order): ${line}`).toBeGreaterThanOrEqual(0);
at = found + 1;
}
}
/** Runs one of the example's scripts with `node` from packages/chord: what it printed. */
async function run(node: string, script: string, ...args: string[]): Promise<string> {
const flags = ["--conditions=source", "--experimental-strip-types", "--no-warnings"];
const { stdout } = await promisify(execFile)(node, [...flags, join(HERE, script), ...args], {
cwd: join(HERE, "../../.."),
timeout: 180_000,
});
return stdout;
}
describe("the tutorial's code blocks", () => {
it("quotes every TypeScript, JSON and CSS block from a file", () => {
const unquoted = BLOCKS.filter(
({ lang, marker }) =>
["ts", "tsx", "json", "css"].includes(lang) &&
!marker?.startsWith("quote: ") &&
!marker?.startsWith("p2: "),
);
expect(unquoted.map(({ line }) => `book.md:${line}`)).toEqual([]);
});
it("matches the example's files", () => {
const quotes = marked("quote");
expect(quotes.length).toBeGreaterThan(20);
for (const { block, value } of quotes) {
const file = readFileSync(join(HERE, value), "utf8");
expect(file.includes(block.text), `book.md:${block.line}: ${value} no longer has this block`).toBe(true);
}
});
});
describe("the tutorial's steps", () => {
it("prints what the book shows", { timeout: 200_000 }, async () => {
const printed = await run(TUI_NODE ?? process.execPath, "main.ts");
for (const { block } of marked("output").filter(({ value }) => value === "main")) expectPrinted(block, printed);
if (TUI_NODE === undefined) return;
for (const { block } of marked("output").filter(({ value }) => value === "tui")) expectPrinted(block, printed);
});
it.skipIf(CHROMIUM === undefined)("takes the screenshots", { timeout: 200_000 }, async () => {
const out = mkdtempSync(join(tmpdir(), "todo-screens-"));
try {
const printed = await run(process.execPath, "screenshots.ts", out);
for (const { block } of marked("output").filter(({ value }) => value === "screenshots")) {
expectPrinted(block, printed.replaceAll(out, "docs/tutorial/screens"));
}
} finally {
rmSync(out, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,7 @@
// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects
// to the server and loads the plugins' worker facets.
import { startWorker } from "../../../src/node/app/index.ts";
import { API, KINDS } from "./start.ts";
startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) });

View file

@ -31,6 +31,10 @@
"@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"],
"@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"],
"@chat/app/*": ["./packages/chord/test/examples/59-chat/app/*.ts"],
"@todo/app/*": [
"./packages/chord/test/examples/61-tutorial/src/api/*.ts",
"./packages/chord/test/examples/61-tutorial/src/api/*/index.ts"
],
"@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"],
"@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"],
"@earendil-works/pi-telemetry/render": ["./packages/telemetry/src/render.ts"],