@abide/abide 0.38.1 → 0.40.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 +5 -5
- package/CHANGELOG.md +63 -0
- package/package.json +9 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
- package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
- package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +19 -3
- package/src/lib/server/sockets/defineSocket.ts +3 -1
- package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
- package/src/lib/shared/buildRpcRequest.ts +8 -1
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
- package/src/lib/ui/compile/compileModule.ts +5 -2
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +25 -10
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +87 -76
- package/src/lib/ui/compile/generateSSR.ts +217 -71
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +118 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
- package/src/lib/ui/createScope.ts +14 -0
- package/src/lib/ui/dom/appendText.ts +2 -3
- package/src/lib/ui/dom/appendTextAt.ts +2 -3
- package/src/lib/ui/dom/applyResolved.ts +5 -8
- package/src/lib/ui/dom/awaitBlock.ts +14 -1
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/installHotBridge.ts +10 -0
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +84 -68
- package/src/lib/ui/resumeSeedScript.ts +27 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
- package/src/lib/ui/socketChannel.ts +5 -2
- package/src/lib/ui/tryEncodeResume.ts +20 -0
- package/src/lib/ui/types/Scope.ts +9 -3
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
|
@@ -12,12 +12,17 @@ import { cloneStatic } from './dom/cloneStatic.ts'
|
|
|
12
12
|
import { each } from './dom/each.ts'
|
|
13
13
|
import { eachAsync } from './dom/eachAsync.ts'
|
|
14
14
|
import { hydrate } from './dom/hydrate.ts'
|
|
15
|
+
import { mergeProps } from './dom/mergeProps.ts'
|
|
15
16
|
import { mount } from './dom/mount.ts'
|
|
16
17
|
import { mountChild } from './dom/mountChild.ts'
|
|
17
18
|
import { mountSlot } from './dom/mountSlot.ts'
|
|
18
19
|
import { on } from './dom/on.ts'
|
|
19
20
|
import { outlet } from './dom/outlet.ts'
|
|
21
|
+
import { readCall } from './dom/readCall.ts'
|
|
22
|
+
import { restProps } from './dom/restProps.ts'
|
|
20
23
|
import { skeleton } from './dom/skeleton.ts'
|
|
24
|
+
import { spreadAttrs } from './dom/spreadAttrs.ts'
|
|
25
|
+
import { spreadProps } from './dom/spreadProps.ts'
|
|
21
26
|
import { switchBlock } from './dom/switchBlock.ts'
|
|
22
27
|
import { tryBlock } from './dom/tryBlock.ts'
|
|
23
28
|
import { when } from './dom/when.ts'
|
|
@@ -70,6 +75,11 @@ export function installHotBridge(): void {
|
|
|
70
75
|
mountSlot,
|
|
71
76
|
outlet,
|
|
72
77
|
mountChild,
|
|
78
|
+
mergeProps,
|
|
79
|
+
spreadProps,
|
|
80
|
+
restProps,
|
|
81
|
+
spreadAttrs,
|
|
82
|
+
readCall,
|
|
73
83
|
hydrate,
|
|
74
84
|
escapeKey,
|
|
75
85
|
nextBlockId,
|
package/src/lib/ui/navigate.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { historyEntries } from './runtime/historyEntries.ts'
|
|
2
2
|
import { runtimePath } from './runtime/runtimePath.ts'
|
|
3
3
|
|
|
4
|
+
/* Options for `navigate`. `replace` swaps the current history entry instead of pushing.
|
|
5
|
+
`keepScroll` carries the live scroll offset onto the destination so it isn't reset. */
|
|
6
|
+
export type NavigateOptions = {
|
|
7
|
+
replace?: boolean
|
|
8
|
+
keepScroll?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
/* Navigates to `path`: writes a history entry (when available) and updates the
|
|
5
12
|
reactive route, which re-mounts the matching page via `router`. `replace` swaps
|
|
6
13
|
the current entry instead of pushing — used when honouring a server redirect, so
|
|
@@ -9,16 +16,34 @@ import { runtimePath } from './runtime/runtimePath.ts'
|
|
|
9
16
|
teardown the rebuild does. A push leaves the current entry behind — its scroll is
|
|
10
17
|
bucketed so back restores it — and mints a fresh id. A replace destroys the current
|
|
11
18
|
entry and lands fresh content (a redirect), so its saved scroll no longer applies:
|
|
12
|
-
the bucket is discarded and the id kept, so the new page restores to top/anchor.
|
|
19
|
+
the bucket is discarded and the id kept, so the new page restores to top/anchor.
|
|
20
|
+
`keepScroll` opts the destination out of that reset — for an in-page URL swap (e.g.
|
|
21
|
+
selecting another episode on the same detail page) where a top jump is jarring: the
|
|
22
|
+
live offset is bucketed under the destination entry id, so the post-rebuild `restore`
|
|
23
|
+
reapplies it instead of scrolling to top. */
|
|
13
24
|
// @documentation navigate
|
|
14
|
-
export function navigate(
|
|
25
|
+
export function navigate(
|
|
26
|
+
path: string,
|
|
27
|
+
{ replace = false, keepScroll = false }: NavigateOptions = {},
|
|
28
|
+
): void {
|
|
15
29
|
if (typeof history !== 'undefined') {
|
|
16
30
|
if (replace) {
|
|
17
|
-
|
|
31
|
+
/* keepScroll buckets the live offset under the (unchanged) entry id so
|
|
32
|
+
restore reapplies it; otherwise the superseded content drops its bucket. */
|
|
33
|
+
if (keepScroll) {
|
|
34
|
+
historyEntries.save()
|
|
35
|
+
} else {
|
|
36
|
+
historyEntries.discard()
|
|
37
|
+
}
|
|
18
38
|
history.replaceState({ abideEntry: historyEntries.current }, '', path)
|
|
19
39
|
} else {
|
|
20
40
|
historyEntries.save()
|
|
21
41
|
history.pushState({ abideEntry: historyEntries.next() }, '', path)
|
|
42
|
+
/* Re-bucket the same offset under the freshly minted id so the pushed entry
|
|
43
|
+
restores to it rather than to top. */
|
|
44
|
+
if (keepScroll) {
|
|
45
|
+
historyEntries.save()
|
|
46
|
+
}
|
|
22
47
|
}
|
|
23
48
|
}
|
|
24
49
|
runtimePath.value = path
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { browserClientFlags } from '../shared/browserClientFlags.ts'
|
|
2
2
|
import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
|
|
3
|
+
import { cacheManagedSlot } from '../shared/cacheManagedSlot.ts'
|
|
3
4
|
import { createRemoteFunction } from '../shared/createRemoteFunction.ts'
|
|
4
5
|
import { HttpError } from '../shared/HttpError.ts'
|
|
5
6
|
import { OFFLINE_HEADER } from '../shared/OFFLINE_HEADER.ts'
|
|
@@ -7,6 +8,7 @@ import { rpcTimeoutSlot } from '../shared/rpcTimeoutSlot.ts'
|
|
|
7
8
|
import { trace } from '../shared/trace.ts'
|
|
8
9
|
import type { HttpVerb } from '../shared/types/HttpVerb.ts'
|
|
9
10
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
11
|
+
import type { RpcOptions } from '../shared/types/RpcOptions.ts'
|
|
10
12
|
import { withBase } from '../shared/withBase.ts'
|
|
11
13
|
import { currentAbortSignal } from './runtime/currentAbortSignal.ts'
|
|
12
14
|
import { REQUEST_SUPERSEDED } from './runtime/REQUEST_SUPERSEDED.ts'
|
|
@@ -38,41 +40,51 @@ export function remoteProxy<Args, Return>(
|
|
|
38
40
|
proxy (/v2/rpc/…); the cache key keeps the bare `url` (keyForRemoteCall
|
|
39
41
|
reads fn.url), so SSR snapshots round-trip base-independently.
|
|
40
42
|
*/
|
|
41
|
-
buildRequest: (args) =>
|
|
43
|
+
buildRequest: (args, opts) =>
|
|
42
44
|
buildRpcRequest({
|
|
43
45
|
method,
|
|
44
46
|
url: withBase(url),
|
|
45
47
|
args,
|
|
46
48
|
baseUrl: window.location.href,
|
|
47
|
-
headers: rpcHeaders(),
|
|
49
|
+
headers: rpcHeaders(opts?.headers),
|
|
48
50
|
}),
|
|
49
51
|
/*
|
|
50
52
|
Forcing `getRequest()` once builds the Request and seeds the
|
|
51
53
|
cache meta thunk in createRemoteFunction with the same instance,
|
|
52
54
|
so cache() readers don't reconstruct it.
|
|
53
55
|
*/
|
|
54
|
-
invoke: (_args, getRequest) => fetchWithTimeout(getRequest()),
|
|
56
|
+
invoke: (_args, getRequest, opts) => fetchWithTimeout(getRequest(), opts),
|
|
55
57
|
})
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/*
|
|
59
|
-
Fetches under
|
|
60
|
-
superseded/torn-down read cancels its in-flight request — currentAbortSignal)
|
|
61
|
-
the env-configured client timeout
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
Fetches under three optional aborts: the reactive scope that fired the call (so a
|
|
62
|
+
superseded/torn-down read cancels its in-flight request — currentAbortSignal), the
|
|
63
|
+
caller-supplied opts.signal, and the env-configured client timeout
|
|
64
|
+
(ABIDE_CLIENT_TIMEOUT, ms). None present and no transport opts → the unbounded
|
|
65
|
+
fetch, exactly as before. A timeout surfaces as a 504 HttpError so a consumer reads
|
|
66
|
+
an honest status instead of a raw DOMException → 500. Our scope abort (reason
|
|
67
|
+
REQUEST_SUPERSEDED) is swallowed into a never-settling promise: the reactive owner is
|
|
68
|
+
gone, so the result must neither resolve into a dead tree nor surface as a rejection.
|
|
69
|
+
Other rejections (genuine network failure) propagate untouched. The caller's
|
|
70
|
+
keepalive/priority/cache opts pass through to fetch unchanged.
|
|
67
71
|
*/
|
|
68
|
-
function fetchWithTimeout(request: Request): Promise<Response> {
|
|
72
|
+
function fetchWithTimeout(request: Request, opts?: RpcOptions): Promise<Response> {
|
|
69
73
|
const timeout = rpcTimeoutSlot.ms
|
|
70
74
|
const timeoutSignal = timeout === undefined ? undefined : AbortSignal.timeout(timeout)
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
/*
|
|
76
|
+
A cache-managed flight is shared across readers (cache() owns its lifetime), so a
|
|
77
|
+
single caller's signal must not abort it for the others — the same opt-out
|
|
78
|
+
currentAbortSignal makes for the scope signal. keepalive/priority/cache are
|
|
79
|
+
harmless to keep there.
|
|
80
|
+
*/
|
|
81
|
+
const callerSignal = cacheManagedSlot.active ? undefined : (opts?.signal ?? undefined)
|
|
82
|
+
const signal = combineSignals(currentAbortSignal(), callerSignal, timeoutSignal)
|
|
83
|
+
const init = fetchInit(signal, opts)
|
|
84
|
+
if (init === undefined) {
|
|
73
85
|
return fetch(request)
|
|
74
86
|
}
|
|
75
|
-
return fetch(request,
|
|
87
|
+
return fetch(request, init).catch((error: unknown) => {
|
|
76
88
|
if (error === REQUEST_SUPERSEDED) {
|
|
77
89
|
return new Promise<Response>(() => {})
|
|
78
90
|
}
|
|
@@ -85,39 +97,59 @@ function fetchWithTimeout(request: Request): Promise<Response> {
|
|
|
85
97
|
})
|
|
86
98
|
}
|
|
87
99
|
|
|
88
|
-
/*
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
/*
|
|
101
|
+
The fetch init from the merged abort signal plus the caller's transport opts
|
|
102
|
+
(keepalive/priority/cache). headers are deliberately absent — they live on the
|
|
103
|
+
Request built in buildRequest, since fetch(request, { headers }) replaces rather than
|
|
104
|
+
merges. undefined when nothing applies, preserving the allocation-free unbounded
|
|
105
|
+
fetch for the common reactive-free call.
|
|
106
|
+
*/
|
|
107
|
+
function fetchInit(signal: AbortSignal | undefined, opts?: RpcOptions): RequestInit | undefined {
|
|
108
|
+
const init: RequestInit = {}
|
|
109
|
+
if (signal !== undefined) {
|
|
110
|
+
init.signal = signal
|
|
111
|
+
}
|
|
112
|
+
if (opts?.keepalive !== undefined) {
|
|
113
|
+
init.keepalive = opts.keepalive
|
|
114
|
+
}
|
|
115
|
+
if (opts?.priority !== undefined) {
|
|
116
|
+
init.priority = opts.priority
|
|
96
117
|
}
|
|
97
|
-
if (
|
|
98
|
-
|
|
118
|
+
if (opts?.cache !== undefined) {
|
|
119
|
+
init.cache = opts.cache
|
|
120
|
+
}
|
|
121
|
+
return Object.keys(init).length === 0 ? undefined : init
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* One AbortSignal merging the scope abort, the caller signal, and the timeout:
|
|
125
|
+
AbortSignal.any over those present, the lone one when one, undefined when none
|
|
126
|
+
(the unbounded fetch). */
|
|
127
|
+
function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
|
|
128
|
+
const present = signals.filter((signal): signal is AbortSignal => signal !== undefined)
|
|
129
|
+
if (present.length <= 1) {
|
|
130
|
+
return present[0]
|
|
99
131
|
}
|
|
100
|
-
return AbortSignal.any(
|
|
132
|
+
return AbortSignal.any(present)
|
|
101
133
|
}
|
|
102
134
|
|
|
103
135
|
/*
|
|
104
|
-
abide's per-RPC headers
|
|
105
|
-
only while offline, the offline marker so the
|
|
106
|
-
caller's connectivity.
|
|
107
|
-
|
|
136
|
+
abide's per-RPC headers, merged onto the caller's opts.headers: the page traceparent
|
|
137
|
+
(continues the server trace) and, only while offline, the offline marker so the
|
|
138
|
+
handler's online() reflects the caller's connectivity. Caller headers go in first and
|
|
139
|
+
the framework's are set last, so a caller adds transport metadata (idempotency-key,
|
|
140
|
+
authorization) but can never overwrite traceparent or the offline marker; content-type
|
|
141
|
+
stays owned by buildRpcRequest. Returns undefined when neither caller nor framework set
|
|
142
|
+
a header, so the allocation-free fetch path stays the common case.
|
|
108
143
|
*/
|
|
109
|
-
function rpcHeaders(): Headers | undefined {
|
|
110
|
-
const headers = new Headers()
|
|
111
|
-
let any = false
|
|
144
|
+
function rpcHeaders(callerHeaders?: HeadersInit): Headers | undefined {
|
|
145
|
+
const headers = new Headers(callerHeaders)
|
|
112
146
|
const traceparent = trace()
|
|
113
147
|
if (traceparent) {
|
|
114
148
|
headers.set('traceparent', traceparent)
|
|
115
|
-
any = true
|
|
116
149
|
}
|
|
117
150
|
/* Presence = offline; absence = online/unknown. navigator.onLine's offline signal is the reliable direction. */
|
|
118
151
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
119
152
|
headers.set(OFFLINE_HEADER, '1')
|
|
120
|
-
any = true
|
|
121
153
|
}
|
|
122
|
-
return
|
|
154
|
+
return headers.keys().next().done ? undefined : headers
|
|
123
155
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { enterRenderPass } from './runtime/enterRenderPass.ts'
|
|
2
|
-
import { exitRenderPass } from './runtime/exitRenderPass.ts'
|
|
3
1
|
import { OUTLET_CLOSE, OUTLET_OPEN } from './runtime/OUTLET_MARKER.ts'
|
|
2
|
+
import type { RenderContext } from './runtime/types/RenderContext.ts'
|
|
4
3
|
import type { SsrRender } from './runtime/types/SsrRender.ts'
|
|
5
4
|
import type { UiComponent } from './runtime/types/UiComponent.ts'
|
|
6
5
|
|
|
@@ -11,48 +10,51 @@ const OUTLET_PLACEHOLDER = `${OPEN}${CLOSE}`
|
|
|
11
10
|
|
|
12
11
|
/*
|
|
13
12
|
Server-renders a route's layout chain wrapped around its page into one SsrRender.
|
|
14
|
-
`views` is ordered outermost layout → … → page. The whole chain
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
the
|
|
13
|
+
`views` is ordered outermost layout → … → page. The whole chain shares ONE request-local
|
|
14
|
+
block-id counter (`$ctx`): each `render()` is awaited sequentially (render is async), so
|
|
15
|
+
every `await`/`try` block across all layers draws a unique id from the shared counter — in
|
|
16
|
+
the same layer-sequential order the client hydrates them, keeping the streamed fragments
|
|
17
|
+
and the resume manifest aligned. Sequential (not `Promise.all`) so the counter advances
|
|
18
|
+
deterministically and the reactive scopes never interleave.
|
|
20
19
|
|
|
21
20
|
The html nests inner-to-outer: each parent layout's empty outlet boundary
|
|
22
21
|
(`<!--abide:outlet--><!--/abide:outlet-->`) is filled with the accumulated child html —
|
|
23
22
|
no `<abide-outlet>` ELEMENT, so the filled child lays out as a direct child of the
|
|
24
23
|
slot's parent (the router mounts/hydrates it as a marker range, see `outlet`/`fillBoundary`).
|
|
25
24
|
The whole chain is wrapped in a ROOT boundary the router fills into `#app`. Awaits
|
|
26
|
-
concatenate (already uniquely numbered); state
|
|
27
|
-
a build error surfaced here.
|
|
25
|
+
concatenate (already uniquely numbered); state and inline blocking `resume` values merge.
|
|
26
|
+
A layout missing its `<slot/>` is a build error surfaced here.
|
|
28
27
|
*/
|
|
29
|
-
export function renderChain(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
return {
|
|
49
|
-
/* Root boundary — the router fills `#app` by claiming/creating this same
|
|
50
|
-
boundary and mounting the outermost layer (or lone page) into it. */
|
|
51
|
-
html: OPEN + html + CLOSE,
|
|
52
|
-
awaits: renders.flatMap((render) => render.awaits),
|
|
53
|
-
state: Object.assign({}, ...renders.map((render) => render.state)),
|
|
28
|
+
export async function renderChain(
|
|
29
|
+
views: UiComponent[],
|
|
30
|
+
params: Record<string, string>,
|
|
31
|
+
): Promise<SsrRender> {
|
|
32
|
+
const ctx: RenderContext = { next: 0 }
|
|
33
|
+
const renders: SsrRender[] = []
|
|
34
|
+
for (const view of views) {
|
|
35
|
+
renders.push(await view.render(params, ctx))
|
|
36
|
+
}
|
|
37
|
+
let html = renders[renders.length - 1]?.html ?? ''
|
|
38
|
+
for (let index = renders.length - 2; index >= 0; index -= 1) {
|
|
39
|
+
const parent = renders[index] as SsrRender
|
|
40
|
+
/* EXACTLY one outlet, not at-least-one: `.replace` fills only the first, and
|
|
41
|
+
the client router fills the LAST `outlet()` call's boundary (`PENDING_OUTLET`),
|
|
42
|
+
so a second outlet would mount the SSR child and the hydrated child into
|
|
43
|
+
DIFFERENT slots — a silent desync. Throw at build instead. */
|
|
44
|
+
if (parent.html.split(OUTLET_PLACEHOLDER).length - 1 !== 1) {
|
|
45
|
+
throw new Error('[abide] a layout.abide must contain exactly one <slot/> outlet')
|
|
54
46
|
}
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
/* Fold the child between the outlet markers (function replacement so `$&`/`$\``
|
|
48
|
+
in the child html insert literally). */
|
|
49
|
+
const child = html
|
|
50
|
+
html = parent.html.replace(OUTLET_PLACEHOLDER, () => OPEN + child + CLOSE)
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
/* Root boundary — the router fills `#app` by claiming/creating this same
|
|
54
|
+
boundary and mounting the outermost layer (or lone page) into it. */
|
|
55
|
+
html: OPEN + html + CLOSE,
|
|
56
|
+
awaits: renders.flatMap((render) => render.awaits),
|
|
57
|
+
state: Object.assign({}, ...renders.map((render) => render.state)),
|
|
58
|
+
resume: Object.assign({}, ...renders.map((render) => render.resume)),
|
|
57
59
|
}
|
|
58
60
|
}
|
|
@@ -1,88 +1,102 @@
|
|
|
1
|
+
import { resumeSeedScript } from './resumeSeedScript.ts'
|
|
1
2
|
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
2
3
|
import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
|
|
4
|
+
import { tryEncodeResume } from './tryEncodeResume.ts'
|
|
3
5
|
|
|
4
6
|
/*
|
|
5
|
-
Out-of-order SSR streaming. Yields the
|
|
6
|
-
immediately), then one resolved fragment per await block as its promise
|
|
7
|
-
in completion order, not source order, so a slow read never blocks a fast
|
|
8
|
-
Each resolved fragment is a `<abide-resolve data-id="ID"><script
|
|
9
|
-
|
|
10
|
-
`<!--abide:await:ID-->` boundary; the leading script holds the
|
|
11
|
-
registered for hydration so an `await` block adopts the
|
|
12
|
-
instead of re-running.
|
|
7
|
+
Out-of-order SSR streaming. Yields the shell first (so the browser paints
|
|
8
|
+
immediately), then one resolved fragment per STREAMING await block as its promise
|
|
9
|
+
settles — in completion order, not source order, so a slow read never blocks a fast
|
|
10
|
+
one. Each resolved fragment is a `<abide-resolve data-id="ID"><script
|
|
11
|
+
type="application/json">…</script>…</abide-resolve>` that `applyResolved` swaps into
|
|
12
|
+
the matching `<!--abide:await:ID-->` boundary; the leading script holds the
|
|
13
|
+
JSON-serialized value, registered for hydration so an `await` block adopts the
|
|
14
|
+
resolved branch on resume instead of re-running.
|
|
13
15
|
|
|
14
16
|
This is the await-block-streams half of the cache rule: a top-level `await` in the
|
|
15
|
-
script would have blocked the shell (inlined), but
|
|
16
|
-
shell now and streams the value when ready. Driven by
|
|
17
|
+
script would have blocked the shell (inlined), but a streaming await *block* flushes
|
|
18
|
+
its shell now and streams the value when ready. Driven by an async `render()` result,
|
|
17
19
|
so it composes with any transport (HTTP chunked, a socket frame, a test).
|
|
18
20
|
|
|
19
|
-
A `then` on the `await` tag makes the block BLOCKING: it
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
A `then` on the `await` tag makes the block BLOCKING: it is NOT streamed — it renders
|
|
22
|
+
inline during the async render pass (depth-first, matching the client) and its value
|
|
23
|
+
lands in `render().resume`. The shell already carries the resolved branch, so the
|
|
24
|
+
first yield just seeds those values into the manifest; only streaming blocks flush
|
|
25
|
+
out of order after it.
|
|
24
26
|
*/
|
|
25
27
|
// @documentation plumbing
|
|
26
|
-
export async function* renderToStream(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
export async function* renderToStream(
|
|
29
|
+
render: () => SsrRender | Promise<SsrRender>,
|
|
30
|
+
): AsyncGenerator<string> {
|
|
31
|
+
const { html, awaits, resume } = await render()
|
|
32
|
+
/* The shell already contains every blocking await's resolved branch (rendered
|
|
33
|
+
inline); seed their values so hydration adopts them without a refetch. */
|
|
34
|
+
yield html + resumeSeedScript(resume)
|
|
35
|
+
/* A BLOCKING await nested inside a streaming branch renders inline during `settle`
|
|
36
|
+
(after the seed above), writing its value onto this same `$resume` object — so the
|
|
37
|
+
initial seed misses it. Track which resume ids are already seeded and emit the delta
|
|
38
|
+
alongside each streamed fragment, so the client adopts the nested blocking branch
|
|
39
|
+
instead of refetching. (`resume` is the render body's live object, so late writes
|
|
40
|
+
appear here.) */
|
|
41
|
+
const seededResume = new Set<number>(Object.keys(resume).map(Number))
|
|
42
|
+
const resumeDelta = (): Record<number, ResumeEntry> => {
|
|
43
|
+
const delta: Record<number, ResumeEntry> = {}
|
|
44
|
+
for (const [key, entry] of Object.entries(resume)) {
|
|
45
|
+
const id = Number(key)
|
|
46
|
+
if (!seededResume.has(id)) {
|
|
47
|
+
seededResume.add(id)
|
|
48
|
+
delta[id] = entry
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return delta
|
|
37
52
|
}
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
/* Streaming awaits flush their resolved fragment out of order as each settles. A
|
|
54
|
+
streaming block's async resolved/error renderer may itself register NESTED streaming
|
|
55
|
+
awaits — its `branchContent` runs `$awaits.push(...)` onto this same `awaits` array
|
|
56
|
+
during `settle`, AFTER the initial scan. So re-scan for newly-appended blocks after
|
|
57
|
+
every settle (tracking which ids are already enqueued), composing to any depth. */
|
|
40
58
|
const inflight = new Map<number, Promise<Settled>>()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
const enqueued = new Set<number>()
|
|
60
|
+
const enqueueNew = (): void => {
|
|
61
|
+
for (const block of awaits) {
|
|
62
|
+
if (!enqueued.has(block.id)) {
|
|
63
|
+
enqueued.add(block.id)
|
|
64
|
+
inflight.set(block.id, settle(block))
|
|
65
|
+
}
|
|
44
66
|
}
|
|
45
67
|
}
|
|
68
|
+
enqueueNew()
|
|
46
69
|
while (inflight.size > 0) {
|
|
47
70
|
const resolved = await Promise.race(inflight.values())
|
|
48
71
|
inflight.delete(resolved.id)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
enqueueNew()
|
|
73
|
+
/* An unserializable value (e.g. a cyclic media tree) streams its rendered HTML
|
|
74
|
+
with NO seed script: both swap consumers (SSR_SWAP_SCRIPT, applyResolved) skip
|
|
75
|
+
registration when the leading child isn't a parseable script, so hydration
|
|
76
|
+
re-runs this one branch's promise — degrading to a refetch instead of aborting
|
|
77
|
+
the whole stream. */
|
|
78
|
+
const encoded = encodeStreamResume(resolved.resume, resolved.id)
|
|
79
|
+
yield resumeSeedScript(resumeDelta()) +
|
|
80
|
+
`<abide-resolve data-id="${resolved.id}">` +
|
|
81
|
+
(encoded === undefined ? '' : `<script type="application/json">${encoded}</script>`) +
|
|
52
82
|
`${resolved.html}</abide-resolve>`
|
|
53
83
|
}
|
|
54
84
|
}
|
|
55
85
|
|
|
56
|
-
/* Inserts a blocking await's resolved markup into its (empty) boundary in the shell,
|
|
57
|
-
between the open and close markers. */
|
|
58
|
-
function spliceResolved(shell: string, id: number, resolved: string): string {
|
|
59
|
-
const open = `<!--abide:await:${id}-->`
|
|
60
|
-
const close = `<!--/abide:await:${id}-->`
|
|
61
|
-
/* A function replacement, so a `$&`/`$\`` etc. in the rendered value is inserted
|
|
62
|
-
literally rather than interpreted as a special replacement pattern. */
|
|
63
|
-
return shell.replace(`${open}${close}`, () => `${open}${resolved}${close}`)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/* A self-contained script seeding the resume manifest with the blocking values, so
|
|
67
|
-
client hydration adopts each resolved branch instead of re-running the promise.
|
|
68
|
-
Empty when no blocking awaits settled. */
|
|
69
|
-
function resumeScript(resumed: Record<number, ResumeEntry>): string {
|
|
70
|
-
if (Object.keys(resumed).length === 0) {
|
|
71
|
-
return ''
|
|
72
|
-
}
|
|
73
|
-
const payload = JSON.stringify(resumed).replace(/</g, '\\u003c')
|
|
74
|
-
return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${payload})</script>`
|
|
75
|
-
}
|
|
76
|
-
|
|
77
86
|
type Settled = { id: number; html: string; resume: ResumeEntry }
|
|
78
87
|
|
|
79
|
-
/* Awaits one block's promise and renders the resolved or error branch to
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
/* Awaits one streaming block's promise and renders the resolved or error branch to
|
|
89
|
+
HTML (the renderers are async so a nested `await` block composes), capturing the
|
|
90
|
+
value (serializable) for the resume manifest. Errors serialize as their message —
|
|
91
|
+
enough for the catch branch, without leaking a stack. */
|
|
82
92
|
function settle(block: SsrAwait): Promise<Settled> {
|
|
83
93
|
return Promise.resolve(block.promise()).then(
|
|
84
|
-
(value) => ({
|
|
85
|
-
|
|
94
|
+
async (value) => ({
|
|
95
|
+
id: block.id,
|
|
96
|
+
html: await block.then(value),
|
|
97
|
+
resume: { ok: true, value },
|
|
98
|
+
}),
|
|
99
|
+
async (error) => {
|
|
86
100
|
/* No catch branch → surface the rejection (500 before the first flush,
|
|
87
101
|
mid-stream error after) instead of swallowing it into an empty fragment. */
|
|
88
102
|
if (block.catch === undefined) {
|
|
@@ -90,18 +104,20 @@ function settle(block: SsrAwait): Promise<Settled> {
|
|
|
90
104
|
}
|
|
91
105
|
return {
|
|
92
106
|
id: block.id,
|
|
93
|
-
html: block.catch(error),
|
|
107
|
+
html: await block.catch(error),
|
|
94
108
|
resume: { ok: false, error: String(error) },
|
|
95
109
|
}
|
|
96
110
|
},
|
|
97
111
|
)
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
/*
|
|
101
|
-
text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
|
|
102
|
-
literal `</script>` from closing the block early — quotes stay raw
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
/* ref-json for a `<script type="application/json">` data block: script content is
|
|
115
|
+
raw text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
|
|
116
|
+
literal `</script>` from closing the block early — quotes stay raw, and the escape
|
|
117
|
+
survives `decodeRefJson`'s inner JSON.parse since `<` only ever appears inside JSON
|
|
118
|
+
strings. `tryEncodeResume` handles the serialize-or-refetch policy (undefined → no
|
|
119
|
+
script → the swap consumers skip registration → hydration re-runs that one promise).
|
|
120
|
+
`applyResolved`/the inline swap script store it via `.textContent`; `awaitBlock` decodes it. */
|
|
121
|
+
function encodeStreamResume(resume: ResumeEntry, id: number): string | undefined {
|
|
122
|
+
return tryEncodeResume(resume, id)?.replace(/</g, '\\u003c')
|
|
107
123
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { safeJsonForScript } from '../shared/safeJsonForScript.ts'
|
|
2
|
+
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
3
|
+
import { tryEncodeResume } from './tryEncodeResume.ts'
|
|
4
|
+
|
|
5
|
+
/* A self-contained `<script>` seeding the await-resume manifest with the blocking
|
|
6
|
+
values rendered inline on the server, so client hydration adopts each resolved
|
|
7
|
+
branch instead of re-running its promise. Empty when nothing blocking resolved.
|
|
8
|
+
Each entry is ref-json-encoded to a string (decoded at read in `awaitBlock`); the
|
|
9
|
+
id→string map runs as JS (`Object.assign`), so it's wrapped in `safeJsonForScript`
|
|
10
|
+
— escaping `<`, `-->`, and U+2028/U+2029 so an encoded value can't close the script
|
|
11
|
+
early or parse as a line terminator. Shared by the buffered (`createUiPageRenderer`)
|
|
12
|
+
and streaming (`renderToStream`) paths. */
|
|
13
|
+
// @documentation plumbing
|
|
14
|
+
export function resumeSeedScript(resume: Record<number, ResumeEntry>): string {
|
|
15
|
+
/* ref-json (not JSON) so a value carrying cycles or shared back-references — a
|
|
16
|
+
media tree with parent↔child links — seeds instead of being dropped. `tryEncodeResume`
|
|
17
|
+
drops just an unserializable entry (the client re-runs that one branch's promise),
|
|
18
|
+
keeping every other branch seeded rather than blanking the whole page. */
|
|
19
|
+
const encoded = Object.entries(resume).flatMap(([id, entry]) => {
|
|
20
|
+
const text = tryEncodeResume(entry, id)
|
|
21
|
+
return text === undefined ? [] : [[id, text] as const]
|
|
22
|
+
})
|
|
23
|
+
if (encoded.length === 0) {
|
|
24
|
+
return ''
|
|
25
|
+
}
|
|
26
|
+
return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(Object.fromEntries(encoded))})</script>`
|
|
27
|
+
}
|