@abide/abide 0.53.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 (129) hide show
  1. package/AGENTS.md +330 -494
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +166 -146
  4. package/package.json +3 -4
  5. package/src/checkAbide.ts +1 -1
  6. package/src/lib/server/rpc/defineRpc.ts +2 -1
  7. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  9. package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
  10. package/src/lib/server/runtime/createUiPageRenderer.ts +54 -20
  11. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  12. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  13. package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
  14. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  15. package/src/lib/server/sockets/defineSocket.ts +11 -1
  16. package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
  17. package/src/lib/shared/SEEDS.ts +33 -0
  18. package/src/lib/shared/SOCKET_SEED.ts +12 -0
  19. package/src/lib/shared/amend.ts +48 -0
  20. package/src/lib/shared/amendBroadcastSlot.ts +19 -0
  21. package/src/lib/shared/applyAmendLocally.ts +20 -0
  22. package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
  23. package/src/lib/shared/buildSocketOverChannel.ts +9 -2
  24. package/src/lib/shared/cache.ts +203 -84
  25. package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
  26. package/src/lib/shared/createCacheStore.ts +8 -0
  27. package/src/lib/shared/createRemoteFunction.ts +4 -1
  28. package/src/lib/shared/encodeSeedValue.ts +24 -0
  29. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  30. package/src/lib/shared/peek.ts +4 -7
  31. package/src/lib/shared/socketTailsSlot.ts +13 -0
  32. package/src/lib/shared/types/AmendApply.ts +15 -0
  33. package/src/lib/shared/types/CacheReaderHook.ts +11 -0
  34. package/src/lib/shared/types/RemoteFunction.ts +30 -19
  35. package/src/lib/shared/types/SocketTails.ts +13 -0
  36. package/src/lib/shared/types/SsrPayload.ts +6 -0
  37. package/src/lib/test/createTestApp.ts +6 -0
  38. package/src/lib/ui/amendReaderHook.ts +125 -0
  39. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  40. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  41. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  42. package/src/lib/ui/compile/cachedShadowProgram.ts +39 -0
  43. package/src/lib/ui/compile/compileComponent.ts +1 -3
  44. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  45. package/src/lib/ui/compile/desugarSignals.ts +65 -36
  46. package/src/lib/ui/compile/generateBuild.ts +25 -24
  47. package/src/lib/ui/compile/generateSSR.ts +37 -24
  48. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  49. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  50. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  51. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  52. package/src/lib/ui/compile/lowerContext.ts +26 -6
  53. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  54. package/src/lib/ui/compile/lowerScript.ts +1 -11
  55. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  56. package/src/lib/ui/compile/renameSignalRefs.ts +25 -45
  57. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +10 -15
  58. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  59. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  60. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  61. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  62. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  63. package/src/lib/ui/computed.ts +16 -0
  64. package/src/lib/ui/createScope.ts +4 -3
  65. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  66. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  67. package/src/lib/ui/dom/appendStatic.ts +11 -20
  68. package/src/lib/ui/dom/appendText.ts +41 -43
  69. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  70. package/src/lib/ui/dom/awaitBlock.ts +31 -45
  71. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  72. package/src/lib/ui/dom/claimText.ts +38 -0
  73. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  74. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  75. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  76. package/src/lib/ui/dom/each.ts +3 -5
  77. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  78. package/src/lib/ui/dom/hydrate.ts +5 -12
  79. package/src/lib/ui/dom/mountChild.ts +73 -5
  80. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  81. package/src/lib/ui/dom/openMarker.ts +2 -4
  82. package/src/lib/ui/dom/outlet.ts +4 -3
  83. package/src/lib/ui/dom/readCell.ts +26 -11
  84. package/src/lib/ui/dom/skeleton.ts +2 -7
  85. package/src/lib/ui/dom/switchBlock.ts +2 -1
  86. package/src/lib/ui/dom/tryBlock.ts +27 -39
  87. package/src/lib/ui/dom/when.ts +6 -2
  88. package/src/lib/ui/linked.ts +14 -0
  89. package/src/lib/ui/resumeSeedScript.ts +1 -1
  90. package/src/lib/ui/router.ts +12 -16
  91. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  92. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  93. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  94. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  95. package/src/lib/ui/runtime/RENDER.ts +7 -5
  96. package/src/lib/ui/runtime/RESUME.ts +5 -7
  97. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  98. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  99. package/src/lib/ui/runtime/claimChild.ts +5 -7
  100. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  101. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  102. package/src/lib/ui/runtime/claimRun.ts +22 -0
  103. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  104. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  105. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  106. package/src/lib/ui/runtime/createDoc.ts +5 -1
  107. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  108. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  109. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  110. package/src/lib/ui/runtime/readNode.ts +21 -1
  111. package/src/lib/ui/runtime/renderPath.ts +2 -2
  112. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  113. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  114. package/src/lib/ui/runtime/runNode.ts +14 -0
  115. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  116. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  117. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  118. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  119. package/src/lib/ui/settleAsyncCells.ts +30 -3
  120. package/src/lib/ui/socketProxy.ts +17 -1
  121. package/src/lib/ui/startClient.ts +23 -0
  122. package/src/lib/ui/tryEncodeResume.ts +6 -15
  123. package/src/lib/ui/types/Scope.ts +1 -1
  124. package/src/lib/ui/watch.ts +21 -16
  125. package/src/serverEntry.ts +15 -0
  126. package/src/lib/shared/patch.ts +0 -41
  127. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
  128. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  129. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
