@abide/abide 0.53.0 → 0.54.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 (56) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +1 -1
  4. package/package.json +2 -2
  5. package/src/checkAbide.ts +1 -1
  6. package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
  7. package/src/lib/server/runtime/createUiPageRenderer.ts +26 -0
  8. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  9. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  10. package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
  11. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  12. package/src/lib/server/sockets/defineSocket.ts +11 -1
  13. package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
  14. package/src/lib/shared/SOCKET_SEED.ts +13 -0
  15. package/src/lib/shared/amend.ts +48 -0
  16. package/src/lib/shared/amendBroadcastSlot.ts +19 -0
  17. package/src/lib/shared/applyAmendLocally.ts +20 -0
  18. package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
  19. package/src/lib/shared/buildSocketOverChannel.ts +9 -2
  20. package/src/lib/shared/cache.ts +35 -10
  21. package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
  22. package/src/lib/shared/createCacheStore.ts +8 -0
  23. package/src/lib/shared/peek.ts +1 -1
  24. package/src/lib/shared/socketTailsSlot.ts +13 -0
  25. package/src/lib/shared/types/AmendApply.ts +15 -0
  26. package/src/lib/shared/types/CacheReaderHook.ts +11 -0
  27. package/src/lib/shared/types/RemoteFunction.ts +30 -19
  28. package/src/lib/shared/types/SocketTails.ts +13 -0
  29. package/src/lib/shared/types/SsrPayload.ts +6 -0
  30. package/src/lib/test/createTestApp.ts +6 -0
  31. package/src/lib/ui/amendReaderHook.ts +125 -0
  32. package/src/lib/ui/compile/cachedShadowProgram.ts +38 -0
  33. package/src/lib/ui/compile/compileComponent.ts +9 -2
  34. package/src/lib/ui/compile/desugarSignals.ts +68 -3
  35. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  36. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  37. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  38. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  39. package/src/lib/ui/compile/renameSignalRefs.ts +18 -24
  40. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
  41. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  42. package/src/lib/ui/dom/awaitBlock.ts +1 -1
  43. package/src/lib/ui/dom/tryBlock.ts +1 -1
  44. package/src/lib/ui/router.ts +8 -0
  45. package/src/lib/ui/runtime/createDoc.ts +5 -1
  46. package/src/lib/ui/runtime/restoreWarmSeeds.ts +20 -0
  47. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  48. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  49. package/src/lib/ui/runtime/warmSeedBackup.ts +10 -0
  50. package/src/lib/ui/socketProxy.ts +17 -1
  51. package/src/lib/ui/startClient.ts +30 -0
  52. package/src/lib/ui/types/Scope.ts +1 -1
  53. package/src/lib/ui/watch.ts +18 -5
  54. package/src/serverEntry.ts +15 -0
  55. package/src/lib/shared/patch.ts +0 -41
  56. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
package/AGENTS.md CHANGED
@@ -100,11 +100,11 @@ For tests, add `preload = ["@abide/abide/preload"]` under `[test]` in
100
100
  - **Consume forms** — the **bare call `fn(args)` is the smart read** (cached,
101
101
  coalesced, reactive; in-process during SSR and baked into the HTML for warm
102
102
  hydration — there is no `cache()` wrapper). Around it: `fn.raw(args, opts?)`
103
- (raw `Response`), `fn.refresh` / `fn.invalidate` / `fn.patch` / `fn.peek` /
103
+ (raw `Response`), `fn.refresh` / `fn.invalidate` / `fn.amend` / `fn.peek` /
104
104
  `fn.pending` / `fn.refreshing` / `fn.error` / `fn.watch` / `fn.isError`. A
105
105
  streaming handler (`jsonl`/`sse`) makes the bare call return a
