@abide/abide 0.54.0 → 0.55.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 (96) hide show
  1. package/AGENTS.md +330 -498
  2. package/CHANGELOG.md +49 -0
  3. package/README.md +166 -146
  4. package/package.json +2 -3
  5. package/src/lib/server/rpc/defineRpc.ts +2 -1
  6. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  7. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  8. package/src/lib/server/runtime/createUiPageRenderer.ts +34 -26
  9. package/src/lib/shared/SEEDS.ts +33 -0
  10. package/src/lib/shared/SOCKET_SEED.ts +4 -5
  11. package/src/lib/shared/cache.ts +169 -75
  12. package/src/lib/shared/createRemoteFunction.ts +4 -1
  13. package/src/lib/shared/encodeSeedValue.ts +24 -0
  14. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  15. package/src/lib/shared/peek.ts +3 -6
  16. package/src/lib/ui/amendReaderHook.ts +1 -1
  17. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  18. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  19. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  20. package/src/lib/ui/compile/cachedShadowProgram.ts +2 -1
  21. package/src/lib/ui/compile/compileComponent.ts +1 -10
  22. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  23. package/src/lib/ui/compile/desugarSignals.ts +8 -44
  24. package/src/lib/ui/compile/generateBuild.ts +25 -24
  25. package/src/lib/ui/compile/generateSSR.ts +37 -24
  26. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  27. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  28. package/src/lib/ui/compile/lowerContext.ts +26 -6
  29. package/src/lib/ui/compile/lowerScript.ts +1 -11
  30. package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
  31. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
  32. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  33. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  34. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  35. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  36. package/src/lib/ui/computed.ts +16 -0
  37. package/src/lib/ui/createScope.ts +4 -3
  38. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  39. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  40. package/src/lib/ui/dom/appendStatic.ts +11 -20
  41. package/src/lib/ui/dom/appendText.ts +41 -43
  42. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  43. package/src/lib/ui/dom/awaitBlock.ts +30 -44
  44. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  45. package/src/lib/ui/dom/claimText.ts +38 -0
  46. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  47. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  48. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  49. package/src/lib/ui/dom/each.ts +3 -5
  50. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  51. package/src/lib/ui/dom/hydrate.ts +5 -12
  52. package/src/lib/ui/dom/mountChild.ts +73 -5
  53. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  54. package/src/lib/ui/dom/openMarker.ts +2 -4
  55. package/src/lib/ui/dom/outlet.ts +4 -3
  56. package/src/lib/ui/dom/readCell.ts +26 -11
  57. package/src/lib/ui/dom/skeleton.ts +2 -7
  58. package/src/lib/ui/dom/switchBlock.ts +2 -1
  59. package/src/lib/ui/dom/tryBlock.ts +26 -38
  60. package/src/lib/ui/dom/when.ts +6 -2
  61. package/src/lib/ui/linked.ts +14 -0
  62. package/src/lib/ui/resumeSeedScript.ts +1 -1
  63. package/src/lib/ui/router.ts +12 -24
  64. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  65. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  66. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  67. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  68. package/src/lib/ui/runtime/RENDER.ts +7 -5
  69. package/src/lib/ui/runtime/RESUME.ts +5 -7
  70. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  71. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  72. package/src/lib/ui/runtime/claimChild.ts +5 -7
  73. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  74. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  75. package/src/lib/ui/runtime/claimRun.ts +22 -0
  76. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  77. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  78. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  79. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  80. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  81. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  82. package/src/lib/ui/runtime/readNode.ts +21 -1
  83. package/src/lib/ui/runtime/renderPath.ts +2 -2
  84. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  85. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  86. package/src/lib/ui/runtime/runNode.ts +14 -0
  87. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  88. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  89. package/src/lib/ui/settleAsyncCells.ts +30 -3
  90. package/src/lib/ui/startClient.ts +0 -7
  91. package/src/lib/ui/tryEncodeResume.ts +6 -15
  92. package/src/lib/ui/watch.ts +3 -11
  93. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  94. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
  95. package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
  96. package/src/lib/ui/runtime/warmSeedBackup.ts +0 -10
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # abide
2
2
 
