@abide/abide 0.38.1 → 0.39.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 (54) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +16 -0
  3. package/package.json +9 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  7. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  8. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  10. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  11. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  12. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
  13. package/src/lib/shared/createRemoteFunction.ts +20 -11
  14. package/src/lib/shared/escapeHtml.ts +15 -0
  15. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  16. package/src/lib/shared/streamResponse.ts +8 -1
  17. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  18. package/src/lib/shared/types/RpcOptions.ts +22 -0
  19. package/src/lib/shared/types/SourceMap.ts +14 -0
  20. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  21. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
  22. package/src/lib/ui/compile/compileModule.ts +5 -2
  23. package/src/lib/ui/compile/compileSSR.ts +32 -9
  24. package/src/lib/ui/compile/compileShadow.ts +11 -3
  25. package/src/lib/ui/compile/composeProps.ts +53 -0
  26. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  27. package/src/lib/ui/compile/generateBuild.ts +46 -18
  28. package/src/lib/ui/compile/generateSSR.ts +196 -52
  29. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  30. package/src/lib/ui/compile/parseTemplate.ts +44 -1
  31. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  32. package/src/lib/ui/compile/staticAttr.ts +1 -1
  33. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  34. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  35. package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
  36. package/src/lib/ui/dom/mergeProps.ts +32 -0
  37. package/src/lib/ui/dom/readCall.ts +27 -0
  38. package/src/lib/ui/dom/restProps.ts +32 -0
  39. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  40. package/src/lib/ui/dom/spreadProps.ts +32 -0
  41. package/src/lib/ui/installHotBridge.ts +10 -0
  42. package/src/lib/ui/remoteProxy.ts +68 -36
  43. package/src/lib/ui/renderChain.ts +39 -37
  44. package/src/lib/ui/renderToStream.ts +69 -61
  45. package/src/lib/ui/resumeSeedScript.ts +17 -0
  46. package/src/lib/ui/router.ts +81 -51
  47. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  48. package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
  49. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  50. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  51. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  52. package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
  53. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  54. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -0,0 +1,27 @@
