@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.
- package/AGENTS.md +509 -525
- package/CHANGELOG.md +29 -0
- package/README.md +128 -157
- package/package.json +4 -1
- package/src/abideResolverPlugin.ts +12 -0
- package/src/checkAbide.ts +60 -7
- package/src/lib/cli/completeCli.ts +38 -0
- package/src/lib/cli/printTopLevelHelp.ts +1 -0
- package/src/lib/cli/renderCliCompletions.ts +56 -0
- package/src/lib/cli/runCli.ts +27 -0
- package/src/lib/server/render.ts +70 -0
- package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
- package/src/lib/server/runtime/createServer.ts +43 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
- package/src/lib/server/runtime/pageRenderSlot.ts +15 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/registerSocket.ts +12 -0
- package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
- package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
- package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
- package/src/lib/shared/cache.ts +32 -4
- package/src/lib/shared/cacheStalenessSlot.ts +21 -0
- package/src/lib/shared/createRpcServerProgram.ts +187 -0
- package/src/lib/shared/docSnapshotsSlot.ts +13 -0
- package/src/lib/shared/invalidate.ts +39 -0
- package/src/lib/shared/matcherFromEnvelope.ts +31 -0
- package/src/lib/shared/prepareRpcModule.ts +22 -3
- package/src/lib/shared/refresh.ts +9 -2
- package/src/lib/shared/selectorMatcher.ts +2 -15
- package/src/lib/shared/serializeSelector.ts +69 -0
- package/src/lib/shared/setsIntersect.ts +15 -0
- package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
- package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
- package/src/lib/shared/types/DocSnapshots.ts +12 -0
- package/src/lib/shared/types/RemoteFunction.ts +11 -8
- package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
- package/src/lib/shared/types/SsrPayload.ts +5 -0
- package/src/lib/test/createTestApp.ts +15 -0
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +92 -14
- package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
- package/src/lib/ui/compile/lowerScript.ts +6 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +32 -0
- package/src/lib/ui/createScope.ts +42 -1
- package/src/lib/ui/dom/assertClaimedText.ts +14 -4
- package/src/lib/ui/dom/attr.ts +25 -2
- package/src/lib/ui/dom/writeCell.ts +22 -0
- package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
- package/src/lib/ui/runtime/claimExpected.ts +6 -1
- package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
- package/src/lib/ui/startClient.ts +22 -1
- package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
- package/src/serverEntry.ts +12 -0
|
@@ -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
|
+
}
|
package/src/lib/cli/runCli.ts
CHANGED
|
@@ -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),
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -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
|
-
|
|
762
|
-
|
|
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
|
-
|
|
840
|
-
|
|
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
|
+
)
|