@@ -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
  }
@@ -1,7 +1,8 @@
1
1
  /*
2
2
  The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
3
3
  `<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
4
- stores its raw ref-json text into `window.__abideResume` (the resume manifest hydration
4
+ stores its raw ref-json text into `window.__abideSeeds.resume` (the resume partition of the
5
+ one seed manifest, ADR-0048 — the store hydration
5
6
  reads, decoding each entry at read time) and swaps the resolved markup into the matching
6
7
  `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary — so the pending shell paints
7
8
  instantly and each value lands as it arrives, before the client bundle even loads. It
@@ -12,7 +13,7 @@ inlines cheaply ahead of the document body.
12
13
  export const SSR_SWAP_SCRIPT =
13
14
  "function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
14
15
  "var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
15
- "if(p&&p.nodeName==='SCRIPT'){(window.__abideResume=window.__abideResume||{})[id]=p.textContent||'';p.remove();}" +
16
+ "if(p&&p.nodeName==='SCRIPT'){var s=window.__abideSeeds=window.__abideSeeds||{};(s.resume=s.resume||{})[id]=p.textContent||'';p.remove();}" +
16
17
  "while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
17
18
  "if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
18
19
  "while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
@@ -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
+ }
@@ -1,12 +1,13 @@
1
1
  import { appNameSlot } from '../../shared/appNameSlot.ts'
2
2
  import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
3
3
  import { docSnapshotsSlot } from '../../shared/docSnapshotsSlot.ts'
4
- import { encodeRefJson } from '../../shared/encodeRefJson.ts'
4
+ import { encodeSeedValue } from '../../shared/encodeSeedValue.ts'
5
5
  import { formatTraceparent } from '../../shared/formatTraceparent.ts'
6
6
  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'
@@ -115,12 +116,13 @@ export function createUiPageRenderer({
115
116
  }
116
117
  const cells: Record<string, string> = {}
117
118
  for (const { key, value } of entries) {
118
- try {
119
- cells[key] = encodeRefJson(value)
120
- } catch {
121
- console.warn(
122
- `[abide] async cell "${key}" resolved to an unserializable value — it will re-run on the client instead of hydrating warm.`,
123
- )
119
+ const encoded = encodeSeedValue(
120
+ value,
121
+ `async cell "${key}" resolved value`,
122
+ 'it will re-run on the client instead of hydrating warm',
123
+ )
124
+ if (encoded !== undefined) {
125
+ cells[key] = encoded
124
126
  }
125
127
  }
126
128
  return Object.keys(cells).length > 0 ? cells : undefined
@@ -151,16 +153,43 @@ export function createUiPageRenderer({
151
153
  ) {
152
154
  continue
153
155
  }
154
- try {
155
- docs[id] = encodeRefJson(snapshot)
156
- } catch {
157
- /* Unserializable synchronous state (a function/class instance) — the client re-inits
158
- it cold rather than blanking the payload. */
156
+ const encoded = encodeSeedValue(
157
+ snapshot,
158
+ `scope "${id}" state snapshot`,
159
+ 'the client re-inits its state cold',
160
+ )
161
+ if (encoded !== undefined) {
162
+ docs[id] = encoded
159
163
  }
160
164
  }
161
165
  return Object.keys(docs).length > 0 ? docs : undefined
162
166
  }
163
167
 
168
+ /* The `sockets` partition of __SSR__: each socket whose retained frame this render read via
169
+ `peek(socket)` (drained from the request-scoped `socketTailsSlot` populated by
170
+ `defineSocket.peek`), keyed by socket NAME, ref-json-encoded. Last write per name wins — a
171
+ socket peeked twice ships its latest read. An unserializable frame is dropped (a warn): that
172
+ socket falls back to a cold client peek (undefined) rather than blanking the payload. Undefined
173
+ when no socket was peeked during the render (the common page). */
174
+ function socketTailSnapshots(): Record<string, string> | undefined {
175
+ const entries = socketTailsSlot.get()?.entries ?? []
176
+ if (entries.length === 0) {
177
+ return undefined
178
+ }
179
+ const sockets: Record<string, string> = {}
180
+ for (const { name, value } of entries) {
181
+ const encoded = encodeSeedValue(
182
+ value,
183
+ `socket "${name}" retained frame`,
184
+ 'its client peek() reads undefined at hydration instead of the server value',
185
+ )
186
+ if (encoded !== undefined) {
187
+ sockets[name] = encoded
188
+ }
189
+ }
190
+ return Object.keys(sockets).length > 0 ? sockets : undefined
191
+ }
192
+
164
193
  /* Build the __SSR__ <script> the client (startClient) reads on boot. The inline
165
194
  (settled) cache partition is computed once by the caller and threaded in, so the
166
195
  streaming branch can also drain the pending partition over the same render. */
