@abide/abide 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/AGENTS.md +509 -525
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +128 -157
  4. package/package.json +4 -1
  5. package/src/abideResolverPlugin.ts +12 -0
  6. package/src/checkAbide.ts +60 -7
  7. package/src/lib/cli/completeCli.ts +38 -0
  8. package/src/lib/cli/printTopLevelHelp.ts +1 -0
  9. package/src/lib/cli/renderCliCompletions.ts +56 -0
  10. package/src/lib/cli/runCli.ts +27 -0
  11. package/src/lib/server/render.ts +70 -0
  12. package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
  13. package/src/lib/server/runtime/createServer.ts +43 -0
  14. package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
  15. package/src/lib/server/runtime/pageRenderSlot.ts +15 -0
  16. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  17. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  18. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  19. package/src/lib/server/sockets/registerSocket.ts +12 -0
  20. package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
  21. package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
  22. package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
  23. package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
  24. package/src/lib/shared/cache.ts +32 -4
  25. package/src/lib/shared/cacheStalenessSlot.ts +21 -0
  26. package/src/lib/shared/createRpcServerProgram.ts +187 -0
  27. package/src/lib/shared/docSnapshotsSlot.ts +13 -0
  28. package/src/lib/shared/invalidate.ts +39 -0
  29. package/src/lib/shared/matcherFromEnvelope.ts +31 -0
  30. package/src/lib/shared/prepareRpcModule.ts +22 -3
  31. package/src/lib/shared/refresh.ts +9 -2
  32. package/src/lib/shared/selectorMatcher.ts +2 -15
  33. package/src/lib/shared/serializeSelector.ts +69 -0
  34. package/src/lib/shared/setsIntersect.ts +15 -0
  35. package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
  36. package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
  37. package/src/lib/shared/types/DocSnapshots.ts +12 -0
  38. package/src/lib/shared/types/RemoteFunction.ts +11 -8
  39. package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
  40. package/src/lib/shared/types/SsrPayload.ts +5 -0
  41. package/src/lib/test/createTestApp.ts +15 -0
  42. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
  43. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  44. package/src/lib/ui/compile/compileShadow.ts +92 -14
  45. package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
  46. package/src/lib/ui/compile/lowerScript.ts +6 -1
  47. package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
  48. package/src/lib/ui/compile/wrapReactionCellSources.ts +32 -0
  49. package/src/lib/ui/createScope.ts +42 -1
  50. package/src/lib/ui/dom/assertClaimedText.ts +14 -4
  51. package/src/lib/ui/dom/attr.ts +25 -2
  52. package/src/lib/ui/dom/writeCell.ts +22 -0
  53. package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
  54. package/src/lib/ui/runtime/claimExpected.ts +6 -1
  55. package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
  56. package/src/lib/ui/startClient.ts +22 -1
  57. package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
  58. package/src/serverEntry.ts +12 -0
@@ -109,6 +109,20 @@ export type RpcServerProgram = {
109
109
  it.
110
110
  */
111
111
  inputSchemaForModule(modulePath: string): Record<string, unknown> | undefined
112
+ /*
113
+ The top-level statements the CLIENT rpc module must retain — reachability rooted at the endpoint
114
+ `opts` argument, resolved through the binder/checker rather than by scanning source text. On the
115
+ client an rpc is only a `remoteProxy` fetch, so the handler and every declaration/import only it
116
+ reaches is dead; but `opts` (schemas/cache/stream) is a LIVE expression that may reference
117
+ imported values and module-level consts (e.g. a `const inputSchema = z.object(...)` under
118
+ `schemas.input`), so those — and what THEY reach — must survive. Returns each kept statement's
119
+ source text in source order (imports + declarations transitively reachable from `opts`),
120
+ EXCLUDING the exported rpc statement itself (it becomes the `remoteProxy` line) and, naturally,
121
+ the rpc-helper import (`opts` never names it). An endpoint with no `opts` returns `[]` — a bare
122
+ `remoteProxy` needs nothing. undefined when the module or its rpc call can't be resolved, so the
123
+ caller falls open to the keep-the-file char-scan rewrite (ADR-0022 D2 legacy path).
124
+ */
125
+ clientKeepForModule(modulePath: string): string[] | undefined
112
126
  }
113
127
 