1
+ /*
2
+ Guarded method call on a reactive-document read. The doc-access lowering rewrites
3
+ `model.draft.trim()` into `model.read("draft").trim()`; when that read is nullish
4
+ the bare form throws the engine's opaque `undefined is not an object (evaluating
5
+ '…read("draft").trim()')`, naming only the desugared call. This wraps the
6
+ non-optional call so the throw names the authored scope path and member instead —
7
+ the source-mapped stack frame still resolves to the `.abide` line, so message and
8
+ location together read in authored terms. Both opaque engine errors are covered: a
9
+ nullish receiver (`undefined is not an object`) AND a present receiver whose member
10
+ is not callable (`… is not a function`, the misspelled/missing-method case).
11
+ Optional-chained calls are left bare: `?.` means skip-if-absent, not throw, so
12
+ guarding them would change semantics. `.apply(target, …)` preserves the receiver, so
13
+ the method sees the same `this` the bare `target.member(…)` would.
14
+ */
15
+ // @documentation plumbing
16
+ export function readCall(target: unknown, path: string, member: string, args: unknown[]): unknown {
17
+ if (target === undefined || target === null) {
18
+ throw new TypeError(`abide: cannot call .${member}() — scope value "${path}" is ${target}`)
19
+ }
20
+ const method = (target as Record<string, unknown>)[member]
21
+ if (typeof method !== 'function') {
22
+ throw new TypeError(
23
+ `abide: cannot call .${member}() — "${path}".${member} is not a function (got ${typeof method})`,
24
+ )
25
+ }
26
+ return (method as (...args: unknown[]) => unknown).apply(target, args)
27
+ }
@@ -0,0 +1,32 @@
1
+ import type { UiProps } from '../runtime/types/UiProps.ts'
2
+
3
+ /*
4
+ The rest of a component's props — `const { foo, ...rest } = props()` — as a live
5
+ object of the UNCONSUMED prop values. A child receives prop THUNKS, so `rest.key`
6
+ unwraps them (`$props[key]?.()`), tracking as a reactive dependency; a top-level
7
+ page/layout instead receives its route params as PLAIN values, so a non-function
8
+ value is returned as-is rather than called (which would throw `value is not a
9
+ function`). The explicitly-destructured keys and the `$children` slot are excluded.
10
+ Enumerable (`ownKeys`/`for…in`/`Object.keys`), so `{...rest}` can forward the
11
+ remaining props onto a child or a native element. Key membership is live, but a
12
+ consumer that captures the key SET (a `{...rest}` spread) snapshots it at that point.
13
+ */
14
+ // @documentation plumbing
15
+ export function restProps(props: UiProps, consumed: string[]): Record<string, unknown> {
16
+ const skip = new Set([...consumed, '$children'])
17
+ const bag = props as Record<string, unknown>
18
+ const visible = (key: string | symbol): key is string =>
19
+ typeof key === 'string' && !skip.has(key) && key in bag
20
+ const read = (key: string): unknown =>
21
+ typeof bag[key] === 'function' ? (bag[key] as () => unknown)() : bag[key]
22
+ return new Proxy(
23
+ {},
24
+ {
25
+ get: (_target, key) => (visible(key) ? read(key) : undefined),
26
+ has: (_target, key) => visible(key),
27
+ ownKeys: () => Reflect.ownKeys(bag).filter(visible),
28
+ getOwnPropertyDescriptor: (_target, key) =>
29
+ visible(key) ? { enumerable: true, configurable: true } : undefined,
30
+ },
31
+ )
32
+ }
@@ -0,0 +1,34 @@
1
+ import { attr } from './attr.ts'
2
+ import { on } from './on.ts'
3
+
4
+ /*
5
+ Spreads an object's keys onto a native element — the `<div {...rest}>` runtime. `source`
6
+ is a thunk over the spread expression; its keys are enumerated ONCE (so the attribute set
7
+ is fixed at build, not reactively added/removed), but each value stays live: an `on<event>`
8
+ key holding a function attaches as that event listener (mirroring `onclick={…}` on an
9
+ element), and every other key binds as a reactive attribute (`attr`, re-reading the source
10
+ per change, with the same present/absent semantics — `false`/`null`/`undefined` removes it,
11
+ `true` sets it bare). A function under a non-`on` key is skipped (no attribute for it). A key
12
+ in `exclude` is skipped — the element names it explicitly, and an explicit attribute wins
13
+ over a spread key (the same set SSR's `$spread` skips, keeping the two sides congruent).
14
+ */
15
+ // @documentation plumbing
16
+ export function spreadAttrs(
17
+ element: Element,
18
+ source: () => Record<string, unknown>,
19
+ exclude: string[] = [],
20
+ ): void {
21
+ const skip = new Set(exclude)
22
+ const object = source()
23
+ for (const key in object) {
24
+ if (skip.has(key)) {
25
+ continue
26
+ }
27
+ const value = object[key]
28
+ if (key.startsWith('on') && typeof value === 'function') {
29
+ on(element, key.slice(2), value as EventListener)
30
+ } else if (typeof value !== 'function') {
31
+ attr(element, key, () => source()[key])
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,32 @@
1
+ /*
2
+ Wraps a `{...source}` spread layer so every key resolves to a live value thunk —
3
+ the same `() => value` shape an explicit `name={expr}` prop compiles to, so a child
4
+ reads a spread key exactly like an authored one (`$props[key]?.()`). `source` is a
5
+ THUNK over the spread expression (not its value), re-evaluated on each key read and
6
+ membership test, so a reactive source stays live both when its keys mutate and when
7
+ the whole object is replaced — and the read registers as the reader's dependency. A
8
+ nullish source spreads nothing. The reserved `$children` slot key is never surfaced —
9
+ a source happening to carry one must not masquerade as slot content.
10
+ */
11
+ // @documentation plumbing
12
+ export function spreadProps(
13
+ source: () => Record<string, unknown> | null | undefined,
14
+ ): Record<string, () => unknown> {
15
+ /* A spread key the merged bag exposes — present on the current source and not the
16
+ reserved `$children` slot key (a source carrying one must not become slot content). */
17
+ const carries = (key: string | symbol): boolean =>
18
+ key !== '$children' && key in (source() ?? {})
19
+ return new Proxy(
20
+ {},
21
+ {
22
+ /* Each read returns a fresh thunk that re-evaluates the source for the key. */
23
+ get: (_target, key) => () => source()?.[key as string],
24
+ /* Live membership: whether the current source object carries the key. */
25
+ has: (_target, key) => carries(key),
26
+ /* Enumerable over the current source, so a merged bag reports spread keys. */
27
+ ownKeys: () => Reflect.ownKeys(source() ?? {}).filter(carries),
28
+ getOwnPropertyDescriptor: (_target, key) =>
29
+ carries(key) ? { enumerable: true, configurable: true } : undefined,
30
+ },
31
+ ) as Record<string, () => unknown>
32
+ }
@@ -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,
@@ -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 two optional aborts: the reactive scope that fired the call (so a
60
- superseded/torn-down read cancels its in-flight request — currentAbortSignal) and
61
- the env-configured client timeout (ABIDE_CLIENT_TIMEOUT, ms). Neither present →
62
- the unbounded fetch, exactly as before. A timeout surfaces as a 504 HttpError so a
63
- consumer reads an honest status instead of a raw DOMException 500. Our scope abort
64
- (reason REQUEST_SUPERSEDED) is swallowed into a never-settling promise: the reactive
65
- owner is gone, so the result must neither resolve into a dead tree nor surface as a
66
- rejection. Other rejections (genuine network failure) propagate untouched.
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
- const signal = combineSignals(currentAbortSignal(), timeoutSignal)
72
- if (signal === undefined) {
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, { signal }).catch((error: unknown) => {
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
- /* One AbortSignal from the scope-abort and timeout sources: whichever is present,
89
- AbortSignal.any when both, or undefined when neither (the unbounded fetch). */
90
- function combineSignals(
91
- scopeSignal: AbortSignal | undefined,
92
- timeoutSignal: AbortSignal | undefined,
93
- ): AbortSignal | undefined {
94
- if (scopeSignal === undefined) {
95
- return timeoutSignal
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 (timeoutSignal === undefined) {
98
- return scopeSignal
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([scopeSignal, timeoutSignal])
132
+ return AbortSignal.any(present)
101
133
  }
102
134
 
103
135
  /*
104
- abide's per-RPC headers: the page traceparent (continues the server trace) and,
105
- only while offline, the offline marker so the handler's online() reflects the
106
- caller's connectivity. Returns undefined when neither applies so the
107
- allocation-free fetch path stays the common case.
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 any ? headers : undefined
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 renders under a
15
- SINGLE render pass: one outer `enterRenderPass` resets the block-id counter, then
16
- each `render()` runs nested (depth > 0, no reset), so every `await`/`try` block
17
- across all layers draws a unique id from the shared counter in the same
18
- layer-sequential order the client hydrates them, keeping the streamed fragments and
19
- the resume manifest aligned.
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 merges. A layout missing its `<slot/>` is
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(views: UiComponent[], params: Record<string, string>): SsrRender {
30
- enterRenderPass()
31
- try {
32
- const renders = views.map((view) => view.render(params))
33
- let html = renders[renders.length - 1]?.html ?? ''
34
- for (let index = renders.length - 2; index >= 0; index -= 1) {
35
- const parent = renders[index] as SsrRender
36
- /* EXACTLY one outlet, not at-least-one: `.replace` fills only the first, and
37
- the client router fills the LAST `outlet()` call's boundary (`PENDING_OUTLET`),
38
- so a second outlet would mount the SSR child and the hydrated child into
39
- DIFFERENT slots a silent desync. Throw at build instead. */
40
- if (parent.html.split(OUTLET_PLACEHOLDER).length - 1 !== 1) {
41
- throw new Error('[abide] a layout.abide must contain exactly one <slot/> outlet')
42
- }
43
- /* Fold the child between the outlet markers (function replacement so `$&`/`$\``
44
- in the child html insert literally). */
45
- const child = html
46
- html = parent.html.replace(OUTLET_PLACEHOLDER, () => OPEN + child + CLOSE)
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
- } finally {
56
- exitRenderPass()
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,96 @@
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'
3
4
 
4
5
  /*
5
- Out-of-order SSR streaming. Yields the pending shell first (so the browser paints
6
- immediately), then one resolved fragment per await block as its promise settles —
7
- in completion order, not source order, so a slow read never blocks a fast one.
8
- Each resolved fragment is a `<abide-resolve data-id="ID"><script type="application/json">
9
- …</script>…</abide-resolve>` that `applyResolved` swaps into the matching
10
- `<!--abide:await:ID-->` boundary; the leading script holds the JSON-serialized value,
11
- registered for hydration so an `await` block adopts the resolved branch on resume
12
- instead of re-running.
6
+ Out-of-order SSR streaming. Yields the shell first (so the browser paints
7
+ immediately), then one resolved fragment per STREAMING await block as its promise
8
+ settles — in completion order, not source order, so a slow read never blocks a fast
9
+ one. Each resolved fragment is a `<abide-resolve data-id="ID"><script
10
+ type="application/json">…</script>…</abide-resolve>` that `applyResolved` swaps into
11
+ the matching `<!--abide:await:ID-->` boundary; the leading script holds the
12
+ JSON-serialized value, registered for hydration so an `await` block adopts the
13
+ resolved branch on resume instead of re-running.
13
14
 
14
15
  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 an await *block* flushes its
16
- shell now and streams the value when ready. Driven by a plain `render()` result,
16
+ script would have blocked the shell (inlined), but a streaming await *block* flushes
17
+ its shell now and streams the value when ready. Driven by an async `render()` result,
17
18
  so it composes with any transport (HTTP chunked, a socket frame, a test).
18
19
 
19
- A `then` on the `await` tag makes the block BLOCKING: it settles before the first
20
- flush, its resolved branch spliced into its (empty) boundary and its value seeded
21
- into the resume manifest inline so the value is in the first paint, no pending
22
- shell, no swap. The first yield therefore awaits all blocking blocks; only the
23
- remaining streaming blocks flush out of order after it.
20
+ A `then` on the `await` tag makes the block BLOCKING: it is NOT streamed — it renders
21
+ inline during the async render pass (depth-first, matching the client) and its value
22
+ lands in `render().resume`. The shell already carries the resolved branch, so the
23
+ first yield just seeds those values into the manifest; only streaming blocks flush
24
+ out of order after it.
24
25
  */
25
26
  // @documentation plumbing
26
- export async function* renderToStream(render: () => SsrRender): AsyncGenerator<string> {
27
- const { html, awaits } = render()
28
- /* Blocking awaits (a `then` on the `await` tag) settle BEFORE the first flush:
29
- their resolved branch is spliced into its empty boundary and its value registered
30
- inline, so the first paint already has the value no pending, no swap. */
31
- const blocking = awaits.filter((block) => block.blocking === true)
32
- let shell = html
33
- const resumed: Record<number, ResumeEntry> = {}
34
- for (const settled of await Promise.all(blocking.map(settle))) {
35
- shell = spliceResolved(shell, settled.id, settled.html)
36
- resumed[settled.id] = settled.resume
27
+ export async function* renderToStream(
28
+ render: () => SsrRender | Promise<SsrRender>,
29
+ ): AsyncGenerator<string> {
30
+ const { html, awaits, resume } = await render()
31
+ /* The shell already contains every blocking await's resolved branch (rendered
32
+ inline); seed their values so hydration adopts them without a refetch. */
33
+ yield html + resumeSeedScript(resume)
34
+ /* A BLOCKING await nested inside a streaming branch renders inline during `settle`
35
+ (after the seed above), writing its value onto this same `$resume` object — so the
36
+ initial seed misses it. Track which resume ids are already seeded and emit the delta
37
+ alongside each streamed fragment, so the client adopts the nested blocking branch
38
+ instead of refetching. (`resume` is the render body's live object, so late writes
39
+ appear here.) */
40
+ const seededResume = new Set<number>(Object.keys(resume).map(Number))
41
+ const resumeDelta = (): Record<number, ResumeEntry> => {
42
+ const delta: Record<number, ResumeEntry> = {}
43
+ for (const [key, entry] of Object.entries(resume)) {
44
+ const id = Number(key)
45
+ if (!seededResume.has(id)) {
46
+ seededResume.add(id)
47
+ delta[id] = entry
48
+ }
49
+ }
50
+ return delta
37
51
  }
38
- yield shell + resumeScript(resumed)
39
- /* Streaming awaits flush their resolved fragment out of order as each settles. */
52
+ /* Streaming awaits flush their resolved fragment out of order as each settles. A
53
+ streaming block's async resolved/error renderer may itself register NESTED streaming
54
+ awaits — its `branchContent` runs `$awaits.push(...)` onto this same `awaits` array
55
+ during `settle`, AFTER the initial scan. So re-scan for newly-appended blocks after
56
+ every settle (tracking which ids are already enqueued), composing to any depth. */
40
57
  const inflight = new Map<number, Promise<Settled>>()
41
- for (const block of awaits) {
42
- if (block.blocking !== true) {
43
- inflight.set(block.id, settle(block))
58
+ const enqueued = new Set<number>()
59
+ const enqueueNew = (): void => {
60
+ for (const block of awaits) {
61
+ if (!enqueued.has(block.id)) {
62
+ enqueued.add(block.id)
63
+ inflight.set(block.id, settle(block))
64
+ }
44
65
  }
45
66
  }
67
+ enqueueNew()
46
68
  while (inflight.size > 0) {
47
69
  const resolved = await Promise.race(inflight.values())
48
70
  inflight.delete(resolved.id)
49
- const resume = encodeResume(resolved.resume)
50
- yield `<abide-resolve data-id="${resolved.id}">` +
51
- `<script type="application/json">${resume}</script>` +
71
+ enqueueNew()
72
+ const encoded = encodeResume(resolved.resume)
73
+ yield resumeSeedScript(resumeDelta()) +
74
+ `<abide-resolve data-id="${resolved.id}">` +
75
+ `<script type="application/json">${encoded}</script>` +
52
76
  `${resolved.html}</abide-resolve>`
53
77
  }
54
78
  }
55
79
 
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
80
  type Settled = { id: number; html: string; resume: ResumeEntry }
78
81
 
79
- /* Awaits one block's promise and renders the resolved or error branch to HTML,
80
- capturing the value (serializable) for the resume manifest. Errors serialize as
81
- their message — enough for the catch branch, without leaking a stack. */
82
+ /* Awaits one streaming block's promise and renders the resolved or error branch to
83
+ HTML (the renderers are async so a nested `await` block composes), capturing the
84
+ value (serializable) for the resume manifest. Errors serialize as their message —
85
+ enough for the catch branch, without leaking a stack. */
82
86
  function settle(block: SsrAwait): Promise<Settled> {
83
87
  return Promise.resolve(block.promise()).then(
84
- (value) => ({ id: block.id, html: block.then(value), resume: { ok: true, value } }),
85
- (error) => {
88
+ async (value) => ({
89
+ id: block.id,
90
+ html: await block.then(value),
91
+ resume: { ok: true, value },
92
+ }),
93
+ async (error) => {
86
94
  /* No catch branch → surface the rejection (500 before the first flush,
87
95
  mid-stream error after) instead of swallowing it into an empty fragment. */
88
96
  if (block.catch === undefined) {
@@ -90,7 +98,7 @@ function settle(block: SsrAwait): Promise<Settled> {
90
98
  }
91
99
  return {
92
100
  id: block.id,
93
- html: block.catch(error),
101
+ html: await block.catch(error),
94
102
  resume: { ok: false, error: String(error) },
95
103
  }
96
104
  },
@@ -0,0 +1,17 @@
1
+ import { safeJsonForScript } from '../shared/safeJsonForScript.ts'
2
+ import type { ResumeEntry } from './runtime/RESUME.ts'
3
+
4
+ /* A self-contained `<script>` seeding the await-resume manifest with the blocking
5
+ values rendered inline on the server, so client hydration adopts each resolved
6
+ branch instead of re-running its promise. Empty when nothing blocking resolved.
7
+ The payload runs as JS (`Object.assign`), so it's encoded via `safeJsonForScript`
8
+ — escaping `<`, `-->`, and U+2028/U+2029 so a serialized body value can't close
9
+ the script early or parse as a line terminator. Shared by the buffered
10
+ (`createUiPageRenderer`) and streaming (`renderToStream`) paths. */
11
+ // @documentation plumbing
12
+ export function resumeSeedScript(resume: Record<number, ResumeEntry>): string {
13
+ if (Object.keys(resume).length === 0) {
14
+ return ''
15
+ }
16
+ return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(resume)})</script>`
17
+ }