@abide/abide 0.50.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
13
+ ## 0.50.1
14
+
15
+ ### Patch Changes
16
+
17
+ - ef143f5: regenerate README + AGENTS surface map ([`8665efa`](https://github.com/briancray/abide/commit/8665efa7eb9f4106882cf246738c08e465d7e8d7))
18
+
3
19
  ## 0.50.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -1,103 +1,83 @@
1
1
  # abide
2
2
 
3
- **One typed declaration, every surface: an RPC you write once fans out to HTTP,
4
- a CLI, an MCP tool, and an OpenAPI operation — and the same callable runs
5
- in-process on the server and over fetch in the browser.**
3
+ **One typed declaration, every surface: SSR, browser fetch, MCP, CLI, and OpenAPI from a single Bun runtime.**
6
4
 
7
- abide is an isomorphic multimodal HTTP framework built for humans and machines
8
- in a single Bun runtime: file-path routing, Standard Schema validation, and a
9
- compiled `.abide` UI layer that server-renders, streams, and hydrates the same
10
- components. The bundler swaps each callable's runtime per side — same name,
11
- same behavior, no client/server forks in app code.
5
+ abide is an isomorphic HTTP framework where a typed RPC you declare once fans out to an in-process SSR call, a browser fetch, an MCP tool, a CLI subcommand, and an OpenAPI operation — the bundler swaps the runtime per side, so the same callable behaves the same in-process on the server and over `fetch` in the browser. It is built for humans and machines: the same schema that types your code projects the tool, the flag, and the spec.
12
6
 
13
- - One direct dependency (TypeScript). One runtime (Bun ≥ 1.3).
7
+ - One direct dependency (TypeScript), one runtime (Bun ≥ 1.3.0).
8
+ - No barrels — every public name is its own module path (`@abide/abide/server/GET`, `@abide/abide/ui/state`, …). The namespace marks the side: `server/*` runs server-side, `ui/*` client-side, `shared/*` isomorphic.
14
9
 
15
10
  ## Quick start
16
11
 
17
12
  ```sh
18
- bunx abide scaffold my-app # scaffolds, installs, and starts the dev server
13
+ # Scaffold a project from the bundled template, install it, and start dev.
14
+ bunx abide scaffold my-app
19
15
  ```
20
16
 
21
- Or run the kitchen-sink example, which demos the full surface:
17
+ Or clone the kitchen-sink example, which exercises the whole surface:
22
18
 
23
19
  ```sh
24
20
  git clone https://github.com/briancray/abide
25
- cd abide && bun install
26
- cd examples/kitchen-sink && bun run dev
21
+ cd abide/examples/kitchen-sink
22
+ bun install
23
+ bun run dev
27
24
  ```
28
25
 
29
26
  ## RPCs
30
27
 
31
- An RPC is one exported handler per file under `src/server/rpc/` — the file path
32
- is the URL. The export wraps the handler in an HTTP-method helper (`GET`,
33
- `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`); a Standard Schema `schemas.input`
34
- (zod, valibot, arktype — no adapter) validates the args and projects the CLI
35
- flags, the MCP tool, and the OpenAPI operation from the one declaration.
28
+ An RPC is one export per file under `src/server/rpc/`. The file path is the URL; the declared method helper (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`) picks the verb. Attach a `schemas.input` (any Standard Schema zod, valibot, or arktype, unadapted) and the same schema validates the args and projects the MCP tool, the CLI flags, and the OpenAPI operation.
36
29
 
37
30
  ```ts
38
31
  // src/server/rpc/getMessages.ts
39
32
  import { GET } from '@abide/abide/server/GET'
40
33
  import { json } from '@abide/abide/server/json'
41
34
  import { z } from 'zod'
42
- import { listMessages } from '$server/store'
43
-
44
- export const getMessages = GET(
45
- async ({ room, limit }) => json(await listMessages(room, limit)),
46
- {
47
- schemas: {
48
- input: z.object({
49
- room: z.string(),
50
- limit: z.coerce.number().default(50),
51
- }),
52
- },
35
+ import { load } from '$server/db.ts'
36
+
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
+ }),
53
43
  },
54
- )
44
+ })
55
45
  ```
56
46
 
57
- That one file fans out:
47
+ One declaration, five surfaces:
58
48
 
59
49
  ```text
