@abide/abide 0.50.1 → 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 +486 -490
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +130 -149
  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 +141 -25
  45. package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
  46. package/src/lib/ui/compile/lowerScript.ts +23 -0
  47. package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
  48. package/src/lib/ui/compile/wrapReactionCellSources.ts +117 -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,44 @@
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
+
32
+ ## 0.51.0
33
+
34
+ ### Minor Changes
35
+
36
+ - aec503f: make watch(cell, handler) reactive ([`4db4e3d`](https://github.com/briancray/abide/commit/4db4e3d21cb04dcad5463831621140ec00ccbbe2))
37
+
38
+ ### Patch Changes
39
+
40
+ - aec503f: correct README/AGENTS and lead the async story with the bare call ([`c96e936`](https://github.com/briancray/abide/commit/c96e93682cc8e05762bcde7bb808850dfdb40b6f))
41
+
3
42
  ## 0.50.1
4
43
 
5
44
  ### Patch 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,65 +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'
32
+ import { recentMessages } from '../db.ts'
36
33
 
34
+ // The success body's type flows back to every caller; `cache` retains the read.
37
35
  export const getMessages = GET(
38
- (args) => json(load(args.channel, args.limit)),
39
- {
40
- schemas: {
41
- input: z.object({
42
- channel: z.string().default('general'),
43
- limit: z.number().max(100).default(20),
44
- }),
45
- },
46
- },
36
+ async ({ limit }: { limit: number }) => json(await recentMessages(limit)),
37
+ { schemas: { input: z.object({ limit: z.number().int().max(100) }) }, cache: { ttl: 5_000 } },
47
38
  )
48
39
  ```
49
40
 
50
- One declaration, five surfaces:
41
+ One declaration branches to every surface:
51
42
 
52
43
  ```text
53
- export const getMessages = GET(fn, { schemas })
54
-
55
- ┌───────────┬──────────────┼──────────────┬─────────────┐
56
- ▼ ▼ ▼
57
- SSR call browser fetch MCP tool CLI subcmd OpenAPI op
58
- (bare, (same call, (read-only abide-cli /openapi.json
59
- in-proc) swap to fetch) from schema) 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
60
52
  ```
61
53
 
62
- A schema unlocks the CLI on any RPC, and MCP for read-only methods (`GET`/`HEAD`). A mutating method never auto-exposes to MCP it opts in explicitly:
63
-
64
- ```ts
65
- // src/server/rpc/sendMessage.ts
66
- import { POST } from '@abide/abide/server/POST'
67
- import { json } from '@abide/abide/server/json'
68
- import { z } from 'zod'
69
- import { append } from '$server/db.ts'
70
-
71
- export const sendMessage = POST(
72
- (args) => json(append(args.channel, args.body)),
73
- {
74
- schemas: { input: z.object({ channel: z.string(), body: z.string() }) },
75
- clients: { mcp: true }, // a mutating RPC must opt into MCP by hand
76
- },
77
- )
78
- ```
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 }`.
79
55
 
80
- 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`.
81
57
 
82
- > 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`.
83
59
 
84
60
  ## Sockets
85
61
 
86
- 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`.
87
63
 
88
64
  ```ts
89
65
  // src/server/sockets/chat.ts
@@ -91,155 +67,160 @@ import { socket } from '@abide/abide/server/socket'
91
67
  import { z } from 'zod'
92
68
 
93
69
  export const chat = socket({
94
- schema: z.object({ author: z.string(), body: z.string() }),
95
- tail: 20, // retain the last 20 frames for late joiners and reconnects
96
- 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
97
73
  })
98
74
  ```
99
75
 
100
- 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.
101
77
 
102
78
  ## Components
103
79
 
104
- A `.abide` component is HTML with a leading `<script>`. The example below is one page that imports the RPC and socket above and exercises the whole template grammar. Reactive primitives are imported by their own module paths and called bare: `state(0)` is a writable cell (read/write via `.value`), `state.computed(fn)` is read-only derived, `state.linked(fn)` is writable and reseeded from a thunk. `watch(source, handler)` is the single reaction primitive — over a cell, a socket, or an RPC. `props()` is the ambient prop reader (no import).
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:
105
81
 
106
82
  ```html
107
83
  <script>
108
- import { getMessages } from '$server/rpc/getMessages.ts'
109
- import { sendMessage } from '$server/rpc/sendMessage.ts'
110
- import { chat } from '$server/sockets/chat.ts'
111
- 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'
112
91
  import { state } from '@abide/abide/ui/state'
113
92
  import { watch } from '@abide/abide/ui/watch'
114
93
  import { html } from '@abide/abide/ui/html'
94
+ import { props } from '@abide/abide/ui/props'
115
95
 
116
- const { title = 'Chat', ...rest } = props()
96
+ const { title = 'Chat' } = props()
117
97
 
118
- let channel = state('general')
98
+ let limit = state(20) // writable cell, read/reassigned as a plain variable
119
99
  let draft = state('')
120
- let pinned = state(false)
121
- 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
122
104
 
123
- let trimmed = state.computed(() => draft.value.trim())
124
- let live = state.linked(() => limit.value)
105
+ const rootAttrs = { role: 'log' }
106
+ const extra = { compact: true }
107
+ const get = () => draft.toUpperCase()
108
+ const set = (next: string) => { draft = next }
125
109
 
126
- 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))
127
113
 
128
- watch(trimmed, (value) => console.log('draft is now', value))
129
- watch(chat, (frame) => {
130
- live.value = live.value + 1
131
- })
114
+ function focus(node: HTMLElement) { node.focus() }
115
+ function connection() { return chat.pending() ? 'connecting' : 'open' }
116
+ function risky() { return draft.at(999)!.length }
132
117
 
133
- async function submit(event) {
134
- event.preventDefault()
135
- if (trimmed.value === '') return
136
- await sendMessage({ channel: channel.value, body: trimmed.value })
137
- draft.value = ''
118
+ async function send() {
119
+ await sendMessage({ text: trimmed }) // a mutating RPC from an event handler
120
+ draft = ''
138
121
  }
139
-
140
- function autofocus(node) {
141
- node.focus()
142
- return () => {}
143
- }
144
-
145
- const rowProps = { class: 'flex gap-2' }
146
-
147
- // Derived two-way binding: read a string, coerce writes back into the numeric cell.
148
- const get = () => String(limit.value)
149
- const set = (next) => (limit.value = Number(next))
150
122
  </script>
151
123
 
152
- <section {...rest} class:pinned={pinned.value} style:opacity={pinned.value ? '1' : '0.85'}>
153
- <h1>{title} {badge}</h1>
124
+ <section class="chat" class:empty={limit === 0} style:--rows={shown} {...rootAttrs}>
125
+ <h1>{title}</h1>
154
126
 
155
- <form onsubmit={submit}>
156
- <input bind:value={draft} attach={autofocus} placeholder="Say something" />
157
- <label><input type="checkbox" bind:checked={pinned} /> pin</label>
158
- <label><input type="radio" bind:group={channel} value="general" /> general</label>
159
- <label><input type="radio" bind:group={channel} value="random" /> random</label>
160
- <input bind:value={{ get, set }} />
161
- <button type="submit">Send</button>
162
- </form>
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 })}>
130
+ {getMessages({ limit })?.length ?? 0} messages
131
+ {#if getMessages.error({ limit })}<span>failed to load</span>{/if}
132
+ </p>
163
133
 
164
- {#snippet row(message, index)}
165
- <MessageCard {...rowProps} name={message.author} onclick={() => console.log(index)}>
166
- <p>{message.body}</p>
167
- </MessageCard>
168
- {/snippet}
134
+ <!-- `{await}` is an inline read: it blocks SSR until the value is in the HTML. -->
135
+ <small>today: {await countToday()}</small>
169
136
 
170
- {#if live.value > 100}
171
- <p>Busy channel</p>
172
- {:else if live.value > 0}
173
- <p>{live.value} updates</p>
174
- {:else}
175
- <script>
176
- let seenAt = state(Date.now())
177
- let ageLabel = state.computed(() => `waiting since ${seenAt.value}`)
178
- </script>
179
- <style>
180
- p { color: gray; }
181
- </style>
182
- <p>{ageLabel.value}</p>
183
- {/if}
184
-
185
- {#await getMessages({ limit: limit.value })}
186
- <p>Loading…</p>
137
+ <!-- `{#await}` is the explicit opt-in — a distinct pending branch and `{:then}` narrowing. -->
138
+ {#await getMessages({ limit })}
139
+ <p>loading…</p>
187
140
  {:then messages}
188
- {#for message, i of messages by message.id}
189
- {row(message, i)}
190
- {/for}
191
- {:catch problem}
192
- <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>
193
154
  {:finally}
194
155
  <hr />
195
156
  {/await}
196
157
 
197
- {#for await frame of chat}
198
- <p class="text-sm opacity-70">{frame.author}: {frame.body}</p>
199
- {/for}
200
-
201
- {#switch channel.value}
202
- {:case 'general'}
203
- <span>General channel</span>
204
- {:case 'random'}
205
- <span>Random channel</span>
206
- {:default}
207
- <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>
208
169
  {/switch}
209
170
 
210
171
  {#try}
211
- <MessageCard name="system">
212
- <p>System notice</p>
213
- </MessageCard>
214
- {:catch}
215
- <p>Card crashed</p>
172
+ <p>{risky()}</p>
173
+ {:catch e}
174
+ <p>widget crashed: {e.message}</p>
216
175
  {:finally}
217
- <span class="sr-only">done</span>
176
+ <span>rendered</span>
218
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}
219
207
  </section>
220
208
 
221
209
  <style>
222
- section {
223
- display: grid;
224
- gap: 0.5rem;
225
- }
210
+ .chat { display: grid; gap: 0.5rem; }
226
211
  </style>
227
212
  ```
228
213
 
229
- 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):
230
215
 
231
216
  ```html
232
217
  <script>
233
- const { name, ...rest } = props()
218
+ import { props } from '@abide/abide/ui/props'
219
+ const { children } = props()
234
220
  </script>
235
221
 
236
- <article {...rest}>
237
- <strong>{name}</strong>
238
- {#if children}
239
- {children()}
240
- {:else}
241
- <em>No content</em>
242
- {/if}
222
+ <article class="card">
223
+ {#if children}{children()}{:else}<p>empty</p>{/if}
243
224
  </article>
244
225
  ```
245
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.50.1",
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:`)