@abide/abide 0.50.1 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -26,11 +26,14 @@ One declared RPC fans out to five surfaces:
26
26
  ▼ ▼ ▼ ▼ ▼
27
27
  SSR call browser fetch MCP tool CLI subcmd OpenAPI op
28
28
  (bare, (same call, (read-only abide-cli /openapi.json
29
- in-proc) swap to fetch) from schema) getMessages
29
+ in-proc) swap to fetch) from type) getMessages
30
30
  ```
31
31
 
32
- A `schemas.input` (any Standard Schema) unlocks the **CLI** on any RPC and
33
- **MCP** for read-only methods (`GET`/`HEAD`). A mutating method
32
+ A typed input unlocks the **CLI** on any RPC and **MCP** for read-only methods
33
+ (`GET`/`HEAD`): the handler's input type is projected to JSON Schema at build
34
+ (ADR-0030), so a plainly-typed handler auto-exposes with no hand-written
35
+ `schemas.input` (a declared `schemas.input` adds runtime validation on top, it
36
+ isn't what flips the surfaces on). A mutating method
34
37
  (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to MCP — it needs an explicit
35
38
  `clients: { mcp: true }`. Explicit `clients` values always win; `browser`
36
39
  defaults on. A socket with a `schema` auto-exposes to MCP and CLI regardless of
@@ -41,40 +44,40 @@ direction.
41
44
  The bundler and route resolver read these paths by convention (dir aliases
42
45
  `$server`, `$ui`, `$shared`, `$mcp`, `$cli` point at the matching `src/` dirs):
43
46
 
44
- | Path | Meaning |
45
- |---|---|
46
- | `src/server/rpc/<name>.ts` | One RPC per file; filename = export name = URL under `/rpc/`. The method helper picks the verb. Rewritten to `defineRpc` (server) / `remoteProxy` (client). |
47
- | `src/server/sockets/<name>.ts` | One socket per file (`export const <name> = socket(...)`); path → socket name. Rewritten to `defineSocket` (server) / `socketProxy` (client). |
48
- | `src/mcp/prompts/<name>.md` | Markdown MCP prompt: frontmatter (description + arguments) + `{{arg}}` template body, compiled to `definePrompt`. |
49
- | `src/mcp/resources/*` | MCP resource files served by the generated MCP server. |
50
- | `src/server/config.ts` | Optional typed-env module: `export const config = env(schema)` validates `Bun.env` at boot (or the floor `export const config = Bun.env`). Eager-imported; deletable. |
51
- | `src/app.ts` | Optional app hooks (`AppModule` shape): `init` / `handle` / `handleError` / `health` / `forwardHeaders`. Deletable. |
52
- | `src/ui/pages/**/page.abide` | Folder-based route: a folder's `page.abide` mounts at that folder's URL. `[name]` / `[[name]]` (optional) / `[...rest]` (catch-all) are dynamic segments → `page.params`. |
53
- | `src/ui/pages/**/layout.abide` | Wraps every page at/below its folder; renders the page where it calls `{children()}`; kept mounted across navigation. |
54
- | `src/ui/app.html`, `src/ui/app.css` | Custom document shell and root stylesheet. |
55
- | `src/ui/public/` | Static assets, served at the site root (`/<file>`). |
56
- | `src/bundle/window.ts` | Optional default-exported `BundleWindow` for the desktop bundle (plus optional `src/bundle/disconnected.abide` connect-screen override). |
57
- | `src/cli/banner.txt`, `src/cli/footer.txt` | CLI help chrome. |
58
- | `src/.abide/*.d.ts` | Generated ambient types (`rpc.d.ts`, `routes.d.ts`, `health.d.ts`, `publicAssets.d.ts`, `testRpc.d.ts`, `testSockets.d.ts`). Do not hand-edit. |
59
- | `dist/` | Build output: `dist/_app/` (prod client) or `dist/_app.gen-<id>/` (dev), `dist/app` (compiled binary), `dist/cli-*`. |
47
+ | Path | Meaning |
48
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `src/server/rpc/<name>.ts` | One RPC per file; filename = export name = URL under `/rpc/`. The method helper picks the verb. Rewritten to `defineRpc` (server) / `remoteProxy` (client). |
50
+ | `src/server/sockets/<name>.ts` | One socket per file (`export const <name> = socket(...)`); path → socket name. Rewritten to `defineSocket` (server) / `socketProxy` (client). |
51
+ | `src/mcp/prompts/<name>.md` | Markdown MCP prompt: frontmatter (description + arguments) + `{{arg}}` template body, compiled to `definePrompt`. |
52
+ | `src/mcp/resources/*` | MCP resource files served by the generated MCP server. |
53
+ | `src/server/config.ts` | Optional typed-env module: `export const config = env(schema)` validates `Bun.env` at boot (or the floor `export const config = Bun.env`). Eager-imported; deletable. |
54
+ | `src/app.ts` | Optional app hooks (`AppModule` shape): `init` / `handle` / `handleError` / `health` / `forwardHeaders`. Deletable. |
55
+ | `src/ui/pages/**/page.abide` | Folder-based route: a folder's `page.abide` mounts at that folder's URL. `[name]` / `[[name]]` (optional) / `[...rest]` (catch-all) are dynamic segments → `page.params`. |
56
+ | `src/ui/pages/**/layout.abide` | Wraps every page at/below its folder; renders the page where it calls `{children()}`; kept mounted across navigation. |
57
+ | `src/ui/app.html`, `src/ui/app.css` | Custom document shell and root stylesheet. |
58
+ | `src/ui/public/` | Static assets, served at the site root (`/<file>`). |
59
+ | `src/bundle/window.ts` | Optional default-exported `BundleWindow` for the desktop bundle (plus optional `src/bundle/disconnected.abide` connect-screen override). |
60
+ | `src/cli/banner.txt`, `src/cli/footer.txt` | CLI help chrome. |
61
+ | `src/.abide/*.d.ts` | Generated ambient types (`rpc.d.ts`, `routes.d.ts`, `health.d.ts`, `publicAssets.d.ts`, `testRpc.d.ts`, `testSockets.d.ts`). Do not hand-edit. |
62
+ | `dist/` | Build output: `dist/_app/` (prod client) or `dist/_app.gen-<id>/` (dev), `dist/app` (compiled binary), `dist/cli-*`. |
60
63
 
61
64
  ## CLI
62
65
 
63
66
  `abide <command>` (the `abide` bin):
64
67
 
65
- | Command | Does |
66
- |---|---|
67
- | `abide scaffold <name> [--no-install] [--no-dev]` | Scaffold a project from the bundled template, install it, and (TTY only) start dev. |
68
- | `abide dev` | Dev orchestrator: build client, spawn the server child, watch `src/`, rebuild + restart on change, browser live-reload. |
69
- | `abide build` | Single client build into `dist/_app/`, no server (CI / static deploys). |
70
- | `abide start` | Run the production server against an already-built `dist/`. |
71
- | `abide run <file> [args...]` | Run an arbitrary script under the abide preload (same runtime as the server); argv after the file is forwarded verbatim. |
72
- | `abide compile [--target=<bun-…>] [--out=<path>]` | Build a standalone server executable. |
68
+ | Command | Does |
69
+ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
70
+ | `abide scaffold <name> [--no-install] [--no-dev]` | Scaffold a project from the bundled template, install it, and (TTY only) start dev. |
71
+ | `abide dev` | Dev orchestrator: build client, spawn the server child, watch `src/`, rebuild + restart on change, browser live-reload. |
72
+ | `abide build` | Single client build into `dist/_app/`, no server (CI / static deploys). |
73
+ | `abide start` | Run the production server against an already-built `dist/`. |
74
+ | `abide run <file> [args...]` | Run an arbitrary script under the abide preload (same runtime as the server); argv after the file is forwarded verbatim. |
75
+ | `abide compile [--target=<bun-…>] [--out=<path>]` | Build a standalone server executable. |
73
76
  | `abide cli [--target=…] [--out=…] [--platforms=<a,b,c>]` | Build the thin CLI binary (manifest baked in, ships the compiled server beside it); `--platforms` cross-compiles into `dist/cli-thin/<platform>/`. |
74
- | `abide bundle` | Assemble a self-contained desktop app bundle for the host platform (`.app` on macOS), unsigned. |
75
- | `abide check` | Type-check every `.abide` component's template + props through its shadow program; non-zero on error. |
76
- | `abide lsp` | Run the `.abide` language server over stdio (JSON-RPC) for editor diagnostics. |
77
- | `abide init-agent` | Write/refresh the abide agent-guide pointer in the project root `CLAUDE.md`. |
77
+ | `abide bundle` | Assemble a self-contained desktop app bundle for the host platform (`.app` on macOS), unsigned. |
78
+ | `abide check` | Type-check every `.abide` component's template + props through its shadow program; non-zero on error. |
79
+ | `abide lsp` | Run the `.abide` language server over stdio (JSON-RPC) for editor diagnostics. |
80
+ | `abide init-agent` | Write/refresh the abide agent-guide pointer in the project root `CLAUDE.md`. |
78
81
 
79
82
  For tests, add `preload = ["@abide/abide/preload"]` under `[test]` in
80
83
  `bunfig.toml` and run `bun test`.
@@ -157,48 +160,57 @@ A `.abide` component is HTML with a leading `<script>` (its component script);
157
160
  that branch (a nested `<script>` declares branch-local `state`/`state.computed`/
158
161
  `state.linked`, re-seeded per mount, and takes **no** module imports — imports
159
162
  live only in the leading script; a nested `<style>` scopes to its sibling
160
- subtree). A *root* `<style>` is component-scoped. Reactive primitives are reached
163
+ subtree). A _root_ `<style>` is component-scoped. Reactive primitives are reached
161
164
  through their own imported bindings (alias-safe) — `state` from `abide/ui/state`,
162
165
  `watch` from `abide/ui/watch`, `html` from `abide/ui/html`, `snippet` from
163
- `abide/shared/snippet` — never through `scope()` (internal plumbing). The one
164
- ambient reader is `props()` (no import).
166
+ `abide/shared/snippet`, `props` from `abide/ui/props` — never through `scope()`
167
+ (internal plumbing). Every one of these, `props()` included, is a required import:
168
+ a missing one surfaces as `Cannot find name '…'`.
165
169
 
166
170
  Reactive state:
167
171
 
168
- | Form | Meaning |
169
- |---|---|
170
- | `state(initial, transform?)` | Writable cell; read/write via `.value`; `transform(next, prev)` gates writes. |
171
- | `state.computed(fn)` | Read-only cell derived from other cells (lazy, never serialized). |
172
- | `state.linked(fn, transform?)` | Writable cell reseeded when the thunk's deps change. |
173
- | `watch(source, handler)` | The single reaction primitive: over a cell, a cell array, a socket/stream, or an RPC; bare `watch(thunk)` is an auto-tracked effect. Client-only. |
174
- | `props()` | Ambient prop reader: `const { name = fallback, ...rest } = props()`. |
172
+ | Form | Meaning |
173
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
174
+ | `state(initial, transform?)` | Writable cell; read/write via `.value`; `transform(next, prev)` gates writes. |
175
+ | `state.computed(fn)` | Read-only cell derived from other cells (lazy, never serialized). |
176
+ | `state.linked(fn, transform?)` | Writable cell reseeded when the thunk's deps change. |
177
+ | `watch(source, handler)` | The single reaction primitive: over a cell, a cell array, a socket/stream, or an RPC; bare `watch(thunk)` is an auto-tracked effect. Client-only. |
178
+ | `props()` | Ambient prop reader: `const { name = fallback, ...rest } = props()`. |
175
179
 
176
180
  Bindings and directives (attribute kinds `event` / `bind` / `class` / `style` /
177
181
  `attach` / spread, plus plain `expression` / static):
178
182
 
179
- | Form | Meaning |
180
- |---|---|
181
- | `{expr}` | Reactive text (escaped); an `html`-branded value inserts unescaped raw HTML. |
182
- | `name={expr}` | Reactive attribute. |
183
- | `on<event>={fn}` | Event listener (`onclick`, `oninput`, `onsubmit`, …). |
184
- | `bind:value` / `bind:checked` / `bind:group` | Two-way form binds. |
185
- | `bind:value={{ get, set }}` | Derived two-way binding. |
186
- | `class:name={cond}` | Toggle a class. |
187
- | `style:property={value}` | Set one style property. |
188
- | `attach={fn}` | Run `fn(element)` at mount; its return is the teardown. |
189
- | `{...spread}` | Spread an object's keys as attributes (element) or props (component). |
183
+ | Form | Meaning |
184
+ | -------------------------------------------- | ---------------------------------------------------------------------------- |
185
+ | `{expr}` | Reactive text (escaped); an `html`-branded value inserts unescaped raw HTML. |
186
+ | `name={expr}` | Reactive attribute. |
187
+ | `on<event>={fn}` | Event listener (`onclick`, `oninput`, `onsubmit`, …). |
188
+ | `bind:value` / `bind:checked` / `bind:group` | Two-way form binds. |
189
+ | `bind:value={{ get, set }}` | Derived two-way binding. |
190
+ | `class:name={cond}` | Toggle a class. |
191
+ | `style:property={value}` | Set one style property. |
192
+ | `attach={fn}` | Run `fn(element)` at mount; its return is the teardown. |
193
+ | `{...spread}` | Spread an object's keys as attributes (element) or props (component). |
190
194
 
191
195
  Control flow — mustache `{#…}` blocks (NOT `<template>`):
192
196
 
193
- | Block | Form |
194
- |---|---|
195
- | Conditional | `{#if}` / `{:else if}` / `{:else}` / `{/if}` |
196
- | Keyed list | `{#for item, i of list by key}` / `{/for}` |
197
- | Async list | `{#for await item of source}` / `{/for}` (over an `AsyncIterable`) |
198
- | Promise | `{#await p}` / `{:then v}` / `{:catch e}` / `{:finally}` / `{/await}` |
199
- | Switch | `{#switch subject}` / `{:case v}` / `{:default}` / `{/switch}` |
200
- | Error boundary | `{#try}` / `{:catch}` / `{:finally}` / `{/try}` |
201
- | Snippet | `{#snippet name(args)}…{/snippet}`, called `{name(args)}` |
197
+ | Block | Form |
198
+ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
199
+ | Conditional | `{#if}` / `{:else if}` / `{:else}` / `{/if}` |
200
+ | Keyed list | `{#for item, i of list by key}` / `{/for}` |
201
+ | Async list | `{#for await item of source}` / `{/for}` (over an `AsyncIterable`) |
202
+ | Promise | bare peek `{fn()}` is the default (below); `{#await p}` / `{:then v}` / `{:catch e}` / `{:finally}` / `{/await}` is the explicit opt-in |
203
+ | Switch | `{#switch subject}` / `{:case v}` / `{:default}` / `{/switch}` |
204
+ | Error boundary | `{#try}` / `{:catch}` / `{:finally}` / `{/try}` |
205
+ | Snippet | `{#snippet name(args)}…{/snippet}`, called `{name(args)}` |
206
+
207
+ Reading async data does **not** require `{#await}`. A promise/async-cell-typed
208
+ `{expr}` is a **peek** — `undefined` while pending (auto-streamed on SSR), composing
209
+ with `?? fallback` / `?.` / `{#if}` / attributes in every position (ADR-0032), paired
210
+ with the `.pending()` / `.error()` probes for affordances; `{await expr}` blocks SSR
211
+ inline (the shorthand for a `{#await … then}` head). The `{#await}` block above is the
212
+ opt-in reserved for a distinct pending branch, a local `{:catch}`, or `{:then}`
213
+ type-narrowing — ADR-0019 narrowed its authored role to exactly that.
202
214
 
203
215
  Components are capitalised tags; content nested in them renders where the
204
216
  component calls `{children()}` (`{#if children}{children()}{:else}…{/if}` is the
@@ -304,7 +316,7 @@ error. The branch keyword is `{:else if}` (a space).
304
316
  ### Cache mutation — @documentation cache
305
317
 
306
318
  - `@abide/abide/shared/patch` — `patch(fn, args?, updater)` / `patch({tags},
307
- updater)`: reactively mutate the retained value of matching cached reads in
319
+ updater)`: reactively mutate the retained value of matching cached reads in
308
320
  place, no network — the optimistic-update / real-time primitive.
309
321
  - `@abide/abide/shared/refresh` — `refresh(selector?, args?)`: refetch every
310
322
  matching cached read, keeping the stale value visible (`refreshing()` true)
@@ -365,20 +377,20 @@ error. The branch keyword is `{:else if}` (a space).
365
377
 
366
378
  ### Reactive state — @documentation reactive-state
367
379
 
368
- - `@abide/abide/ui/state` — `state(initial, transform?)` writable cell (`.value`);
380
+ - `@abide/abide/ui/state` — `state(initial, transform?)` writable cell (read/reassigned as a plain variable; compiler desugars to `.value`);
369
381
  members `state.computed(fn)` (read-only derived), `state.linked(fn, transform?)`
370
382
  (writable, reseeded from a thunk), `state.share(key, value)` / `state.shared(key)`
371
383
  (ambient scope context).
372
384
  - `@abide/abide/ui/watch` — `watch(source, handler)` the single reaction primitive
373
385
  (cell / cell array / socket-stream / RPC); bare `watch(thunk)` is an auto-tracked
374
386
  effect; returns a scope-tied disposer; SSR-inert.
375
- - `@abide/abide/ui/props` — `props()` ambient prop reader; compiler-lowered inside
376
- a component; throws if called directly.
387
+ - `@abide/abide/ui/props` — `props()` prop reader (a required import, like the other
388
+ reactive names); compiler-lowered inside a component; throws if called directly.
377
389
 
378
390
  ### Templating — @documentation templating
379
391
 
380
- - `@abide/abide/ui/html` — `html\`…\`` (or `html(string)`) returns branded
381
- **unescaped** raw HTML for `{expr}` insertion; interpolations are not
392
+ - `@abide/abide/ui/html` — `html\`…\``(or`html(string)`) returns branded
393
+ **unescaped** raw HTML for `{expr}` insertion; interpolations are not
382
394
  auto-escaped; nullish → empty.
383
395
 
384
396
  ### Navigate — @documentation navigate
@@ -518,7 +530,7 @@ error. The branch keyword is `{:else if}` (a space).
518
530
  resolver plugin, and the `.css` no-op loader (used by `bunfig` `[test]` and the
519
531
  CLI `--preload`).
520
532
  - `@abide/abide/resolver-plugin` — `abideResolverPlugin({ cwd?, embedAssets?,
521
- target? })` wiring every `abide:*` virtual module, the `$server`/`$ui`/`$shared`/
533
+ target? })` wiring every `abide:*` virtual module, the `$server`/`$ui`/`$shared`/
522
534
  `$mcp`/`$cli` aliases, and the per-target RPC/socket rewrite (with the
523
535
  client-side server-code-leak guard).
524
536
  - `@abide/abide/tsconfig` — the base `tsconfig.app.json` for consuming apps to
@@ -554,7 +566,7 @@ error. The branch keyword is `{:else if}` (a space).
554
566
 
555
567
  - `@abide/abide/test/createTestApp` — `createTestApp()` boots the real app on an
556
568
  ephemeral port; returns `{ origin, fetch, rpc, sockets, health, stop,
557
- [Symbol.asyncDispose] }` (`rpc`/`sockets` typed by generated d.ts). Use
569
+ [Symbol.asyncDispose] }` (`rpc`/`sockets` typed by generated d.ts). Use
558
570
  `await using`.
559
571
 
560
572
  ### Testing plumbing — @documentation plumbing
@@ -569,34 +581,34 @@ error. The branch keyword is `{:else if}` (a space).
569
581
 
570
582
  Runtime routes the framework serves:
571
583
 
572
- | Route | Serves |
573
- |---|---|
574
- | `/openapi.json` | OpenAPI spec for the public `/rpc/*` surface, built lazily from the frozen RPC registry. |
575
- | `/__abide/mcp` | MCP endpoint (POST → `mcp.handle`), through the app auth/CSRF pipeline; mounted when an MCP is configured. |
576
- | `/__abide/health` | Health/identity probe answered ahead of app middleware (framework identity + `app.health()` fields), `no-store`. |
577
- | `/__abide/identity` | Compatibility alias of the health payload, stamped `{ abide: true }` for legacy probers. |
578
- | `/__abide/inspector` | Operator inspector UI + data/SSE routes, gated by `ABIDE_ENABLE_INSPECTOR` (optional `@abide/inspector`). |
579
- | `/__abide/sockets` | WebSocket upgrade for the socket multiplex hub; `/__abide/sockets/<name>` is one socket's HTTP face (GET tail, POST publish). |
580
- | `/__abide/cli` | GET returns the platform-detecting install script; `/__abide/cli/<platform>` streams the built CLI binary tarball. |
584
+ | Route | Serves |
585
+ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
586
+ | `/openapi.json` | OpenAPI spec for the public `/rpc/*` surface, built lazily from the frozen RPC registry. |
587
+ | `/__abide/mcp` | MCP endpoint (POST → `mcp.handle`), through the app auth/CSRF pipeline; mounted when an MCP is configured. |
588
+ | `/__abide/health` | Health/identity probe answered ahead of app middleware (framework identity + `app.health()` fields), `no-store`. |
589
+ | `/__abide/identity` | Compatibility alias of the health payload, stamped `{ abide: true }` for legacy probers. |
590
+ | `/__abide/inspector` | Operator inspector UI + data/SSE routes, gated by `ABIDE_ENABLE_INSPECTOR` (optional `@abide/inspector`). |
591
+ | `/__abide/sockets` | WebSocket upgrade for the socket multiplex hub; `/__abide/sockets/<name>` is one socket's HTTP face (GET tail, POST publish). |
592
+ | `/__abide/cli` | GET returns the platform-detecting install script; `/__abide/cli/<platform>` streams the built CLI binary tarball. |
581
593
 
582
594
  ## Environment variables
583
595
 
584
- | Variable | Effect |
585
- |---|---|
586
- | `PORT` | Exact TCP port to bind; unset/invalid scans from 3000. |
587
- | `APP_URL` | Derives the server's mount base path from its pathname (e.g. `/v2`). |
588
- | `ABIDE_APP_URL` | Default server URL the CLI connects to; its pathname sets the mount base. |
589
- | `ABIDE_APP_TOKEN` | Sent as `Authorization: Bearer <value>` on CLI→server requests. |
590
- | `ABIDE_APP_DIR` | Overrides the dir the server serves chunks/shell/assets from (set per dev build generation). |
591
- | `ABIDE_DATA_DIR` | Overrides the app data directory on all platforms, used as-is (no program-name suffix). |
592
- | `ABIDE_CLIENT_TIMEOUT` | RPC client timeout in ms (1–600000), shipped to the browser transport. |
593
- | `ABIDE_MAX_REQUEST_BODY_SIZE` | Server-wide max request body size. |
594
- | `ABIDE_IDLE_TIMEOUT` | Bun per-connection idle timeout in seconds (default 10). |
595
- | `ABIDE_LOG_FORMAT` | `json` renders log records as JSON instead of TSV. |
596
- | `ABIDE_ENABLE_INSPECTOR` | `true` mounts the opt-in operator inspector UI/routes. |
597
- | `ABIDE_INSPECT` | Enables webview devtools/inspect for desktop bundles. |
598
- | `ABIDE_DEV_SURFACE` | `1` forces the worker to print its surface map (set by the dev orchestrator). |
599
- | `DEBUG` | npm-debug-style channel gate for diagnostic log channels (e.g. `abide:cache`, `-abide`). |
596
+ | Variable | Effect |
597
+ | ----------------------------- | -------------------------------------------------------------------------------------------- |
598
+ | `PORT` | Exact TCP port to bind; unset/invalid scans from 3000. |
599
+ | `APP_URL` | Derives the server's mount base path from its pathname (e.g. `/v2`). |
600
+ | `ABIDE_APP_URL` | Default server URL the CLI connects to; its pathname sets the mount base. |
601
+ | `ABIDE_APP_TOKEN` | Sent as `Authorization: Bearer <value>` on CLI→server requests. |
602
+ | `ABIDE_APP_DIR` | Overrides the dir the server serves chunks/shell/assets from (set per dev build generation). |
603
+ | `ABIDE_DATA_DIR` | Overrides the app data directory on all platforms, used as-is (no program-name suffix). |
604
+ | `ABIDE_CLIENT_TIMEOUT` | RPC client timeout in ms (1–600000), shipped to the browser transport. |
605
+ | `ABIDE_MAX_REQUEST_BODY_SIZE` | Server-wide max request body size. |
606
+ | `ABIDE_IDLE_TIMEOUT` | Bun per-connection idle timeout in seconds (default 10). |
607
+ | `ABIDE_LOG_FORMAT` | `json` renders log records as JSON instead of TSV. |
608
+ | `ABIDE_ENABLE_INSPECTOR` | `true` mounts the opt-in operator inspector UI/routes. |
609
+ | `ABIDE_INSPECT` | Enables webview devtools/inspect for desktop bundles. |
610
+ | `ABIDE_DEV_SURFACE` | `1` forces the worker to print its surface map (set by the dev orchestrator). |
611
+ | `DEBUG` | npm-debug-style channel gate for diagnostic log channels (e.g. `abide:cache`, `-abide`). |
600
612
 
601
613
  ---
602
614
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # abide
2
2
 
3
+ ## 0.51.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aec503f: make watch(cell, handler) reactive ([`4db4e3d`](https://github.com/briancray/abide/commit/4db4e3d21cb04dcad5463831621140ec00ccbbe2))
8
+
9
+ ### Patch Changes
10
+
11
+ - aec503f: correct README/AGENTS and lead the async story with the bare call ([`c96e936`](https://github.com/briancray/abide/commit/c96e93682cc8e05762bcde7bb808850dfdb40b6f))
12
+
3
13
  ## 0.50.1
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -34,17 +34,14 @@ import { json } from '@abide/abide/server/json'
34
34
  import { z } from 'zod'
35
35
  import { load } from '$server/db.ts'
36
36
 
37
- export const getMessages = GET(
38
- (args) => json(load(args.channel, args.limit)),
39
- {
40
- schemas: {
41
- input: z.object({
42
- channel: z.string().default('general'),
43
- limit: z.number().max(100).default(20),
44
- }),
45
- },
37
+ export const getMessages = GET((args) => json(load(args.channel, args.limit)), {
38
+ schemas: {
39
+ input: z.object({
40
+ channel: z.string().default('general'),
41
+ limit: z.number().max(100).default(20),
42
+ }),
46
43
  },
47
- )
44
+ })
48
45
  ```
49
46
 
50
47
  One declaration, five surfaces:
@@ -56,10 +53,10 @@ One declaration, five surfaces:
56
53
  ▼ ▼ ▼ ▼ ▼
57
54
  SSR call browser fetch MCP tool CLI subcmd OpenAPI op
58
55
  (bare, (same call, (read-only abide-cli /openapi.json
59
- in-proc) swap to fetch) from schema) getMessages
56
+ in-proc) swap to fetch) from type) getMessages
60
57
  ```
61
58
 
62
- A schema unlocks the CLI on any RPC, and MCP for read-only methods (`GET`/`HEAD`). A mutating method never auto-exposes to MCP — it opts in explicitly:
59
+ A typed input unlocks the CLI on any RPC, and MCP for read-only methods (`GET`/`HEAD`) — the handler's input type is projected to JSON Schema at build (ADR-0030), so a plainly-typed handler auto-exposes with no hand-written `schemas.input` (declaring one adds runtime validation on top, it isn't what flips the surfaces on). A mutating method never auto-exposes to MCP — it opts in explicitly:
63
60
 
64
61
  ```ts
65
62
  // src/server/rpc/sendMessage.ts
@@ -68,13 +65,10 @@ import { json } from '@abide/abide/server/json'
68
65
  import { z } from 'zod'
69
66
  import { append } from '$server/db.ts'
70
67
 
71
- export const sendMessage = POST(
72
- (args) => json(append(args.channel, args.body)),
73
- {
74
- schemas: { input: z.object({ channel: z.string(), body: z.string() }) },
75
- clients: { mcp: true }, // a mutating RPC must opt into MCP by hand
76
- },
77
- )
68
+ export const sendMessage = POST((args) => json(append(args.channel, args.body)), {
69
+ schemas: { input: z.object({ channel: z.string(), body: z.string() }) },
70
+ clients: { mcp: true }, // a mutating RPC must opt into MCP by hand
71
+ })
78
72
  ```
79
73
 
80
74
  Consume forms are isomorphic. The **bare call `fn(args)` is the smart read** — cached, coalesced, reactive, resolved in-process during SSR and over `fetch` in the browser (there is no `cache()` wrapper; the bare call carries the caching). Alongside it: `fn.raw(args, init?)` for the raw `Response`, and the mutators/probes `fn.refresh()`, `fn.patch(...)`, `fn.peek()`, `fn.pending()`, `fn.refreshing()`, and `fn.error()`. A streaming handler (`jsonl`/`sse`) makes the bare call return a `Subscribable` you iterate.
@@ -101,7 +95,9 @@ Publish with `chat.publish(frame)`; seed a late reader with `chat.tail(count)`.
101
95
 
102
96
  ## Components
103
97
 
104
- A `.abide` component is HTML with a leading `<script>`. The example below is one page that imports the RPC and socket above and exercises the whole template grammar. Reactive primitives are imported by their own module paths and called bare: `state(0)` is a writable cell (read/write via `.value`), `state.computed(fn)` is read-only derived, `state.linked(fn)` is writable and reseeded from a thunk. `watch(source, handler)` is the single reaction primitive — over a cell, a socket, or an RPC. `props()` is the ambient prop reader (no import).
98
+ A `.abide` component is HTML with a leading `<script>`. The example below is one page that imports the RPC and socket above and exercises the whole template grammar. Reactive primitives are imported by their own module paths and called bare: `state(0)` is a writable cell you read and reassign as a plain variable (`count`, `count += 1`) — the compiler desugars those to the cell's `.value`; `state.computed(fn)` is read-only derived, `state.linked(fn)` is writable and reseeded from a thunk. `watch(source, handler)` is the single reaction primitive — over a cell, a socket, or an RPC. `props()` is the prop reader, imported from `abide/ui/props`.
99
+
100
+ Async data has no ceremony: **the bare call in an interpolation — `{getMessages({ limit })}` — is the way to read it.** It's a _peek_ that reads `undefined` while pending (and auto-streams on SSR), so it composes with `?? fallback`, `?.`, `{#if}`, and attributes with no wrapping block; pair it with the `.pending()` / `.error()` probes for loading and error affordances, and use `{await getMessages(...)}` to block SSR until the value is in the initial HTML. The `{#await}…{:then}…{:catch}…{:finally}…{/await}` block is the explicit opt-in — reach for it only when you want a distinct pending branch, a local `{:catch}`, or `{:then}` type-narrowing.
105
101
 
106
102
  ```html
107
103
  <script>
@@ -112,32 +108,35 @@ import MessageCard from '$ui/components/MessageCard.abide'
112
108
  import { state } from '@abide/abide/ui/state'
113
109
  import { watch } from '@abide/abide/ui/watch'
114
110
  import { html } from '@abide/abide/ui/html'
111
+ import { props } from '@abide/abide/ui/props'
115
112
 
116
113
  const { title = 'Chat', ...rest } = props()
117
114
 
115
+ type Message = { id: string; author: string; body: string }
116
+
118
117
  let channel = state('general')
119
118
  let draft = state('')
120
119
  let pinned = state(false)
121
120
  let limit = state(20)
122
121
 
123
- let trimmed = state.computed(() => draft.value.trim())
124
- let live = state.linked(() => limit.value)
122
+ let trimmed = state.computed(() => draft.trim())
123
+ let live = state.linked(() => limit)
125
124
 
126
125
  const badge = html`<sup class="ml-1 text-xs text-emerald-600">live</sup>`
127
126
 
128
127
  watch(trimmed, (value) => console.log('draft is now', value))
129
128
  watch(chat, (frame) => {
130
- live.value = live.value + 1
129
+ live = live + 1
131
130
  })
132
131
 
133
- async function submit(event) {
132
+ async function submit(event: SubmitEvent) {
134
133
  event.preventDefault()
135
- if (trimmed.value === '') return
136
- await sendMessage({ channel: channel.value, body: trimmed.value })
137
- draft.value = ''
134
+ if (trimmed === '') return
135
+ await sendMessage({ channel, body: trimmed })
136
+ draft = ''
138
137
  }
139
138
 
140
- function autofocus(node) {
139
+ function autofocus(node: HTMLInputElement) {
141
140
  node.focus()
142
141
  return () => {}
143
142
  }
@@ -145,11 +144,11 @@ function autofocus(node) {
145
144
  const rowProps = { class: 'flex gap-2' }
146
145
 
147
146
  // Derived two-way binding: read a string, coerce writes back into the numeric cell.
148
- const get = () => String(limit.value)
149
- const set = (next) => (limit.value = Number(next))
147
+ const get = () => String(limit)
148
+ const set = (next: string) => (limit = Number(next))
150
149
  </script>
151
150
 
152
- <section {...rest} class:pinned={pinned.value} style:opacity={pinned.value ? '1' : '0.85'}>
151
+ <section {...rest} class:pinned={pinned} style:opacity={pinned ? '1' : '0.85'}>
153
152
  <h1>{title} {badge}</h1>
154
153
 
155
154
  <form onsubmit={submit}>
@@ -161,28 +160,39 @@ const set = (next) => (limit.value = Number(next))
161
160
  <button type="submit">Send</button>
162
161
  </form>
163
162
 
164
- {#snippet row(message, index)}
163
+ {#snippet row(message: Message, index: number)}
165
164
  <MessageCard {...rowProps} name={message.author} onclick={() => console.log(index)}>
166
165
  <p>{message.body}</p>
167
166
  </MessageCard>
168
167
  {/snippet}
169
168
 
170
- {#if live.value > 100}
169
+ {#if live > 100}
171
170
  <p>Busy channel</p>
172
- {:else if live.value > 0}
173
- <p>{live.value} updates</p>
171
+ {:else if live > 0}
172
+ <p>{live} updates</p>
174
173
  {:else}
175
174
  <script>
176
175
  let seenAt = state(Date.now())
177
- let ageLabel = state.computed(() => `waiting since ${seenAt.value}`)
176
+ let ageLabel = state.computed(() => `waiting since ${seenAt}`)
178
177
  </script>
179
178
  <style>
180
179
  p { color: gray; }
181
180
  </style>
182
- <p>{ageLabel.value}</p>
181
+ <p>{ageLabel}</p>
183
182
  {/if}
184
183
 
185
- {#await getMessages({ limit: limit.value })}
184
+ <!-- The default: a bare peek — `undefined` while pending, composes with `?.`/`??`; probes for affordances. -->
185
+ <p>
186
+ {getMessages({ limit })?.length ?? 0} messages
187
+ {#if getMessages.pending({ limit })} · loading…{/if}
188
+ {#if getMessages.error({ limit })} · failed{/if}
189
+ </p>
190
+
191
+ <!-- {await fn()} blocks SSR so the value is in the initial HTML (no streamed/pending pass). -->
192
+ <p>newest: {await getMessages({ limit }).then((list) => list.at(-1)?.author ?? '—')}</p>
193
+
194
+ <!-- The explicit opt-in: a distinct pending branch, a local {:catch}, and {:then} narrowing. -->
195
+ {#await getMessages({ limit })}
186
196
  <p>Loading…</p>
187
197
  {:then messages}
188
198
  {#for message, i of messages by message.id}
@@ -198,7 +208,7 @@ const set = (next) => (limit.value = Number(next))
198
208
  <p class="text-sm opacity-70">{frame.author}: {frame.body}</p>
199
209
  {/for}
200
210
 
201
- {#switch channel.value}
211
+ {#switch channel}
202
212
  {:case 'general'}
203
213
  <span>General channel</span>
204
214
  {:case 'random'}
@@ -230,15 +240,15 @@ The capitalised `MessageCard` renders its passed content where it calls `{childr
230
240
 
231
241
  ```html
232
242
  <script>
233
- const { name, ...rest } = props()
243
+ import { props } from '@abide/abide/ui/props'
244
+ import type { Snippet } from '@abide/abide/shared/snippet'
245
+ const { name, children, ...rest } = props<{ name: string; children?: Snippet }>()
234
246
  </script>
235
247
 
236
248
  <article {...rest}>
237
249
  <strong>{name}</strong>
238
- {#if children}
239
- {children()}
240
- {:else}
241
- <em>No content</em>
250
+ {#if children} {children()} {:else}
251
+ <em>No content</em>
242
252
  {/if}
243
253
  </article>
244
254
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.50.1",
3
+ "version": "0.51.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -39,9 +39,6 @@ function shadowPreamble(importedReactives: ReadonlySet<string>): string {
39
39
  importedReactives.has('effect')
40
40
  ? undefined
41
41
  : `import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'`,
42
- importedReactives.has('watch')
43
- ? undefined
44
- : `import { watch } from '${ABIDE_PACKAGE_NAME}/ui/watch'`,
45
42
  `import { snippet } from '${ABIDE_PACKAGE_NAME}/shared/snippet'`,
46
43
  `import { scope } from '${ABIDE_PACKAGE_NAME}/ui/currentScope'`,
47
44
  importedReactives.has('state')
@@ -86,8 +83,16 @@ export function compileShadow(
86
83
  const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
87
84
  const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
88
85
 
89
- const { imports, types, scope, propsShapes, diagnostics, importedReactives, propsLocalName } =
90
- analyzeScript(scriptBody, scriptStart)
86
+ const {
87
+ imports,
88
+ types,
89
+ scope,
90
+ propsShapes,
91
+ diagnostics,
92
+ importedReactives,
93
+ propsLocalName,
94
+ watchLocalName,
95
+ } = analyzeScript(scriptBody, scriptStart)
91
96
  builder.raw(shadowPreamble(importedReactives))
92
97
  /* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
93
98
  sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
@@ -112,15 +117,30 @@ export function compileShadow(
112
117
  if (propsLocalName !== undefined) {
113
118
  builder.raw(`declare function ${propsLocalName}<T = {}>(): (${propsType}) & T\n`)
114
119
  }
120
+ /* `watch` is re-typed with a trailing value-source overload: the real overloads
121
+ (`State`/socket/rpc/thunk) intersected with `<T>(source: T, handler: (value: T) => void)`.
122
+ A `watch(cell, handler)` source is projected to the cell's VALUE type (like every read),
123
+ so it never matches the real `State<T>` overload — the value-source form catches it and
124
+ types `handler`'s parameter as that value. Ordered after the real overloads (intersection
125
+ left-to-right), so a socket/rpc source still resolves to its specific frame/return type.
126
+ The real type is referenced through `typeof import(...)` (no value import to keep live), and
127
+ the binding uses the author's local alias — or the `watch` fallback for a bare/nested use
128
+ that isn't author-imported. */
129
+ const watchSpecifier = `${ABIDE_PACKAGE_NAME}/ui/watch`
130
+ builder.raw(
131
+ `declare const ${watchLocalName ?? 'watch'}: typeof import('${watchSpecifier}').watch & (<__WatchT>(source: __WatchT, handler: (value: __WatchT) => void) => () => void)\n`,
132
+ )
115
133
  const propsSpecifier = `${ABIDE_PACKAGE_NAME}/ui/props`
