@abide/abide 0.51.0 → 0.53.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 (83) hide show
  1. package/AGENTS.md +518 -525
  2. package/CHANGELOG.md +54 -0
  3. package/README.md +130 -157
  4. package/package.json +5 -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 +2 -0
  44. package/src/lib/ui/compile/analyzeComponent.ts +2 -0
  45. package/src/lib/ui/compile/compileComponent.ts +3 -1
  46. package/src/lib/ui/compile/compileSSR.ts +12 -0
  47. package/src/lib/ui/compile/compileShadow.ts +97 -16
  48. package/src/lib/ui/compile/desugarSignals.ts +60 -32
  49. package/src/lib/ui/compile/generateBuild.ts +5 -1
  50. package/src/lib/ui/compile/hasTopLevelAwait.ts +28 -0
  51. package/src/lib/ui/compile/isFunctionScopeBoundary.ts +20 -0
  52. package/src/lib/ui/compile/liftAsyncSubExpressions.ts +2 -2
  53. package/src/lib/ui/compile/lowerContext.ts +4 -0
  54. package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
  55. package/src/lib/ui/compile/lowerScript.ts +17 -2
  56. package/src/lib/ui/compile/renameSignalRefs.ts +83 -0
  57. package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
  58. package/src/lib/ui/compile/wrapReactionCellSources.ts +32 -0
  59. package/src/lib/ui/createScope.ts +42 -1
  60. package/src/lib/ui/dom/appendText.ts +29 -8
  61. package/src/lib/ui/dom/appendTextAt.ts +21 -4
  62. package/src/lib/ui/dom/assertClaimedText.ts +14 -4
  63. package/src/lib/ui/dom/attr.ts +41 -3
  64. package/src/lib/ui/dom/awaitBlock.ts +34 -13
  65. package/src/lib/ui/dom/each.ts +10 -2
  66. package/src/lib/ui/dom/eachAsync.ts +9 -1
  67. package/src/lib/ui/dom/readCellBlocking.ts +29 -0
  68. package/src/lib/ui/dom/readTextOrSuspend.ts +21 -0
  69. package/src/lib/ui/dom/spreadAttrs.ts +47 -1
  70. package/src/lib/ui/dom/switchBlock.ts +19 -2
  71. package/src/lib/ui/dom/tryBlock.ts +10 -0
  72. package/src/lib/ui/dom/when.ts +19 -1
  73. package/src/lib/ui/dom/withSuspense.ts +22 -0
  74. package/src/lib/ui/dom/writeCell.ts +22 -0
  75. package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
  76. package/src/lib/ui/runtime/SuspenseSignal.ts +24 -0
  77. package/src/lib/ui/runtime/claimExpected.ts +6 -1
  78. package/src/lib/ui/runtime/flushEffects.ts +18 -10
  79. package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
  80. package/src/lib/ui/startClient.ts +22 -1
  81. package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
  82. package/src/lib/ui/watch.ts +7 -2
  83. package/src/serverEntry.ts +12 -0
