mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(chord): slice 11 spec, web and TUI on the app kit
This commit is contained in:
parent
8d81a0f49d
commit
7b991fd96a
1 changed files with 78 additions and 72 deletions
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue