@abide/abide 0.51.0 → 0.52.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.
Files changed (58) hide show
  1. package/AGENTS.md +509 -525
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +128 -157
  4. package/package.json +4 -1
  5. package/src/abideResolverPlugin.ts +12 -0
  6. package/src/checkAbide.ts +60 -7
  7. package/src/lib/cli/completeCli.ts +38 -0
  8. package/src/lib/cli/printTopLevelHelp.ts +1 -0
  9. package/src/lib/cli/renderCliCompletions.ts +56 -0
  10. package/src/lib/cli/runCli.ts +27 -0
  11. package/src/lib/server/render.ts +70 -0
  12. package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
  13. package/src/lib/server/runtime/createServer.ts +43 -0
  14. package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
  15. package/src/lib/server/runtime/pageRenderSlot.ts +15 -0
  16. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  17. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  18. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  19. package/src/lib/server/sockets/registerSocket.ts +12 -0
  20. package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
  21. package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
  22. package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
  23. package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
  24. package/src/lib/shared/cache.ts +32 -4
  25. package/src/lib/shared/cacheStalenessSlot.ts +21 -0
  26. package/src/lib/shared/createRpcServerProgram.ts +187 -0
  27. package/src/lib/shared/docSnapshotsSlot.ts +13 -0
  28. package/src/lib/shared/invalidate.ts +39 -0
  29. package/src/lib/shared/matcherFromEnvelope.ts +31 -0
  30. package/src/lib/shared/prepareRpcModule.ts +22 -3
  31. package/src/lib/shared/refresh.ts +9 -2
  32. package/src/lib/shared/selectorMatcher.ts +2 -15
  33. package/src/lib/shared/serializeSelector.ts +69 -0
  34. package/src/lib/shared/setsIntersect.ts +15 -0
  35. package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
  36. package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
  37. package/src/lib/shared/types/DocSnapshots.ts +12 -0
  38. package/src/lib/shared/types/RemoteFunction.ts +11 -8
  39. package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
  40. package/src/lib/shared/types/SsrPayload.ts +5 -0
  41. package/src/lib/test/createTestApp.ts +15 -0
  42. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
  43. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  44. package/src/lib/ui/compile/compileShadow.ts +92 -14
  45. package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
  46. package/src/lib/ui/compile/lowerScript.ts +6 -1
  47. package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
  48. package/src/lib/ui/compile/wrapReactionCellSources.ts +32 -0
  49. package/src/lib/ui/createScope.ts +42 -1
  50. package/src/lib/ui/dom/assertClaimedText.ts +14 -4
  51. package/src/lib/ui/dom/attr.ts +25 -2
  52. package/src/lib/ui/dom/writeCell.ts +22 -0
  53. package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
  54. package/src/lib/ui/runtime/claimExpected.ts +6 -1
  55. package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
  56. package/src/lib/ui/startClient.ts +22 -1
  57. package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
  58. package/src/serverEntry.ts +12 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # abide
2
2
 