116
134
  for (const line of imports) {
117
- /* The `props` import is replaced by the contextual `declare function` above;
118
- emitting it too would be a duplicate-identifier error. Matched by EXACT specifier
135
+ /* The `props`/`watch` imports are replaced by the contextual declarations above;
136
+ emitting one too would be a duplicate-identifier error. Matched by EXACT specifier
119
137
  (either quote style, not a loose suffix match) so an unrelated user module merely
120
- named `.../ui/props` survives verbatim. */
138
+ named `.../ui/props` (or `.../ui/watch`) survives verbatim. */
121
139
  if (
122
140
  line.text.includes(`from '${propsSpecifier}'`) ||
123
- line.text.includes(`from "${propsSpecifier}"`)
141
+ line.text.includes(`from "${propsSpecifier}"`) ||
142
+ line.text.includes(`from '${watchSpecifier}'`) ||
143
+ line.text.includes(`from "${watchSpecifier}"`)
124
144
  ) {
125
145
  continue
126
146
  }
@@ -331,6 +351,11 @@ type ScriptAnalysis = {
331
351
  for the canonical import, `p` for `props as p`), or undefined when not imported. The
332
352
  `declare function` for `props` must target this name, not the canonical `'props'`. */
333
353
  propsLocalName: string | undefined
354
+ /* The LOCAL binding name the author's `watch` import is bound to (alias-safe), or undefined
355
+ when not imported. The shadow re-types `watch` under this name with a value-source
356
+ overload so the `watch(cell, handler)` form type-checks (the cell is projected to its
357
+ value, so the real State/socket/rpc overloads never match it). */
358
+ watchLocalName: string | undefined
334
359
  }