106
106
  `NamedAsyncIterable<Frame>` (the iterable IS the value; `await`-ing it is a
107
- compile error; `patch` is dropped).
107
+ compile error; `amend` is dropped).
108
108
  - **Socket** (`src/server/sockets/<name>.ts`) — `export const x =
109
109
  socket<T>(opts?)`. `opts`: `tail` (retention depth; defaults to keeping the last
110
110
  frame), `ttl` (per-frame eviction ms), `clientPublish` (accept `pub` frames from
@@ -291,9 +291,13 @@ from the server.
291
291
  - `abide/shared/invalidate` — `invalidate(selector?, args?)`: the drop verb — drop
292
292
  matching cached reads so the next read reloads lazily. Selector grammar:
293
293
  `(fn, args)` / `(fn)` / `({ tags })` / `()`.
294
- - `abide/shared/patch` — `patch(fn, args?, updater)` / `patch({ tags }, updater)`:
295
- mutate the retained value of matching reads in place, reactive, no network (the
296
- optimistic-update / socket-frame primitive).
294
+ - `abide/shared/amend` — `amend(fn, args?, value | updater)` / `amend({ tags }, )`:
295
+ set the retained value of matching reads in place (a concrete `Return`) or transform
296
+ it (`updater`), reactive, no network — the optimistic-update / socket-frame primitive.
297
+ Isomorphic like `invalidate`/`refresh` (ADR-0043): the value form applies locally on
298
+ the client but from the server broadcasts the keyed value to every client reading that
299
+ exact call (a push-refresh — zero refetches); the updater form is client-local (a
300
+ closure can't broadcast). For a no-input rpc the args collapse (`fn.amend(value)`).
297
301
  - `abide/shared/refresh` — `refresh(selector?, args?)`: refetch matching reads now,
298
302
  keeping the stale value visible (`refreshing()` true) until the fresh one swaps in.
299
303
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # abide
2
2
 
3
+ ## 0.54.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7cd75d7: `amend` is now isomorphic like `invalidate`/`refresh` (ADR-0043): called on the client it mutates the local cache, but called on the **server** with a concrete value it broadcasts that value to every client reading the call — a push-refresh with zero refetches.
8
+
9
+ `amend` gains a value form alongside the updater form. The last argument may be a concrete `Return` (set it) or an updater `(current) => Return` (transform it); for a no-input rpc the args collapse (`getFoo.amend(value)`), matching how `fn()` vs `fn(args)` already read. From the server, only the value form broadcasts — an updater is a closure with no wire form and throws, and the value must target a keyed remote call (a producer/`{ tags }` selector throws). Delivery is confined to clients already reading that exact call (over a per-call reserved `__abide/amend/<key>` topic), so a pushed value reaches only readers already authorized for that key; a dropped frame heals on the next reconnect refresh. The updater form and client-side `amend` are unchanged.
10
+
11
+ - 42354ba: A bare `state.computed(getFoo())` over an rpc now unwraps to the resolved value, so `{foo?.field}` renders and reactively updates — the pattern the docs and examples already teach.
12
+
13
+ Previously a no-`await` `state.computed(getFoo())` was classified as an opaque `Computed<Promise>` (ADR-0023 routing), so a template read like `{foo?.messages}` read `.messages` off a Promise and always showed `undefined`, with no cache reactivity reaching it. Now `getFoo()` and `await getFoo()` inside `state.computed(...)` differ only by SSR tier and are both unwrapped value cells (ADR-0045):
14
+
15
+ - `state.computed(getFoo())` — **streaming**: SSR ships the pending branch, the client resolves after hydration.
16
+ - `state.computed(await getFoo())` — **blocking**: SSR waits and bakes the value into the HTML (warm hydrate, never pending).
17
+
18
+ **Breaking behavior change:** `foo` is now a reactive value cell in both forms, never a `Promise`. Code that treated the bare binding as a thenable — `state.computed(getFoo()).then(…)` or `await foo` — no longer gets the fetch promise (it gets the unwrapped value, or `undefined` while pending). For an imperative one-off, call `await getFoo()` directly (cached/coalesced); to render with an await block use `{#await getFoo()}` inline; for a genuinely opaque `Computed<Promise>` binding use an explicit thunk, `state.computed(() => getFoo())`. `{#await foo}` on a bare computed still works (it reads the async-cell subject reactively).
19
+
20
+ - 7bc2bfb: Rename the cache-mutation verb `patch` → `amend`. The word "patch" collided with the HTTP `PATCH` method (`abide/server/PATCH`) and the internal DOM-tree `Patch` type; `amend` names the same intent — mutate the retained value of matching cached reads in place, reactive, no network — without the overload.
21
+
22
+ **Breaking:**
23
+
24
+ - **`abide/shared/patch` is now `abide/shared/amend`.** The standalone `patch(fn, args?, updater)` / `patch({ tags }, updater)` is now `amend(...)` with identical signatures.
25
+ - **The rpc selector method `fn.patch(...)` is now `fn.amend(...)`.** `fn.amend(args?, updater)` ≡ `amend(fn, args, updater)`. Still fetch-only (omitted for a streaming rpc).
26
+
27
+ Mechanical rename — no behavior change. Update imports (`abide/shared/patch` → `abide/shared/amend`) and call sites (`.patch(` → `.amend(`). The HTTP `PATCH` method export is unaffected.
28
+
29
+ - 88959d7: `watch(source, handler)` now runs the handler untracked — the named source(s) are the sole triggers (ADR-0044).
30
+
31
+ Previously the two cell forms (`watch(cell, h)` / `watch([a, b], h)`) wrapped the handler in an effect that captured _every_ reactive read in the handler body, so `watch(foo, () => { id = bar.id })` silently re-ran on `bar.id` as well as `foo`. The handler is now a sink: reactive reads inside it no longer become extra triggers. This makes the cell forms match the socket and rpc forms, whose handlers already ran outside the tracking window. Only the bare `watch(() => …)` binding form still auto-tracks everything it reads. If you relied on a handler-body read re-triggering the watch, name it as a source instead (`watch([foo, bar], …)`) or use the auto-track form (`watch(() => { id = bar.id })`).
32
+
33
+ ### Patch Changes
34
+
35
+ - dc4ddfc: normalize pre-existing format drift ([`757f142`](https://github.com/briancray/abide/commit/757f1420b4e37064da1b5048d2f5d3b1a556b6fe))
36
+ - 11965e9: Fix the JS semantics of reactive-state mutation expressions so `{ }` code behaves like plain TypeScript.
37
+
38
+ - **Logical assignments now short-circuit.** `count ??= 5` / `x ||= v` / `x &&= v` on reactive state (`state` slot or `linked` cell) now write **only when the guard passes** — `count ??= 5` no longer fires a redundant patch (and reseed) when `count` is already non-nullish. Previously every logical assignment lowered to an unconditional write.
39
+ - **Postfix `x++` / `x--` now evaluates to the previous value** (and prefix `++x` to the new value), matching JS. Previously postfix returned the stepped value.
40
+ - As a consequence, reactive writes now evaluate to the written value, so chained assignment (`a = b = count`) through a state slot works too.
41
+
42
+ Internally, `Cell.set` and `Doc.replace` now return the written value, and the two lowering sites share `lowerCompoundAssignment` / `lowerUpdateExpression` helpers so they can't drift.
43
+
44
+ - 9773885: Warn when the type program for a project can't be built, instead of silently disabling async detection.
45
+
46
+ The type-directed async lowering (deciding whether `{getFoo()}` is a promise/stream and streaming it) depends on a warm shadow program built once per project root. If that program failed to build (e.g. a broken `tsconfig`), the failure was swallowed and **every** async interpolation in the project silently degraded to plain text — a bare `{getFoo()}` shipping as the literal `[object Promise]` with no signal. It now emits a one-time `console.warn` per root naming the cause, so a build with async detection disabled is visible. Behavior is otherwise unchanged (the build still never breaks on a program-build failure).
47
+
48
+ - 0d9316e: `watch(cell.foo, handler)` is now a compile error instead of a silent no-op.
49
+
50
+ abide has no per-property cells — a member access on a cell is read once as a plain value, so `watch(s.foo, handler)` subscribed to nothing and the handler silently never fired. It was only flagged by a `console.warn` (easily lost in build output). It now fails compilation with a message pointing at the corrective forms: wrap it in a thunk (`watch(() => …(s.foo))`) or watch the whole cell (`watch(s, v => …)`). A member of a cell is never itself a cell, so this can never reject a valid `watch`.
51
+
52
+ - 66e5d3b: Fix a hydration desync (and the dead page it could cascade into) when a render reads a `tail`-retaining socket's `peek(socket)`. At SSR the server holds the socket's retained frame so `peek` returns it; a not-yet-connected client's `peek` returned `undefined`, so any markup derived from it (e.g. `state.computed(() => peek(refreshStatus))`) disagreed with the server HTML and tripped a hydration mismatch — which discards the server markup and cold-renders.
53
+
54
+ Sockets now warm-seed their retained frame across SSR→client like async cells do: the server records each frame read via `peek(socket)` during the render, ships it in `__SSR__.sockets` keyed by socket name, and the client seeds the socket's latest frame before mount so `peek(socket)` returns the same value the server rendered. (Contrast `cache.peek`, which withholds on the client because the server materializes no cache value — sockets carry a real server value forward, so they seed instead.)
55
+
56
+ Also hardens the hydration-desync recovery: the discard→cold-rebuild path now re-primes the async-cell and doc-state warm-seed manifests the failed hydration pass consumed, so the cold rebuild re-adopts the SSR-resolved values instead of refetching. A cold refetch left blocking `await` cells pending, and a top-level blocking read sits in no suspense region, so its `SuspenseSignal` escaped the rebuild and killed the mount ("page threw while mounting"). The rebuild now reads settled — a live page with real data, no loading flash.
57
+
58
+ No public API change.
59
+
3
60
  ## 0.53.0
4
61
 
5
62
  ### Minor Changes
package/README.md CHANGED
@@ -53,7 +53,7 @@ One declaration branches to every surface:
53
53
 
54
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 }`.
55
55
 
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`.
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`.
57
57
 
58
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`.
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.53.0",
3
+ "version": "0.54.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",
@@ -60,7 +60,7 @@
60
60
  "./ui/watch": "./src/lib/ui/watch.ts",
61
61
  "./shared/pending": "./src/lib/shared/pending.ts",
62
62
  "./shared/invalidate": "./src/lib/shared/invalidate.ts",
63
- "./shared/patch": "./src/lib/shared/patch.ts",
63
+ "./shared/amend": "./src/lib/shared/amend.ts",
64
64
  "./shared/peek": "./src/lib/shared/peek.ts",
65
65
  "./shared/refresh": "./src/lib/shared/refresh.ts",
66
66
  "./shared/refreshing": "./src/lib/shared/refreshing.ts",
package/src/checkAbide.ts CHANGED
@@ -78,7 +78,7 @@ function collectByProject(cwd: string): { diagnostics: AbideDiagnostic[]; checke
78
78
  )
79
79
  /* The shadow program already holds the project's real `.ts` files (loaded so the
80
80
  components' imports/types resolve), but `collectAbideDiagnostics` only reports the
81
- `.abide` shadows. Report the `.ts` files too, so a mistyped `navigate`/`url`/`patch`
81
+ `.abide` shadows. Report the `.ts` files too, so a mistyped `navigate`/`url`/`amend`
82
82
  call — or any type error — in an rpc handler, `app.ts`, or a `$shared` helper fails
83
83
  `abide check` instead of only surfacing under a separately-run `tsc`. */
84
84
  return [...collectAbideDiagnostics(shadow), ...collectTsDiagnostics(shadow.program)]
@@ -0,0 +1,53 @@
1
+ import { AMEND_TOPIC_PREFIX } from '../../shared/AMEND_TOPIC_PREFIX.ts'
2
+ import { encodeRefJson } from '../../shared/encodeRefJson.ts'
3
+ import { keyForRemoteCall } from '../../shared/keyForRemoteCall.ts'
4
+ import { REMOTE_FUNCTION } from '../../shared/REMOTE_FUNCTION.ts'
5
+ import type { CacheSelector } from '../../shared/types/CacheSelector.ts'
6
+ import type { RawRemoteFunction } from '../../shared/types/RawRemoteFunction.ts'
7
+ import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
8
+ import { getActiveServer } from './getActiveServer.ts'
9
+
10
+ /*
11
+ The server half of the isomorphic amend value form (ADR-0043): serialize the keyed
12
+ value to a standard socket `msg` frame and publish it on the per-call reserved topic
13
+ `__abide/amend/<key>`, so every browser with a live reader of that exact call (and thus
14
+ already authorized for it) sets the pushed value locally with no refetch. Installed as
15
+ the amendBroadcastSlot resolver by serverEntry ONLY — never imported from a shared or ui
16
+ module — so this server socket code stays out of the client reachability graph; the
17
+ resolver-slot indirection is the guarantee, not the ADR-0022 DCE guard (which polices the
18
+ app's own src/server edge, not abide's internal lib/server modules). Do not import this
19
+ from a client-reachable module.
20
+
21
+ Only the value form broadcasts: an updater is a closure with no wire form, so it throws
22
+ here (the mistake surfaces at the mutation site). The value must target an exact call — a
23
+ remote function with its args, or a no-input rpc's single key — since a value has to land
24
+ on one entry; a producer/closure or `{ tags }` selector has no single cross-client key and
25
+ throws. The rpc's own key derivation (keyForRemoteCall) names the topic, so it agrees with
26
+ the client cache key by construction. Subscriber-gated: skip the encode + native publish
27
+ when no client is reading that key (the topic evaporates — it holds no tail).
28
+ */
29
+ export function broadcastAmend<Args, Return>(
30
+ selector: CacheSelector<Args, Return>,
31
+ args: Args | undefined,
32
+ isValue: boolean,
33
+ payload: Return | ((current: Return) => Return),
34
+ ): void {
35
+ if (!isValue) {
36
+ throw new Error(
37
+ '[abide] amend() with an updater is client-local (a closure has no wire form) — pass a value to broadcast from the server.',
38
+ )
39
+ }
40
+ if (typeof selector !== 'function' || !(REMOTE_FUNCTION in selector)) {
41
+ throw new Error(
42
+ '[abide] amend(value) can only broadcast for a remote function — a producer/closure or { tags } selector has no cross-client key.',
43
+ )
44
+ }
45
+ const remote = selector as RawRemoteFunction<Args>
46
+ const topic = `${AMEND_TOPIC_PREFIX}${keyForRemoteCall(remote.method, remote.url, args)}`
47
+ const server = getActiveServer()
48
+ if (server === undefined || server.subscriberCount(`socket:${topic}`) === 0) {
49
+ return
50
+ }
51
+ const frame: SocketServerFrame = { type: 'msg', socket: topic, message: payload }
52
+ server.publish(`socket:${topic}`, encodeRefJson(frame))
53
+ }
@@ -7,6 +7,7 @@ import { hasSeedableRequest } from '../../shared/hasSeedableRequest.ts'
7
7
  import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
8
8
  import { resolvedCellsSlot } from '../../shared/resolvedCellsSlot.ts'
9
9
  import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
10
+ import { socketTailsSlot } from '../../shared/socketTailsSlot.ts'
10
11
  import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
11
12
  import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
12
13
  import type { SsrPayload } from '../../shared/types/SsrPayload.ts'
@@ -161,6 +162,30 @@ export function createUiPageRenderer({
161
162
  return Object.keys(docs).length > 0 ? docs : undefined
162
163
  }
163
164
 
165
+ /* The `sockets` partition of __SSR__: each socket whose retained frame this render read via
166
+ `peek(socket)` (drained from the request-scoped `socketTailsSlot` populated by
167
+ `defineSocket.peek`), keyed by socket NAME, ref-json-encoded. Last write per name wins — a
168
+ socket peeked twice ships its latest read. An unserializable frame is dropped (a warn): that
169
+ socket falls back to a cold client peek (undefined) rather than blanking the payload. Undefined
170
+ when no socket was peeked during the render (the common page). */
171
+ function socketTailSnapshots(): Record<string, string> | undefined {
172
+ const entries = socketTailsSlot.get()?.entries ?? []
173
+ if (entries.length === 0) {
174
+ return undefined
175
+ }
176
+ const sockets: Record<string, string> = {}
177
+ for (const { name, value } of entries) {
178
+ try {
179
+ sockets[name] = encodeRefJson(value)
180
+ } catch {
181
+ console.warn(
182
+ `[abide] socket "${name}" retained frame is unserializable — its client peek() will read undefined at hydration instead of the server value.`,
183
+ )
184
+ }
185
+ }
186
+ return Object.keys(sockets).length > 0 ? sockets : undefined
187
+ }
188
+
164
189
  /* Build the __SSR__ <script> the client (startClient) reads on boot. The inline
165
190
  (settled) cache partition is computed once by the caller and threaded in, so the
166
191
  streaming branch can also drain the pending partition over the same render. */
@@ -177,6 +202,7 @@ export function createUiPageRenderer({
177
202
  cache: inline,
178
203
  cells: resolvedCellCells(),
179
204
  docs: docSeedSnapshots(),
205
+ sockets: socketTailSnapshots(),
180
206
  base: base || undefined,
181
207
  trace: formatTraceparent(store.trace),
182
208
  app: appNameSlot.name,
@@ -34,6 +34,7 @@ export function runWithRequestScope(
34
34
  pendingAsyncCells: { promises: [] },
35
35
  resolvedCells: { entries: [] },
36
36
  streamedCells: { entries: [] },
37
+ socketTails: { entries: [] },
37
38
  docSnapshots: { entries: [] },
38
39
  trace: createTraceContext(req.headers.get('traceparent')),
39
40
  start: Bun.nanoseconds(),
@@ -2,6 +2,7 @@ import type { CacheStore } from '../../../shared/types/CacheStore.ts'
2
2
  import type { DocSnapshots } from '../../../shared/types/DocSnapshots.ts'
3
3
  import type { PendingAsyncCells } from '../../../shared/types/PendingAsyncCells.ts'
4
4
  import type { ResolvedCells } from '../../../shared/types/ResolvedCells.ts'
5
+ import type { SocketTails } from '../../../shared/types/SocketTails.ts'
5
6
  import type { StreamedCells } from '../../../shared/types/StreamedCells.ts'
6
7
  import type { TraceContext } from '../../../shared/types/TraceContext.ts'
7
8
  import type { Scope } from '../../../ui/types/Scope.ts'
@@ -42,6 +43,14 @@ export type RequestStore = {
42
43
  */
43
44
  streamedCells: StreamedCells
44
45
  /*
46
+ Retained socket frames read via `peek(socket)` during this request's SSR pass, keyed by socket
47
+ name. `defineSocket`'s server `peek()` pushes each read; the page renderer stamps them into
48
+ `__SSR__.sockets` (ref-json) so the client seeds `peek(socket)` to the SAME retained value the
49
+ server rendered instead of `undefined` on the not-yet-connected client. Sibling of `resolvedCells`
50
+ — a server value carried forward, read at render-return, not awaited.
51
+ */
52
+ socketTails: SocketTails
53
+ /*
45
54
  Reactive-document snapshots captured during this request's SSR pass, keyed by render-path id.
46
55
  `createScope` registers a lazy `take` for each rendered scope; the page renderer stamps the
47
56
  non-empty ones into `__SSR__.docs` (ref-json) so the client seeds a plain `state(initial)` to
@@ -0,0 +1,39 @@
1
+ import { browserClientFlags } from '../../shared/browserClientFlags.ts'
2
+ import type { Socket } from '../../shared/types/Socket.ts'
3
+ import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
4
+
5
+ /* An empty async iterable — the amend channel replays nothing (live-only, no tail). */
6
+ const emptyIterable = {
7
+ [Symbol.asyncIterator]: () => ({
8
+ next: async () => ({ done: true as const, value: undefined }),
9
+ }),
10
+ }
11
+
12
+ /*
13
+ The single synthetic registry entry the dispatcher resolves every `__abide/amend/<key>`
14
+ topic to (ADR-0043). The amend value channel is a per-call reserved topic family: the
15
+ per-key Bun topic is the sub frame's own socket name, so one shared entry serves the whole
16
+ family — there is no per-key socket object or module to load. It is subscribe-only
17
+ (clientPublish false, so a browser can't forge a push), holds no tail (snapshotTail empty —
18
+ a missed frame heals via the reader's reconnect refresh), and is browser-only, so the REST
19
+ face 404s. The stub Socket methods are never reached: the ws sub path subscribes the raw
20
+ Bun topic and replays via snapshotTail, and the REST face is gated off by the clients flags.
21
+ */
22
+ const socket = {
23
+ name: '__abide/amend',
24
+ clients: browserClientFlags,
25
+ publish: () => undefined,
26
+ tail: () => emptyIterable,
27
+ peek: () => undefined,
28
+ refresh: () => undefined,
29
+ watch: () => () => undefined,
30
+ [Symbol.asyncIterator]: emptyIterable[Symbol.asyncIterator],
31
+ } as unknown as Socket<unknown>
32
+
33
+ export const amendFamilyEntry: SocketRegistryEntry = {
34
+ socket,
35
+ allowClientPublish: false,
36
+ schema: undefined,
37
+ clients: browserClientFlags,
38
+ snapshotTail: () => [],
39
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ServerWebSocket } from 'bun'
2
+ import { AMEND_TOPIC_PREFIX } from '../../shared/AMEND_TOPIC_PREFIX.ts'
2
3
  import { abideLog } from '../../shared/abideLog.ts'
3
4
  import { decodeWireBody } from '../../shared/decodeWireBody.ts'
4
5
  import { encodeRefJson } from '../../shared/encodeRefJson.ts'
@@ -10,6 +11,7 @@ import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
10
11
  import { error } from '../error.ts'
11
12
  import { json } from '../json.ts'
12
13
  import { sse } from '../sse.ts'
14
+ import { amendFamilyEntry } from './amendFamilyEntry.ts'
13
15
  import { lookupSocket } from './lookupSocket.ts'
14
16
  import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
15
17
  import type { SocketRoutes } from './types/SocketRoutes.ts'
@@ -87,6 +89,13 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
87
89
  | { entry: SocketRegistryEntry }
88
90
  | { failure: 'unregistered' | 'load-failed' | 'no-export'; message: string }
89
91
  > {
92
+ /* The amend value channel (ADR-0043) is a per-call reserved topic family: one synthetic,
93
+ subscribe-only entry serves every `__abide/amend/<key>` topic — the per-key Bun topic is
94
+ the sub frame's own name, and there is no module to load or tail to replay. Checked
95
+ before the generic reserved lookup, which would miss (no per-key registry entry). */
96
+ if (name.startsWith(AMEND_TOPIC_PREFIX)) {
97
+ return { entry: amendFamilyEntry }
98
+ }
90
99
  /* A reserved `__abide/` topic has no user module to load — it is minted at boot into
91
100
  the registry (ADR-0041), so skip the loader and read it straight from there. */
92
101
  if (name.startsWith(RESERVED_SOCKET_PREFIX)) {
@@ -2,6 +2,7 @@ import { attachSocketSelectorMethods } from '../../shared/attachSocketSelectorMe
2
2
  import { createPushIterator } from '../../shared/createPushIterator.ts'
3
3
  import { encodeRefJson } from '../../shared/encodeRefJson.ts'
4
4
  import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
5
+ import { socketTailsSlot } from '../../shared/socketTailsSlot.ts'
5
6
  import { socketTapSlot } from '../../shared/socketTapSlot.ts'
6
7
  import type { Socket } from '../../shared/types/Socket.ts'
7
8
  import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
@@ -185,7 +186,16 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
185
186
  /* The latest retained frame (after ttl pruning), or undefined when none is held. */
186
187
  peek: () => {
187
188
  pruneExpired(Date.now())
188
- return buffer.length > 0 ? (buffer[buffer.length - 1] as BufferEntry).value : undefined
189
+ const value =
190
+ buffer.length > 0 ? (buffer[buffer.length - 1] as BufferEntry).value : undefined
191
+ /* Record the retained frame this SSR render read so the page renderer can ship it in
192
+ `__SSR__.sockets` — the client seeds `peek()` to the same value instead of undefined,
193
+ keeping hydration congruent. `get()` is undefined off-request (ws/boot), so this is a
194
+ no-op outside an SSR render; a `tail: 0` socket holds no frame and records nothing. */
195
+ if (value !== undefined) {
196
+ socketTailsSlot.get()?.entries.push({ name, value })
197
+ }
198
+ return value
189
199
  },
190
200
  /* No-op on the server: it is the source of truth, so there is nothing to re-pull. */
191
201
  refresh: () => undefined,
@@ -0,0 +1,9 @@
1
+ /*
2
+ Reserved topic-family prefix for the per-call amend value channel (ADR-0043). A
3
+ server-side `amend(args, value)` broadcasts on `${AMEND_TOPIC_PREFIX}${cacheKey}`, and
4
+ a client with a live reader of that exact call subscribes to the same topic — so a
5
+ pushed value only ever reaches a browser already reading (and therefore authorized
6
+ for) that key. Under the reserved `__abide/` namespace so user code can't shadow it,
7
+ and per-call (not per-rpc) so the subscription set doubles as the authorized-reader set.
8
+ */
9
+ export const AMEND_TOPIC_PREFIX = '__abide/amend/'
@@ -0,0 +1,13 @@
1
+ /* The socket warm-seed manifest: a socket's retained latest frame as read via `peek(socket)` during
2
+ SSR, keyed by the socket's name, as a ref-json-encoded STRING (decoded at the read site in
3
+ `socketProxy`). `startClient` drains `__SSR__.sockets` into here before mount; a hydrating
4
+ `socketProxy` reads its key to seed the socket's `lastFrame` WARM, so `peek(socket)` returns the
5
+ SAME retained value the server rendered instead of `undefined` on the not-yet-connected client —
6
+ killing the SSR/hydration divergence that would otherwise discard the server markup and cold-render.
7
+ Backed by `globalThis.__abideSockets` so an inline pre-bundle script and the framework share one
8
+ store — whoever runs first creates it, the other adopts the same reference (mirrors CELL_SEED). */
9
+ const globalScope = globalThis as { __abideSockets?: Record<string, string> }
10
+ globalScope.__abideSockets ??= {}
11
+
12
+ // @documentation plumbing
13
+ export const SOCKET_SEED: Record<string, string> = globalScope.__abideSockets
@@ -0,0 +1,48 @@
1
+ import { amendBroadcastSlot } from './amendBroadcastSlot.ts'
2
+ import type { CacheOptions } from './types/CacheOptions.ts'
3
+ import type { CacheSelector } from './types/CacheSelector.ts'
4
+ import type { RemoteFunction } from './types/RemoteFunction.ts'
5
+
6
+ /*
7
+ Mutate the retained value of the matching cached read(s) in place — reactive
8
+ (readers re-render), no network. The optimistic-update / real-time primitive:
9
+ amend a cached list from a socket frame (`on(chat, m => getList.amend(l => [...l,
10
+ m]))`) or apply an optimistic edit before a write lands. The last argument is
11
+ either a concrete `Return` value (set it) or an updater `(current) => Return`
12
+ (transform the current decoded value). The value form is what a server-side
13
+ `amend(args, value)` broadcasts (ADR-0043 phase 2); the updater form is a closure
14
+ and stays local.
15
+
16
+ amend(getFoo, args, value | updater) → that exact call
17
+ amend(getFoo, value | updater) → every args-variant of that rpc
18
+ amend({ tags }, value | updater) → every entry sharing a tag
19
+
20
+ Instance sugar `getFoo.amend(args?, value | updater)` ≡ `amend(getFoo, args, …)`;
21
+ for a no-input rpc the args collapse away (`getFoo.amend(value)` — ADR-0043). The
22
+ value/updater is always the last argument; a not-yet-read key has nothing to amend.
23
+ */
24
+ // @documentation cache
25
+ export function amend<Args, Return>(
26
+ fn: RemoteFunction<Args, Return> | ((args?: Args) => Promise<Return>),
27
+ args: Args | undefined,
28
+ value: Return | ((current: Return) => Return),
29
+ ): void
30
+ export function amend<Args, Return>(
31
+ fn: RemoteFunction<Args, Return> | ((args?: Args) => Promise<Return>),
32
+ value: Return | ((current: Return) => Return),
33
+ ): void
34
+ export function amend<Return>(
35
+ selector: Pick<CacheOptions, 'tags'>,
36
+ value: Return | ((current: Return) => Return),
37
+ ): void
38
+ export function amend(selector: unknown, argsOrPayload: unknown, maybePayload?: unknown): void {
39
+ /* Payload (value or updater) is always last: 3 args → (selector, args, payload); 2 args →
40
+ (selector, payload). A function payload is an updater (a local closure); anything else is
41
+ a replacement value — a Return that is itself a function must be wrapped by the caller
42
+ (ADR-0043). Both forms route through the side-swap slot: on the client it applies locally,
43
+ on the server the value form broadcasts and the updater form throws. */
44
+ const payload = maybePayload !== undefined ? maybePayload : argsOrPayload
45
+ const args = maybePayload !== undefined ? argsOrPayload : undefined
46
+ const isValue = typeof payload !== 'function'
47
+ amendBroadcastSlot.get()?.(selector as CacheSelector<unknown, unknown>, args, isValue, payload)
48
+ }
@@ -0,0 +1,19 @@
1
+ import { applyAmendLocally } from './applyAmendLocally.ts'
2
+ import { createResolverSlot } from './createResolverSlot.ts'
3
+ import type { AmendApply } from './types/AmendApply.ts'
4
+
5
+ /*
6
+ The one side-swap seam for the isomorphic value form of amend (ADR-0043), mirroring
7
+ cacheStalenessSlot. amend() routes both forms through this — the value form so the side
8
+ decides apply-vs-broadcast, and the updater form only so the server resolver can reject
9
+ it loudly:
10
+
11
+ - client entry (startClient): installs applyAmendLocally — set this tab's retained value.
12
+ - server entry (serverEntry): installs broadcastAmend — publish the keyed value to every
13
+ authorized reader over the reserved __abide/amend/<key> topic, and throw on an updater
14
+ (a closure can't broadcast).
15
+
16
+ With no resolver registered the fallback is applyAmendLocally too, so isolated unit tests
17
+ keep local behaviour without booting a runtime.
18
+ */
19
+ export const amendBroadcastSlot = createResolverSlot<AmendApply>(() => applyAmendLocally)
@@ -0,0 +1,20 @@
1
+ import { cache } from './cache.ts'
2
+ import type { CacheSelector } from './types/CacheSelector.ts'
3
+
4
+ /*
5
+ Applies an amend to THIS side's cache store(s) — the local half of the isomorphic
6
+ amend value form (ADR-0043). A concrete value folds to an updater that ignores the
7
+ current value; an updater is used as-is. Both the amendBroadcastSlot fallback and the
8
+ client entry's resolver point here so the local-apply path can't diverge between an
9
+ unbooted unit test and a booted client tab. The server entry installs a broadcaster
10
+ instead (the side-swap seam), so this never runs on the server's throwaway request store.
11
+ */
12
+ export function applyAmendLocally<Args, Return>(
13
+ selector: CacheSelector<Args, Return>,
14
+ args: Args | undefined,
15
+ isValue: boolean,
16
+ payload: Return | ((current: Return) => Return),
17
+ ): void {
18
+ const updater = isValue ? () => payload as Return : (payload as (current: Return) => Return)
19
+ cache.amend(selector, args, updater, isValue)
20
+ }
@@ -1,7 +1,7 @@
1
+ import { amend } from './amend.ts'
1
2
  import { invalidate } from './invalidate.ts'
2
3
  import { keyForRemoteCall } from './keyForRemoteCall.ts'
3
4
  import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
4
- import { patch } from './patch.ts'
5
5
  import { peek } from './peek.ts'
6
6
  import { pending } from './pending.ts'
7
7
  import { refresh } from './refresh.ts'
@@ -12,13 +12,13 @@ import type { RemoteFunction } from './types/RemoteFunction.ts'
12
12
  /*
13
13
  Attaches the pre-bound selector sugar onto an assembled RemoteFunction:
14
14
  `fn.pending(args?)` ≡ `pending(fn, args?)`, likewise refreshing / refresh / invalidate / peek,
15
- `fn.patch(args?, updater)` ≡ `patch(fn, args, updater)`, and `fn.error(args?)` — the typed
15
+ `fn.amend(args?, value | updater)` ≡ `amend(fn, args, )`, and `fn.error(args?)` — the typed
16
16
  last error from the rpc error registry (most-recent across the rpc when args omitted, that
17
17
  exact call when given). The cached read is the bare call `fn(args, opts)` itself; refetch is
18
18
  `fn.refresh(args?)`. The methods only reference the globals at call time, so the shared import
19
19
  edge carries no module-init dependency (safe against any cache ↔ createRemoteFunction cycle).
20
20
  Attached in createRemoteFunction so the server (defineRpc) and client (remoteProxy) shapes are
21
- identical. `patch` is attached uniformly; the type omits it for a streaming rpc (harmless at
21
+ identical. `amend` is attached uniformly; the type omits it for a streaming rpc (harmless at
22
22
  runtime — a stream has no cache entry).
23
23
  */
24
24
  export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args, Return>): void {
@@ -28,8 +28,8 @@ export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args,
28
28
  refresh: (args?: Args) => refresh(fn, args),
29
29
  invalidate: (args?: Args) => invalidate(fn, args),
30
30
  peek: (args?: Args) => peek(fn, args),
31
- patch: (argsOrUpdater?: unknown, updater?: unknown) =>
32
- (patch as (fn: unknown, a?: unknown, b?: unknown) => void)(fn, argsOrUpdater, updater),
31
+ amend: (argsOrUpdater?: unknown, updater?: unknown) =>
32
+ (amend as (fn: unknown, a?: unknown, b?: unknown) => void)(fn, argsOrUpdater, updater),
33
33
  /* Reaction sugar is client-only (`watch` is a ui primitive that must not ride into a
34
34
  server bundle), so the shared attach binds an inert no-op — the server (defineRpc)
35
35
  shape carries `.watch` too, and an author `fn.watch(…)` surviving the SSR effect-strip
@@ -22,15 +22,22 @@ Bare iteration is the live stream (replay 0); `.tail(n)` seeds from the
22
22
  retained tail (no-arg = the whole tail). Each iterator mints its own sub id for
23
23
  lifecycle routing (end/err), and `hooks.replayed` fires in-band after the
24
24
  replay batch so a window reader commits its seed strictly before any live frame.
25
+
26
+ `initialFrame` warm-seeds `lastFrame` at construction (socketProxy passes the
27
+ server's retained frame from `__SSR__.sockets`) so `peek()` returns the SSR value
28
+ at hydration instead of undefined — congruent with the server HTML until the ws
29
+ delivers its own replay of the same frame.
25
30
  */
26
31
  export function buildSocketOverChannel<T>(
27
32
  name: string,
28
33
  resolveChannel: () => SocketChannel,
34
+ initialFrame?: T,
29
35
  ): Socket<T> {
30
36
  /* The latest frame seen across every iterator of this socket — what peek() returns and
31
37
  what refresh() re-seeds from the server tail. A plain slot (not reactive): peek(socket)
32
- is a synchronous snapshot; live reactivity is the for-await / watch(socket) path. */
33
- let lastFrame: T | undefined
38
+ is a synchronous snapshot; live reactivity is the for-await / watch(socket) path. Seeded
39
+ from the SSR-retained frame so a not-yet-connected client's peek matches the server. */
40
+ let lastFrame: T | undefined = initialFrame
34
41
  function iterate(replay: number | undefined, hooks?: TailHooks): AsyncIterable<T> {
35
42
  return {
36
43
  [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {