@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 +106 -94
- package/CHANGELOG.md +10 -0
- package/README.md +54 -44
- package/package.json +1 -1
- package/src/lib/ui/compile/compileShadow.ts +49 -11
- package/src/lib/ui/compile/lowerScript.ts +18 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +85 -0
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
|
|
29
|
+
in-proc) swap to fetch) from type) getMessages
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
A
|
|
33
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
| `src/server/rpc/<name>.ts`
|
|
47
|
-
| `src/server/sockets/<name>.ts`
|
|
48
|
-
| `src/mcp/prompts/<name>.md`
|
|
49
|
-
| `src/mcp/resources/*`
|
|
50
|
-
| `src/server/config.ts`
|
|
51
|
-
| `src/app.ts`
|
|
52
|
-
| `src/ui/pages/**/page.abide`
|
|
53
|
-
| `src/ui/pages/**/layout.abide`
|
|
54
|
-
| `src/ui/app.html`, `src/ui/app.css`
|
|
55
|
-
| `src/ui/public/`
|
|
56
|
-
| `src/bundle/window.ts`
|
|
57
|
-
| `src/cli/banner.txt`, `src/cli/footer.txt` | CLI help chrome.
|
|
58
|
-
| `src/.abide/*.d.ts`
|
|
59
|
-
| `dist/`
|
|
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
|
|
66
|
-
|
|
67
|
-
| `abide scaffold <name> [--no-install] [--no-dev]`
|
|
68
|
-
| `abide dev`
|
|
69
|
-
| `abide build`
|
|
70
|
-
| `abide start`
|
|
71
|
-
| `abide run <file> [args...]`
|
|
72
|
-
| `abide compile [--target=<bun-…>] [--out=<path>]`
|
|
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`
|
|
75
|
-
| `abide check`
|
|
76
|
-
| `abide lsp`
|
|
77
|
-
| `abide init-agent`
|
|
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
|
|
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()`
|
|
164
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
| `state(initial, transform?)`
|
|
171
|
-
| `state.computed(fn)`
|
|
172
|
-
| `state.linked(fn, transform?)` | Writable cell reseeded when the thunk's deps change.
|
|
173
|
-
| `watch(source, handler)`
|
|
174
|
-
| `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
|
|
180
|
-
|
|
181
|
-
| `{expr}`
|
|
182
|
-
| `name={expr}`
|
|
183
|
-
| `on<event>={fn}`
|
|
184
|
-
| `bind:value` / `bind:checked` / `bind:group` | Two-way form binds.
|
|
185
|
-
| `bind:value={{ get, set }}`
|
|
186
|
-
| `class:name={cond}`
|
|
187
|
-
| `style:property={value}`
|
|
188
|
-
| `attach={fn}`
|
|
189
|
-
| `{...spread}`
|
|
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
|
|
194
|
-
|
|
195
|
-
| Conditional
|
|
196
|
-
| Keyed list
|
|
197
|
-
| Async list
|
|
198
|
-
| Promise
|
|
199
|
-
| Switch
|
|
200
|
-
| Error boundary | `{#try}` / `{:catch}` / `{:finally}` / `{/try}`
|
|
201
|
-
| Snippet
|
|
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
|
-
|
|
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()`
|
|
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\`…\``
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
573
|
-
|
|
574
|
-
| `/openapi.json`
|
|
575
|
-
| `/__abide/mcp`
|
|
576
|
-
| `/__abide/health`
|
|
577
|
-
| `/__abide/identity`
|
|
578
|
-
| `/__abide/inspector` | Operator inspector UI + data/SSE routes, gated by `ABIDE_ENABLE_INSPECTOR` (optional `@abide/inspector`).
|
|
579
|
-
| `/__abide/sockets`
|
|
580
|
-
| `/__abide/cli`
|
|
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
|
|
585
|
-
|
|
586
|
-
| `PORT`
|
|
587
|
-
| `APP_URL`
|
|
588
|
-
| `ABIDE_APP_URL`
|
|
589
|
-
| `ABIDE_APP_TOKEN`
|
|
590
|
-
| `ABIDE_APP_DIR`
|
|
591
|
-
| `ABIDE_DATA_DIR`
|
|
592
|
-
| `ABIDE_CLIENT_TIMEOUT`
|
|
593
|
-
| `ABIDE_MAX_REQUEST_BODY_SIZE` | Server-wide max request body size.
|
|
594
|
-
| `ABIDE_IDLE_TIMEOUT`
|
|
595
|
-
| `ABIDE_LOG_FORMAT`
|
|
596
|
-
| `ABIDE_ENABLE_INSPECTOR`
|
|
597
|
-
| `ABIDE_INSPECT`
|
|
598
|
-
| `ABIDE_DEV_SURFACE`
|
|
599
|
-
| `DEBUG`
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
56
|
+
in-proc) swap to fetch) from type) getMessages
|
|
60
57
|
```
|
|
61
58
|
|
|
62
|
-
A
|
|
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
|
-
(
|
|
73
|
-
{
|
|
74
|
-
|
|
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
|
|
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.
|
|
124
|
-
let live = state.linked(() => limit
|
|
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
|
|
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
|
|
136
|
-
await sendMessage({ channel
|
|
137
|
-
draft
|
|
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
|
|
149
|
-
const set = (next) => (limit
|
|
147
|
+
const get = () => String(limit)
|
|
148
|
+
const set = (next: string) => (limit = Number(next))
|
|
150
149
|
</script>
|
|
151
150
|
|
|
152
|
-
<section {...rest} class:pinned={pinned
|
|
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
|
|
169
|
+
{#if live > 100}
|
|
171
170
|
<p>Busy channel</p>
|
|
172
|
-
{:else if live
|
|
173
|
-
<p>{live
|
|
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
|
|
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
|
|
181
|
+
<p>{ageLabel}</p>
|
|
183
182
|
{/if}
|
|
184
183
|
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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 {
|
|
90
|
-
|
|
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`
|
|
118
|
-
emitting
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|