335
360
 
336
361
  /* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
@@ -451,6 +476,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
451
476
  diagnostics,
452
477
  importedReactives: new Set(),
453
478
  propsLocalName: undefined,
479
+ watchLocalName: undefined,
454
480
  }
455
481
  }
456
482
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
@@ -462,10 +488,13 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
462
488
  /* The local name bound to `props` (alias-safe): the key whose value is the canonical
463
489
  `'props'`. At most one — a file imports `props` from one specifier. */
464
490
  let propsLocalName: string | undefined
491
+ let watchLocalName: string | undefined
465
492
  for (const [local, canonical] of bindings.direct) {
466
493
  if (canonical === 'props') {
467
494
  propsLocalName = local
468
- break
495
+ }
496
+ if (canonical === 'watch') {
497
+ watchLocalName = local
469
498
  }
470
499
  }
471
500
  /* The `$$` prefix is reserved for the compiler's injected runtime (`$$each`, `$$model`,
@@ -507,7 +536,16 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
507
536
  scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings))
508
537
  }
509
538
  }
510
- return { imports, types, scope, propsShapes, diagnostics, importedReactives, propsLocalName }
539
+ return {
540
+ imports,
541
+ types,
542
+ scope,
543
+ propsShapes,
544
+ diagnostics,
545
+ importedReactives,
546
+ propsLocalName,
547
+ watchLocalName,
548
+ }
511
549
  }
512
550
 
513
551
  /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
@@ -5,9 +5,11 @@ import { desugarSignals } from './desugarSignals.ts'
5
5
  import { identifierReferencePattern } from './identifierReferencePattern.ts'
6
6
  import { docAccessTransformer } from './lowerDocAccess.ts'
7
7
  import { signalRefsTransformer } from './renameSignalRefs.ts'
8
+ import { reactiveImportBindings } from './resolveReactiveExport.ts'
8
9
  import { stripEffectsTransformer } from './stripEffects.ts'
9
10
  import { TS_PRINTER } from './TS_PRINTER.ts'
10
11
  import type { SeedTypeClassifier } from './types/SeedTypeClassifier.ts'
12
+ import { wrapReactionCellSources } from './wrapReactionCellSources.ts'
11
13
 
12
14
  /* The `abide/ui/*` modules the reactive surface is imported from. An author's import of
13
15
  one is compiler-recognised and lowered, so its binding is often fully consumed — a plain
@@ -100,8 +102,24 @@ export function lowerScript(
100
102
  seedClassify,
101
103
  scriptBase,
102
104
  )
105
+ /* The local names bound to `watch` (alias-safe), and the full set of read-rewritten cell
106
+ names — together they let `wrapReactionCellSources` fold a `watch(cell, handler)` into
107
+ the thunk form BEFORE the read-lowering turns the cell reference into a value read. */
108
+ const watchLocalNames = new Set<string>()
109
+ for (const [local, canonical] of reactiveImportBindings(source).direct) {
110
+ if (canonical === 'watch') {
111
+ watchLocalNames.add(local)
112
+ }
113
+ }
114
+ const cellNames = new Set<string>([
115
+ ...stateNames,
116
+ ...derivedNames,
117
+ ...computedNames,
118
+ ...cellReadNames,
119
+ ])
103
120
  const result = ts.transform(source, [
104
121
  transformer,
122
+ wrapReactionCellSources(cellNames, watchLocalNames),
105
123
  signalRefsTransformer(
106
124
  stateNames,
107
125
  derivedNames,
@@ -0,0 +1,85 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ Folds a cell-source `watch(source, handler)` into the auto-tracked thunk form
5
+ `watch(() => (handler)(source))`, so the reaction reads the cell reactively
6
+ instead of receiving a one-time value.
7
+
8
+ The plain-variable ergonomic makes a bare `count` a VALUE read everywhere the
9
+ read-lowering runs (`renameSignalRefs` → `docAccessTransformer`). That is right
10
+ for `{count}` / `count + 1`, but it also lowered `watch(count, handler)`'s source
11
+ to `$$model.read("count")` — a number — so the runtime `watch` (which expects a
12
+ `State` and reads `.value` inside an effect) never subscribed: the reaction was
13
+ inert. The `watch(count, n => …)` form was documented but silently dead.
14
+
15
+ This runs BEFORE the read-lowering, so the `source` moved inside the new thunk is
16
+ lowered to its normal reactive read there — one rewrite covers every cell kind
17
+ (doc-slot `state`, `.value` cell, `computed`, `linked`), because it reuses the
18
+ read the rest of the pipeline already emits. The runtime `watch(thunk)` is the
19
+ same auto-tracked effect the compiler's own bindings use, so semantics match the
20
+ old `effect(() => handler(cell.value))` cell branch exactly.
21
+
22
+ Only a bare cell reference (or an array literal of them — the `watch([a, b], …)`
23
+ form) is folded. A socket / rpc / arbitrary object source is left untouched so the
24
+ runtime's own source dispatch (`cache.on` / `reactToRpc`) still handles it; the
25
+ thunk (`watch(() => …)`) and rpc-with-args (`watch(fn, args, handler)`) forms are
26
+ already correct and are matched out by arity.
27
+ */
28
+ export function wrapReactionCellSources(
29
+ cellNames: ReadonlySet<string>,
30
+ watchLocalNames: ReadonlySet<string>,
31
+ ): ts.TransformerFactory<ts.SourceFile> {
32
+ /* A source that names a reactive cell: a bare cell identifier, or a non-empty array
33
+ literal whose every element is one (the multi-cell `watch([a, b], …)` form). */
34
+ function isCellSource(source: ts.Expression): boolean {
35
+ if (ts.isIdentifier(source)) {
36
+ return cellNames.has(source.text)
37
+ }
38
+ if (ts.isArrayLiteralExpression(source)) {
39
+ return (
40
+ source.elements.length > 0 &&
41
+ source.elements.every(
42
+ (element) => ts.isIdentifier(element) && cellNames.has(element.text),
43
+ )
44
+ )
45
+ }
46
+ return false
47
+ }
48
+
49
+ return (context) => (root) => {
50
+ function visit(node: ts.Node): ts.Node {
51
+ const [sourceArg, handlerArg] = ts.isCallExpression(node) ? node.arguments : []
52
+ if (
53
+ ts.isCallExpression(node) &&
54
+ ts.isIdentifier(node.expression) &&
55
+ watchLocalNames.has(node.expression.text) &&
56
+ node.arguments.length === 2 &&
57
+ sourceArg !== undefined &&
58
+ handlerArg !== undefined &&
59
+ isCellSource(sourceArg)
60
+ ) {
61
+ /* Visit the operands first — a handler body may itself contain a nested cell-source
62
+ watch, and the source stays a plain identifier the read-lowering rewrites next. */
63
+ const source = ts.visitNode(sourceArg, visit) as ts.Expression
64
+ const handler = ts.visitNode(handlerArg, visit) as ts.Expression
65
+ /* `(handler)(source)` — parenthesise the handler so an arrow callee prints callable. */
66
+ const call = ts.factory.createCallExpression(
67
+ ts.factory.createParenthesizedExpression(handler),
68
+ undefined,
69
+ [source],
70
+ )
71
+ const thunk = ts.factory.createArrowFunction(
72
+ undefined,
73
+ undefined,
74
+ [],
75
+ undefined,
76
+ ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
77
+ call,
78
+ )
79
+ return ts.factory.createCallExpression(node.expression, undefined, [thunk])
80
+ }
81
+ return ts.visitEachChild(node, visit, context)
82
+ }
83
+ return ts.visitNode(root, visit) as ts.SourceFile
84
+ }
85
+ }