@@ -0,0 +1,38 @@
1
+ import type { CliManifest } from './types/CliManifest.ts'
2
+
3
+ /*
4
+ Shell-completion candidates for a partially-typed command line, derived
5
+ entirely from the baked manifest so completion can't drift from dispatch.
6
+ `cword` is the token index being completed (the shell's COMP_CWORD /
7
+ CURRENT), `command` is the first positional (`words[1]`). Completing the
8
+ first positional lists every command plus the `/`-prefixed connection
9
+ verbs; past it, the chosen command's flags (mirroring parseArgvForRpc's
10
+ grammar — `--name`, plus `--no-name` for a boolean, and the always-present
11
+ `--json`). The shell filters these by the current prefix.
12
+ */
13
+ export function completeCli(
14
+ manifest: CliManifest,
15
+ cword: number,
16
+ command: string | undefined,
17
+ ): string[] {
18
+ // Completing the command itself: every rpc/socket command + the connection verbs.
19
+ if (cword <= 1) {
20
+ const connection = ['/connect', '/start', '/disconnect', '/help', '/completions']
21
+ return [...Object.keys(manifest).toSorted(), ...connection]
22
+ }
23
+ const entry = command === undefined ? undefined : manifest[command]
24
+ if (!entry) {
25
+ return []
26
+ }
27
+ const properties =
28
+ (entry.jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
29
+ const flags = ['--json']
30
+ for (const [key, value] of Object.entries(properties)) {
31
+ flags.push(`--${key}`)
32
+ // A boolean flag also accepts its negation, matching printCommandHelp's `--no-<key>`.
33
+ if (value.type === 'boolean') {
34
+ flags.push(`--no-${key}`)
35
+ }
36
+ }
37
+ return flags
38
+ }
@@ -70,6 +70,7 @@ export function printTopLevelHelp(
70
70
  console.log(` ${programName} /start start a local instance`)
71
71
  console.log(` ${programName} /disconnect forget the saved connection`)
72
72
  console.log(` ${programName} resume the saved connection (session)`)
73
+ console.log(` ${programName} /completions <bash|zsh|fish> print a shell completion script`)
73
74
  console.log(`\n --help, -h show this help`)
74
75
  console.log(` <command> --help show help for a specific command`)
75
76
  console.log(`\nenv:`)
@@ -0,0 +1,56 @@
1
+ /*
2
+ The shell-completion script for a standalone CLI binary. Emits a thin
3
+ wrapper that shells back into the binary's `/completions --query` on every
4
+ tab — so the candidate list always reflects the manifest baked into the
5
+ running binary (rebuild the binary and completion updates, no re-source).
6
+ Returns undefined for an unrecognised shell so the caller can error. The
7
+ `\0` sentinel below is the program name, substituted once here rather than
8
+ threaded through every heredoc line.
9
+ */
10
+ export function renderCliCompletions(
11
+ programName: string,
12
+ shell: string | undefined,
13
+ ): string | undefined {
14
+ const script = SCRIPTS[shell ?? '']
15
+ if (!script) {
16
+ return undefined
17
+ }
18
+ return script.replaceAll('\0', programName)
19
+ }
20
+
21
+ /*
22
+ One wrapper per shell. Each forwards the tokens typed so far — the cursor
23
+ index and the command word (`words[1]`) — to `\0 /completions --query`,
24
+ then lets the shell filter the newline-separated candidates by the current
25
+ prefix. `2>/dev/null` keeps a connection error off the completion output.
26
+ The index is normalised to bash's 0-based `COMP_CWORD` convention (program =
27
+ 0, first positional = 1) that `completeCli` expects: zsh's `$CURRENT` is
28
+ 1-based (`words[1]` is the program), so it forwards `CURRENT - 1`.
29
+ */
30
+ const SCRIPTS: Record<string, string> = {
31
+ bash: `_\0_complete() {
32
+ local cur cmd candidates
33
+ cur="\${COMP_WORDS[COMP_CWORD]}"
34
+ cmd="\${COMP_WORDS[1]}"
35
+ candidates="$(\0 /completions --query "\${COMP_CWORD}" "\${cmd}" 2>/dev/null)"
36
+ COMPREPLY=( $(compgen -W "\${candidates}" -- "\${cur}") )
37
+ }
38
+ complete -F _\0_complete \0
39
+ `,
40
+ zsh: `#compdef \0
41
+ _\0() {
42
+ local cmd="\${words[2]}"
43
+ local -a candidates
44
+ candidates=("\${(@f)$(\0 /completions --query "\$((CURRENT - 1))" "\${cmd}" 2>/dev/null)}")
45
+ compadd -- "\${candidates[@]}"
46
+ }
47
+ compdef _\0 \0
48
+ `,
49
+ fish: `function __\0_complete
50
+ set -l words (commandline -opc)
51
+ set -l cword (count $words)
52
+ \0 /completions --query (math $cword) $words[2] 2>/dev/null
53
+ end
54
+ complete -c \0 -f -a '(__\0_complete)'
55
+ `,
56
+ }
@@ -1,12 +1,14 @@
1
1
  import { clearLastConnection } from '../shared/clearLastConnection.ts'
2
2
  import { loadEnvFromDataDir } from '../shared/loadEnvFromDataDir.ts'
3
3
  import { messageFromError } from '../shared/messageFromError.ts'
4
+ import { completeCli } from './completeCli.ts'
4
5
  import { connectToServer } from './connectToServer.ts'
5
6
  import { dispatchCommand } from './dispatchCommand.ts'
6
7
  import { loadEnvFromBinaryDir } from './loadEnvFromBinaryDir.ts'
7
8
  import { printCommandHelp } from './printCommandHelp.ts'
8
9
  import { printTopLevelHelp } from './printTopLevelHelp.ts'
9
10
  import { printTrimmed } from './printTrimmed.ts'
11
+ import { renderCliCompletions } from './renderCliCompletions.ts'
10
12
  import { resolveCliTarget } from './resolveCliTarget.ts'
11
13
  import { runSession } from './runSession.ts'
12
14
  import { startLocalInstance } from './startLocalInstance.ts'
@@ -51,6 +53,7 @@ a bare word runs a command:
51
53
  /connect <url> → connect to a remote server, open a session
52
54
  /start → boot a local instance, open a session
53
55
  /disconnect → forget the saved connection, exit
56
+ /completions <shell> → print a bash/zsh/fish completion script (local, no connection)
54
57
  <cmd> [--flags] → one-shot RPC against the resumed target
55
58
 
56
59
  The connection rpcs are `/`-prefixed only — no bare aliases — so a bare word is
@@ -93,6 +96,30 @@ export async function runCli({
93
96
  return 0
94
97
  }
95
98
 
99
+ // Shell completion: `--query` answers a live tab (candidates from the baked
100
+ // manifest, no server needed), otherwise emit the wrapper script for a shell.
101
+ if (first === '/completions') {
102
+ if (argv[1] === '--query') {
103
+ const cword = Number(argv[2] ?? '1')
104
+ const command = argv[3]
105
+ for (const candidate of completeCli(
106
+ manifest,
107
+ Number.isNaN(cword) ? 1 : cword,
108
+ command,
109
+ )) {
110
+ console.log(candidate)
111
+ }
112
+ return 0
113
+ }
114
+ const script = renderCliCompletions(programName, argv[1])
115
+ if (!script) {
116
+ console.error(`${programName}: /completions requires a shell — bash, zsh, or fish`)
117
+ return 1
118
+ }
119
+ process.stdout.write(script)
120
+ return 0
121
+ }
122
+
96
123
  // No command: interactive session on a TTY, help otherwise (scripts/pipes).
97
124
  if (!first) {
98
125
  if (!process.stdin.isTTY) {
@@ -0,0 +1,70 @@
1
+ import { basePath } from '../shared/basePath.ts'
2
+ import { forwardHeaders } from '../shared/forwardHeaders.ts'
3
+ import type { PageRoutes, PathParams } from '../shared/url.ts'
4
+ import { url } from '../shared/url.ts'
5
+ import { getActiveServer } from './runtime/getActiveServer.ts'
6
+ import { pageRenderSlot } from './runtime/pageRenderSlot.ts'
7
+ import { requestContext } from './runtime/requestContext.ts'
8
+
9
+ /* Query args carried onto the rendered URL — the same shape url() appends. */
10
+ type Query = Record<string, string | number | boolean | undefined>
11
+
12
+ /*
13
+ Renders a page route to its HTML string, server-side, from anywhere in a request
14
+ scope — the same pipeline (app.html shell, layout chain, params, inline rpc
15
+ reads) an HTTP GET of that URL would run, so the page stays directly linkable and
16
+ its emailed form is one call away. Argument shape mirrors url()/navigate: a route
17
+ literal with `[name]` segments takes its params first (`render('/emails/[id]',
18
+ { id })`), then optional query; a paramless route takes optional query directly.
19
+
20
+ The page renders in a fresh nested request scope (like an in-process rpc call), so
21
+ its own `cache()`/rpc reads resolve exactly as under a live request; app.handle
22
+ middleware and gzip are not applied. A page whose content is baked inline —
23
+ top-level `await` or a blocking `{#await expr then value}` — returns complete,
24
+ self-contained HTML. A page with streaming `{#await}` blocks returns the shell
25
+ plus trailing `<abide-resolve>` fragments a browser reassembles client-side, so
26
+ for a no-JS surface (email) use blocking awaits.
27
+ */
28
+ // @documentation render
29
+ export function render<P extends keyof PageRoutes | (string & {})>(
30
+ path: P,
31
+ ...args: keyof PathParams<P> extends never
32
+ ? [query?: Query]
33
+ : [params: PathParams<P>, query?: Query]
34
+ ): Promise<string>
35
+ export async function render(path: string, first?: Query, second?: Query): Promise<string> {
36
+ const dispatch = pageRenderSlot.render
37
+ if (!dispatch) {
38
+ throw new Error(
39
+ '[abide] render() called before init — make sure your call happens inside or after app.ts init() resolves',
40
+ )
41
+ }
42
+ /* url() discriminates params-vs-query off the path's own segments at runtime;
43
+ cast past its typed overload since `path` is a plain string here. */
44
+ const resolved = (url as (path: string, first?: Query, second?: Query) => string)(
45
+ path,
46
+ first,
47
+ second,
48
+ )
49
+ /* url() prefixes the mount base for browser-facing links; strip it back off so
50
+ the pathname matches the base-less page route keys the resolver matches on. */
51
+ const base = basePath()
52
+ const routePath =
53
+ base && resolved.startsWith(base) ? resolved.slice(base.length) || '/' : resolved
54
+ /* Origin only shapes absolute-URL generation inside the page; the live server's
55
+ own origin when booted, a stable localhost otherwise. */
56
+ const origin = getActiveServer()?.url.origin ?? 'http://localhost'
57
+ const requestUrl = new URL(routePath, origin)
58
+ /* Called inside a request scope, inherit the caller's auth/identity context —
59
+ the same allowlisted cookies/authorization/trace/forwarded headers defineRpc
60
+ threads onto an in-process rpc, cached per scope. Outside a scope (a cron/CLI
61
+ render) the page renders with no forwarded headers. */
62
+ const store = requestContext.getStore()
63
+ let headers: Headers | undefined
64
+ if (store) {
65
+ store.forwardedHeaders ??= forwardHeaders(store.req.headers)
66
+ headers = store.forwardedHeaders
67
+ }
68
+ const response = await dispatch(new Request(requestUrl, { headers }), requestUrl)
69
+ return response.text()
70
+ }
@@ -0,0 +1,33 @@
1
+ import { CACHE_STALENESS_SOCKET } from '../../shared/CACHE_STALENESS_SOCKET.ts'
2
+ import { serializeSelector } from '../../shared/serializeSelector.ts'
3
+ import type { CacheSelector } from '../../shared/types/CacheSelector.ts'
4
+ import { lookupSocket } from '../sockets/lookupSocket.ts'
5
+
6
+ /*
7
+ The server half of the isomorphic staleness verbs (ADR-0041): serialize the selector
8
+ to the cross-client envelope and publish it on the reserved __abide/cache socket so
9
+ every connected browser applies the same drop/refetch locally. Installed as the
10
+ cacheStalenessSlot resolver by serverEntry ONLY — never imported from a shared or ui
11
+ module. That single discipline is what keeps this server socket code (and lookupSocket)
12
+ out of the client bundle: the ADR-0022 DCE guard polices the app's own src/server edge,
13
+ NOT abide's internal lib/server modules, so it would NOT catch a leak from here — the
14
+ resolver-slot indirection is the actual guarantee. Do not import this from a
15
+ client-reachable module.
16
+
17
+ serializeSelector throws for a producer/closure or bare match-all selector (not
18
+ cross-client serializable) — the throw surfaces at the mutation site as the
19
+ programming error it is. Publishing is subscriber-gated by defineSocket.publish
20
+ (it skips the encode + native fan-out when no ws client is subscribed), and the
21
+ reserved socket keeps no tail, so a frame published to nobody simply evaporates.
22
+ */
23
+ export function broadcastCacheStaleness<Args, Return>(
24
+ op: 'invalidate' | 'refresh',
25
+ selector: CacheSelector<Args, Return>,
26
+ args?: Args,
27
+ ): void {
28
+ const frame = serializeSelector(op, selector, args)
29
+ /* Resolved lazily from the registry — the socket is minted at server boot, before any
30
+ request-scoped mutation can call a staleness verb. Absent only in a degenerate boot. */
31
+ const entry = lookupSocket(CACHE_STALENESS_SOCKET)
32
+ entry?.socket.publish(frame)
33
+ }
@@ -5,12 +5,14 @@ import type { McpServer } from '../../mcp/types/McpServer.ts'
5
5
  import { abideLog } from '../../shared/abideLog.ts'
6
6
  import { basePathFromAppUrl } from '../../shared/basePathFromAppUrl.ts'
7
7
  import { baseSlot } from '../../shared/baseSlot.ts'
8
+ import { CACHE_STALENESS_SOCKET } from '../../shared/CACHE_STALENESS_SOCKET.ts'
8
9
  import { extraForwardHeaders } from '../../shared/extraForwardHeaders.ts'
9
10
  import { healthReadSlot } from '../../shared/healthReadSlot.ts'
10
11
  import { isDebugNegated } from '../../shared/isDebugNegated.ts'
11
12
  import { logClosingRecord } from '../../shared/logClosingRecord.ts'
12
13
  import { OFFLINE_HEADER } from '../../shared/OFFLINE_HEADER.ts'
13
14
  import { parseBoundedEnvInt } from '../../shared/parseBoundedEnvInt.ts'
15
+ import { RESERVED_SOCKET_PREFIX } from '../../shared/RESERVED_SOCKET_PREFIX.ts'
14
16
  import { requestScopeSlot } from '../../shared/requestScopeSlot.ts'
15
17
  import { setAppName } from '../../shared/setAppName.ts'
16
18
  import type { Layouts } from '../../ui/types/Layouts.ts'
@@ -19,6 +21,7 @@ import type { AppModule } from '../AppModule.ts'
19
21
  import type { PromptRoutes } from '../prompts/types/PromptRoutes.ts'
20
22
  import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
21
23
  import { createSocketDispatcher } from '../sockets/createSocketDispatcher.ts'
24
+ import { defineSocket } from '../sockets/defineSocket.ts'
22
25
  import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
23
26
  import { buildHealthPayload } from './buildHealthPayload.ts'
24
27
  import { buildOpenApiSpec } from './buildOpenApiSpec.ts'
@@ -39,6 +42,7 @@ import { internalErrorResponse } from './internalErrorResponse.ts'
39
42
  import { listenOnOpenPort } from './listenOnOpenPort.ts'
40
43
  import { logExposedSurfaces } from './logExposedSurfaces.ts'
41
44
  import { maybeMountInspector } from './maybeMountInspector.ts'
45
+ import { pageRenderSlot } from './pageRenderSlot.ts'
42
46
  import { parseIdleTimeout } from './parseIdleTimeout.ts'
43
47
  import { parsePort } from './parsePort.ts'
44
48
  import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
@@ -348,6 +352,30 @@ export async function createServer({
348
352
  })
349
353
  }
350
354
 
355
+ /*
356
+ Publish the in-process page-render seam for the public `render()`. It resolves
357
+ a synthetic GET request the same way the fetch handler resolves live app routes
358
+ (matchRoute → page handler / 308 redirect), then runs the matched handler
359
+ directly under runWithRequestScope — the page analogue of dispatchRpcInProcess,
360
+ so it skips app.handle middleware and wire finalization (gzip) exactly as the
361
+ in-process rpc seam does. A URL that resolves to no page renders the framework
362
+ 404, matching the fetch fallback.
363
+ */
364
+ pageRenderSlot.render = (request, url) => {
365
+ const resolution = resolveAppRoute(request, url)
366
+ if (resolution.kind === 'redirect') {
367
+ return Promise.resolve(resolution.response)
368
+ }
369
+ if (resolution.kind === 'handler') {
370
+ return runWithRequestScope(request, { app, logRequests: false, url }, (store) =>
371
+ resolution.handler(request, resolution.params, store),
372
+ )
373
+ }
374
+ return runWithRequestScope(request, { app, logRequests: false, url }, async (store) => {
375
+ return (await renderError(404, 'Not Found', store)) ?? textResponse(404)
376
+ })
377
+ }
378
+
351
379
  /*
352
380
  Abide's only native WebSocket surface is the sockets hub: every Socket
353
381
  declared under src/server/sockets/ multiplexes onto one framework-owned
@@ -356,6 +384,21 @@ export async function createServer({
356
384
  lifecycle. Steady-state fan-out rides Bun's native server.publish so
357
385
  a busy socket doesn't iterate JS per subscriber per message.
358
386
  */
387
+ /* Reserve the `__abide/` socket namespace: a user socket file whose name lands there
388
+ would shadow a framework-minted internal topic (the cache-staleness pipe below), so
389
+ fail the boot loudly rather than let it silently override. */
390
+ for (const name of Object.keys(sockets)) {
391
+ if (name.startsWith(RESERVED_SOCKET_PREFIX)) {
392
+ throw new Error(
393
+ `[abide] socket name "${name}" is reserved — the "${RESERVED_SOCKET_PREFIX}" namespace is framework-internal. Rename the file under src/server/sockets.`,
394
+ )
395
+ }
396
+ }
397
+ /* Mint the reserved cache-staleness topic (ADR-0041): server-publish-only, no retention
398
+ tail (a pure live pipe). defineSocket registers it in socketRegistry, where the
399
+ broadcaster and the dispatcher's reserved-name path both resolve it. */
400
+ defineSocket(CACHE_STALENESS_SOCKET, { tail: 0, clientPublish: false })
401
+
359
402
  const socketDispatcher = createSocketDispatcher(sockets)
360
403
 
361
404
  /*
@@ -1,5 +1,6 @@
1
1
  import { appNameSlot } from '../../shared/appNameSlot.ts'
2
2
  import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
3
+ import { docSnapshotsSlot } from '../../shared/docSnapshotsSlot.ts'
3
4
  import { encodeRefJson } from '../../shared/encodeRefJson.ts'
4
5
  import { formatTraceparent } from '../../shared/formatTraceparent.ts'
5
6
  import { hasSeedableRequest } from '../../shared/hasSeedableRequest.ts'
@@ -125,6 +126,41 @@ export function createUiPageRenderer({
125
126
  return Object.keys(cells).length > 0 ? cells : undefined
126
127
  }
127
128
 
129
+ /* The `docs` partition of __SSR__: each rendered scope's reactive-document snapshot (drained from
130
+ the request-scoped `docSnapshotsSlot` populated by `createScope`), keyed by its render-path id,
131
+ ref-json-encoded. A scope that used no state snapshots to `{}` and is dropped; an unserializable
132
+ doc value is dropped with the cell falling back to a client re-init. Undefined when no scope
133
+ carried seedable synchronous state (the common cell-only or static page). */
134
+ function docSeedSnapshots(): Record<string, string> | undefined {
135
+ const entries = docSnapshotsSlot.get()?.entries ?? []
136
+ if (entries.length === 0) {
137
+ return undefined
138
+ }
139
+ const docs: Record<string, string> = {}
140
+ for (const { id, take } of entries) {
141
+ let snapshot: unknown
142
+ try {
143
+ snapshot = take()
144
+ } catch {
145
+ continue
146
+ }
147
+ if (
148
+ snapshot === null ||
149
+ typeof snapshot !== 'object' ||
150
+ Object.keys(snapshot).length === 0
151
+ ) {
152
+ continue
153
+ }
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. */
159
+ }
160
+ }
161
+ return Object.keys(docs).length > 0 ? docs : undefined
162
+ }
163
+
128
164
  /* Build the __SSR__ <script> the client (startClient) reads on boot. The inline
129
165
  (settled) cache partition is computed once by the caller and threaded in, so the
130
166
  streaming branch can also drain the pending partition over the same render. */
@@ -140,6 +176,7 @@ export function createUiPageRenderer({
140
176
  params,
141
177
  cache: inline,
142
178
  cells: resolvedCellCells(),
179
+ docs: docSeedSnapshots(),
143
180
  base: base || undefined,
144
181
  trace: formatTraceparent(store.trace),
145
182
  app: appNameSlot.name,
@@ -0,0 +1,15 @@
1
+ /*
2
+ Internal holder for the in-process page-render dispatch. createServer sets this
3
+ once at boot, closing over the app's route resolver + render pipeline, so the
4
+ public `render()` can turn a route URL into the same Response the HTTP fetch
5
+ handler would — a page render, without a socket hop. Empty until boot: `render()`
6
+ throws on access before the slot is set (mirroring serverSlot / server()). The
7
+ closure runs the matched page handler directly under runWithRequestScope — like
8
+ dispatchRpcInProcess for rpcs — so it skips app.handle middleware and wire
9
+ finalization (gzip); it is the page analogue of the in-process rpc seam.
10
+ */
11
+ export const pageRenderSlot: {
12
+ render: ((request: Request, url: URL) => Promise<Response>) | undefined
13
+ } = {
14
+ render: undefined,
15
+ }
@@ -34,6 +34,7 @@ export function runWithRequestScope(
34
34
  pendingAsyncCells: { promises: [] },
35
35
  resolvedCells: { entries: [] },
36
36
  streamedCells: { entries: [] },
37
+ docSnapshots: { entries: [] },
37
38
  trace: createTraceContext(req.headers.get('traceparent')),
38
39
  start: Bun.nanoseconds(),
39
40
  }
@@ -1,4 +1,5 @@
1
1
  import type { CacheStore } from '../../../shared/types/CacheStore.ts'
2
+ import type { DocSnapshots } from '../../../shared/types/DocSnapshots.ts'
2
3
  import type { PendingAsyncCells } from '../../../shared/types/PendingAsyncCells.ts'
3
4
  import type { ResolvedCells } from '../../../shared/types/ResolvedCells.ts'
4
5
  import type { StreamedCells } from '../../../shared/types/StreamedCells.ts'
@@ -41,6 +42,14 @@ export type RequestStore = {
41
42
  */
42
43
  streamedCells: StreamedCells
43
44
  /*
45
+ Reactive-document snapshots captured during this request's SSR pass, keyed by render-path id.
46
+ `createScope` registers a lazy `take` for each rendered scope; the page renderer stamps the
47
+ non-empty ones into `__SSR__.docs` (ref-json) so the client seeds a plain `state(initial)` to
48
+ the server value rather than re-running a divergent init. Sibling of `resolvedCells` — this holds
49
+ synchronous document state, taken at render-return rather than at settle.
50
+ */
51
+ docSnapshots: DocSnapshots
52
+ /*
44
53
  W3C trace position: inbound `traceparent` continued (prefer-incoming) or a
45
54
  fresh sampled trace minted at the boundary. Read by trace()/log via the
46
55
  request-scope resolver and stamped into __SSR__ for the browser half.
@@ -4,6 +4,7 @@ import { decodeWireBody } from '../../shared/decodeWireBody.ts'
4
4
  import { encodeRefJson } from '../../shared/encodeRefJson.ts'
5
5
  import { memoizeByKey } from '../../shared/memoizeByKey.ts'
6
6
  import { messageFromError } from '../../shared/messageFromError.ts'
7
+ import { RESERVED_SOCKET_PREFIX } from '../../shared/RESERVED_SOCKET_PREFIX.ts'
7
8
  import type { SocketClientFrame } from '../../shared/types/SocketClientFrame.ts'
8
9
  import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
9
10
  import { error } from '../error.ts'
@@ -86,6 +87,14 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
86
87
  | { entry: SocketRegistryEntry }
87
88
  | { failure: 'unregistered' | 'load-failed' | 'no-export'; message: string }
88
89
  > {
90
+ /* A reserved `__abide/` topic has no user module to load — it is minted at boot into
91
+ the registry (ADR-0041), so skip the loader and read it straight from there. */
92
+ if (name.startsWith(RESERVED_SOCKET_PREFIX)) {
93
+ const reserved = lookupSocket(name)
94
+ return reserved
95
+ ? { entry: reserved }
96
+ : { failure: 'unregistered', message: `[abide] no socket registered at ${name}` }
97
+ }
89
98
  const loader = ensureLoaded(name)
90
99
  if (!loader) {
91
100
  return { failure: 'unregistered', message: `[abide] no socket registered at ${name}` }
@@ -1,6 +1,18 @@
1
+ import { RESERVED_SOCKET_PREFIX } from '../../shared/RESERVED_SOCKET_PREFIX.ts'
1
2
  import { socketRegistry } from './socketRegistry.ts'
2
3
  import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
3
4
 
4
5
  export function registerSocket(entry: SocketRegistryEntry): void {
6
+ /* A reserved (__abide/) topic is framework-internal and server-publish-only. Enforce
7
+ that invariant at the one chokepoint every socket flows through: a reserved name can
8
+ never register with client publish enabled, so no later trusted-but-mistaken
9
+ defineSocket call can turn an internal topic into one a browser could forge frames on
10
+ (ADR-0041). The framework mints its reserved topics clientPublish:false, so this never
11
+ fires on the legitimate path; the boot scan separately rejects reserved user files. */
12
+ if (entry.socket.name.startsWith(RESERVED_SOCKET_PREFIX) && entry.allowClientPublish) {
13
+ throw new Error(
14
+ `[abide] socket "${entry.socket.name}" is reserved — the "${RESERVED_SOCKET_PREFIX}" namespace is framework-internal and cannot enable clientPublish.`,
15
+ )
16
+ }
5
17
  socketRegistry.set(entry.socket.name, entry)
6
18
  }
@@ -0,0 +1,8 @@
1
+ /*
2
+ The reserved internal socket the server broadcasts cache-staleness frames on
3
+ (ADR-0041): server-publish-only (`clientPublish: false`) and a pure live pipe
4
+ (`tail: 0`, no retention) — delivery is live-only, never replayed, so an offline
5
+ client falls back to SWR staleness. Named in the reserved `__abide/` namespace so
6
+ user code can't shadow it.
7
+ */
8
+ export const CACHE_STALENESS_SOCKET = '__abide/cache'
@@ -0,0 +1,8 @@
1
+ /*
2
+ The reserved socket-name namespace (ADR-0041). Framework-minted internal topics
3
+ live under `__abide/` (e.g. the cache-staleness pipe); user socket files under
4
+ `src/server/sockets` may not declare a name in this namespace — createServer rejects
5
+ the boot, and the dispatcher resolves these names straight from the registry
6
+ (bypassing the user-module loader) so an internal topic can never be shadowed.
7
+ */
8
+ export const RESERVED_SOCKET_PREFIX = '__abide/'
@@ -0,0 +1,18 @@
1
+ import { cache } from './cache.ts'
2
+ import type { CacheSelector } from './types/CacheSelector.ts'
3
+
4
+ /*
5
+ Applies a staleness verb to THIS side's cache store(s) — the local half of the
6
+ isomorphic invalidate/refresh (ADR-0041). Both the cacheStalenessSlot fallback and
7
+ the client entry's resolver point at this one function so the local-apply path can't
8
+ diverge between "unbooted unit test" and "booted client tab". The server entry
9
+ replaces the resolver with a broadcaster instead (the side-swap seam), so this never
10
+ runs on the server's throwaway request store.
11
+ */
12
+ export function applyCacheStalenessLocally<Args, Return>(
13
+ op: 'invalidate' | 'refresh',
14
+ selector: CacheSelector<Args, Return>,
15
+ args?: Args,
16
+ ): void {
17
+ cache[op](selector, args)
18
+ }
@@ -1,3 +1,4 @@
1
+ import { invalidate } from './invalidate.ts'
1
2
  import { keyForRemoteCall } from './keyForRemoteCall.ts'
2
3
  import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
3
4
  import { patch } from './patch.ts'
@@ -10,7 +11,7 @@ import type { RemoteFunction } from './types/RemoteFunction.ts'
10
11
 
11
12
  /*
12
13
  Attaches the pre-bound selector sugar onto an assembled RemoteFunction:
13
- `fn.pending(args?)` ≡ `pending(fn, args?)`, likewise refreshing / refresh / peek,
14
+ `fn.pending(args?)` ≡ `pending(fn, args?)`, likewise refreshing / refresh / invalidate / peek,
14
15
  `fn.patch(args?, updater)` ≡ `patch(fn, args, updater)`, and `fn.error(args?)` — the typed
15
16
  last error from the rpc error registry (most-recent across the rpc when args omitted, that
16
17
  exact call when given). The cached read is the bare call `fn(args, opts)` itself; refetch is
@@ -25,6 +26,7 @@ export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args,
25
26
  pending: (args?: Args) => pending(fn, args),
26
27
  refreshing: (args?: Args) => refreshing(fn, args),
27
28
  refresh: (args?: Args) => refresh(fn, args),
29
+ invalidate: (args?: Args) => invalidate(fn, args),
28
30
  peek: (args?: Args) => peek(fn, args),
29
31
  patch: (argsOrUpdater?: unknown, updater?: unknown) =>
30
32
  (patch as (fn: unknown, a?: unknown, b?: unknown) => void)(fn, argsOrUpdater, updater),
@@ -758,8 +758,22 @@ cache; the lifecycle ping still fires but recomputes pending() to the same value
758
758
  function invalidate<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
759
759
  /* Resolve the fn-selector prefix once; the matcher and the label both consume it. */
760
760
  const prefix = selectorPrefix(arg, args)
761
- const matches = selectorMatcher(arg, args, prefix)
762
- invalidateTripwire(selectorLabel(arg, args, prefix))
761
+ invalidateMatching(selectorMatcher(arg, args, prefix), selectorLabel(arg, args, prefix), prefix)
762
+ }
763
+
764
+ /*
765
+ The store-loop body of invalidate(), driven by a raw entry predicate rather than a
766
+ selector — the single apply-by-matcher seam so a local invalidate() and a
767
+ wire-driven one (a server broadcast decoded via matcherFromEnvelope) run the EXACT
768
+ same drop loop and can't diverge (ADR-0041). `label` feeds the cycle tripwire;
769
+ `prefix` (undefined for a tag selector) resets recorded rpc errors for the selector.
770
+ */
771
+ function invalidateMatching(
772
+ matches: (entry: CacheEntry) => boolean,
773
+ label: string,
774
+ prefix: string | undefined,
775
+ ): void {
776
+ invalidateTripwire(label)
763
777
  /* Reset any recorded rpc errors for this selector too (independent of cache entries — a
764
778
  bare call that errored never became one). Only fn selectors resolve a prefix. */
765
779
  if (prefix !== undefined) {
@@ -814,6 +828,7 @@ function selectorLabel<Args, Return>(
814
828
  }
815
829
 
816
830
  cache.invalidate = invalidate
831
+ cache.invalidateMatching = invalidateMatching
817
832
 
818
833
  /*
819
834
  The smart-call refetch: refetches every entry matching the selector, keeping the
@@ -836,8 +851,20 @@ way to re-run, so it drops (the next read reloads) — the invalidate fallback.
836
851
  */
837
852
  function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
838
853
  const prefix = selectorPrefix(arg, args)
839
- const matches = selectorMatcher(arg, args, prefix)
840
- invalidateTripwire(selectorLabel(arg, args, prefix))
854
+ refreshMatching(selectorMatcher(arg, args, prefix), selectorLabel(arg, args, prefix), prefix)
855
+ }
856
+
857
+ /*
858
+ The store-loop body of refresh(), driven by a raw entry predicate — the refetch
859
+ analogue of invalidateMatching, so a local refresh() and a wire-driven one share
860
+ one refetch loop (ADR-0041).
861
+ */
862
+ function refreshMatching(
863
+ matches: (entry: CacheEntry) => boolean,
864
+ label: string,
865
+ prefix: string | undefined,
866
+ ): void {
867
+ invalidateTripwire(label)
841
868
  if (prefix !== undefined) {
842
869
  rpcErrorRegistry.clearMatching(prefix)
843
870
  }
@@ -883,6 +910,7 @@ function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args):
883
910
  }
884
911
 
885
912
  cache.refresh = refresh
913
+ cache.refreshMatching = refreshMatching
886
914
 
887
915
  /*
888
916
  Local value mutation: replaces the retained value of every entry matching the
@@ -0,0 +1,21 @@
1
+ import { applyCacheStalenessLocally } from './applyCacheStalenessLocally.ts'
2
+ import { createResolverSlot } from './createResolverSlot.ts'
3
+ import type { CacheStalenessApply } from './types/CacheStalenessApply.ts'
4
+
5
+ /*
6
+ The one side-swap seam for the isomorphic staleness verbs (ADR-0041), mirroring
7
+ cacheStoreSlot exactly. `invalidate()` and `refresh()` route through this after their
8
+ async-cell short-circuit, so their sources stay byte-identical on both sides — the
9
+ resolver decides the side:
10
+
11
+ - client entry (startClient): installs applyCacheStalenessLocally — drop/refetch
12
+ this tab's cache.
13
+ - server entry (serverEntry): installs a broadcaster — serialize the selector and
14
+ publish it to every connected client over the reserved __abide/cache socket.
15
+
16
+ With no resolver registered the fallback is applyCacheStalenessLocally too, so
17
+ isolated unit tests keep today's local behaviour without booting a runtime.
18
+ */
19
+ export const cacheStalenessSlot = createResolverSlot<CacheStalenessApply>(
20
+ () => applyCacheStalenessLocally,
21
+ )