114
128
  /*
@@ -174,7 +188,180 @@ export function createRpcServerProgram(cwd: string, rpcDir: string): RpcServerPr
174
188
  inputSchemaForModule(modulePath) {
175
189
  return query(modulePath, (call) => inputArgsJsonSchema(checker, call.node))
176
190
  },
191
+ clientKeepForModule(modulePath) {
192
+ /* Needs the SourceFile alongside the call (statement walk), so it resolves both itself
193
+ rather than going through `query` (which yields only the call). Same fail-open
194
+ contract: an unresolved module / any checker throw yields undefined. */
195
+ try {
196
+ const sourceFile = program.getSourceFile(modulePath)
197
+ if (sourceFile === undefined) {
198
+ return undefined
199
+ }
200
+ const call = resolveRpcCall(checker, sourceFile)
201
+ if (call === undefined) {
202
+ return undefined
203
+ }
204
+ return clientKeepStatements(checker, sourceFile, call.node)
205
+ } catch {
206
+ return undefined
207
+ }
208
+ },
209
+ }
210
+ }
211
+
212
+ /*
213
+ The top-level statements the client rpc module retains, rooted at the endpoint `opts` argument
214
+ (ADR-0022 addendum). Builds a reachability graph over the module's top-level bindings — each binding
215
+ symbol → its declaring statement, plus which top-level symbols each statement references — then
216
+ marks every statement reachable from `opts` and returns the marked ones' source text in source
217
+ order. The exported rpc statement is excluded (it becomes the `remoteProxy` line); everything the
218
+ handler alone reaches is simply never marked, so it drops out.
219
+ */
220
+ function clientKeepStatements(
221
+ checker: ts.TypeChecker,
222
+ sourceFile: ts.SourceFile,
223
+ call: ts.CallExpression,
224
+ ): string[] {
225
+ const opts = call.arguments[1]
226
+ // No opts → the client module needs nothing but the bare remoteProxy call.
227
+ if (opts === undefined) {
228
+ return []
229
+ }
230
+ // The exported rpc statement becomes the remoteProxy line, so it is never a retained statement.
231
+ const exportStatement = enclosingTopLevelStatement(call, sourceFile)
232
+ // Every top-level binding symbol → the statement that declares it (the export statement's own
233
+ // bindings excluded so a self-reference can't re-admit it).
234
+ const declaringStatement = new Map<ts.Symbol, ts.Statement>()
235
+ for (const statement of sourceFile.statements) {
236
+ if (statement === exportStatement) {
237
+ continue
238
+ }
239
+ for (const symbol of topLevelBindingSymbols(checker, statement)) {
240
+ declaringStatement.set(symbol, statement)
241
+ }
242
+ }
243
+ // Mark from opts outward: a statement is kept once any of its bindings is referenced by opts or
244
+ // by an already-kept statement (transitive closure via the work queue).
245
+ const kept = new Set<ts.Statement>()
246
+ const queue: ts.Node[] = [opts]
247
+ while (queue.length > 0) {
248
+ const node = queue.pop() as ts.Node
249
+ for (const symbol of referencedTopLevelSymbols(checker, node, declaringStatement)) {
250
+ const statement = declaringStatement.get(symbol)
251
+ if (statement !== undefined && !kept.has(statement)) {
252
+ kept.add(statement)
253
+ queue.push(statement)
254
+ }
255
+ }
256
+ }
257
+ const texts: string[] = []
258
+ for (const statement of sourceFile.statements) {
259
+ if (kept.has(statement)) {
260
+ texts.push(statement.getText(sourceFile))
261
+ }
262
+ }
263
+ return texts
264
+ }
265
+
266
+ /* The top-level statement enclosing `node` — walk parents until the one whose parent is the source
267
+ file. */
268
+ function enclosingTopLevelStatement(node: ts.Node, sourceFile: ts.SourceFile): ts.Statement {
269
+ let current: ts.Node = node
270
+ while (current.parent !== undefined && current.parent !== sourceFile) {
271
+ current = current.parent
272
+ }
273
+ return current as ts.Statement
274
+ }
275
+
276
+ /*
277
+ The binding symbols a top-level statement introduces — an import's clause names (default /
278
+ namespace / each named element), a variable statement's declaration names (destructuring patterns
279
+ walked to their leaves), and a function/class declaration's name. A statement that binds nothing
280
+ (a bare side-effect `import './x'`, an expression statement) contributes no symbol and so is never
281
+ reachable from opts — exactly the server-only setup the client drops.
282
+ */
283
+ function topLevelBindingSymbols(checker: ts.TypeChecker, statement: ts.Statement): ts.Symbol[] {
284
+ const names: ts.Node[] = []
285
+ if (ts.isImportDeclaration(statement)) {
286
+ const clause = statement.importClause
287
+ if (clause?.name !== undefined) {
288
+ names.push(clause.name)
289
+ }
290
+ const bindings = clause?.namedBindings
291
+ if (bindings !== undefined) {
292
+ if (ts.isNamespaceImport(bindings)) {
293
+ names.push(bindings.name)
294
+ } else {
295
+ for (const element of bindings.elements) {
296
+ names.push(element.name)
297
+ }
298
+ }
299
+ }
300
+ } else if (ts.isVariableStatement(statement)) {
301
+ for (const declaration of statement.declarationList.declarations) {
302
+ collectBindingNames(declaration.name, names)
303
+ }
304
+ } else if (
305
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
306
+ statement.name !== undefined
307
+ ) {
308
+ names.push(statement.name)
309
+ }
310
+ const symbols: ts.Symbol[] = []
311
+ for (const name of names) {
312
+ const symbol = checker.getSymbolAtLocation(name)
313
+ if (symbol !== undefined) {
314
+ symbols.push(symbol)
315
+ }
316
+ }
317
+ return symbols
318
+ }
319
+
320
+ /* The leaf identifiers of a binding name — a plain identifier is itself; a destructuring pattern
321
+ (`{ a, b: { c } }`, `[x, ...rest]`) descends to each bound identifier. */
322
+ function collectBindingNames(name: ts.BindingName, out: ts.Node[]): void {
323
+ if (ts.isIdentifier(name)) {
324
+ out.push(name)
325
+ return
326
+ }
327
+ for (const element of name.elements) {
328
+ if (ts.isBindingElement(element)) {
329
+ collectBindingNames(element.name, out)
330
+ }
331
+ }
332
+ }
333
+
334
+ /*
335
+ The top-level binding symbols referenced within `node` — every identifier in the subtree whose
336
+ resolved symbol is a known top-level binding. An import alias resolves to the same local symbol at
337
+ its use site and its declaration, so uses match declarations; a property name (`obj.map`) resolves
338
+ to a property symbol, not a top-level one, so it can't false-match. Over-inclusion is safe (it only
339
+ keeps more), so a bare identifier scan needs no scope analysis.
340
+ */
341
+ function referencedTopLevelSymbols(
342
+ checker: ts.TypeChecker,
343
+ node: ts.Node,
344
+ declaringStatement: Map<ts.Symbol, ts.Statement>,
345
+ ): ts.Symbol[] {
346
+ const found: ts.Symbol[] = []
347
+ const consider = (symbol: ts.Symbol | undefined): void => {
348
+ if (symbol !== undefined && declaringStatement.has(symbol)) {
349
+ found.push(symbol)
350
+ }
351
+ }
352
+ const visit = (current: ts.Node): void => {
353
+ // A shorthand `{ schemas }` resolves to the object PROPERTY symbol at its location, not the
354
+ // referenced binding — so read the value symbol explicitly, else the used import is missed
355
+ // (the one unsafe direction: a dropped-but-needed statement).
356
+ if (ts.isShorthandPropertyAssignment(current)) {
357
+ consider(checker.getShorthandAssignmentValueSymbol(current))
358
+ } else if (ts.isIdentifier(current)) {
359
+ consider(checker.getSymbolAtLocation(current))
360
+ }
361
+ ts.forEachChild(current, visit)
177
362
  }
363
+ visit(node)
364
+ return found
178
365
  }
