docs(chord): slice 11 spec, web and TUI on the app kit

This commit is contained in:
Mario Zechner 2026-11-02 11:15:21 +00:00
parent 8d81a0f49d
commit 7b991fd96a

View file

@ -1,93 +1,99 @@
# Slice 11: web and TUI surfaces (spec)
Status: **draft** (owner, 2026-10-30: "start on slice 11"; "this one needs to be generic, so apps can do whatever they
want: css, tui components, themes"). Points marked **Decision needed** go to the owner one at a time.
Status: **approved** (owner, 2026-10-30/11-01: P1-P9 in the decisions "Slice 11's points" and "Slice 11, P3" to "P9",
then "go ahead, just do all the things"). Built after slices 12 and 13: the surfaces are generic plumbing that the app
kit wires in (`startApp` serves the web surface; a TUI client entry). The app keeps everything visible: HTML shell,
CSS, components, themes, navigation, layout, keys.
Slice 11 builds the generic plumbing of the app kit's two client surfaces (decision "Slice 7b's open points" (6)):
`chord/next/web` (a browser page as a Chord client host that loads the plugins' web facets the server built) and
`chord/next/tui` (a terminal client that does the same for TUI facets). The app keeps everything visible: its HTML
shell, CSS, components, themes, navigation, layout, keys. Built on slice 10 (`http` serves, the plugins component
builds and ships) and 7c (scripts as plain data, `createPluginRuntime`). Rule "The app kit is malleable by plugins"
applies.
Read in full before writing code: this spec; `decisions.md` (slice 11's entries, the plugin model, the app kit);
`design.md` §3 (clients), §21, §23 (all, "Web and TUI" included); `slice-10.md`, `slice-12.md`, `slice-13.md`; p2's
`src/hosts/web.ts`, `src/hosts/web/main.tsx`, `src/web/reconnect.ts`, `src/runtime/web.tsx`, `src/hosts/bundles.ts`,
`src/hosts/tui.ts`, `src/hosts/tui-client.ts`, `src/hosts/tui-sandbox.ts`, `src/hosts/tui-bootstrap.mjs`,
`src/hosts/tui-worker-runtime.ts`, `src/runtime/tui.ts` (`TuiScope`).
Read in full before writing code: this spec; `decisions.md` ("Slice 7c's points", "Slice 10's points"); `design.md`
§3 (clients), §21, §23; `slice-10.md`; p2's `src/hosts/web.ts`, `src/hosts/web/main.tsx`, `src/web/reconnect.ts`,
`src/runtime/web.tsx`, `src/hosts/bundles.ts`, `src/hosts/tui.ts`, `src/hosts/tui-client.ts`, `src/hosts/tui-sandbox.ts`,
`src/hosts/tui-bootstrap.mjs`, `src/hosts/tui-worker-runtime.ts`.
## 1. What p2 has, and what is generic
| p2 today | Generic (slice 11) | The app's |
| --- | --- | --- |
| one esbuild bundle per surface: runtime, React, every plugin's web facet, CSS; the page reloads whole when its version changes | a base bundle per surface (host packages and the surface runtime) plus each plugin's facet script (7c); new scripts applied in place | which host packages a surface has |
| the page: HTML with a CSP nonce, a theme script, `#root`, the bundle | serving the base bundle, scripts, stylesheets and assets through `http`; a page template hook | the HTML shell, CSP, theme, manifest and icons, service worker |
| `startWeb`: a client host, `keepConnected` (reconnect with backoff, wake on visibility and network change, stale-socket checks), protocol mismatch reloads the page | `startPage`: the client host, the connection loop, facet loading and reloading | `canRetry` policy (an ended share), connection UI |
| `runtime/web.tsx`: `HostContext`, `useObject`, `useReadable`, `useRegistry`, slots, sheets, navigation, owner state | optional React bindings: provider, hold, watch, registry, reference state | slots, sheets, navigation, everything else |
| TUI: a trusted launcher (auth, terminal, local preferences, actions), a permission-dropped worker thread running the downloaded bundle, a bridge terminal, a link through the launcher | a Node client surface: connect, download the base bundle and TUI scripts, evaluate, reload in place; the sandbox as an option | the TUI's components (pi-tui or other), commands, keys, themes, launcher actions |
## 2. `chord/next/web`
## 1. `chord/next/web` (server side)
```ts
// server: serve the page surface (routes contributed to `Http`, so they reload like any contribution)
// inside startApp (slice 13), or by hand:
const web = webSurface(server, {
kind: "web",
plugins, // the plugins component: the web kind's scripts
hostPackages: { "@earendil-works/chord/next": "chord-next", react: "react", "react/jsx-runtime": "react-jsx", "@app/web": "app-web" },
base: "./src/web/base.ts", // the surface runtime's entry: built once into the base bundle
page: (request, ctx, assets) => appShell(assets), // the app's HTML; `assets` lists the base script and stylesheets
kind: "web", plugins,
entry: "./src/web/main.tsx", // the app's web start code: the base bundle's entry
page: (request, assets) => shell(request.nonce, assets), // the app's HTML; assets: base script, plugins file, styles
}, ctx);
// its facet (routes to Http, the WebScripts object) goes into the server's core facets
```
// browser: src/web/base.ts (in the base bundle)
startPage({
principal: { kind: "server" },
- **Base bundle** per client kind: built on the server by esbuild from `entry`, with the kind's host packages inlined
once (the app's API modules for the kind, Chord, React), content-hashed (`/_chord/web/base.<hash>.js`), cached
forever (`Cache-Control: immutable`, overriding the baseline's `no-store` for these routes only); rebuilt when base
code changes (the server restarts anyway: built at start).
- **Plugin scripts:** each web facet served as a script that registers itself (`chordFacet("<plugin>", "<digest>",
function (require, module, exports) { … })`, P2), at `/_chord/web/plugin/<plugin>.<digest>.js`, immutable; the
current set as one combined file `/_chord/web/plugins.<set-hash>.js` (P3); stylesheets and assets of the build
likewise, under `publicPath`.
- **`WebScripts`** (a `remote` object the surface serves, params `{}`, any principal that may load the app): its state
is the current set `{ plugins: [{ name, digest, url, css? }], base }`; a rollout that ships the web kind updates it
(the plugins component's `scripts("web")` changes; the surface follows them) without waiting for pages (P5).
- Routes and the page are `Http` contributions of the surface's facet; the page route is `page: true` (navigation from
another site allowed) and public or not as the app says.
## 2. `chord/next/web` (browser side)
```ts
// src/web/main.tsx (the app's entry, in the base bundle)
startPage<P>({
connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`),
hostPackages: { "@earendil-works/chord/next": chordNext, react: React, "react/jsx-runtime": jsx, "@app/web": appWeb },
core: [{ name: "@app/web", facet: webCore }],
canRetry: async () => !(await shareEnded()),
onConnected: (connected) => setBanner(!connected),
modules: { "@acme/app/v3": api, "@acme/app/v3/web": apiWeb, react: React, "react/jsx-runtime": jsx },
core: [{ name: "@acme/web", facet: webCore }],
canRetry: async () => true,
onConnection: (state) => setBanner(state),
render: (host) => createRoot(document.getElementById("root")!).render(<HostProvider host={host}><App /></HostProvider>),
});
```
> **Decision needed P1: scope.** What `chord/next/web` ships: serving the surface (base bundle, scripts, stylesheets,
> assets, the page through an app template), the browser's client host with its connection loop, loading the plugins'
> web facets and applying new ones. Not shipped: HTML, CSS, components, themes, navigation, slots. React bindings
> separately (P7).
- A client host (no address); the connection loop (P6, p2's `keepConnected`: reconnect 250 ms to 5 s, wake on
visibility, `online` and network change, replace a silent socket, stop on an outdated protocol (reload the page) or
when `canRetry` says no); `onConnection` reports `connecting | connected | waiting`.
- Loads the combined plugins file, evaluates each registered facet with `modules` (the `require` they get answers only
host packages and `@earendil-works/chord/next/plugins`'s browser half if any), loads them with `core` in one
`host.load`, then holds `WebScripts`: on a new set it fetches the changed scripts (`<script src>`, one each), and
calls `host.reload` with the complete set (P4: in place; a failed plugin keeps its old facet, reported to the app);
a changed `base` hash reloads the page.
- CSP: scripts are `'self'` plus the nonce for the page's inline bootstrap; no `eval`.
> **Decision needed P2: how the browser evaluates a facet script.** (a) Each script served as a file wrapped to register
> itself (`chordFacet("<plugin>", "<digest>", function (require, module, exports) { … })`), loaded with `<script
> src>` under a `'self'` CSP; (b) fetched and run with `new Function` (needs `'unsafe-eval'`).
## 3. `chord/next/react`
> **Decision needed P3: the base bundle.** Built on the server per surface by esbuild from the app's `base` entry with
> the host packages inlined once; content-hashed; a change of base code reloads the page (as a server restart).
`HostProvider`, `useHost()`, `useHold(Type, params)` (a reference held for the component's life, released on unmount,
re-held when params change), `useWatch(watchable)` (the current value, re-rendering on change; `useSyncExternalStore`),
`useRegistry(R)`, `useRefState(ref)`. A component unmounted by a reload has not left (design §21): releases are plain
releases, presence is the app's. No components, no styling.
> **Decision needed P4: applying new scripts in a page.** (a) In place: the page's host reloads per plugin
> (`host.reload`, 7b), the UI re-renders from its registries; (b) the whole page reloads, as p2.
## 4. `chord/next/tui`
> **Decision needed P5: how a page learns of new scripts.** (a) Through Chord: the surface serves a `remote` object
> whose state is the current set (digests and URLs); every page holds it and applies changes; the rollout step publishes
> without waiting for pages; (b) polling a manifest (p2: every 5 s while visible).
- **Launcher** (trusted, a Node process): `startTerminal({ url, token | tokenFile, pairing?, ... })` connects (a token
from the owner token file, slice 13, or device pairing), downloads the base bundle and the TUI kind's plugin scripts
over HTTP, and starts the **sandbox**: a worker thread with dropped permissions (Node's permission model: no file
system, no child processes, no network except the bridge; p2's `tui-sandbox.ts`), always (P8), whose runtime gets a
bridge terminal (input events in, frames out), the Chord link (messages relayed by the launcher) and the evaluated
scripts. The launcher keeps credentials, the real terminal and local preferences, and exposes a small set of actions
to the sandbox (clipboard, open URL), each confirmed by the launcher's own UI when it touches the machine.
- **Runtime** (inside the sandbox, in the base bundle): a client host like the page's, the same connection loop over
the bridged link, in-place reloads through a `TuiScripts` object as the web.
- **`TuiScope`** (P9): `new TuiScope(host, changed)` with `hold`, `watch`, `registry`, `dispose`; no pi-tui dependency.
> **Decision needed P6: the connection loop.** p2's `keepConnected`: reconnect after a drop with backoff (250 ms to 5
> s), wake on visibility, `online` and network change, replace a stale socket, stop on an outdated protocol (reload) or
> when the app's `canRetry` says no. A network reconnect, not a crash restart.
## 5. In the app kit
> **Decision needed P7: React bindings.** An optional subpath (`chord/next/react`) with `HostProvider`,
> `useHold(Type, params)`, `useWatch(watchable)`, `useRegistry(R)`, `useRefState(ref)`; or none in Chord.
`startApp` gains `web: { entry, page }` and `tui: { entry }` (optional); with them it creates the surfaces and adds
their facets. Without, an app has no web or TUI surface (an API-only app).
## 3. `chord/next/tui`
## 6. Tests, examples
> **Decision needed P8: scope.** A Node client surface (`startTerminal`): connect over WebSocket with the app's
> credentials, download the base bundle and the TUI scripts from the server, evaluate them, run the app's entry with
> its host, apply new scripts in place; the terminal belongs to the app. p2's sandbox (a permission-dropped worker
> thread for code from another person's server, a bridge terminal, launcher actions) as an option or not in Chord.
> **Decision needed P9: pi-tui bindings.** None in Chord (the app chooses its TUI library), or optional helpers.
## 4. Not in slice 11
App shells, themes, navigation, slots, offline support (service workers), the TUI's commands and preferences;
telemetry (slice 9).
## 5. Tests, examples, cost
Filled once the points are decided. Examples: small and cute (owner), each a folder.
- One test file per source file. Web server side: the base bundle (hash, immutable, rebuilt per start), plugin
scripts and the combined file (registration wrapper, no eval, digests), `WebScripts` updated by a rollout, the page
route and CSP. Browser side under jsdom or a real browser (the repo's browser smoke setup, if it fits): the loop
(backoff, wake events, outdated protocol, `canRetry`), loading the combined file, an in-place reload keeping a
component's state, a base change reloading. React hooks (hold released on unmount, watch re-render, registry).
TUI: the sandbox cannot read a file or spawn a process (a test plugin that tries), the bridge, an in-place reload,
`TuiScope` disposal; pairing a TUI client against a kit app.
- Examples: `56-web` (a kit app with a page, two web plugins, an edit applied in place), `57-tui` (a kit app with a
TUI client, sandboxed).