60
- src/server/rpc/getMessages.ts
61
-
62
- ├─ SSR / server await getMessages({ room }) in-process, no HTTP
63
- ├─ browser await getMessages({ room }) typed fetch proxy
64
- ├─ HTTP GET /rpc/getMessages?room=…
65
- ├─ CLI my-app get-messages --room …
66
- ├─ MCP tool: get-messages
67
- └─ OpenAPI operation in /openapi.json
50
+ export const getMessages = GET(fn, { schemas })
51
+
52
+ ┌───────────┬──────────────┼──────────────┬─────────────┐
53
+ ▼ ▼ ▼ ▼ ▼
54
+ SSR call browser fetch MCP tool CLI subcmd OpenAPI op
55
+ (bare, (same call, (read-only abide-cli /openapi.json
56
+ in-proc) swap to fetch) from type) getMessages
57
+ ```
58
+
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:
60
+
61
+ ```ts
62
+ // src/server/rpc/sendMessage.ts
63
+ import { POST } from '@abide/abide/server/POST'
64
+ import { json } from '@abide/abide/server/json'
65
+ import { z } from 'zod'
66
+ import { append } from '$server/db.ts'
67
+
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
+ })
68
72
  ```
69
73
 
70
- A schema is the gate: it unlocks the CLI, and for read-only methods
71
- (GET/HEAD) — the MCP tool. A mutating method (POST/PUT/PATCH/DELETE) never
72
- auto-exposes to MCP; it needs an explicit `clients: { mcp: true }`.
73
-
74
- The bare call **is** the smart read — cached, coalesced, reactive,
75
- stale-while-revalidate — and it is isomorphic: the same callable resolves
76
- in-process during SSR (its value baked into the HTML for warm hydration) and
77
- over `fetch` in the browser. Around it:
78
-
79
- - `getMessages.raw(args, init?)` — the raw `Response`; per-call transport
80
- options (`signal`, `headers`, …) live here
81
- - `getMessages.refresh(args?)` — refetch, keeping the stale value visible
82
- - `getMessages.patch(args?, updater)` — mutate the retained value locally,
83
- no network
84
- - `getMessages.peek(args?)` — read the retained value synchronously
85
- - `getMessages.pending(args?)` / `.refreshing(args?)` / `.error(args?)` —
86
- reactive probes
87
- - a handler that returns `jsonl()`/`sse()` makes the bare call return a
88
- `NamedAsyncIterable` you `for await` — detected at build time, nothing to declare
89
-
90
- > Query args travel as strings — use `z.coerce.*` for numbers and booleans on
91
- > GET/HEAD. The per-RPC `timeout` option is a server-side handler deadline
92
- > (504, enforced on every surface); `ABIDE_CLIENT_TIMEOUT` bounds browser calls
93
- > and is separate.
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.
75
+
76
+ > Query and path args auto-coerce from the endpoint's typed shape a numeric field arrives as a number, no `z.coerce` needed. The per-RPC `timeout` (a 504 on every surface) is distinct from the client-wide `ABIDE_CLIENT_TIMEOUT`.
94
77
 
95
78
  ## Sockets
96
79
 
97
- A socket is one broadcast topic per file under `src/server/sockets/` — the
98
- export name is the topic. A `Socket<T>` is an isomorphic `AsyncIterable<T>`:
99
- server code iterates and broadcasts in-process; browser code gets the same
100
- shape over one multiplexed WebSocket at `/__abide/sockets`.
80
+ A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — the same value you `for await` on both sides — and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
101
81
 
102
82
  ```ts
103
83
  // src/server/sockets/chat.ts
@@ -105,184 +85,172 @@ import { socket } from '@abide/abide/server/socket'
105
85
  import { z } from 'zod'
106
86
 