179
366
 
180
367
  type ResolvedRpcCall = {
@@ -0,0 +1,13 @@
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { DocSnapshots } from './types/DocSnapshots.ts'
3
+
4
+ /*
5
+ The active doc-snapshot slot — mirrors `resolvedCellsSlot`. The server entry installs an ALS-backed
6
+ resolver (a fresh list per request, so concurrent SSR renders never mix their doc seeds); with no
7
+ resolver registered a single fallback list is created lazily so isolated tests work without booting
8
+ the runtime. `createScope` pushes a `{ id, take }` for each rendered scope server-side; the page
9
+ renderer reads it at render-return to stamp `__SSR__.docs`.
10
+ */
11
+ export const docSnapshotsSlot = createResolverSlot<DocSnapshots>(() => ({
12
+ entries: [],
13
+ }))
@@ -0,0 +1,39 @@
1
+ import { cacheStalenessSlot } from './cacheStalenessSlot.ts'
2
+ import { isAsyncCell } from './isAsyncCell.ts'
3
+ import type { AsyncComputed } from './types/AsyncComputed.ts'
4
+ import type { CacheSelector } from './types/CacheSelector.ts'
5
+
6
+ /*
7
+ Drop every cached read matching the selector so the next read reloads it lazily —
8
+ the drop verb, distinct from refresh (which refetches now, keeping the stale value
9
+ visible). A mounted RETAINED reader revalidates stale-in-place under invalidate too
10
+ (its retain+invalidation policy keeps the value and refetches); a background /
11
+ non-retained entry (an explicit `cache()`, a producer, a reader-less entry) is
12
+ dropped and refetches only when re-read. Follows the shared selector grammar:
13
+
14
+ invalidate(getFoo, args) → that exact call
15
+ invalidate(getFoo) → every args-variant of that rpc
16
+ invalidate({ tags }) → every entry sharing a tag
17
+ invalidate() → every entry
18
+
19
+ Instance sugar `getFoo.invalidate(args?)` ≡ `invalidate(getFoo, args?)`.
20
+
21
+ Isomorphic (ADR-0041): applied LOCALLY on the client, and on the SERVER it
22
+ broadcasts to every connected client (each applies the local drop). The side-swap
23
+ rides the cacheStalenessSlot resolver — this source is byte-identical on both
24
+ sides. `invalidate(asyncCell)` aliases `cell.refresh()` (a cell has no droppable
25
+ entry; its staleness is re-running its seed), matching `refresh(cell)`.
26
+ */
27
+ // @documentation cache
28
+ export function invalidate<Args, Return>(
29
+ arg?: CacheSelector<Args, Return> | AsyncComputed<unknown>,
30
+ args?: Args,
31
+ ): void {
32
+ /* An async cell re-invokes its own seed (`invalidate(cell)` ≡ `cell.refresh()`). */
33
+ if (isAsyncCell(arg)) {
34
+ arg.refresh()
35
+ return
36
+ }
37
+ /* Side-swap: the client entry applies locally; the server entry broadcasts. */
38
+ cacheStalenessSlot.get()?.('invalidate', arg as CacheSelector<Args, Return>, args)
39
+ }
@@ -0,0 +1,31 @@
1
+ import { keyMatchesPrefix } from './keyMatchesPrefix.ts'
2
+ import { setsIntersect } from './setsIntersect.ts'
3
+ import { toTagSet } from './toTagSet.ts'
4
+ import type { CacheEntry } from './types/CacheEntry.ts'
5
+ import type { CacheStalenessFrame } from './types/CacheStalenessFrame.ts'
6
+
7
+ /*
8
+ Rebuilds the entry predicate from a decoded staleness envelope (ADR-0041) — the
9
+ decode half of serializeSelector, driving the SAME store loop a local apply does
10
+ (cache.invalidateMatching / cache.refreshMatching). The three modes are
11
+ selectorMatcher's three real branches with the `args !== undefined` discriminant
12
+ replaced by the explicit `mode`, reusing keyMatchesPrefix/toTagSet so a decoded
13
+ predicate can't disagree with what a local selector would have matched:
14
+
15
+ key → exactly that call's entry (args already the canonical string the read
16
+ path keyed with, so it re-matches by equality — no ref-json codec crosses)
17
+ prefix → every args-variant of that fn (method+url prefix)
18
+ tags → any entry sharing one of the requested tags
19
+ */
20
+ export function matcherFromEnvelope(frame: CacheStalenessFrame): (entry: CacheEntry) => boolean {
21
+ if (frame.mode === 'key') {
22
+ const key = frame.match
23
+ return (entry) => entry.key === key
24
+ }
25
+ if (frame.mode === 'prefix') {
26
+ const prefix = frame.match
27
+ return (entry) => keyMatchesPrefix(entry.key, prefix)
28
+ }
29
+ const requestedTags = toTagSet(frame.tags)
30
+ return (entry) => entry.tags !== undefined && setsIntersect(requestedTags, entry.tags)
31
+ }
@@ -118,7 +118,6 @@ export function prepareRpcModule(
118
118
  remoteProxy reads only streaming/cache/stream off the opts and ignores the rest.
119
119
  */
120
120
  rewriteForClient(url: string): string {
121
- const callHead = `${stripped.slice(0, site.callStart)}${REMOTE_PROXY_GLOBAL}(${JSON.stringify(method)}, ${JSON.stringify(url)}`
122
121
  const opts = argParts[1]
123
122
  /* Build-injected CLIENT opts: `streaming` (build-derived from the elided handler body)
124
123
  and the `outputWirePlan` (the handler's structured success fields → the client revives
@@ -142,8 +141,28 @@ export function prepareRpcModule(
142
141
  ? `, { ${injectedText} }`
143
142
  : `, { ${injectedText}, ...(${opts}) }`
144
143
  }
145
- /* Keep everything from the call's closing paren onward (same slicing discipline as
146
- rewriteForServer) so any trailing content after the call survives. */
144
+ const remoteCall = `${REMOTE_PROXY_GLOBAL}(${JSON.stringify(method)}, ${JSON.stringify(url)}${argsText})`
145
+ /*
146
+ Minimal emit (ADR-0022 addendum): the warm program resolved which top-level statements
147
+ the live `opts` actually reaches, so emit ONLY those plus the `remoteProxy` export. The
148
+ handler and every declaration/import only it used are never emitted — nothing server-side
149
+ is loaded, tree-shaken, or flagged, matching "the client is only ever a fetch". An empty
150
+ plan (no opts, or opts references nothing top-level) emits the bare call alone.
151
+ */
152
+ const clientKeep = stamps.clientKeep
153
+ if (clientKeep !== undefined) {
154
+ const exportLine = `export const ${site.exportName} = ${remoteCall}`
155
+ return clientKeep.length > 0
156
+ ? `${clientKeep.join('\n')}\n${exportLine}\n`
157
+ : `${exportLine}\n`
158
+ }
159
+ /*
160
+ Fallback (no warm program): keep the real module, swap the METHOD( call for the
161
+ remoteProxy( call, and elide the handler argument — leaning on the bundler's DCE to drop
162
+ the dead server imports. `stripped.slice(0, site.callStart)` keeps the imports + the
163
+ `export const <name> = ` head; `stripped.slice(site.parenEnd)` keeps any trailing content.
164
+ */
165
+ const callHead = `${stripped.slice(0, site.callStart)}${REMOTE_PROXY_GLOBAL}(${JSON.stringify(method)}, ${JSON.stringify(url)}`
147
166
  return `${callHead}${argsText}${stripped.slice(site.parenEnd)}`
148
167
  },
149
168
  }
@@ -1,4 +1,4 @@
1
- import { cache } from './cache.ts'
1
+ import { cacheStalenessSlot } from './cacheStalenessSlot.ts'
2
2
  import { isAsyncCell } from './isAsyncCell.ts'
3
3
  import type { AsyncComputed } from './types/AsyncComputed.ts'
4
4
  import type { CacheSelector } from './types/CacheSelector.ts'
@@ -17,6 +17,12 @@ pending blank. Follows the shared selector grammar:
17
17
 
18
18
  Instance sugar `getFoo.refresh(args?)` ≡ `refresh(getFoo, args?)`. Reports, never
19
19
  retains a spinner — pair with `refreshing()` to surface the in-flight reload.
20
+
21
+ Isomorphic (ADR-0041): applied LOCALLY on the client, and on the SERVER it now
22
+ broadcasts to every connected client (each refetches eagerly) instead of mutating a
23
+ throwaway request store that reached no browser. The side-swap rides the
24
+ cacheStalenessSlot resolver — this source is byte-identical on both sides. The
25
+ paired drop verb is `invalidate`.
20
26
  */
21
27
  // @documentation cache
22
28
  export function refresh<Args, Return>(
@@ -28,5 +34,6 @@ export function refresh<Args, Return>(
28
34
  arg.refresh()
29
35
  return
30
36
  }
31
- cache.refresh(arg as CacheSelector<Args, Return>, args)
37
+ /* Side-swap: the client entry applies locally; the server entry broadcasts. */
38
+ cacheStalenessSlot.get()?.('refresh', arg as CacheSelector<Args, Return>, args)
32
39
  }
@@ -1,5 +1,6 @@
1
1
  import { keyMatchesPrefix } from './keyMatchesPrefix.ts'
2
2
  import { selectorPrefix } from './selectorPrefix.ts'
3
+ import { setsIntersect } from './setsIntersect.ts'
3
4
  import { toTagSet } from './toTagSet.ts'
4
5
  import type { CacheEntry } from './types/CacheEntry.ts'
5
6
  import type { CacheSelector } from './types/CacheSelector.ts'
@@ -50,19 +51,5 @@ export function selectorMatcher<Args, Return>(
50
51
  return () => false
51
52
  }
52
53
  const requestedTags = toTagSet(arg.tags)
53
- return (entry) => entry.tags !== undefined && intersects(entry.tags, requestedTags)
54
- }
55
-
56
- /*
57
- True when an entry's tags and the requested tags overlap on any tag. A plain
58
- for-of over the Set rather than .values().some() — Iterator Helpers are too new
59
- for this module's browser baseline (see producerKey).
60
- */
61
- function intersects(entryTags: Set<string>, requestedTags: Set<string>): boolean {
62
- for (const tag of requestedTags) {
63
- if (entryTags.has(tag)) {
64
- return true
65
- }
66
- }
67
- return false
54
+ return (entry) => entry.tags !== undefined && setsIntersect(requestedTags, entry.tags)
68
55
  }
