@abide/abide 0.38.0 → 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.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +24 -0
- package/package.json +10 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- 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 +7 -0
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- 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/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +6 -0
- package/src/lib/ui/compile/asOutlet.ts +6 -5
- package/src/lib/ui/compile/compileModule.ts +18 -11
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +11 -3
- 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 +100 -61
- package/src/lib/ui/compile/generateSSR.ts +226 -73
- package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
- package/src/lib/ui/compile/isControlFlow.ts +3 -1
- package/src/lib/ui/compile/isTextLeaf.ts +1 -1
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +44 -1
- package/src/lib/ui/compile/skeletonContext.ts +19 -20
- package/src/lib/ui/compile/skeletonable.ts +3 -2
- 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 +3 -1
- package/src/lib/ui/dom/commentData.ts +14 -0
- package/src/lib/ui/dom/disposeRange.ts +21 -0
- package/src/lib/ui/dom/fillBoundary.ts +38 -0
- package/src/lib/ui/dom/fillRange.ts +30 -0
- package/src/lib/ui/dom/hydrate.ts +11 -21
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/mount.ts +16 -25
- package/src/lib/ui/dom/mountChild.ts +27 -14
- package/src/lib/ui/dom/mountRange.ts +45 -0
- package/src/lib/ui/dom/outlet.ts +62 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/scopeLabel.ts +8 -6
- package/src/lib/ui/dom/skeleton.ts +11 -30
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/dom/withScope.ts +33 -0
- package/src/lib/ui/installHotBridge.ts +12 -0
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +49 -39
- package/src/lib/ui/renderToStream.ts +69 -61
- package/src/lib/ui/resumeSeedScript.ts +17 -0
- package/src/lib/ui/router.ts +155 -85
- package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
- package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
- package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
- package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/hotReplace.ts +14 -10
- package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/Route.ts +6 -5
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +12 -1
- package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- package/src/lib/ui/runtime/firstOutlet.ts +0 -22
- /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/*
|
|
2
|
-
The human-readable name for the scope
|
|
3
|
-
its host element's tag.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
The human-readable name for the scope the direct-mount entry (`mount`/`hydrate`)
|
|
3
|
+
establishes, derived from its host element's tag. The router instead fills layers into
|
|
4
|
+
marker boundaries with no host element, so it passes each layer's route key as an
|
|
5
|
+
explicit label (`fillBoundary`); a nested child likewise passes its component name (see
|
|
6
|
+
`mountRange`). The `abide-` prefix strip turns a framework host tag like `abide-resolve`
|
|
7
|
+
into `resolve`; any other host yields its lowercased tag. Dev-only — feeds the
|
|
8
|
+
inspector's Reactive tab a readable scope name. Returns undefined when there's no element
|
|
9
|
+
to name from.
|
|
8
10
|
*/
|
|
9
11
|
import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
|
|
10
12
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
|
|
2
1
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
3
2
|
import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
|
|
4
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
|
+
import { commentData } from './commentData.ts'
|
|
5
5
|
import { foreignWrapperTag } from './foreignWrapperTag.ts'
|
|
6
6
|
import type { SkeletonHoles } from './types/SkeletonHoles.ts'
|
|
7
7
|
|
|
@@ -27,28 +27,6 @@ function isElement(node: Node): node is Element {
|
|
|
27
27
|
return typeof (node as Element).hasAttribute === 'function'
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/* A child component's mount wrapper (`abide-<name>`, see `componentWrapperTag`). Its
|
|
31
|
-
content is a SEPARATE skeleton (the child's own), so the parent's walks must treat it
|
|
32
|
-
as opaque: in the shallow skeleton it's an empty leaf, so the compiler counts no
|
|
33
|
-
anchors inside it; on hydrate it's populated, so a descent would over-collect the
|
|
34
|
-
child's anchors and shift every parent index past it (same hazard as a block range,
|
|
35
|
-
but bounded by the wrapper element instead of `[`…`]` markers). */
|
|
36
|
-
function isComponentWrapper(node: Node): boolean {
|
|
37
|
-
return (
|
|
38
|
-
isElement(node) && (node.tagName ?? '').toLowerCase().startsWith(COMPONENT_WRAPPER_PREFIX)
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/* A comment node's data, or undefined for elements/text. A comment is a node that is
|
|
43
|
-
neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
|
|
44
|
-
exposes no `nodeType`, so detect by method. */
|
|
45
|
-
function commentData(node: Node): string | undefined {
|
|
46
|
-
if (isElement(node) || typeof (node as Text).splitText === 'function') {
|
|
47
|
-
return undefined
|
|
48
|
-
}
|
|
49
|
-
return (node as Comment).data
|
|
50
|
-
}
|
|
51
|
-
|
|
52
30
|
/* Block-range boundary markers. A control-flow block's rendered content sits between an
|
|
53
31
|
OPEN and CLOSE comment: `[`…`]` for each rows / if / switch / slot ranges, and named
|
|
54
32
|
`abide:…`…`/abide:…` boundaries for await / try / snippet / html. The skeleton's own
|
|
@@ -116,19 +94,22 @@ function indexElementHoles(container: Node, prefix: number[], paths: number[][])
|
|
|
116
94
|
In hydrate mode the claimed tree is FULLY EXPANDED — a nested block's rendered content
|
|
117
95
|
(each rows, branches, await/try boundaries) sits inline — so a naive descent would also
|
|
118
96
|
collect the inner block's anchors, which belong to that block's OWN skeleton, shifting
|
|
119
|
-
every index past the first block. Block content is bounded by range
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
97
|
+
every index past the first block. Block AND child-component content is bounded by range
|
|
98
|
+
markers (a component mounts as a `[`…`]` range at its anchor, like a block — see
|
|
99
|
+
`mountRange`), so track depth per sibling list and take an anchor (and recurse into an
|
|
100
|
+
element) only at depth 0, where the skeleton's own structure lives. In create mode the
|
|
101
|
+
clone is shallow (the ranges have not built yet — no markers), so depth stays 0 and this
|
|
102
|
+
is a plain document scan. */
|
|
123
103
|
function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
|
|
124
104
|
let depth = 0
|
|
125
105
|
for (let index = 0; index < nodes.length; index += 1) {
|
|
126
106
|
const node = nodes[index] as Node
|
|
127
107
|
const data = commentData(node)
|
|
128
108
|
if (data === undefined) {
|
|
129
|
-
/* Recurse into this skeleton's own elements
|
|
130
|
-
|
|
131
|
-
|
|
109
|
+
/* Recurse into this skeleton's own elements at depth 0. A child component's
|
|
110
|
+
content sits inside its `[`…`]` range (depth > 0), so it is skipped like any
|
|
111
|
+
block range — its anchors belong to the child's own skeleton. */
|
|
112
|
+
if (isElement(node) && depth === 0) {
|
|
132
113
|
scanAnchors(node.childNodes, anchors)
|
|
133
114
|
}
|
|
134
115
|
} else if (isCloseMarker(data)) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createScope } from '../createScope.ts'
|
|
2
|
+
import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
|
|
3
|
+
import { enterRenderPass } from '../runtime/enterRenderPass.ts'
|
|
4
|
+
import { exitRenderPass } from '../runtime/exitRenderPass.ts'
|
|
5
|
+
import type { Scope } from '../types/Scope.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The shared mount core every page/layout/component build path runs (`mount`, `hydrate`,
|
|
9
|
+
`fillRange`, `mountRange`, `fillBoundary`). Establishes the layer's lexical scope nested
|
|
10
|
+
under the current one in `awaiting` mode — so it adopts the model doc its first `doc()`
|
|
11
|
+
creates — brackets a render pass — so its `await`/`try` block ids draw from the shared
|
|
12
|
+
counter in SSR-stream order — runs `build`, then restores the previous scope (synchronous
|
|
13
|
+
build, so the restore is exact). `build` returns its reactivity stopper (from `scope` or
|
|
14
|
+
`fillBefore`); the caller composes its own DOM teardown (clear host vs clear range) around
|
|
15
|
+
the returned `stop`/`lexical`, keeping the one scope/render-pass contract in a single place.
|
|
16
|
+
*/
|
|
17
|
+
export function withScope(
|
|
18
|
+
label: string | undefined,
|
|
19
|
+
build: () => () => void,
|
|
20
|
+
): { stop: () => void; lexical: Scope } {
|
|
21
|
+
const parentScope = CURRENT_SCOPE.current
|
|
22
|
+
const lexical = createScope({}, parentScope, true, label)
|
|
23
|
+
enterRenderPass()
|
|
24
|
+
CURRENT_SCOPE.current = lexical
|
|
25
|
+
let stop: () => void = () => undefined
|
|
26
|
+
try {
|
|
27
|
+
stop = build()
|
|
28
|
+
} finally {
|
|
29
|
+
exitRenderPass()
|
|
30
|
+
CURRENT_SCOPE.current = parentScope
|
|
31
|
+
}
|
|
32
|
+
return { stop, lexical }
|
|
33
|
+
}
|
|
@@ -12,11 +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'
|
|
20
|
+
import { outlet } from './dom/outlet.ts'
|
|
21
|
+
import { readCall } from './dom/readCall.ts'
|
|
22
|
+
import { restProps } from './dom/restProps.ts'
|
|
19
23
|
import { skeleton } from './dom/skeleton.ts'
|
|
24
|
+
import { spreadAttrs } from './dom/spreadAttrs.ts'
|
|
25
|
+
import { spreadProps } from './dom/spreadProps.ts'
|
|
20
26
|
import { switchBlock } from './dom/switchBlock.ts'
|
|
21
27
|
import { tryBlock } from './dom/tryBlock.ts'
|
|
22
28
|
import { when } from './dom/when.ts'
|
|
@@ -67,7 +73,13 @@ export function installHotBridge(): void {
|
|
|
67
73
|
tryBlock,
|
|
68
74
|
switchBlock,
|
|
69
75
|
mountSlot,
|
|
76
|
+
outlet,
|
|
70
77
|
mountChild,
|
|
78
|
+
mergeProps,
|
|
79
|
+
spreadProps,
|
|
80
|
+
restProps,
|
|
81
|
+
spreadAttrs,
|
|
82
|
+
readCall,
|
|
71
83
|
hydrate,
|
|
72
84
|
escapeKey,
|
|
73
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
|
|
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,50 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { OUTLET_TAG } from './runtime/OUTLET_TAG.ts'
|
|
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
|
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
6
|
+
const OPEN = `<!--${OUTLET_OPEN}-->`
|
|
7
|
+
const CLOSE = `<!--${OUTLET_CLOSE}-->`
|
|
8
|
+
/* A layout's empty outlet boundary, before the child layer is folded in. */
|
|
9
|
+
const OUTLET_PLACEHOLDER = `${OPEN}${CLOSE}`
|
|
10
10
|
|
|
11
11
|
/*
|
|
12
12
|
Server-renders a route's layout chain wrapped around its page into one SsrRender.
|
|
13
|
-
`views` is ordered outermost layout → … → page. The whole chain
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
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.
|
|
19
19
|
|
|
20
|
-
The html nests inner-to-outer: each parent's empty
|
|
21
|
-
the accumulated child html —
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
The html nests inner-to-outer: each parent layout's empty outlet boundary
|
|
21
|
+
(`<!--abide:outlet--><!--/abide:outlet-->`) is filled with the accumulated child html —
|
|
22
|
+
no `<abide-outlet>` ELEMENT, so the filled child lays out as a direct child of the
|
|
23
|
+
slot's parent (the router mounts/hydrates it as a marker range, see `outlet`/`fillBoundary`).
|
|
24
|
+
The whole chain is wrapped in a ROOT boundary the router fills into `#app`. Awaits
|
|
25
|
+
concatenate (already uniquely numbered); state and inline blocking `resume` values merge.
|
|
26
|
+
A layout missing its `<slot/>` is a build error surfaced here.
|
|
26
27
|
*/
|
|
27
|
-
export function renderChain(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
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')
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
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)),
|
|
49
59
|
}
|
|
50
60
|
}
|