3
+ ## 0.55.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b000b40: accept a cache policy on mutating helpers ([`4ddc496`](https://github.com/briancray/abide/commit/4ddc496a1a4c9250f83d6dcbb8711be96f1aeded))
8
+ - dcf9868: Hydration internals consolidated — one pass owner, shared claim verbs, one recovery dance, one child adopter.
9
+
10
+ - `runHydrationPass` now owns the hydration pass lifecycle (claim cursor, cache-withhold window, render pass, and the two-phase seed consume) — both entry sites (`hydrate`, the router's hydrating first mount) run the same bracket.
11
+ - The claim-cursor mechanics live in four verbs (`claimMarker`, `claimRun`, `claimText`, `parkCursor`) instead of ~15 hand-rolled advance sites across the dom helpers; helpers keep their hydrate-vs-create branch and divergence policy.
12
+ - `discardAndRebuild` carries the shared divergence-recovery dance (remove the SSR boundary, build fresh with the cursor cleared) for the await/try blocks and the child adopter.
13
+ - **Removed export:** `abide/ui/dom/mountStreamedChild` — `abide/ui/dom/mountChild` is now the single dual-mode child adopter (it probes the markup for inlined vs streamed, so the client build needs no hoistable-child knowledge). Both are compiler-emitted plumbing an author never imports; compiled output now always emits `$$mountChild`.
14
+ - Congruence decisions once mirrored by hand across the two compiler back-ends are now single shared sites: the branch render-path segment alphabet (`BRANCH_SEGMENT`), the `{#await}` bare-cell subject classification (`awaitSubjectExpr`), and `<Child/>` render-path ordinals (`componentOrdinals`, one document-order walk — fixing a latent SSR↔client ordinal desync for a `{:default}` case that isn't last and for components in slot content).
15
+ - Warm-seed adoption is two-phase (ADR-0048 first slice): a hydration pass marks the `CELL_SEED`/`DOC_SEED` entries it adopts and deletes them only on a clean exit, so a desync throw hands the seeds to the cold rebuild directly — the `warmSeedBackup`/`restoreWarmSeeds` shadow-copy recovery is deleted.
16
+
17
+ Server markup and authored APIs are untouched (the `__abideSeeds` wire-global change ships separately).
18
+
19
+ - f1e925e: `await` now blocks a **script** read the same way it blocks a template read — pending propagates through the whole dependency graph (ADR-0046).
20
+
21
+ Previously only _template_ reads of an `await`-marked `computed`/`linked` cell suspended; a _script_ read (a `computed`/derive seed like `computed(() => result.root)` over a blocking `result`) peeked `undefined` while pending, so on a cold client navigation a downstream script derivation dereferenced `undefined` and crashed. Now a read of a pending blocking cell suspends its reader wherever it happens: a derive over it withholds and re-runs on settle, an async seed that reads a pending blocking dependency stays pending (not error), and the SSR barrier drains to a fixpoint so chained blocking cells resolve in order and bake correct values into the first-pass HTML.
22
+
23
+ Whether a read pauses is now a property of the **cell** (its `blocking`/`await` bit — the same one that decides SSR-barrier registration), not of the read site: there is one read callee, `$$readCell`, script and template, client and server. The separate `$$readCellBlocking` codegen path and the `abide/ui/dom/readCellBlocking` export are removed; bare/streaming reads (no `await`) are unchanged.
24
+
25
+ **Breaking:** a script read of an `await` cell now suspends where it previously returned `undefined` while pending. Read a blocking cell inside a `computed`/derive or the template — a plain top-level `const x = cell.field` cannot pause (it is not a graph node) and is fatal at mount, exactly as `undefined.field` already was.
26
+
27
+ ### Patch Changes
28
+
29
+ - b000b40: runHydrationPass owns the hydration pass lifecycle ([`0ede531`](https://github.com/briancray/abide/commit/0ede531971e6c351397c2c8c4eea248c37bdd399))
30
+ - b000b40: abide check reads a bare async {#for} source as its resolved peek ([`10b78cf`](https://github.com/briancray/abide/commit/10b78cfe46ef5c6719087ed29f6238ed0d91f348))
31
+ - b000b40: code-review fixes across the reactive core, hydration seeds, and {#await} ([`1d91f49`](https://github.com/briancray/abide/commit/1d91f491bc16f3120f1e513a0db0a6e2e225c77f))
32
+ - b000b40: anchoredBranch owns anchor parking; await/try use advanceClaim ([`473f289`](https://github.com/briancray/abide/commit/473f2894ae02bcbcd67c4ca5d6b45b7de9b1439c))
33
+ - b000b40: name the hydration-cursor advance (advanceClaim) ([`50a2d3d`](https://github.com/briancray/abide/commit/50a2d3d69cfbd82effb2980ba53e58011928dc47))
34
+ - b000b40: consolidate simplify cleanups across shared/ui ([`5956928`](https://github.com/briancray/abide/commit/595692861152619c95a8a7eb83f48fd6d016ec37))
35
+ - b000b40: two-phase warm-seed consume replaces the shadow-copy recovery ([`5b07997`](https://github.com/briancray/abide/commit/5b07997bb7cd01112161f5e67f698e438a96ce1b))
36
+ - b000b40: one degradation site for every hydration seed ([`5bb7fd4`](https://github.com/briancray/abide/commit/5bb7fd490ba2b401cf5c88d616b5ba6c1e420aa5))
37
+ - b000b40: branch segments, await subjects, and child ordinals decided once ([`6f2981d`](https://github.com/briancray/abide/commit/6f2981d5ac49c14b693228c41c14dc54e92a3367))
38
+ - b000b40: appendTextAt's create path delegates to appendText ([`a178acd`](https://github.com/briancray/abide/commit/a178acd4a2dc21477b722a95da99c63d77b794bd))
39
+ - b000b40: hydration claim verbs own the cursor mechanics ([`a624939`](https://github.com/briancray/abide/commit/a624939eb2ff625e4a52c06bf324c45b02ec6d51))
40
+ - b000b40: auto-wrap nested-<script> state.computed/linked bare seeds ([`c4aa0eb`](https://github.com/briancray/abide/commit/c4aa0ebb686875236ba945c15de88278098b10b8))
41
+ - b000b40: mountStreamedChild delegates every mount to mountChild ([`d794580`](https://github.com/briancray/abide/commit/d794580572610304019b698340b5477b7a326241))
42
+ - b000b40: regenerate AGENTS.md + README for amend and cache-on-mutating surface ([`ddef71c`](https://github.com/briancray/abide/commit/ddef71c8ffceab01dcea2a2e49397df29795be2e))
43
+ - 856901e: `{#await <cell>}` now awaits an async cell's resolution instead of peeking its `undefined`-while-pending value (ADR-0047).
44
+
45
+ `{#await rates}` where `const rates = state.computed(getRates(...))` used to lower its subject to `$$readCell(rates)` — a peek — so a pending cell read `undefined`, fired `{:then}` immediately, and `{:then data}` crashed dereferencing `data.foo` on `undefined`. (`{#if}`/`{#switch}` cell subjects already avoided this with a `cellPending` guard; `{#await}` never got the equivalent, so it regressed the day a `computed`/`linked` of an async source became a peek-cell.) A bare cell subject is now passed raw and normalised by `$$awaitSubject`, which resolves an async cell to a promise-of-its-value (showing the pending branch until it settles, then `{:then}`, or `{:catch}` on error — SWR on a refresh) and unwraps a lazy `computed(promise)` to its held promise. `{#await getFoo()}` (a plain promise) is byte-identical, on the client, on hydrate, and in the SSR stream.
46
+
47
+ - d33ed87: `computed`/`linked` now honor the bare-seed contract (ADR-0045) when a bare seed reaches the runtime literally — a branch-nested `<script>` (`{:then names}` + `let total = state.computed(names.length)`) or a direct JS caller. The compile-time `wrapSeed` normalization only covers a component's leading script; a nested branch script keeps its calls literal, so the raw value hit the primitive: `computed(3)` stored `3` as the node's compute and the first read crashed (`node.compute is not a function`), killing the SSR stream mid-drain — a blank page after the shell. Both primitives now route a non-function seed by value: a promise → a streaming async cell, a stream → a frame cell, any other value → a constant seed. Regression-tested against the kitchen-sink templating/async live demo shape.
48
+ - f4e98bd: A bare rpc read (`fn(args)`) no longer derives its cache key twice. The callable already computes `keyForRemoteCall(method, url, args)` to tag its rpc-error registry entries, then `readThrough` re-derived the identical string (`callable.raw` carries the same method/url) on every read — a query-string encode for GET/DELETE or a canonical-JSON stringify for body methods, warm cache hits included. The callable now threads its key into `cache.read` and `readThrough` reuses it; the public `cache()` and raw paths still derive their own. Behavior is unchanged — the two keys were provably identical — this just removes the duplicate work from the framework's primary read path.
49
+ - e8f2144: `invalidate(fn, args)`, `refresh(fn, args)`, and `amend(fn, args)` now resolve their target entry with a direct store lookup instead of scanning every cache entry in every store. A `fn + args` selector identifies exactly one key, so the full-store scan it previously ran was O(total cache entries) work to act on a single known key — noticeable for `amend`, which the real-time path can fire once per incoming socket frame. Behavior is unchanged (the scan is still used for bare-fn, tag, and wire-driven selectors that can match many entries); this is purely a hot-path optimization.
50
+ - 171fdee: One hydration seed manifest: the four passive warm-seed globals (`__abideResume`, `__abideCells`, `__abideDocs`, `__abideSockets`) collapse into one kind-partitioned `window.__abideSeeds` (ADR-0048). The in-bundle names (`RESUME`, `CELL_SEED`, `DOC_SEED`, `SOCKET_SEED`) stay as views onto its partitions, so consumers are unchanged; the inline SSR swap/seed scripts write the `resume` partition. Each partition self-initializes, so the vanilla scripts and the bundle compose in either run order. The live streamed-cell machinery and the head collector buffer stay separate — they are apply-timing phases (ADR-0040), not passive manifests. Internal wire-shape change only; `__SSR__` and all authored APIs are untouched.
51
+
3
52
  ## 0.54.0
4
53
 
5
54
  ### Minor Changes
package/README.md CHANGED
@@ -1,65 +1,75 @@
1
1
  # abide
2
2
 
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.**
3
+ **One typed declaration fans out to HTTP, a browser proxy, a CLI, an MCP tool, and an OpenAPI spec — in a single Bun runtime, for humans and machines alike.**
4
4
 
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.
5
+ abide is an isomorphic framework where a server RPC is one typed function: the bundler swaps its runtime per side (in-process on the server, `fetch` in the browser) and projects the same declaration onto every other surface. The `.abide` component grammar ties those RPCs, live sockets, and reactive state into one page that server-renders and hydrates.
6
6
 
7
- - One direct dependency (TypeScript), one runtime (Bun ≥ 1.3.0).
7
+ - One direct dependency (TypeScript); one runtime (Bun ≥ 1.3). Tailwind is an optional peer.
8
8
 
9
9
  ## Quick start
10
10
 
11
11
  ```sh
12
- bunx abide scaffold my-app # scaffold, install, and start the dev server
12
+ bunx abide scaffold my-app # scaffolds the project, installs deps, starts the dev server
13
13
  ```
14
14
 
15
+ Or run the kitchen-sink example:
16
+
15
17
  ```sh
16
- # or explore the full feature tour
17
18
  git clone https://github.com/briancray/abide
18
- cd abide/examples/kitchen-sink
19
- bun install
20
- bun run dev
19
+ cd abide && bun install
20
+ cd examples/kitchen-sink && bun dev
21
21
  ```
22
22
 
23
23
  ## RPCs
24
24
 
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.
25
+ An RPC is one export per file under `src/server/rpc/` — the file path is the URL. The handler's typed input parameter is the contract: at build it projects to JSON Schema (ADR-0030) that drives the MCP tool, CLI flags, and OpenAPI operation. A Standard Schema (zod / valibot / arktype, unadapted) in `schemas.input` adds runtime validation on top.
26
26
 
27
27
  ```ts
28
28
  // src/server/rpc/getMessages.ts
29
29
  import { GET } from '@abide/abide/server/GET'
30
30
  import { json } from '@abide/abide/server/json'
31
31
  import { z } from 'zod'
32
- import { recentMessages } from '../db.ts'
32
+ import { listMessages } from '../db.ts'
33
33
 
34
- // The success body's type flows back to every caller; `cache` retains the read.
35
34
  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 } },
35
+ async ({ limit }: { limit: number }) => json(await listMessages(limit)),
36
+ { schemas: { input: z.object({ limit: z.number().max(100) }) } },
38
37
  )
39
38
  ```
40
39
 
41
- One declaration branches to every surface:
40
+ One declaration, five surfaces:
42
41
 
43
42
  ```text
44
- export const getMessages = GET(handler, { schemas })
43
+ getMessages (src/server/rpc/getMessages.ts)
45
44
  │
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
45
+ ┌──────────────┬──────────┼───────────────┬───────────────────┐
46
+ ▼ ▼ ▼ ▼ ▼
47
+ SSR call browser MCP tool CLI OpenAPI op
48
+ (in-proc) fetch get_messages app get-messages GET /rpc/
49
+ smart read typed proxy (read → auto) --limit 20 getMessages
52
50
  ```
53
51
 
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 }`.
52
+ A **typed input** unlocks the CLI, and for a read-only method (GET/HEAD) the MCP tool, with no hand-written schema — the projected shape is the machine-advertisable contract. A mutating method (POST/PUT/PATCH/DELETE) never auto-exposes to MCP; it needs explicit `clients: { mcp: true }`.
53
+
54
+ ```ts
55
+ // src/server/rpc/sendMessage.ts — a plainly-typed mutation, opted into MCP
56
+ import { POST } from '@abide/abide/server/POST'
57
+ import { json } from '@abide/abide/server/json'
58
+ import { appendMessage } from '../db.ts'
59
+
60
+ export const sendMessage = POST(
61
+ async ({ text }: { text: string }) => json(await appendMessage(text)),
62
+ { clients: { mcp: true } },
63
+ )
64
+ ```
55
65
 
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()` / `.amend(...)` / `.peek()` / `.pending()` / `.refreshing()` / `.error()`, and — when the handler streams (`jsonl`/`sse`) — a bare call that returns an `AsyncIterable`.
66
+ Consume it many ways. The **bare call `fn(args)` is the smart read** — cached, coalesced, reactive, isomorphic (in-process during SSR, `fetch` in the browser). Alongside it: `fn.raw(args, init?)` for the raw `Response`, and the probes/mutators `fn.pending()` / `fn.refreshing()` / `fn.error()` / `fn.peek()` / `fn.refresh()` / `fn.invalidate()` / `fn.amend(...)`. A streaming handler (`jsonl`/`sse`) makes the bare call return a `Subscribable`. There is no `cache()` wrapper — the bare call carries the caching.
57
67
 
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`.
68
+ > Query / path / form args auto-coerce from the endpoint's typed shape (ADR-0028): a numeric / boolean / date field arrives already typed, so no `z.coerce` is needed — 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 `ABIDE_CLIENT_TIMEOUT`.
59
69
 
60
70
  ## Sockets
61
71
 
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`.
72
+ A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>`; every socket multiplexes onto one WebSocket at `/__abide/sockets`.
63
73
 
64
74
  ```ts
65
75
  // src/server/sockets/chat.ts
@@ -67,163 +77,173 @@ import { socket } from '@abide/abide/server/socket'
67
77
  import { z } from 'zod'
68
78
 
69
79
  export const chat = socket({
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
80
+ schema: z.object({ id: z.string(), text: z.string() }),
81
+ tail: 50, // retain the last 50 frames for late joiners / reconnects
82
+ ttl: 60_000, // evict retained frames older than 60s before replay
73
83
  })
74
84
  ```
75
85
 
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.
86
+ The HTTP face is `/__abide/sockets/<name>`: `GET` returns the retained tail, `POST` publishes (gated by `clientPublish`).
77
87
 
78
88
  ## Components
79
89
 
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:
90
+ A `.abide` component is a single page that server-renders and hydrates. This one imports the RPC and socket above and exercises the whole grammar.
81
91
 
82
92
  ```html
83
93
  <script>
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'
94
+ import { getMessages } from '$server/rpc/getMessages.ts'
95
+ import { sendMessage } from '$server/rpc/sendMessage.ts'
96
+ import { chat } from '$server/sockets/chat.ts'
88
97
  import Card from '$ui/Card.abide'
89
- import Avatar from '$ui/Avatar.abide'
90
- import Message from '$ui/Message.abide'
91
98
  import { state } from '@abide/abide/ui/state'
92
99
  import { watch } from '@abide/abide/ui/watch'
93
100
  import { html } from '@abide/abide/ui/html'
94
101
  import { props } from '@abide/abide/ui/props'
95
102
 
96
- const { title = 'Chat' } = props()
103
+ const { room = 'general' } = props()
97
104
 
98
- let limit = state(20) // writable cell, read/reassigned as a plain variable
99
105
  let draft = state('')
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
106
+ let limit = state(20)
107
+ let live = state(true)
108
+ let tab = state('feed')
109
+ let unread = state(0)
104
110
 
105
- const rootAttrs = { role: 'log' }
106
- const extra = { compact: true }
107
- const get = () => draft.toUpperCase()
108
- const set = (next: string) => { draft = next }
111
+ // state.computed — read-only derived; state.linked — writable, reseeded from a thunk
112
+ let trimmed = state.computed(() => draft.trim())
113
+ let title = state.linked(() => `#${room}`)
109
114
 
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))
115
+ // watch — the single reaction primitive (client-only): a cell, then a socket
116
+ watch(trimmed, next => console.debug('draft', next))
117
+ watch(chat, frame => { unread += 1 })
113
118
 
114
- function focus(node: HTMLElement) { node.focus() }
115
- function connection() { return chat.pending() ? 'connecting' : 'open' }
116
- function risky() { return draft.at(999)!.length }
119
+ const badge = html`<sup>${unread}</sup>`
117
120
 
118
121
  async function send() {
119
- await sendMessage({ text: trimmed }) // a mutating RPC from an event handler
120
- draft = ''
122
+ if (!trimmed) return
123
+ await sendMessage({ text: draft }) // mutating RPC in an event handler
124
+ draft = ''
121
125
  }
126
+
127
+ // an attach directive: run against the element, return a teardown
128
+ function autofocus(node: HTMLElement) {
129
+ node.focus()
130
+ return () => {}
131
+ }
132
+
133
+ // the get/set pair for a derived two-way binding
134
+ function get() { return draft.toUpperCase() }
135
+ function set(next: string) { draft = next }
122
136
  </script>
123
137
 
124
- <section class="chat" class:empty={limit === 0} style:--rows={shown} {...rootAttrs}>
125
- <h1>{title}</h1>
126
-
127
- <!-- A bare read STREAMS: it peeks `undefined` while pending, so it composes
128
- with `??` and the probes. `await` is the marker for the other mode. -->
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>
133
-
134
- <!-- `{await}` means RESOLVED: the server blocks the flush and the client suspends
135
- this region until it settles, so the value is never pending at the read (no `?.`)
136
- — the region just shows nothing, then reveals. -->
137
- <small>today: {await countToday()}</small>
138
-
139
- <!-- `{#await}` is the explicit opt-in — a distinct pending branch and `{:then}` narrowing. -->
140
- {#await getMessages({ limit })}
141
- <p>loading…</p>
142
- {:then messages}
143
- {#if messages.length}
144
- {#for message, i of messages by message.id}
145
- <Message message={message} ondelete={() => sendMessage({ text: '' })} {...extra}>
146
- <Avatar alt={message.user} />
147
- </Message>
148
- {/for}
149
- {:else if limit > 0}
150
- <p>no messages yet</p>
151
- {:else}
152
- <p>raise the limit</p>
153
- {/if}
154
- {:catch err}
155
- <p>{err instanceof Error ? err.message : String(err)}</p>
156
- {:finally}
157
- <hr />
158
- {/await}
159
-
160
- <!-- live socket frames over an AsyncIterable -->
161
- <ul>
162
- {#for await frame of chat}
163
- <li>{frame.user}: {frame.text}</li>
164
- {/for}
165
- </ul>
166
-
167
- {#switch connection()}
168
- {:case 'open'}<span>live</span>
169
- {:case 'connecting'}<span>connecting…</span>
170
- {:default}<span>offline</span>
171
- {/switch}
172
-
173
- {#try}
174
- <p>{risky()}</p>
175
- {:catch e}
176
- <p>widget crashed: {e instanceof Error ? e.message : String(e)}</p>
177
- {:finally}
178
- <span>rendered</span>
179
- {/try}
180
-
181
- <form onsubmit={send}>
182
- <input name="text" bind:value={draft} />
183
- <label><input type="checkbox" bind:checked={notify} /> notify</label>
184
- <input type="radio" bind:group={channel} value="general" />
185
- <input aria-label="shout" bind:value={{ get, set }} />
186
- <button attach={focus}>send</button>
187
- </form>
188
-
189
- {#snippet stat(label: string, value: number)}
190
- <dd>{label}: {value}</dd>
191
- {/snippet}
192
- <dl>{stat('shown', limit)}</dl>
193
-
194
- <Card>
195
- <p>{html`<em>${trimmed}</em>`}</p>
196
- </Card>
197
-
198
- {#if limit > 10}
199
- <script>
200
- // a nested branch script: branch-local state, re-seeded per mount, no imports
201
- let expanded = state(false)
202
- const label = state.computed(() => (expanded ? 'less' : 'more'))
203
- </script>
204
- <button onclick={() => (expanded = !expanded)}>{label}</button>
205
- <style>
206
- button { font-weight: 600; }
207
- </style>
208
- {/if}
209
- </section>
138
+ <header class="row" class:live={live} style:opacity={live ? 1 : 0.5} {...{ id: 'top' }}>
139
+ <h1>{title} {badge}</h1>
140
+ <label><input type="checkbox" bind:checked={live} /> live</label>
141
+ </header>
142
+
143
+ <!-- bare async read: undefined while pending, composes with ?. and ?? -->
144
+ <p>{getMessages({ limit })?.length ?? 0} loaded</p>
145
+ {#if getMessages.pending()}<span>loading…</span>{/if}
146
+ {#if getMessages.error()}<span role="alert">failed</span>{/if}
147
+
148
+ <form onsubmit={send}>
149
+ <input name={room} bind:value={draft} attach={autofocus} />
150
+ <!-- derived two-way binding: { get, set } -->
151
+ <input bind:value={{ get, set }} />
152
+ <button type="submit" disabled={trimmed === ''}>Send</button>
153
+ </form>
154
+
155
+ <nav>
156
+ {#for name of ['feed', 'about']}<span>{name}</span>{/for}
157
+ <label><input type="radio" bind:group={tab} value="feed" /> feed</label>
158
+ <label><input type="radio" bind:group={tab} value="about" /> about</label>
159
+ </nav>
160
+
161
+ {#switch tab}
162
+ {:case 'feed'}
163
+ <ol>
164
+ {#for message, i of getMessages({ limit }) ?? [] by message.id}
165
+ <li style:--i={i}>{message.text}</li>
166
+ {/for}
167
+ </ol>
168
+ {:case 'about'}
169
+ <p>Room {room}.</p>
170
+ {:default}
171
+ <p>—</p>
172
+ {/switch}
173
+
174
+ <!-- inline await: blocks SSR until the value is in the initial HTML -->
175
+ <p>total: {await getMessages({ limit: 100 })?.length ?? 0}</p>
176
+
177
+ <!-- {#await} is the opt-in for a distinct pending branch and {:then} narrowing -->
178
+ {#await getMessages({ limit })}
179
+ <p>loading feed…</p>
180
+ {:then messages}
181
+ <p>{messages.length} messages</p>
182
+ {:catch failure}
183
+ <p>{failure instanceof Error ? failure.message : String(failure)}</p>
184
+ {:finally}
185
+ <hr />
186
+ {/await}
187
+
188
+ {#if live}
189
+ <script>
190
+ // a nested branch <script> — branch-local state, re-seeded per mount, no imports
191
+ let seen = state(0)
192
+ let plural = state.computed(() => (seen === 1 ? 'frame' : 'frames'))
193
+ </script>
194
+ <style>
195
+ /* nested <style> scopes to this branch's subtree */
196
+ p { font-variant-numeric: tabular-nums; }
197
+ </style>
198
+ <p>{seen} {plural} this session</p>
199
+ <ul>
200
+ {#for await frame of chat}
201
+ <li>{frame.text}</li>
202
+ {:catch streamError}
203
+ <li>{streamError instanceof Error ? streamError.message : String(streamError)}</li>
204
+ {/for}
205
+ </ul>
206
+ {:else if unread > 0}
207
+ <p>{unread} while paused</p>
208
+ {:else}
209
+ <p>paused</p>
210
+ {/if}
211
+
212
+ {#try}
213
+ <Card {...{ tone: 'accent' }} class:wide={live}>
214
+ <p>{html('<em>trusted</em>')}</p>
215
+ </Card>
216
+ {:catch renderError}
217
+ <p>card failed: {renderError instanceof Error ? renderError.message : String(renderError)}</p>
218
+ {:finally}
219
+ <footer>room {room}</footer>
220
+ {/try}
221
+
222
+ {#snippet row(message: { id: string; text: string })}
223
+ <li data-id={message.id}>{message.text}</li>
224
+ {/snippet}
225
+ <ul>{#if getMessages({ limit })}{row({ id: '0', text: 'pinned' })}{/if}</ul>
210
226
 
211
227
  <style>
212
- .chat { display: grid; gap: 0.5rem; }
228
+ /* a root <style> is component-scoped */
229
+ .row { display: flex; gap: 0.5rem; }
213
230
  </style>
214
231
  ```
215
232
 
216
- The child component fills the passed content at `{children()}`; `{#if children}…{:else}…{/if}` is the fallback (there is no `<slot>` element, no named slots):
233
+ The child component renders its passed content at `{children()}`, with a fallback:
217
234
 
218
235
  ```html
219
236
  <script>
220
237
  import { props } from '@abide/abide/ui/props'
221
- const { children } = props()
238
+ import type { Snippet } from '@abide/abide/shared/snippet'
239
+ const { tone = 'plain', children } = props<{ tone?: string; children?: Snippet }>()
222
240
  </script>
223
241
 
224
- <article class="card">
225
- {#if children}{children()}{:else}<p>empty</p>{/if}
226
- </article>
242
+ <section class:accent={tone === 'accent'}>
243
+ {#if children}{children()}{:else}<em>empty</em>{/if}
244
+ </section>
227
245
  ```
228
246
 
247
+ Async reads have no ceremony: the bare call in `{getMessages({ limit })}` is the way — a peek that reads `undefined` while pending and auto-streams on SSR, pairing with `.pending()` / `.error()` for affordances. Reach for `{#await}` only when you want an explicit pending branch, a local `{:catch}`, or `{:then}` narrowing.
248
+
229
249
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.54.0",
3
+ "version": "0.55.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",
@@ -86,7 +86,6 @@
86
86
  "./ui/exitRenderScope": "./src/lib/ui/exitScope.ts",
87
87
  "./ui/dom/mount": "./src/lib/ui/dom/mount.ts",
88
88
  "./ui/dom/mountChild": "./src/lib/ui/dom/mountChild.ts",
89
- "./ui/dom/mountStreamedChild": "./src/lib/ui/dom/mountStreamedChild.ts",
90
89
  "./ui/dom/mergeProps": "./src/lib/ui/dom/mergeProps.ts",
91
90
  "./ui/dom/spreadProps": "./src/lib/ui/dom/spreadProps.ts",
92
91
  "./ui/dom/restProps": "./src/lib/ui/dom/restProps.ts",
@@ -94,8 +93,8 @@
94
93
  "./ui/dom/bindableProp": "./src/lib/ui/dom/bindableProp.ts",
95
94
  "./ui/dom/spreadAttrs": "./src/lib/ui/dom/spreadAttrs.ts",
96
95
  "./ui/dom/readCall": "./src/lib/ui/dom/readCall.ts",
96
+ "./ui/dom/awaitSubject": "./src/lib/ui/dom/awaitSubject.ts",
97
97
  "./ui/dom/readCell": "./src/lib/ui/dom/readCell.ts",
98
- "./ui/dom/readCellBlocking": "./src/lib/ui/dom/readCellBlocking.ts",
99
98
  "./ui/dom/writeCell": "./src/lib/ui/dom/writeCell.ts",
100
99
  "./ui/dom/cellPending": "./src/lib/ui/dom/cellPending.ts",
101
100
  "./ui/runtime/withPath": "./src/lib/ui/runtime/withPath.ts",
@@ -94,7 +94,8 @@ export function defineRpc<Args, Return>(
94
94
  alongside the 200. Absent on the fail-open path — the surface then omits the error
95
95
  responses exactly as before. */
96
96
  errorJsonSchemas?: ErrorJsonSchemas
97
- /* Endpoint cache policy (ADR-0020) — read helpers only; readThrough's bottom layer. */
97
+ /* Endpoint cache policy (ADR-0020) — any method (a mutating rpc may cache too);
98
+ readThrough's bottom layer. */
98
99
  cache?: CachePolicy<Args>
99
100
  /* Endpoint stream policy (ADR-0020) — streaming read helpers only; replay depth `n`. */
100
101
  stream?: StreamPolicy
@@ -74,30 +74,33 @@ type RpcSchemas = {
74
74
 
75
75
  /*
76
76
  Options every rpc overload accepts: the `schemas` namespace, the `clients` surface
77
- flags (browser/mcp/cli), the same-origin
78
- CSRF exemption (`crossOrigin`), the pre-parse body-byte ceiling
79
- (`maxBodySize`), and the per-surface handler `timeout` (ms). Read helpers add the
80
- endpoint `cache`/`stream` policy (ADR-0020); mutating helpers add neither — a write has
81
- no replayable value to retain or stream. The kind-scoping is what makes `POST(fn, { cache })`
82
- a compile error rather than a silent no-op. The single canonical source both `defineRpc`
83
- and `RpcRegistryEntry` project from.
77
+ flags (browser/mcp/cli), the same-origin CSRF exemption (`crossOrigin`), the pre-parse
78
+ body-byte ceiling (`maxBodySize`), the per-surface handler `timeout` (ms), and the
79
+ endpoint `cache` policy (ADR-0020). `cache` is shared by both kinds: a mutating rpc
80
+ whose method is a transport choice (a POST that carries a large `code` payload in its
81
+ body yet is a pure function of its args) still coalesces/memoises like a read — the
82
+ runtime already routes `fn.cache` through `readThrough` regardless of method. What stays
83
+ read-only is `stream` (`RpcReadOpts`) — a write has no replayable value to stream.
84
+ Generic over `Args` so `cache.tags`'s `(args) => string[]` form is typed against the
85
+ rpc's own argument shape. The single canonical source both `defineRpc` and
86
+ `RpcRegistryEntry` project from.
84
87
  */
85
- type RpcSharedOpts = {
88
+ type RpcSharedOpts<Args> = {
86
89
  schemas?: RpcSchemas
87
90
  clients?: Partial<ClientFlags>
88
91
  crossOrigin?: boolean
89
92
  maxBodySize?: number
90
93
  timeout?: number
94
+ cache?: CachePolicy<Args>
91
95
  }
92
96
 
93
97
  /*
94
- Read-helper (GET/HEAD) options: the shared base plus the endpoint `cache` policy
95
- (`ttl`/`tags`/`throttle`/`debounce`/`shared`) and `stream` policy (`n`, replay depth).
96
- Generic over `Args` so `cache.tags`'s `(args) => string[]` form is typed against the
97
- rpc's own argument shape.
98
+ Read-helper (GET/HEAD) options: the shared base (including `cache`) plus the endpoint
99
+ `stream` policy (`n`, replay depth) — the one option kind-scoped to replayable reads,
100
+ since a write has no replayable stream. Generic over `Args`, threaded into the shared
101
+ base so `cache.tags`'s `(args) => string[]` form is typed against the rpc's own args.
98
102
  */
99
- type RpcReadOpts<Args> = RpcSharedOpts & {
100
- cache?: CachePolicy<Args>
103
+ type RpcReadOpts<Args> = RpcSharedOpts<Args> & {
101
104
  stream?: StreamPolicy
102
105
  }
103
106
 
@@ -147,11 +150,15 @@ export type RpcHelper = {
147
150
  }
148
151
 
149
152
  /*
150
- The mutating helpers (POST/PUT/PATCH/DELETE). The base opts is the shared `RpcSharedOpts`
151
- (no `cache`/`stream`) — coalesce-only is the method default and a write has no replayable
152
- value to retain or stream — which is what makes `cache`/`stream` a compile error here while
153
- they stay legal on the read helpers (`RpcReadOpts`) — the kind-scoping. Overloads mirror the read helpers by argument source
154
- (multipart-upload, schema'd, schemaless-with-opts, bare).
153
+ The mutating helpers (POST/PUT/PATCH/DELETE). The base opts is the shared `RpcSharedOpts`,
154
+ which carries `cache` — a mutating rpc still accepts a cache policy (a POST that is a pure
155
+ function of its args, using the body only to carry a large payload, coalesces/memoises like
156
+ a read; the runtime honours `fn.cache` regardless of method). Note the method default for a
157
+ write stays coalesce-only: a no-ttl write is dropped on settle (the mutation idiom), so
158
+ retention across the in-flight window needs an explicit `cache.ttl` (or `shared`). Only
159
+ `stream` stays read-only (`RpcReadOpts`) — a write has no replayable stream — so `POST(fn,
160
+ { stream })` remains a compile error while `POST(fn, { cache })` is legal. Overloads mirror
161
+ the read helpers by argument source (multipart-upload, schema'd, schemaless-with-opts, bare).
155
162
  */
156
163
  export type MutatingRpcHelper = {
157
164
  <
@@ -163,16 +170,16 @@ export type MutatingRpcHelper = {
163
170
  args: StandardSchemaV1.InferOutput<InputSchema> &
164
171
  StandardSchemaV1.InferOutput<FilesSchema>,
165
172
  ) => R | Promise<R>,
166
- opts: RpcSharedOpts & {
173
+ opts: RpcSharedOpts<StandardSchemaV1.InferInput<InputSchema>> & {
167
174
  schemas: { input: InputSchema; files: FilesSchema; output?: StandardSchemaV1 }
168
175
  },
169
176
  ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, SuccessBody<R>, InferredErrors<R>>
170
177
  <R extends Response, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
171
178
  fn: (args: StandardSchemaV1.InferOutput<InputSchema>) => R | Promise<R>,
172
- opts: RpcSharedOpts & {
179
+ opts: RpcSharedOpts<StandardSchemaV1.InferInput<InputSchema>> & {
173
180
  schemas: { input: InputSchema; output?: StandardSchemaV1 }
174
181
  },
175
182
  ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, SuccessBody<R>, InferredErrors<R>>
176
- <F extends RpcFn>(fn: F, opts: RpcSharedOpts): RpcOf<F>
183
+ <F extends RpcFn>(fn: F, opts: RpcSharedOpts<RpcArgs<F>>): RpcOf<F>
177
184
  <F extends RpcFn>(fn: F): RpcOf<F>
178
185
  }