@@ -0,0 +1,69 @@
1
+ import { keyForRemoteCall } from './keyForRemoteCall.ts'
2
+ import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
3
+ import { REMOTE_FUNCTION } from './REMOTE_FUNCTION.ts'
4
+ import { toTagSet } from './toTagSet.ts'
5
+ import type { CacheSelector } from './types/CacheSelector.ts'
6
+ import type { CacheStalenessFrame } from './types/CacheStalenessFrame.ts'
7
+ import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
8
+
9
+ /*
10
+ Encodes a CacheSelector into the cross-client staleness envelope (ADR-0041) — the
11
+ single authoring site for the on-wire selector, reusing the same encoders the read
12
+ path keys with (keyForRemoteCall / keyPrefixForRemote) so it can never drift from
13
+ selectorMatcher. The three real branches map 1:1 onto the envelope's `mode`:
14
+
15
+ fn + args → { mode: 'key', match: keyForRemoteCall(method, url, args) }
16
+ fn → { mode: 'prefix', match: keyPrefixForRemote(method, url) }
17
+ { tags } → { mode: 'tags', tags }
18
+
19
+ A producer/closure selector mints a per-process ref id (selectorPrefix) that no
20
+ other client shares, and a bare/undefined match-all would nuke every client's whole
21
+ cache — both are NOT cross-client serializable, so they THROW here rather than
22
+ broadcast. The caller (the server broadcaster) surfaces the throw as a programming
23
+ error at the mutation site.
24
+ */
25
+ export function serializeSelector<Args, Return>(
26
+ op: CacheStalenessFrame['op'],
27
+ arg?: CacheSelector<Args, Return>,
28
+ args?: Args,
29
+ ): CacheStalenessFrame {
30
+ if (arg === undefined) {
31
+ throw new Error(
32
+ `[abide] ${op}(): a bare match-all selector cannot broadcast across clients — it would drop every client's whole cache. Pass a remote function or { tags }.`,
33
+ )
34
+ }
35
+ if (typeof arg === 'function') {
36
+ /* Only a remote function carries wire identity (method+url, stable across clients);
37
+ a plain producer's key is a per-process ref id that no other client shares. */
38
+ if (!(REMOTE_FUNCTION in arg)) {
39
+ throw new Error(
40
+ `[abide] ${op}(): a producer/closure selector is not cross-client serializable (its key is a per-process reference id). Broadcast a remote function or { tags }.`,
41
+ )
42
+ }
43
+ const remote = arg as RawRemoteFunction<Args>
44
+ if (args === undefined) {
45
+ return {
46
+ op,
47
+ mode: 'prefix',
48
+ match: keyPrefixForRemote(remote.method, remote.url),
49
+ tags: [],
50
+ }
51
+ }
52
+ return {
53
+ op,
54
+ mode: 'key',
55
+ match: keyForRemoteCall(remote.method, remote.url, args),
56
+ tags: [],
57
+ }
58
+ }
59
+ /* Reject an empty tag set too, not just an absent `tags`: an empty-array selector
60
+ matches nothing locally, so broadcasting it would fan a match-nothing frame out to
61
+ every peer (a wasted store scan). Compute the set once and reuse it. */
62
+ const tags = arg.tags === undefined ? undefined : toTagSet(arg.tags)
63
+ if (tags === undefined || tags.size === 0) {
64
+ throw new Error(
65
+ `[abide] ${op}(): a { tags } selector must list at least one tag to broadcast.`,
66
+ )
67
+ }
68
+ return { op, mode: 'tags', match: '', tags: [...tags] }
69
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ True when two sets share at least one member — a plain for-of over the first set
3
+ rather than `.values().some()` (Iterator Helpers are too new for this module's
4
+ browser baseline; see producerKey). Pass the smaller set first. Shared by
5
+ selectorMatcher (local selectors) and matcherFromEnvelope (wire-decoded frames) so
6
+ the two tag predicates can't drift.
7
+ */
8
+ export function setsIntersect<T>(a: Set<T>, b: Set<T>): boolean {
9
+ for (const value of a) {
10
+ if (b.has(value)) {
11
+ return true
12
+ }
13
+ }
14
+ return false
15
+ }
@@ -0,0 +1,13 @@
1
+ import type { CacheSelector } from './CacheSelector.ts'
2
+
3
+ /*
4
+ The side-swappable staleness applier stored in cacheStalenessSlot (ADR-0041): given
5
+ a verb and a (non-async-cell) selector, either apply it locally (client / fallback)
6
+ or broadcast it (server). The async-cell short-circuit is handled in invalidate() /
7
+ refresh() before the slot, so this only ever sees a real CacheSelector.
8
+ */
9
+ export type CacheStalenessApply = <Args, Return>(
10
+ op: 'invalidate' | 'refresh',
11
+ selector: CacheSelector<Args, Return>,
12
+ args?: Args,
13
+ ) => void
@@ -0,0 +1,24 @@
1
+ /*
2
+ The on-wire envelope for a cross-client staleness signal (ADR-0041). A single
3
+ monomorphic shape — every field always present so the JIT keeps it a stable
4
+ hidden class — carried as the `message` of a standard socket frame on the
5
+ reserved `__abide/cache` topic.
6
+
7
+ op — which local verb each subscriber runs: drop (`invalidate`) or refetch
8
+ (`refresh`).
9
+ mode — how `match`/`tags` select entries, mapping 1:1 onto selectorMatcher's
10
+ three real branches: an exact call key, a fn key prefix, or a tag set.
11
+ match — the exact cache key (`mode: 'key'`), the key prefix (`mode: 'prefix'`),
12
+ or `''` (`mode: 'tags'`, unused).
13
+ tags — the requested tag list (`mode: 'tags'`), or `[]` otherwise.
14
+
15
+ Producer/closure selectors and the bare match-all form are NOT expressible here
16
+ — they are rejected at encode time (serializeSelector), so this envelope only
17
+ ever carries a cross-client-stable selector.
18
+ */
19
+ export interface CacheStalenessFrame {
20
+ op: 'invalidate' | 'refresh'
21
+ mode: 'key' | 'prefix' | 'tags'
22
+ match: string
23
+ tags: string[]
24
+ }
@@ -0,0 +1,12 @@
1
+ /*
2
+ The per-request list of reactive-document snapshots captured during an SSR render, each keyed by
3
+ its scope's serialization-stable render-path id. A rendered scope registers a lazy `take` at
4
+ creation; the page renderer calls it at render-return (after the synchronous `state` inits have
5
+ run), encodes the non-empty result (ref-json) into `__SSR__.docs`, and the client seeds it so a
6
+ plain `state(initial)` — a uuid, a timestamp — hydrates to the SERVER's value instead of
7
+ recomputing a divergent one. Sibling of `ResolvedCells` (async-cell values); this holds the
8
+ synchronous document state, taken lazily rather than at settle.
9
+ */
10
+ export type DocSnapshots = {
11
+ entries: { id: string; take: () => unknown }[]
12
+ }
@@ -63,17 +63,20 @@ export type RemoteFunction<
63
63
  the rpc's own spec. */
64
64
  readonly isError: RpcErrorGuard<Errors>
65
65
  /* Pre-bound selector sugar: `fn.pending(args?)` ≡ `pending(fn, args?)`, and likewise for
66
- refreshing / refresh / peek — the rpc is the leading selector, bound in. The argument is
67
- this rpc's typed `Args` (the by-args refinement); tags / cross-cutting selection stay on
68
- the globals. The bare call `fn(args)` IS the cached read (was `fn.cache`) — all cache
69
- policy lives on the definition, so there is no second options argument, and
70
- `refresh(args?)` refetches keeping the stale value visible (was `invalidate`, which
71
- dropped). `peek(args?)` reads the retained value synchronously (a streaming rpc peeks its
72
- latest frame). `patch` is fetch-only, so it is omitted for a streaming rpc (a stream isn't
73
- a memoized value) via the intersection below. */
66
+ refreshing / refresh / invalidate / peek — the rpc is the leading selector, bound in. The
67
+ argument is this rpc's typed `Args` (the by-args refinement); tags / cross-cutting selection
68
+ stay on the globals. The bare call `fn(args)` IS the cached read (was `fn.cache`) — all cache
69
+ policy lives on the definition, so there is no second options argument. The two staleness
70
+ verbs are distinct again (ADR-0041): `invalidate(args?)` DROPS the entry so the next read
71
+ reloads lazily, while `refresh(args?)` REFETCHES now keeping the stale value visible both
72
+ apply locally on the client and broadcast to every connected client from the server.
73
+ `peek(args?)` reads the retained value synchronously (a streaming rpc peeks its latest
74
+ frame). `patch` is fetch-only, so it is omitted for a streaming rpc (a stream isn't a
75
+ memoized value) via the intersection below. */
74
76
  pending(args?: Args): boolean
75
77
  refreshing(args?: Args): boolean
76
78
  refresh(args?: Args): void
79
+ invalidate(args?: Args): void
77
80
  peek(args?: Args): ([Return] extends [AsyncIterable<infer Frame>] ? Frame : Return) | undefined
78
81
  /* This rpc's last error, typed off `Errors` (a discriminated union already narrowed on
79
82
  `.kind`/`.data`). `args` scopes to one call; omitted aggregates the most-recent across
@@ -12,6 +12,14 @@ unresolvable node), and the rewrite falls open to its char-scan / today's behavi
12
12
  before. `streaming` overrides the char-scan streaming verdict; the rest are stamped as server opts
13
13
  (`coercion`→`coerce`, `inputSchema`→`inputJsonSchema`, `outputSchema`→`outputJsonSchema`,
14
14
  `errorSchemas`→`errorJsonSchemas`) or the client opt (`outputWirePlan`).
15
+
16
+ `clientKeep` is the reachability plan for the CLIENT rewrite: the source text of the top-level
17
+ statements the emitted client module must retain (imports + declarations transitively reachable from
18
+ the endpoint `opts`), computed through the binder/checker. Present → `rewriteForClient` emits a
19
+ MINIMAL module (`remoteProxy(opts)` plus only those statements), so the handler and every
20
+ declaration/import only it reaches is never emitted — nothing server-side is loaded, tree-shaken, or
21
+ flagged (superseding ADR-0022 D2/D3's "keep the file, trust DCE"). Absent (no warm program /
22
+ unresolvable rpc call) falls open to the keep-the-file char-scan rewrite.
15
23
  */
16
24
  export type RpcBuildStamps = {
17
25
  streaming?: boolean
@@ -20,4 +28,5 @@ export type RpcBuildStamps = {
20
28
  outputSchema?: Record<string, unknown>
21
29
  errorSchemas?: ErrorJsonSchemas
22
30
  outputWirePlan?: OutputWirePlan
31
+ clientKeep?: string[]
23
32
  }
@@ -22,4 +22,9 @@ export type SsrPayload = SsrBootState & {
22
22
  /* Async-cell values that resolved during SSR, keyed by render-path id → ref-json string.
23
23
  `startClient` seeds these into `CELL_SEED`; a hydrating cell reads its key to warm-hydrate. */
24
24
  cells?: Record<string, string>
25
+ /* Reactive-document snapshots that carried synchronous `state` during SSR, keyed by render-path
26
+ id → ref-json string. `startClient` seeds these into `DOC_SEED`; a hydrating scope's first
27
+ write to each slot adopts the server value, so a plain `state(uuid())`/`state(Date.now())`
28
+ keeps the SSR value instead of recomputing a divergent one. */
29
+ docs?: Record<string, string>
25
30
  }
@@ -26,7 +26,9 @@ import { shell } from '../../_virtual/shell.ts'
26
26
  // @ts-expect-error virtual module resolved by abideResolverPlugin
27
27
  import { sockets } from '../../_virtual/sockets.ts'
28
28
  import { rpcRegistry } from '../server/rpc/rpcRegistry.ts'
29
+ import { broadcastCacheStaleness } from '../server/runtime/cacheStalenessBroadcaster.ts'
29
30
  import { createServer } from '../server/runtime/createServer.ts'
31
+ import { pageRenderSlot } from '../server/runtime/pageRenderSlot.ts'
30
32
  import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
31
33
  import { requestContext } from '../server/runtime/requestContext.ts'
32
34
  import { resolvePageSnapshot } from '../server/runtime/resolvePageSnapshot.ts'
@@ -34,9 +36,11 @@ import { serverSlot } from '../server/runtime/serverSlot.ts'
34
36
  import { baseSlot } from '../shared/baseSlot.ts'
35
37
  import { buildRpcProxy } from '../shared/buildRpcProxy.ts'
36
38
  import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
39
+ import { cacheStalenessSlot } from '../shared/cacheStalenessSlot.ts'
37
40
  import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
38
41
  import { commandNameForUrl } from '../shared/commandNameForUrl.ts'
39
42
  import { createCacheStore } from '../shared/createCacheStore.ts'
43
+ import { docSnapshotsSlot } from '../shared/docSnapshotsSlot.ts'
40
44
  import { HEALTH_PATH } from '../shared/HEALTH_PATH.ts'
41
45
  import { pageSlot } from '../shared/pageSlot.ts'
42
46
  import { pendingAsyncCellsSlot } from '../shared/pendingAsyncCellsSlot.ts'
@@ -107,7 +111,10 @@ export async function createTestApp(): Promise<TestApp> {
107
111
  pendingAsyncCellsResolver: pendingAsyncCellsSlot.resolver,
108
112
  resolvedCellsResolver: resolvedCellsSlot.resolver,
109
113
  streamedCellsResolver: streamedCellsSlot.resolver,
114
+ docSnapshotsResolver: docSnapshotsSlot.resolver,
110
115
  activeServer: serverSlot.active,
116
+ pageRender: pageRenderSlot.render,
117
+ stalenessResolver: cacheStalenessSlot.resolver,
111
118
  }
112
119
 
113
120
  const sharedStore = createCacheStore()
@@ -122,7 +129,12 @@ export async function createTestApp(): Promise<TestApp> {
122
129
  const sharedStreamedCells = { entries: [] }
123
130
  streamedCellsSlot.resolver = () =>
124
131
  requestContext.getStore()?.streamedCells ?? sharedStreamedCells
132
+ const sharedDocSnapshots = { entries: [] }
133
+ docSnapshotsSlot.resolver = () => requestContext.getStore()?.docSnapshots ?? sharedDocSnapshots
125
134
  pageSlot.resolver = resolvePageSnapshot
135
+ /* Server-side invalidate()/refresh() broadcast over the reserved socket (ADR-0041),
136
+ exactly as serverEntry installs it. */
137
+ cacheStalenessSlot.resolver = () => broadcastCacheStaleness
126
138
 
127
139
  /* Eager env validation, exactly as serverEntry: a top-level env(schema) in
128
140
  src/server/config.ts fails the boot loudly here rather than lazily. No-op
@@ -206,7 +218,10 @@ export async function createTestApp(): Promise<TestApp> {
206
218
  pendingAsyncCellsSlot.resolver = previous.pendingAsyncCellsResolver
207
219
  resolvedCellsSlot.resolver = previous.resolvedCellsResolver
208
220
  streamedCellsSlot.resolver = previous.streamedCellsResolver
221
+ docSnapshotsSlot.resolver = previous.docSnapshotsResolver
209
222
  serverSlot.active = previous.activeServer
223
+ pageRenderSlot.render = previous.pageRender
224
+ cacheStalenessSlot.resolver = previous.stalenessResolver
210
225
  }
211
226
 
212
227
  return {