@@ -177,6 +206,7 @@ export function createUiPageRenderer({
177
206
  cache: inline,
178
207
  cells: resolvedCellCells(),
179
208
  docs: docSeedSnapshots(),
209
+ sockets: socketTailSnapshots(),
180
210
  base: base || undefined,
181
211
  trace: formatTraceparent(store.trace),
182
212
  app: appNameSlot.name,
@@ -392,10 +422,12 @@ export function createUiPageRenderer({
392
422
  streamed and the client cold-runs it. An unserializable value emits nothing.
393
423
  Read from `store` directly since the stream body runs outside the request ALS. */
394
424
  for (const { key, value } of store.streamedCells.entries) {
395
- let encoded: string
396
- try {
397
- encoded = encodeRefJson(value)
398
- } catch {
425
+ const encoded = encodeSeedValue(
426
+ value,
427
+ `streaming cell "${key}" settled value`,
428
+ 'the client cold-runs its seed instead of adopting the streamed value',
429
+ )
430
+ if (encoded === undefined) {
399
431
  continue
400
432
  }
401
433
  controller.enqueue(
@@ -410,10 +442,12 @@ export function createUiPageRenderer({
410
442
  if (seededCellKeys.has(key)) {
411
443
  continue
412
444
  }
413
- let encoded: string
414
- try {
415
- encoded = encodeRefJson(value)
416
- } catch {
445
+ const encoded = encodeSeedValue(
446
+ value,
447
+ `streamed child's async cell "${key}" resolved value`,
448
+ 'it will re-run on the client instead of hydrating warm',
449
+ )
450
+ if (encoded === undefined) {
417
451
  continue
418
452
  }
419
453
  controller.enqueue(
@@ -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,33 @@
1
+ /*
2
+ The ONE hydration seed manifest (ADR-0048): every passive server→client warm-seed
3
+ partition — await resumes, async-cell values, doc snapshots, socket frames — rides a single
4
+ `globalThis.__abideSeeds` global, kind-partitioned. Each partition is a Record of ref-json
5
+ STRINGS decoded lazily at its read site, so the inline pre-bundle scripts (the SSR swap
6
+ script, the resume seed script) can write entries without the codec. The per-kind module
7
+ names (`RESUME`, `CELL_SEED`, `DOC_SEED`, `SOCKET_SEED`) are views onto these partitions,
8
+ so consumers keep their domain name while the wire carries one global.
9
+
10
+ Each partition initializes with its own `??=` — NOT one whole-object create — because the
11
+ vanilla scripts and the bundle race in either order and a script that ran first may have
12
+ created the root with only its own partition; whoever arrives later must fill the gaps and
13
+ adopt the same references. The live streamed-cell machinery (`STREAMED_CELLS`) and the head
14
+ collector buffer (`__abideResumeCache`) deliberately stay separate: they are apply-timing
15
+ phases (ADR-0040), not passive manifests.
16
+ */
17
+ type SeedPartitions = {
18
+ resume: Record<string, string>
19
+ cells: Record<string, string>
20
+ docs: Record<string, string>
21
+ sockets: Record<string, string>
22
+ }
23
+
24
+ const globalScope = globalThis as { __abideSeeds?: Partial<SeedPartitions> }
25
+ globalScope.__abideSeeds ??= {}
26
+ const root = globalScope.__abideSeeds
27
+ root.resume ??= {}
28
+ root.cells ??= {}
29
+ root.docs ??= {}
30
+ root.sockets ??= {}
31
+
32
+ // @documentation plumbing
33
+ export const SEEDS: SeedPartitions = root as SeedPartitions
@@ -0,0 +1,12 @@
1
+ import { SEEDS } from './SEEDS.ts'
2
+
3
+ /* The socket warm-seed manifest: a socket's retained latest frame as read via `peek(socket)` during
4
+ SSR, keyed by the socket's name, as a ref-json-encoded STRING (decoded at the read site in
5
+ `socketProxy`). `startClient` drains `__SSR__.sockets` into here before mount; a hydrating
6
+ `socketProxy` reads its key to seed the socket's `lastFrame` WARM, so `peek(socket)` returns the
7
+ SAME retained value the server rendered instead of `undefined` on the not-yet-connected client —
8
+ killing the SSR/hydration divergence that would otherwise discard the server markup and cold-render.
9
+
10
+ The `sockets` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS). */
11
+ // @documentation plumbing
12
+ export const SOCKET_SEED: Record<string, string> = SEEDS.sockets
@@ -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> {