107
87
  export const chat = socket({
108
- schema: z.object({ author: z.string(), text: z.string() }),
109
- tail: 50, // retain the last 50 frames for late joiners (default 1)
110
- ttl: 60_000, // retained frames expire after a minute
111
- clientPublish: true, // allow browser publishes (off by default)
88
+ schema: z.object({ author: z.string(), body: z.string() }),
89
+ tail: 20, // retain the last 20 frames for late joiners and reconnects
90
+ ttl: 60_000, // evict retained frames older than 60s
112
91
  })
113
92
  ```
114
93
 
115
- `chat.publish(msg)` publishes from either side schema-validated on the
116
- server, and client publishes are gated by `clientPublish`. `for await (const
117
- msg of chat)` is the live stream; `chat.peek()` reads the latest retained
118
- frame; `chat.refresh()` re-pulls the server tail after a reconnect. A schema
119
- also advertises the topic to MCP and the CLI.
120
-
121
- Every exposed socket has an HTTP face at `/__abide/sockets/<name>`: `GET`
122
- returns the retained tail as JSON (or a live SSE stream under
123
- `Accept: text/event-stream`), `POST` publishes — gated by `clientPublish`.
94
+ Publish with `chat.publish(frame)`; seed a late reader with `chat.tail(count)`. Each socket also has an HTTP face at `/__abide/sockets/<name>`: `GET` returns the retained tail, and `POST` publishes when `clientPublish` is set.
124
95
 
125
96
  ## Components
126
97
 
127
- Pages are `.abide` single-file components under `src/ui/pages/` (`page.abide` /
128
- `layout.abide`; a `[id]` folder becomes a route param read via `props()`). A
129
- component is HTML plus `{expr}` bindings and `{#…}` control-flow blocks.
130
- Reactive state comes from imported primitives the compiler lowers, so inside a
131
- component you read and write state as plain variables. The same file renders on
132
- the server — blocking awaits inline, streaming awaits out of order, cached
133
- reads seeded warm — and hydrates in the browser.
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`.
134
99
 
135
- Nested content becomes the component's `children` propan ordinary declared
136
- prop of type `Snippet` — and renders wherever the component calls
137
- `{children()}`:
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.
138
101
 
139
102
  ```html
140
103
  <script>
141
- import { props } from '@abide/abide/ui/props'
142
- import type { Snippet } from '@abide/abide/shared/snippet'
143
-
144
- const { title, children } = props<{ title: string; children?: Snippet }>()
145
- </script>
104
+ import { getMessages } from '$server/rpc/getMessages.ts'
105
+ import { sendMessage } from '$server/rpc/sendMessage.ts'
106
+ import { chat } from '$server/sockets/chat.ts'
107
+ import MessageCard from '$ui/components/MessageCard.abide'
108
+ import { state } from '@abide/abide/ui/state'
109
+ import { watch } from '@abide/abide/ui/watch'
110
+ import { html } from '@abide/abide/ui/html'
111
+ import { props } from '@abide/abide/ui/props'
146
112
 
147
- <section>
148
- <h2>{title}</h2>
149
- {#if children}{children()}{:else}<p>Nothing here yet.</p>{/if}
150
- </section>
113
+ const { title = 'Chat', ...rest } = props()
151
114
 
152
- <style>
153
- section {
154
- border: 1px solid gray;
155
- }
156
- </style>
157
- ```
115
+ type Message = { id: string; author: string; body: string }
158
116
 
159
- And one page tying together the RPC from above, the socket, and the whole
160
- template grammar:
117
+ let channel = state('general')
118
+ let draft = state('')
119
+ let pinned = state(false)
120
+ let limit = state(20)
161
121
 
162
- ```html
163
- <script>
164
- import { getMessages } from '$server/rpc/getMessages'
165
- import { sendMessage } from '$server/rpc/sendMessage' // POST, declared like getMessages
166
- import { chat } from '$server/sockets/chat'
167
- import { state } from '@abide/abide/ui/state'
168
- import { watch } from '@abide/abide/ui/watch'
169
- import { html } from '@abide/abide/ui/html'
170
- import { props } from '@abide/abide/ui/props'
171
- import Panel from '$ui/components/Panel.abide'
122
+ let trimmed = state.computed(() => draft.trim())
123
+ let live = state.linked(() => limit)
172
124
 
173
- const { room = 'lobby' } = props<{ room?: string }>()
125
+ const badge = html`<sup class="ml-1 text-xs text-emerald-600">live</sup>`
174
126
 
175
- let draft = state('')
176
- let author = state('anon')
177
- let tone = state('friendly')
178
- let notify = state(true)
179
- let volume = state(5, (next, previous) => (Number.isFinite(next) ? next : previous))
180
- let shout = state.computed(() => draft.toUpperCase())
181
- let roomDraft = state.linked(() => room)
127
+ watch(trimmed, (value) => console.log('draft is now', value))
128
+ watch(chat, (frame) => {
129
+ live = live + 1
130
+ })
182
131
 
183
- // watch is the one reaction primitive: a socket, an rpc, or a tracked thunk.
184
- watch(chat, () => getMessages.refresh({ room }))
185
- watch(() => console.log(author, tone))
132
+ async function submit(event: SubmitEvent) {
133
+ event.preventDefault()
134
+ if (trimmed === '') return
135
+ await sendMessage({ channel, body: trimmed })
136
+ draft = ''
137
+ }
186
138
 
187
- // a writable computed lives at the binding: bind:value={{ get, set }}
188
- const get = () => draft.trim()
189
- const set = (next) => (draft = next)
139
+ function autofocus(node: HTMLInputElement) {
140
+ node.focus()
141
+ return () => {}
142
+ }
190
143
 
191
- const focus = (element) => element.focus()
192
- const panelProps = { title: 'Live feed' }
193
- const draftAttrs = { placeholder: 'say something', autocomplete: 'off' }
144
+ const rowProps = { class: 'flex gap-2' }
194
145
 
195
- async function send() {
196
- await sendMessage({ room: roomDraft, author, text: draft })
197
- draft = ''
198
- }
146
+ // Derived two-way binding: read a string, coerce writes back into the numeric cell.
147
+ const get = () => String(limit)
148
+ const set = (next: string) => (limit = Number(next))
199
149
  </script>
200
150
 
201
- {#snippet line(message)}
202
- <li>{message.author}: {message.text}</li>
203
- {/snippet}
204
-
205
- <Panel {...panelProps}>
206
- <h1 class:muted={!notify} style:opacity={notify ? '1' : '0.6'}>
207
- {room} — {shout}
208
- </h1>
151
+ <section {...rest} class:pinned={pinned} style:opacity={pinned ? '1' : '0.85'}>
152
+ <h1>{title} {badge}</h1>
209
153
 
210
- <!-- {await expr} awaits inline (blocking); a Promise-typed {expr} would stream -->
211
- <p>total messages: {await getMessages({ room }).then((all) => all.length)}</p>
154
+ <form onsubmit={submit}>
155
+ <input bind:value={draft} attach={autofocus} placeholder="Say something" />
156
+ <label><input type="checkbox" bind:checked={pinned} /> pin</label>
157
+ <label><input type="radio" bind:group={channel} value="general" /> general</label>
158
+ <label><input type="radio" bind:group={channel} value="random" /> random</label>
159
+ <input bind:value={{ get, set }} />
160
+ <button type="submit">Send</button>
161
+ </form>
212
162
 
213
- {#await getMessages({ room })}
214
- <p>loading…</p>
163
+ {#snippet row(message: Message, index: number)}
164
+ <MessageCard {...rowProps} name={message.author} onclick={() => console.log(index)}>
165
+ <p>{message.body}</p>
166
+ </MessageCard>
167
+ {/snippet}
168
+
169
+ {#if live > 100}
170
+ <p>Busy channel</p>
171
+ {:else if live > 0}
172
+ <p>{live} updates</p>
173
+ {:else}
174
+ <script>
175
+ let seenAt = state(Date.now())
176
+ let ageLabel = state.computed(() => `waiting since ${seenAt}`)
177
+ </script>
178
+ <style>
179
+ p { color: gray; }
180
+ </style>
181
+ <p>{ageLabel}</p>
182
+ {/if}
183
+
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 })}
196
+ <p>Loading…</p>
215
197
  {:then messages}
216
- {#if messages.length === 0}
217
- <p>No messages yet.</p>
218
- {:else if messages.length < 100}
219
- <ul>
220
- {#for message, i of messages by message.id}
221
- {line(message)}
222
- {/for}
223
- </ul>
224
- {:else}
225
- <script>
226
- // branch-local state, re-seeded each time this branch mounts
227
- let offset = state(0)
228
- </script>
229
- <p>huge room — showing from {offset}</p>
230
- <button onclick={() => (offset = offset + 100)}>next</button>
231
- <style>
232
- p {
233
- font-variant-numeric: tabular-nums;
234
- }
235
- </style>
236
- {/if}
237
- {:catch error}
238
- <p>failed to load: {error.message}</p>
198
+ {#for message, i of messages by message.id}
199
+ {row(message, i)}
200
+ {/for}
201
+ {:catch problem}
202
+ <p>Failed: {problem.message}</p>
239
203
  {:finally}
240
- <p>checked just now</p>
204
+ <hr />
241
205
  {/await}
242
206
 
243
- <ol>
244
- {#for await message of chat}
245
- {line(message)}
246
- {/for}
247
- </ol>
207
+ {#for await frame of chat}
208
+ <p class="text-sm opacity-70">{frame.author}: {frame.body}</p>
209
+ {/for}
248
210
 
249
- {#switch tone}
250
- {:case 'friendly'}
251
- <p>{html`<em>be&nbsp;kind</em>`}</p>
211
+ {#switch channel}
212
+ {:case 'general'}
213
+ <span>General channel</span>
214
+ {:case 'random'}
215
+ <span>Random channel</span>
252
216
  {:default}
253
- <p>say anything</p>
217
+ <span>Unknown channel</span>
254
218
  {/switch}
255
219
 
256
220
  {#try}
257
- <p>{JSON.parse(draft).summary}</p>
221
+ <MessageCard name="system">
222
+ <p>System notice</p>
223
+ </MessageCard>
258
224
  {:catch}
259
- <p>draft isn't JSON yet</p>
225
+ <p>Card crashed</p>
226
+ {:finally}
227
+ <span class="sr-only">done</span>
260
228
  {/try}
261
-
262
- <form onsubmit={(event) => { event.preventDefault(); send() }}>
263
- <input attach={focus} bind:value={draft} {...draftAttrs} />
264
- <input bind:value={{ get, set }} />
265
- <label><input type="checkbox" bind:checked={notify} /> notify</label>
266
- <label><input type="radio" bind:group={tone} value="friendly" /> friendly</label>
267
- <label><input type="radio" bind:group={tone} value="blunt" /> blunt</label>
268
- <input type="range" bind:value={volume} min="0" max="10" />
269
- <select bind:value={author}>
270
- <option>anon</option>
271
- <option>me</option>
272
- </select>
273
- <button disabled={draft === ''}>send</button>
274
- </form>
275
- </Panel>
229
+ </section>
276
230
 
277
231
  <style>
278
- form {
279
- display: flex;
232
+ section {
233
+ display: grid;
280
234
  gap: 0.5rem;
281
235
  }
282
- .muted {
283
- color: gray;
284
- }
285
236
  </style>
286
237
  ```
287
238
 
239
+ The capitalised `MessageCard` renders its passed content where it calls `{children()}`. The `<slot>` element was removed — `{children()}` is the single fill point, and `{#if children}{children()}{:else}…{/if}` is the fallback form.
240
+
241
+ ```html
242
+ <script>
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 }>()
246
+ </script>
247
+
248
+ <article {...rest}>
249
+ <strong>{name}</strong>
250
+ {#if children} {children()} {:else}
251
+ <em>No content</em>
252
+ {/if}
253
+ </article>
254
+ ```
255
+
288
256
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.50.0",
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,