3
+ ## 0.52.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 83f4e5e: warn on the inert watch(cell.member) source ([`554d3ff`](https://github.com/briancray/abide/commit/554d3ffbdef39a40ccb77c58017b759dbdcb17c2))
8
+ - e0c200b: `abide check` now type-checks the project's `.ts` files too, not only `.abide` templates. The `.ts` files were already loaded into the shadow program to resolve component imports, but their own diagnostics were never reported — so a mistyped `navigate('/p/[id]', {})`, `url()`, or `fn.patch()` call (or any type error) in an rpc handler, `app.ts`, or a `$shared` helper passed `abide check` silently. It now surfaces there, making `abide check` a complete type-check (templates + `.ts`) rather than templates only. Scoped to the project's own root files — a dependency or monorepo sibling resolved on demand is not reported. No new public API.
9
+ - 0858cd6: add `/completions <bash|zsh|fish>` to the generated CLI binary — prints a shell completion script that tab-completes command names and their `--flags` (with `--no-<flag>` for booleans). Candidates derive from the baked manifest via a live `/completions --query` callback, so completion always reflects the binary's real surface and can't drift from dispatch. No new public export.
10
+ - f42c33c: Add a path-addressed hydration-divergence signal on the DEBUG-gated `hydrate` channel (`DEBUG=hydrate`, or `localStorage['abide-debug'] = 'hydrate'` in the browser). With the channel off, behavior is unchanged — a claim divergence still throws the hard guard and the router recovers cold. With it on: a **text** divergence warns with the render-path of the enclosing component/branch/row and keeps hydrating, so one reload surfaces every mismatch instead of aborting at the first; a **structural** divergence names its path before throwing; and an **attribute** divergence — previously invisible, since binding always overwrote the server value — now warns too. The render-path is coarse (it locates the component, the detail names the value) and the compiled class/style/prop setters aren't covered yet. No new public API.
11
+ - fca2f95: add `abide/shared/invalidate` and broadcast both staleness verbs across clients (ADR-0041).
12
+
13
+ `invalidate(selector?, args?)` is reintroduced as the distinct DROP verb (next read reloads lazily; a mounted retained reader revalidates stale-in-place), paired with the existing `refresh` REFETCH verb. Instance sugar `fn.invalidate(args?)` sits beside `fn.refresh(args?)`.
14
+
15
+ Both verbs are now isomorphic: applied locally on the client, and on the SERVER they broadcast to every connected client over a reserved, server-publish-only `__abide/cache` socket (live-only, no replay — an offline client falls back to SWR staleness). **`refresh`'s server behaviour changed** from a local throwaway refetch that reached zero browsers to a cross-client broadcast — a semantic change to an existing public verb. Producer/closure and bare match-all selectors are not cross-client serializable and are rejected when broadcast from the server. The `__abide/*` socket namespace is now reserved (a user socket file may not declare it).
16
+
17
+ - 1c1d115: add `abide/server/render` — `render(path, params?, query?)` renders a page route to its HTML string in-process, through the same pipeline (app.html shell, layout chain, params, inline rpc reads) an HTTP GET of that URL runs, so a page stays directly linkable and its emailed form is one call away. Arg shape mirrors `url()`/`navigate`.
18
+ - ee96a2e: Plain `state(initial)` now warm-seeds from the server across hydration, so a nondeterministic initializer carries the server's value through instead of recomputing a divergent one on the client. `state(crypto.randomUUID())`, `state(Date.now())`, `state(Math.random())` — previously the client re-ran the initializer on hydrate and produced a different value than the SSR HTML (a divergence class the `hydrate` channel now reports). Each rendered scope's document snapshot is serialized into `__SSR__.docs` keyed by its render-path id (the same keying the async-cell warm-seed uses), and on hydration the first write to each slot adopts the server value (consume-once) while the throwaway fresh init is discarded. No new public API — same `state()` call, same authoring. Values that don't round-trip the wire codec (functions, class instances) or state read only client-side fall back to a cold init, unchanged.
19
+
20
+ ### Patch Changes
21
+
22
+ - 83f4e5e: generate .d.ts augmentations before type-checking .ts files ([`5da85e2`](https://github.com/briancray/abide/commit/5da85e2a2f411c221bb552b60b187ebee836d3ba))
23
+ - 83f4e5e: forward a 0-based completion index from the zsh wrapper ([`6d14ccd`](https://github.com/briancray/abide/commit/6d14ccd3aaa8467948fe78dbc4462b40c6ea6750))
24
+ - 83f4e5e: dedupe mirrored maps, skip the inert reaction pass ([`b938e10`](https://github.com/briancray/abide/commit/b938e1058b10d0fa7881d9cd4e3f774bb1dc35c2))
25
+ - 83f4e5e: reflect .ts checking, the hydrate channel, and state seeding ([`e97f0e0`](https://github.com/briancray/abide/commit/e97f0e0c3b4c5bafde99335663cb88d476ff64cc))
26
+ - 83f4e5e: make the bare seed the idiomatic computed/linked form ([`f889416`](https://github.com/briancray/abide/commit/f889416eedab23346c870d491c75b7e0f361bd8e))
27
+ - 83f4e5e: document invalidate, server-side broadcast, and CLI completions ([`fccc4b4`](https://github.com/briancray/abide/commit/fccc4b4e833ea49871c1a955743ce807d1902a28))
28
+ - 090d28e: `abide check` (and the editor shadow) now type-check a `computed`/`linked` seed written with a top-level `await` — `state.computed(await load())` — as its resolved value, instead of flagging a spurious "top-level await" error and mis-typing the binding (which forced `as unknown as Awaited<…>` casts downstream). The shadow now mirrors the compiler's `wrapSeed` lowering: a non-thunk seed is normalised to a thunk (async when it carries a top-level await) and read through a helper that unwraps a promise/stream to the value a bare cell read peeks. A bare `state(await …)` — not a `computed`/`linked` seed — is still correctly flagged, since it genuinely breaks the synchronous build.
29
+ - 129b404: Writing to a `linked` cell from a component `<script>` now works — `let draft = state.linked(() => source); draft = draft + 1` (and `+=`, `++`, `--`, `??=`). Previously the compiler lowered every `linked` reference through `$$readCell(name)` (a call), so an assignment target became `$$readCell(draft) = …` and the build failed with "Invalid assignment target" — only `bind:value={draft}` could write a linked cell, despite `linked` being documented as writable. Assignments to a `linked` binding now lower through a new `$$writeCell` helper (`abide/ui/dom/writeCell`) that dispatches `.value =` for a sync `State` seed and `.set(...)` for an async/stream `AsyncState` seed.
30
+ - 9154f36: The client build of an `$rpc` module now emits a minimal module — the `remoteProxy` fetch plus only the top-level statements the endpoint `opts` transitively reaches — instead of keeping the whole handler file and trusting tree-shaking to drop the server bits. Previously, server code reachable from module-level handler support (the common `const db = getDb()` shape, where `getDb` imports `bun:sqlite`) survived handler-elision and was _loaded_ on the browser target, failing the build with a raw `Browser build cannot import Bun builtin: "bun:sqlite"` before abide's own reachability guard could give a guided message. Reachability is resolved through the warm rpc `ts.Program` (binder/checker), so an `opts` value built from a module-level const — `schemas: { input: inputSchema }` where `const inputSchema = z.object(...)` — keeps that const and its imports, while anything only the handler touches is never emitted. No public API change; the reachability guard for every other client-reaches-server edge is unchanged, and builds with no warm program fall back to the previous keep-the-file transform.
31
+
3
32
  ## 0.51.0
4
33
 
5
34
  ### Minor Changes
package/README.md CHANGED
@@ -1,22 +1,19 @@
1
1
  # abide
2
2
 
3
- **One typed declaration, every surface: SSR, browser fetch, MCP, CLI, and OpenAPI from a single Bun runtime.**
3
+ **A type-safe isomorphic framework where one typed declaration is an HTTP endpoint, a CLI subcommand, an MCP tool, and an OpenAPI operation at once.**
4
4
 
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.
5
+ You write a handler once; abide fans it out across every surface, and the bundler swaps the runtime per side the same call reads in-process during SSR and becomes a typed `fetch` in the browser. Built for humans _and_ machines, on a single runtime.
6
6
 
7
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.
9
8
 
10
9
  ## Quick start
11
10
 
12
11
  ```sh
13
- # Scaffold a project from the bundled template, install it, and start dev.
14
- bunx abide scaffold my-app
12
+ bunx abide scaffold my-app # scaffold, install, and start the dev server
15
13
  ```
16
14
 
17
- Or clone the kitchen-sink example, which exercises the whole surface:
18
-
19
15
  ```sh
16
+ # or explore the full feature tour
20
17
  git clone https://github.com/briancray/abide
21
18
  cd abide/examples/kitchen-sink
22
19
  bun install
@@ -25,59 +22,44 @@ bun run dev
25
22
 
26
23
  ## RPCs
27
24
 
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.
25
+ An RPC is one export per file under `src/server/rpc/`. The file path is the URL; the handler's typed input drives everything downstream. Any [Standard Schema](https://standardschema.dev) (zod, valibot, arktype unadapted) validates the arguments and projects the MCP tool, CLI flags, and OpenAPI operation.
29
26
 
30
27
  ```ts
31
28
  // src/server/rpc/getMessages.ts
32
29
  import { GET } from '@abide/abide/server/GET'
33
30
  import { json } from '@abide/abide/server/json'
34
31
  import { z } from 'zod'
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
- }),
43
- },
44
- })
32
+ import { recentMessages } from '../db.ts'
33
+
34
+ // The success body's type flows back to every caller; `cache` retains the read.
35
+ export const getMessages = GET(
36
+ async ({ limit }: { limit: number }) => json(await recentMessages(limit)),
37
+ { schemas: { input: z.object({ limit: z.number().int().max(100) }) }, cache: { ttl: 5_000 } },
38
+ )
45
39
  ```
46
40
 
47
- One declaration, five surfaces:
41
+ One declaration branches to every surface:
48
42
 
49
43
  ```text
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
44
+ export const getMessages = GET(handler, { schemas })
45
+
46
+ ┌──────────┬──────────────┼──────────────┬──────────────┐
47
+ ▼ ▼ ▼
48
+ SSR call browser MCP tool CLI OpenAPI op
49
+ the bare fetch (read-only, subcommand in
50
+ call, typed proxy auto-exposed) abide cli /openapi.json
51
+ in-process fn(args) getMessages
57
52
  ```
58
53
 
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
- })
72
- ```
54
+ A **typed handler input** is what flips those surfaces on: the input type is projected to JSON Schema at build, so a plainly-typed handler auto-exposes to the CLI and — for read-only methods (`GET`/`HEAD`) — MCP with no hand-written `schemas.input` (a declared `schemas.input` adds runtime validation on top). A mutating method (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to MCP; opt it in with `clients: { mcp: true }`.
73
55
 
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.
56
+ Consume it by importing the export. The **bare call `getMessages(args)` is the smart read** — cached, coalesced, reactive, isomorphic (in-process on the server, `fetch` in the browser). Around it sit `getMessages.raw(args, opts?)` for the raw `Response`, the mutators/probes `.refresh()` / `.invalidate()` / `.patch(...)` / `.peek()` / `.pending()` / `.refreshing()` / `.error()`, and when the handler streams (`jsonl`/`sse`) a bare call that returns an `AsyncIterable`.
75
57
 
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`.
58
+ > Query, path, and form args auto-coerce from the endpoint's typed shape at build, so a numeric/boolean/date field arrives already typed no `z.coerce` (a value that won't parse stays a string, so the schema raises an honest 422). The per-RPC `timeout` (504 on every surface) is distinct from the client transport ceiling `ABIDE_CLIENT_TIMEOUT`.
77
59
 
78
60
  ## Sockets
79
61
 
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`.
62
+ A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — iterate it for the live stream — and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
81
63
 
82
64
  ```ts
83
65
  // src/server/sockets/chat.ts
@@ -85,171 +67,160 @@ import { socket } from '@abide/abide/server/socket'
85
67
  import { z } from 'zod'
86
68
 
87
69
  export const chat = socket({
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
70
+ schema: z.object({ user: z.string(), text: z.string() }),
71
+ tail: 50, // retain the last 50 frames so late joiners / reconnects seed from `.tail()`
72
+ ttl: 60_000, // evict retained frames older than 60s before replay
91
73
  })
92
74
  ```
93
75
 
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.
76
+ `chat.publish(frame)` is isomorphic server code fans out in-process and to remote subscribers; client code sends a `pub` frame (gated by `clientPublish`, off by default). The HTTP face is `/__abide/sockets/<name>`: a `GET` returns the retained tail, a `POST` publishes.
95
77
 
96
78
  ## Components
97
79
 
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.
80
+ A `.abide` component is valid HTML with a `<script>`, reactive primitives imported by their own module paths, and mustache control-flow blocks. The page below imports the RPC and socket above and exercises the whole grammar:
101
81
 
102
82
  ```html
103
83
  <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'
84
+ import { getMessages } from '$server/rpc/getMessages'
85
+ import { sendMessage } from '$server/rpc/sendMessage'
86
+ import { countToday } from '$server/rpc/countToday'
87
+ import { chat } from '$server/sockets/chat'
88
+ import Card from '$ui/Card.abide'
89
+ import Avatar from '$ui/Avatar.abide'
90
+ import Message from '$ui/Message.abide'
108
91
  import { state } from '@abide/abide/ui/state'
109
92
  import { watch } from '@abide/abide/ui/watch'
110
93
  import { html } from '@abide/abide/ui/html'
111
94
  import { props } from '@abide/abide/ui/props'
112
95
 
113
- const { title = 'Chat', ...rest } = props()
114
-
115
- type Message = { id: string; author: string; body: string }
96
+ const { title = 'Chat' } = props()
116
97
 
117
- let channel = state('general')
98
+ let limit = state(20) // writable cell, read/reassigned as a plain variable
118
99
  let draft = state('')
119
- let pinned = state(false)
120
- let limit = state(20)
100
+ let notify = state(true)
101
+ let channel = state('general')
102
+ const trimmed = state.computed(() => draft.trim()) // read-only derived
103
+ const shown = state.linked(() => limit) // writable, reseeded when `limit` changes
121
104
 
122
- let trimmed = state.computed(() => draft.trim())
123
- let live = state.linked(() => limit)
105
+ const rootAttrs = { role: 'log' }
106
+ const extra = { compact: true }
107
+ const get = () => draft.toUpperCase()
108
+ const set = (next: string) => { draft = next }
124
109
 
125
- const badge = html`<sup class="ml-1 text-xs text-emerald-600">live</sup>`
110
+ // watch the single reaction primitive: over a cell, then over a live socket.
111
+ watch(limit, (n) => console.log('limit is now', n))
112
+ watch(chat, (frame) => console.log('new message', frame.text))
126
113
 
127
- watch(trimmed, (value) => console.log('draft is now', value))
128
- watch(chat, (frame) => {
129
- live = live + 1
130
- })
114
+ function focus(node: HTMLElement) { node.focus() }
115
+ function connection() { return chat.pending() ? 'connecting' : 'open' }
116
+ function risky() { return draft.at(999)!.length }
131
117
 
132
- async function submit(event: SubmitEvent) {
133
- event.preventDefault()
134
- if (trimmed === '') return
135
- await sendMessage({ channel, body: trimmed })
118
+ async function send() {
119
+ await sendMessage({ text: trimmed }) // a mutating RPC from an event handler
136
120
  draft = ''
137
121
  }
138
-
139
- function autofocus(node: HTMLInputElement) {
140
- node.focus()
141
- return () => {}
142
- }
143
-
144
- const rowProps = { class: 'flex gap-2' }
145
-
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))
149
122
  </script>
150
123
 
151
- <section {...rest} class:pinned={pinned} style:opacity={pinned ? '1' : '0.85'}>
152
- <h1>{title} {badge}</h1>
153
-
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>
162
-
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}
124
+ <section class="chat" class:empty={limit === 0} style:--rows={shown} {...rootAttrs}>
125
+ <h1>{title}</h1>
168
126
 
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>
127
+ <!-- Async reads have no ceremony: the bare call is the way. It peeks
128
+ `undefined` while pending, so it composes with `??` and the probes. -->
129
+ <p class:loading={getMessages.pending({ limit })}>
186
130
  {getMessages({ limit })?.length ?? 0} messages
187
- {#if getMessages.pending({ limit })} · loading…{/if}
188
- {#if getMessages.error({ limit })} · failed{/if}
131
+ {#if getMessages.error({ limit })}<span>failed to load</span>{/if}
189
132
  </p>
190
133
 
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>
134
+ <!-- `{await}` is an inline read: it blocks SSR until the value is in the HTML. -->
135
+ <small>today: {await countToday()}</small>
193
136
 
194
- <!-- The explicit opt-in: a distinct pending branch, a local {:catch}, and {:then} narrowing. -->
137
+ <!-- `{#await}` is the explicit opt-in a distinct pending branch and `{:then}` narrowing. -->
195
138
  {#await getMessages({ limit })}
196
- <p>Loading…</p>
139
+ <p>loading…</p>
197
140
  {:then messages}
198
- {#for message, i of messages by message.id}
199
- {row(message, i)}
200
- {/for}
201
- {:catch problem}
202
- <p>Failed: {problem.message}</p>
141
+ {#if messages.length}
142
+ {#for message, i of messages by message.id}
143
+ <Message message={message} ondelete={() => sendMessage({ text: '' })} {...extra}>
144
+ <Avatar alt={message.user} />
145
+ </Message>
146
+ {/for}
147
+ {:else if limit > 0}
148
+ <p>no messages yet</p>
149
+ {:else}
150
+ <p>raise the limit</p>
151
+ {/if}
152
+ {:catch err}
153
+ <p>{err.message}</p>
203
154
  {:finally}
204
155
  <hr />
205
156
  {/await}
206
157
 
207
- {#for await frame of chat}
208
- <p class="text-sm opacity-70">{frame.author}: {frame.body}</p>
209
- {/for}
210
-
211
- {#switch channel}
212
- {:case 'general'}
213
- <span>General channel</span>
214
- {:case 'random'}
215
- <span>Random channel</span>
216
- {:default}
217
- <span>Unknown channel</span>
158
+ <!-- live socket frames over an AsyncIterable -->
159
+ <ul>
160
+ {#for await frame of chat}
161
+ <li>{frame.user}: {frame.text}</li>
162
+ {/for}
163
+ </ul>
164
+
165
+ {#switch connection()}
166
+ {:case 'open'}<span>live</span>
167
+ {:case 'connecting'}<span>connecting…</span>
168
+ {:default}<span>offline</span>
218
169
  {/switch}
219
170
 
220
171
  {#try}
221
- <MessageCard name="system">
222
- <p>System notice</p>
223
- </MessageCard>
224
- {:catch}
225
- <p>Card crashed</p>
172
+ <p>{risky()}</p>
173
+ {:catch e}
174
+ <p>widget crashed: {e.message}</p>
226
175
  {:finally}
227
- <span class="sr-only">done</span>
176
+ <span>rendered</span>
228
177
  {/try}
178
+
179
+ <form onsubmit={send}>
180
+ <input name="text" bind:value={draft} />
181
+ <label><input type="checkbox" bind:checked={notify} /> notify</label>
182
+ <input type="radio" bind:group={channel} value="general" />
183
+ <input aria-label="shout" bind:value={{ get, set }} />
184
+ <button attach={focus}>send</button>
185
+ </form>
186
+
187
+ {#snippet stat(label: string, value: number)}
188
+ <dd>{label}: {value}</dd>
189
+ {/snippet}
190
+ <dl>{stat('shown', limit)}</dl>
191
+
192
+ <Card>
193
+ <p>{html`<em>${trimmed}</em>`}</p>
194
+ </Card>
195
+
196
+ {#if limit > 10}
197
+ <script>
198
+ // a nested branch script: branch-local state, re-seeded per mount, no imports
199
+ let expanded = state(false)
200
+ const label = state.computed(() => (expanded ? 'less' : 'more'))
201
+ </script>
202
+ <button onclick={() => (expanded = !expanded)}>{label}</button>
203
+ <style>
204
+ button { font-weight: 600; }
205
+ </style>
206
+ {/if}
229
207
  </section>
230
208
 
231
209
  <style>
232
- section {
233
- display: grid;
234
- gap: 0.5rem;
235
- }
210
+ .chat { display: grid; gap: 0.5rem; }
236
211
  </style>
237
212
  ```
238
213
 
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.
214
+ The child component fills the passed content at `{children()}`; `{#if children}{:else}…{/if}` is the fallback (there is no `<slot>` element, no named slots):
240
215
 
241
216
  ```html
242
217
  <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 }>()
218
+ import { props } from '@abide/abide/ui/props'
219
+ const { children } = props()
246
220
  </script>
247
221
 
248
- <article {...rest}>
249
- <strong>{name}</strong>
250
- {#if children} {children()} {:else}
251
- <em>No content</em>
252
- {/if}
222
+ <article class="card">
223
+ {#if children}{children()}{:else}<p>empty</p>{/if}
253
224
  </article>
254
225
  ```
255
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.51.0",
3
+ "version": "0.52.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",
@@ -45,6 +45,7 @@
45
45
  "./server/sse": "./src/lib/server/sse.ts",
46
46
  "./server/error": "./src/lib/server/error.ts",
47
47
  "./server/redirect": "./src/lib/server/redirect.ts",
48
+ "./server/render": "./src/lib/server/render.ts",
48
49
  "./server/request": "./src/lib/server/request.ts",
49
50
  "./server/cookies": "./src/lib/server/cookies.ts",
50
51
  "./server/env": "./src/lib/server/env.ts",
@@ -58,6 +59,7 @@
58
59
  "./server/prompts/definePrompt": "./src/lib/server/prompts/definePrompt.ts",
59
60
  "./ui/watch": "./src/lib/ui/watch.ts",
60
61
  "./shared/pending": "./src/lib/shared/pending.ts",
62
+ "./shared/invalidate": "./src/lib/shared/invalidate.ts",
61
63
  "./shared/patch": "./src/lib/shared/patch.ts",
62
64
  "./shared/peek": "./src/lib/shared/peek.ts",
63
65
  "./shared/refresh": "./src/lib/shared/refresh.ts",
@@ -93,6 +95,7 @@
93
95
  "./ui/dom/spreadAttrs": "./src/lib/ui/dom/spreadAttrs.ts",
94
96
  "./ui/dom/readCall": "./src/lib/ui/dom/readCall.ts",
95
97
  "./ui/dom/readCell": "./src/lib/ui/dom/readCell.ts",
98
+ "./ui/dom/writeCell": "./src/lib/ui/dom/writeCell.ts",
96
99
  "./ui/dom/cellPending": "./src/lib/ui/dom/cellPending.ts",
97
100
  "./ui/runtime/withPath": "./src/lib/ui/runtime/withPath.ts",
98
101
  "./ui/settleAsyncCells": "./src/lib/ui/settleAsyncCells.ts",
@@ -396,6 +396,17 @@ export function abideResolverPlugin({
396
396
  program / unresolvable body / no structured field) bakes nothing — a response array
397
397
  stays an array. */
398
398
  const outputWirePlan = rpcServerProgram?.outputWirePlanForModule(args.path)
399
+ /* The client-only reachability plan: the top-level statements the emitted client
400
+ module retains, rooted at the endpoint `opts` (imports + declarations opts
401
+ transitively needs). Present → rewriteForClient emits a MINIMAL module so the
402
+ handler and its server-only imports are never emitted — nothing server-side loads
403
+ or trips the reachability guard (ADR-0022 addendum). Only the client target needs
404
+ it; the server rewrite keeps the whole handler. undefined (no warm program /
405
+ unresolvable call) falls open to the keep-the-file rewrite. */
406
+ const clientKeep =
407
+ target === 'client'
408
+ ? rpcServerProgram?.clientKeepForModule(args.path)
409
+ : undefined
399
410
  /* The handler's input args projected to JSON Schema, stamped as `inputJsonSchema` so the
400
411
  runtime OpenAPI parameters/body / MCP inputSchema / inspector input surface renders the
401
412
  real input shape (and the endpoint becomes advertisable) without an author-declared
@@ -410,6 +421,7 @@ export function abideResolverPlugin({
410
421
  outputSchema: returnBodySchema,
411
422
  errorSchemas,
412
423
  outputWirePlan,
424
+ clientKeep,
413
425
  })
414
426
  if (!prepared) {
415
427
  throw new Error(
package/src/checkAbide.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import ts from 'typescript'
3
+ import { generateDeclarations } from './lib/shared/generateDeclarations.ts'
3
4
  import { collectAbideDiagnostics } from './lib/ui/compile/collectAbideDiagnostics.ts'
4
5
  import { createShadowProgram, type ShadowProgram } from './lib/ui/compile/createShadowProgram.ts'
5
6
  import { interpolationClassifierForRoot } from './lib/ui/compile/interpolationClassifierForRoot.ts'
@@ -15,6 +16,13 @@ the same as each package checked on its own — and the same as the LSP. Returns
15
16
  the error count so the CLI can set its exit code.
16
17
  */
17
18
  export async function checkAbide({ cwd }: { cwd: string }): Promise<number> {
19
+ /* Materialize the project's generated augmentations (src/.abide/*.d.ts) first: since the
20
+ check pass now type-checks the project's `.ts` files too (rpc handlers, app.ts, tests),
21
+ those resolve `app.rpc.<name>` / typed `url('/rpc/…')` off the build-written RpcClient /
22
+ RpcRoutes augmentations. Without this, a cold `abide check` (CI, fresh clone — `.abide` is
23
+ gitignored, generated only by dev/build) reports every augmented member as missing. Fails
24
+ open, so a codegen hiccup degrades to the pre-`.ts`-check behaviour rather than aborting. */
25
+ await generateDeclarations({ cwd })
18
26
  const { diagnostics, checked } = collectByProject(cwd)
19
27
  const byFile = new Map<string, AbideDiagnostic[]>()
20
28
  for (const diagnostic of diagnostics) {
@@ -64,17 +72,62 @@ function collectByProject(cwd: string): { diagnostics: AbideDiagnostic[]; checke
64
72
  WHICH sub-expressions are async must read un-wrapped shadows — so it rides a separate verbatim
65
73
  program, built (and reused) here per root. */
66
74
  const classifierCache = new Map<string, ShadowProgram | undefined>()
67
- const diagnostics = [...byProject].flatMap(([root, paths]) =>
68
- collectAbideDiagnostics(
69
- createShadowProgram(root, paths, (abidePath) =>
70
- interpolationClassifierForRoot(classifierCache, root, abidePath),
71
- ),
72
- ),
73
- )
75
+ const diagnostics = [...byProject].flatMap(([root, paths]) => {
76
+ const shadow = createShadowProgram(root, paths, (abidePath) =>
77
+ interpolationClassifierForRoot(classifierCache, root, abidePath),
78
+ )
79
+ /* The shadow program already holds the project's real `.ts` files (loaded so the
80
+ components' imports/types resolve), but `collectAbideDiagnostics` only reports the
81
+ `.abide` shadows. Report the `.ts` files too, so a mistyped `navigate`/`url`/`patch`
82
+ call — or any type error — in an rpc handler, `app.ts`, or a `$shared` helper fails
83
+ `abide check` instead of only surfacing under a separately-run `tsc`. */
84
+ return [...collectAbideDiagnostics(shadow), ...collectTsDiagnostics(shadow.program)]
85
+ })
74
86
  const checked = [...byProject.values()].reduce((total, paths) => total + paths.length, 0)
75
87
  return { diagnostics, checked }
76
88
  }
77
89
 
90
+ /* Syntactic + semantic diagnostics for the project's own `.ts` files already in the shadow
91
+ program. Iterating the ROOT file names (the project's tsconfig inputs) — not every loaded
92
+ source — scopes this to the project itself: an on-demand-resolved import from node_modules or a
93
+ monorepo sibling is in the program but never a root, so its errors aren't attributed here.
94
+ `fileExists` drops the virtual `.abide.ts` shadows and the synthetic asset-modules file (neither
95
+ is on disk), `isDeclarationFile` drops the default libs and the generated `src/.abide/*.d.ts`,
96
+ and the `.ts`/`.tsx` guard drops raw `.abide` root entries. Real source coordinates — no remap. */
97
+ function collectTsDiagnostics(program: ts.Program): AbideDiagnostic[] {
98
+ const diagnostics: AbideDiagnostic[] = []
99
+ for (const rootName of program.getRootFileNames()) {
100
+ if (
101
+ (!rootName.endsWith('.ts') && !rootName.endsWith('.tsx')) ||
102
+ rootName.includes('/node_modules/') ||
103
+ !ts.sys.fileExists(rootName)
104
+ ) {
105
+ continue
106
+ }
107
+ const sourceFile = program.getSourceFile(rootName)
108
+ if (sourceFile === undefined || sourceFile.isDeclarationFile) {
109
+ continue
110
+ }
111
+ const raw = [
112
+ ...program.getSyntacticDiagnostics(sourceFile),
113
+ ...program.getSemanticDiagnostics(sourceFile),
114
+ ]
115
+ for (const diagnostic of raw) {
116
+ if (diagnostic.start === undefined) {
117
+ continue
118
+ }
119
+ diagnostics.push({
120
+ file: sourceFile.fileName,
121
+ start: diagnostic.start,
122
+ length: diagnostic.length ?? 0,
123
+ message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
124
+ category: diagnostic.category,
125
+ })
126
+ }
127
+ }
128
+ return diagnostics
129
+ }
130
+
78
131
  /* Renders one diagnostic as `path:line:col severity message` plus the offending
79
132
  line and a caret underline spanning the mapped range. */
80
133
  function printDiagnostic(file: string, text: string, diagnostic: AbideDiagnostic): void {
@@ -0,0 +1,38 @@
1
+ import type { CliManifest } from './types/CliManifest.ts'
2
+
3
+ /*
4
+ Shell-completion candidates for a partially-typed command line, derived
5
+ entirely from the baked manifest so completion can't drift from dispatch.
6
+ `cword` is the token index being completed (the shell's COMP_CWORD /
7
+ CURRENT), `command` is the first positional (`words[1]`). Completing the
8
+ first positional lists every command plus the `/`-prefixed connection
9
+ verbs; past it, the chosen command's flags (mirroring parseArgvForRpc's
10
+ grammar — `--name`, plus `--no-name` for a boolean, and the always-present
11
+ `--json`). The shell filters these by the current prefix.
12
+ */
13
+ export function completeCli(
14
+ manifest: CliManifest,
15
+ cword: number,
16
+ command: string | undefined,
17
+ ): string[] {
18
+ // Completing the command itself: every rpc/socket command + the connection verbs.
19
+ if (cword <= 1) {
20
+ const connection = ['/connect', '/start', '/disconnect', '/help', '/completions']
21
+ return [...Object.keys(manifest).toSorted(), ...connection]
22
+ }
23
+ const entry = command === undefined ? undefined : manifest[command]
24
+ if (!entry) {
25
+ return []
26
+ }
27
+ const properties =
28
+ (entry.jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
29
+ const flags = ['--json']
30
+ for (const [key, value] of Object.entries(properties)) {
31
+ flags.push(`--${key}`)
32
+ // A boolean flag also accepts its negation, matching printCommandHelp's `--no-<key>`.
33
+ if (value.type === 'boolean') {
34
+ flags.push(`--no-${key}`)
35
+ }
36
+ }
37
+ return flags
38
+ }
@@ -70,6 +70,7 @@ export function printTopLevelHelp(
70
70
  console.log(` ${programName} /start start a local instance`)
71
71
  console.log(` ${programName} /disconnect forget the saved connection`)
72
72
  console.log(` ${programName} resume the saved connection (session)`)
73
+ console.log(` ${programName} /completions <bash|zsh|fish> print a shell completion script`)
73
74
  console.log(`\n --help, -h show this help`)
74
75
  console.log(` <command> --help show help for a specific command`)
75
76
  console.log(`\nenv:`)