@ultimat3/render 29.4.0 → 30.1.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/CLAUDE.md +12 -9
- package/README.md +60 -8
- package/package.json +18 -7
- package/src/client-pins-tag.ts +23 -0
- package/src/client.ts +5 -3
- package/src/css-modules.ts +1 -1
- package/src/head-attributes.ts +57 -0
- package/src/head.ts +5 -10
- package/src/hole.ts +32 -0
- package/src/html.ts +1 -2
- package/src/hydrate.ts +15 -2
- package/src/index.ts +6 -1
- package/src/island-dispose.ts +18 -2
- package/src/island.ts +1 -3
- package/src/jsx.ts +1 -2
- package/src/modes.ts +4 -21
- package/src/module-loader.ts +1 -1
- package/src/navigation-api.ts +1 -1
- package/src/navigation-deadline.ts +36 -0
- package/src/navigation-dom.ts +61 -0
- package/src/navigation-refresh.ts +74 -0
- package/src/navigation.ts +47 -36
- package/src/refresh-idle.ts +171 -0
- package/src/registry.ts +1 -15
- package/src/render-html.ts +95 -0
- package/src/render-isr-key.ts +23 -12
- package/src/render-stream.ts +119 -26
- package/src/route-status.ts +1 -1
- package/src/route.ts +1 -2
- package/src/sass-cache.ts +1 -1
- package/src/server.ts +4 -2
- package/src/type-pins.tsx +8 -0
- package/src/zone-script.ts +38 -0
package/src/render-html.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
IslandPropsInvalidError,
|
|
12
12
|
PrerenderFailedError,
|
|
13
13
|
} from './errors';
|
|
14
|
+
import type { HoleNode } from './hole';
|
|
15
|
+
import { isHoleNode } from './hole';
|
|
14
16
|
import { renderAttributes, VOID_ELEMENTS } from './html';
|
|
15
17
|
import { emitIslandAttributes, emitIslandProps } from './hydrate';
|
|
16
18
|
import type { IslandNode } from './island';
|
|
@@ -19,6 +21,8 @@ import type { IslandCollector } from './island-collector';
|
|
|
19
21
|
import { islandWithoutCollector } from './island-collector';
|
|
20
22
|
import type { JsxComponent, JsxProps } from './jsx';
|
|
21
23
|
import { isJsxNode } from './jsx';
|
|
24
|
+
import type { StreamHole } from './render-stream';
|
|
25
|
+
import { holeMarker } from './render-stream';
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
28
|
* The id of the element every document wraps its route component in — one name, because a service
|
|
@@ -50,6 +54,48 @@ function assertDepth(depth: number): void {
|
|
|
50
54
|
*/
|
|
51
55
|
export interface RenderHtmlOptions {
|
|
52
56
|
readonly islands?: IslandCollector;
|
|
57
|
+
/**
|
|
58
|
+
* Where a `<Hole>` goes instead of being awaited. Only the `stream` branch passes one: every
|
|
59
|
+
* other render has one flush, so a hole there is its children, inline, and is weighed.
|
|
60
|
+
*/
|
|
61
|
+
readonly holes?: HoleSink;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One render's holes, and the counter their ids come from. Per render, as the collector is. */
|
|
65
|
+
export interface HoleSink {
|
|
66
|
+
readonly holes: readonly StreamHole[];
|
|
67
|
+
/** The next id, taken before the walk awaits anything so ids follow the tree. */
|
|
68
|
+
nextId(): string;
|
|
69
|
+
add(hole: StreamHole): void;
|
|
70
|
+
/** Runs a hole's children under its signal: the host's seam for what is ambient inside one. */
|
|
71
|
+
readonly enter: HoleEnter;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How a host runs a hole's children: given the hole's signal (aborted at its deadline and on a
|
|
76
|
+
* client disconnect; a hole that settled keeps it un-aborted), in whatever scope it builds. The CLI makes that signal
|
|
77
|
+
* the ambient one and refuses response headers after the flush; render knows neither.
|
|
78
|
+
*/
|
|
79
|
+
export type HoleEnter = (signal: AbortSignal, run: () => Promise<string>) => Promise<string>;
|
|
80
|
+
|
|
81
|
+
export interface HoleSinkOptions {
|
|
82
|
+
readonly enter?: HoleEnter;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function holeSink(options: HoleSinkOptions = {}): HoleSink {
|
|
86
|
+
const holes: StreamHole[] = [];
|
|
87
|
+
let last = 0;
|
|
88
|
+
return {
|
|
89
|
+
holes,
|
|
90
|
+
enter: options.enter ?? ((_signal, run) => run()),
|
|
91
|
+
nextId() {
|
|
92
|
+
last += 1;
|
|
93
|
+
return String(last);
|
|
94
|
+
},
|
|
95
|
+
add(hole) {
|
|
96
|
+
holes.push(hole);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
53
99
|
}
|
|
54
100
|
|
|
55
101
|
/**
|
|
@@ -69,6 +115,7 @@ async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): P
|
|
|
69
115
|
// configured `JSX.Element` admits — so the generic branch would render its empty contents and
|
|
70
116
|
// drop the island, its props script and its budget line without a word.
|
|
71
117
|
if (isIslandNode(value)) return renderIsland(value, depth, walk);
|
|
118
|
+
if (isHoleNode(value)) return renderHole(value, depth, walk);
|
|
72
119
|
if (Array.isArray(value)) {
|
|
73
120
|
const parts = await Promise.all(value.map((item) => unwrap(item, depth + 1, walk)));
|
|
74
121
|
return parts.join('');
|
|
@@ -118,6 +165,54 @@ async function renderIsland(
|
|
|
118
165
|
return `<${tag} ${emitIslandAttributes(directive)}>${shell}${emitIslandProps(directive)}</${tag}>`;
|
|
119
166
|
}
|
|
120
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A promise already running when the tree was built is awaited only once the stream starts, after
|
|
170
|
+
* the fallback rendered; a rejection before then is unhandled. Marked through arrays and JSX
|
|
171
|
+
* children — never by calling a thunk or a component, which have not run — and the hole still
|
|
172
|
+
* awaits and sees it. `seen` stops a self-referencing array.
|
|
173
|
+
*/
|
|
174
|
+
function observeEagerPromises(value: unknown, seen: Set<object>): void {
|
|
175
|
+
if (value instanceof Promise) {
|
|
176
|
+
value.catch(() => undefined);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (typeof value !== 'object' || value === null || seen.has(value)) return;
|
|
180
|
+
seen.add(value);
|
|
181
|
+
if (Array.isArray(value)) for (const item of value) observeEagerPromises(item, seen);
|
|
182
|
+
// An island or hole node is an array whose own items are empty: its tree is in `props`.
|
|
183
|
+
if (isJsxNode(value) || isIslandNode(value) || isHoleNode(value)) {
|
|
184
|
+
const props = value.props as { children?: unknown; fallback?: unknown; error?: unknown };
|
|
185
|
+
observeEagerPromises(props.children, seen);
|
|
186
|
+
observeEagerPromises(props.fallback, seen);
|
|
187
|
+
observeEagerPromises(props.error, seen);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Everything under a hole walks with no sink: a nested hole is inline inside the outer one, since
|
|
193
|
+
* the set of pending holes is fixed at the first flush (`renderStreamHtml`). `resolve` is a thunk,
|
|
194
|
+
* so `children` start inside `start()`, in the request's async context, after the shell is out.
|
|
195
|
+
*/
|
|
196
|
+
async function renderHole(node: HoleNode, depth: number, walk: RenderHtmlOptions): Promise<string> {
|
|
197
|
+
const sink = walk.holes;
|
|
198
|
+
const inner: RenderHtmlOptions = walk.islands === undefined ? {} : { islands: walk.islands };
|
|
199
|
+
const { children, error, fallback } = node.props;
|
|
200
|
+
if (sink === undefined) return unwrap(children, depth + 1, inner);
|
|
201
|
+
const id = sink.nextId();
|
|
202
|
+
observeEagerPromises(children, new Set());
|
|
203
|
+
const [shown, failed] = await Promise.all([
|
|
204
|
+
unwrap(fallback, depth + 1, inner),
|
|
205
|
+
error === undefined ? undefined : unwrap(error, depth + 1, inner),
|
|
206
|
+
]);
|
|
207
|
+
sink.add({
|
|
208
|
+
id,
|
|
209
|
+
fallback: shown,
|
|
210
|
+
...(failed === undefined ? {} : { error: failed }),
|
|
211
|
+
resolve: (signal) => sink.enter(signal, () => unwrap(children, depth + 1, inner)),
|
|
212
|
+
});
|
|
213
|
+
return holeMarker(id, shown);
|
|
214
|
+
}
|
|
215
|
+
|
|
121
216
|
async function renderNode(
|
|
122
217
|
type: string | JsxComponent,
|
|
123
218
|
props: JsxProps,
|
package/src/render-isr-key.ts
CHANGED
|
@@ -15,17 +15,23 @@ import { unlocalizedPath } from '@ultimat3/i18n';
|
|
|
15
15
|
*/
|
|
16
16
|
export const ISR_LOCALE_PARAM = '__x_locale';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The reserved parameter a request's origin rides in when the app declares none, a parameter for
|
|
20
|
+
* the same reason: canonical and `og:url` are absolute against that origin, so a foreign `Host` is
|
|
21
|
+
* its own entry and never names itself in the page a later visitor is answered. Beside the locale,
|
|
22
|
+
* as `static-document.ts`'s memo keys. `RESERVED_QUERY_PREFIX` stops a route declaring it.
|
|
23
|
+
*/
|
|
24
|
+
export const ISR_ORIGIN_PARAM = '__x_origin';
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* The URL an `isr` route is rendered FOR: the request's, keeping only the query parameters the
|
|
20
28
|
* route declared in `revalidate.query`, one value each, in sorted order.
|
|
21
29
|
*
|
|
22
|
-
* Undeclared (`null`), the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* visitor's to choose. `null` is kept only because flipping it changes what a page that reads an
|
|
28
|
-
* undeclared parameter renders, and that waits for a major; `query: []` is the safe declaration.
|
|
30
|
+
* Undeclared (`null`) is none, the same as `query: []`. An `isr` document is a shared artifact, so
|
|
31
|
+
* what it may vary on is the route's to declare, never the visitor's to choose: a key carrying the
|
|
32
|
+
* whole query made `/blog?x=1`, `/blog?x=2`, … each a miss, a render and a stored entry, so any
|
|
33
|
+
* visitor could make a route render on demand and push real pages out of the bounded store, and
|
|
34
|
+
* `utm_*` / `fbclid` / `gclid` split one page into as many entries as there are campaigns.
|
|
29
35
|
*
|
|
30
36
|
* The SAME narrowed URL is what `load`, `meta` and the page are given (`data.url`, `props.query`),
|
|
31
37
|
* so a parameter that is not in the key cannot change the document stored under it. Nothing is
|
|
@@ -33,9 +39,8 @@ export const ISR_LOCALE_PARAM = '__x_locale';
|
|
|
33
39
|
* A repeated parameter keeps its FIRST value — `?p=1&p=2` and `?p=1&p=3` are one entry, not two.
|
|
34
40
|
*/
|
|
35
41
|
export function isrRequestUrl(url: URL, query: readonly string[] | null): URL {
|
|
36
|
-
if (query === null) return url;
|
|
37
42
|
const kept = new URLSearchParams();
|
|
38
|
-
for (const name of [...new Set(query)].sort()) {
|
|
43
|
+
for (const name of [...new Set(query ?? [])].sort()) {
|
|
39
44
|
const value = url.searchParams.get(name);
|
|
40
45
|
if (value !== null) kept.set(name, value);
|
|
41
46
|
}
|
|
@@ -46,8 +51,8 @@ export function isrRequestUrl(url: URL, query: readonly string[] | null): URL {
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/**
|
|
49
|
-
* The request's parameter names the route did not declare
|
|
50
|
-
*
|
|
54
|
+
* The request's parameter names the route did not declare (`null` declares none): what
|
|
55
|
+
* `isrRequestUrl` drops.
|
|
51
56
|
*/
|
|
52
57
|
export function undeclaredQuery(url: URL, query: readonly string[] | null): readonly string[] {
|
|
53
58
|
const declared = new Set(query ?? []);
|
|
@@ -75,10 +80,16 @@ export function undeclaredQuery(url: URL, query: readonly string[] | null): read
|
|
|
75
80
|
* visitor 1 — for the whole TTL, and with `s-maxage` telling the CDN to do the same. The time zone
|
|
76
81
|
* is deliberately NOT a dimension: it is unbounded where a locale set is declared, and an `isr`
|
|
77
82
|
* page is a shared artifact, so a date on one belongs in an explicit zone the page itself names.
|
|
83
|
+
*
|
|
84
|
+
* `origin` is the caller's when the app declares no public origin, so the document's absolute URLs
|
|
85
|
+
* are the request's own (`ISR_ORIGIN_PARAM`). Added here, after `isrRequestUrl` narrowed, so neither
|
|
86
|
+
* `revalidate.query` nor its default can drop it; omitted, the request cannot supply it either.
|
|
78
87
|
*/
|
|
79
|
-
export function isrKey(url: URL, locale: string): string {
|
|
88
|
+
export function isrKey(url: URL, locale: string, origin?: string): string {
|
|
80
89
|
const params = new URLSearchParams(url.search);
|
|
81
90
|
params.set(ISR_LOCALE_PARAM, locale);
|
|
91
|
+
if (origin === undefined) params.delete(ISR_ORIGIN_PARAM);
|
|
92
|
+
else params.set(ISR_ORIGIN_PARAM, origin);
|
|
82
93
|
params.sort();
|
|
83
94
|
return `${canonicalPath(url.pathname)}?${params.toString()}`;
|
|
84
95
|
}
|
package/src/render-stream.ts
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `stream` — the default for `app/` pages. A genuinely static shell is flushed on the
|
|
3
|
-
* first tick; every `<
|
|
3
|
+
* first tick; every `<Hole>` becomes a slot that is filled out of order, in
|
|
4
4
|
* completion order, as its data resolves.
|
|
5
5
|
*
|
|
6
6
|
* Why the shell is free here: Solid compiles templates to DOM operations and tracks
|
|
7
7
|
* updates with signals, so there is no VDOM tree to replay and no hydration pass over the
|
|
8
8
|
* shell. A resolved hole patches the exact nodes bound to it; the surrounding markup is
|
|
9
9
|
* never re-executed. In a VDOM framework a streamed shell still pays to hydrate the whole
|
|
10
|
-
* tree, which buys TTFB but not TBT — here it buys both, so a `<
|
|
10
|
+
* tree, which buys TTFB but not TBT — here it buys both, so a `<Hole>` that
|
|
11
11
|
* contains no interactive island costs literally zero JS.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
BUILD_ID_HEADER,
|
|
16
|
+
escapeHtml,
|
|
17
|
+
finiteCount,
|
|
18
|
+
logger,
|
|
19
|
+
renderThrowable,
|
|
20
|
+
reportError,
|
|
21
|
+
UltimateError,
|
|
22
|
+
} from '@ultimat3/core';
|
|
15
23
|
import { finiteStatus } from './finite-status';
|
|
16
24
|
import type { RenderResult } from './route';
|
|
17
25
|
import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
|
|
@@ -19,8 +27,15 @@ import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
|
|
|
19
27
|
export interface StreamHole {
|
|
20
28
|
/** Stable within a response; becomes the DOM id, so keep it short. */
|
|
21
29
|
readonly id: string;
|
|
22
|
-
/** Rendered synchronously into the first flush (
|
|
30
|
+
/** Rendered synchronously into the first flush (`<Hole fallback>`). */
|
|
23
31
|
readonly fallback: string;
|
|
32
|
+
/** This hole's own markup when it rejects or misses its deadline (`<Hole error>`). */
|
|
33
|
+
readonly error?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Scripts for the chunk, after its `$X()`: asked once `resolve` answered, so it can see what the
|
|
36
|
+
* hole's render recorded (a hole's island needs a runtime the first flush could not know of).
|
|
37
|
+
*/
|
|
38
|
+
readonly after?: (outcome: 'filled' | 'failed') => string;
|
|
24
39
|
/**
|
|
25
40
|
* `signal` aborts when the response is cancelled — a client that disconnected mid-stream. A
|
|
26
41
|
* hole that ignores it still finishes; it just finishes into a document nobody reads.
|
|
@@ -62,12 +77,12 @@ export function holeMarker(id: string, fallback: string): string {
|
|
|
62
77
|
*/
|
|
63
78
|
export const REVEAL_SCRIPT = `<script>${REVEAL_BODY}</script>`;
|
|
64
79
|
|
|
65
|
-
export function revealChunk(id: string, html: string): string {
|
|
80
|
+
export function revealChunk(id: string, html: string, after = ''): string {
|
|
66
81
|
// The id reaches markup ONLY through `escapeHtml` — never a script — so a quote in it
|
|
67
82
|
// cannot close anything, and no per-hole body exists for a CSP to fail to list.
|
|
68
83
|
return (
|
|
69
84
|
`<template data-x-hole="${escapeHtml(holeId(id))}">${html}</template>` +
|
|
70
|
-
`<script>${REVEAL_CALL}</script
|
|
85
|
+
`<script>${REVEAL_CALL}</script>${after}`
|
|
71
86
|
);
|
|
72
87
|
}
|
|
73
88
|
|
|
@@ -90,8 +105,22 @@ export interface StreamOptions {
|
|
|
90
105
|
* its own timeout, never the default, because "forever" is not a deadline anyone chose.
|
|
91
106
|
*/
|
|
92
107
|
readonly holeTimeoutMs?: number | null;
|
|
108
|
+
/**
|
|
109
|
+
* While a hole is open, an empty comment every this many ms. Nothing else is written while a hole
|
|
110
|
+
* waits, and Bun closes a socket idle for `idleTimeout` — `@ultimat3/http` derives it from
|
|
111
|
+
* `requestTimeoutMs` (+2 s), so a 1 s deadline cut a 6 s hole at 3 s with no `</html>`. A write
|
|
112
|
+
* resets Bun's idle timer (measured, Bun 1.4.4); clamping the hole deadline instead would cut a
|
|
113
|
+
* legitimately slow hole, and render cannot see the socket's timeout to clamp to.
|
|
114
|
+
*/
|
|
115
|
+
readonly keepAliveMs?: number;
|
|
93
116
|
}
|
|
94
117
|
|
|
118
|
+
/** Under the shortest `idleTimeout` `@ultimat3/http` derives: `ceil(requestTimeoutMs / 1000) + 2` ≥ 3 s. */
|
|
119
|
+
export const DEFAULT_STREAM_KEEPALIVE_MS = 1_000;
|
|
120
|
+
|
|
121
|
+
/** The keep-alive write: a comment, inert wherever the parser is between two chunks. */
|
|
122
|
+
export const STREAM_KEEPALIVE_CHUNK = '<!---->';
|
|
123
|
+
|
|
95
124
|
/**
|
|
96
125
|
* `null` is the declared "this hole owns its own timeout" opt-out; every other value is a
|
|
97
126
|
* deadline, and a deadline of 0 is not one — `setTimeout(fn, 0)` fires on the next tick and the
|
|
@@ -104,6 +133,40 @@ const declaredTimeoutMs = (declared: number | null | undefined): number | null =
|
|
|
104
133
|
? null
|
|
105
134
|
: finiteCount('renderStreamHtml', 'holeTimeoutMs', declared, 1);
|
|
106
135
|
|
|
136
|
+
/**
|
|
137
|
+
* A hole's `resolve`, its synchronous throw included. `StreamHole` is public, so nothing makes it an
|
|
138
|
+
* async function, and one that throws before returning a promise threw out of `renderStreamHtml`
|
|
139
|
+
* with no byte sent. Called here and now, not a microtask later: every hole starts as the stream does.
|
|
140
|
+
*/
|
|
141
|
+
const startHole = (hole: StreamHole, signal: AbortSignal): Promise<string> => {
|
|
142
|
+
try {
|
|
143
|
+
return Promise.resolve(hole.resolve(signal));
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return Promise.reject(error);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** A hole's `after`, which is app-shaped code too: a throw there costs the scripts, never the reveal. */
|
|
150
|
+
const afterOf = (hole: StreamHole, outcome: 'filled' | 'failed'): string => {
|
|
151
|
+
try {
|
|
152
|
+
return hole.after?.(outcome) ?? '';
|
|
153
|
+
} catch (error) {
|
|
154
|
+
logger.warn('render.stream.hole_after_failed', {
|
|
155
|
+
hole: hole.id,
|
|
156
|
+
error: renderThrowable(error),
|
|
157
|
+
});
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** What a monitor hears when a hole misses its deadline: the visitor got the error markup. */
|
|
163
|
+
const holeDeadlineMissed = (id: string, timeoutMs: number): UltimateError =>
|
|
164
|
+
new UltimateError({
|
|
165
|
+
code: 'X_TIMEOUT',
|
|
166
|
+
cause: `stream hole ${id} did not settle within ${timeoutMs} ms, so its error markup was shown`,
|
|
167
|
+
fix: 'fetch(url, { signal: AbortSignal.timeout(5_000) }) — bound the read under the <Hole>',
|
|
168
|
+
});
|
|
169
|
+
|
|
107
170
|
/**
|
|
108
171
|
* Flush order is completion order, not declaration order — a fast hole never waits behind
|
|
109
172
|
* a slow one. The stream closes only after every hole has settled, so a rejected boundary
|
|
@@ -122,6 +185,12 @@ export function renderStreamHtml(
|
|
|
122
185
|
// is `setTimeout(fn, 0)`, so every hole would miss a deadline nobody set and the document would
|
|
123
186
|
// be all fallbacks. `null` is the declared opt-out; there is no spelling that means "immediately".
|
|
124
187
|
const timeoutMs = declaredTimeoutMs(options.holeTimeoutMs);
|
|
188
|
+
const keepAliveMs = finiteCount(
|
|
189
|
+
'renderStreamHtml',
|
|
190
|
+
'keepAliveMs',
|
|
191
|
+
options.keepAliveMs ?? DEFAULT_STREAM_KEEPALIVE_MS,
|
|
192
|
+
1,
|
|
193
|
+
);
|
|
125
194
|
/**
|
|
126
195
|
* The response's own lifetime. A client that disconnects mid-stream cancels the stream, and
|
|
127
196
|
* both halves of that have to be honoured: nothing more may be enqueued — `settle`'s
|
|
@@ -131,6 +200,7 @@ export function renderStreamHtml(
|
|
|
131
200
|
*/
|
|
132
201
|
const holes = new AbortController();
|
|
133
202
|
let closed = false;
|
|
203
|
+
let keepAlive: ReturnType<typeof setInterval> | undefined;
|
|
134
204
|
|
|
135
205
|
return new ReadableStream<Uint8Array>({
|
|
136
206
|
start(controller) {
|
|
@@ -144,6 +214,7 @@ export function renderStreamHtml(
|
|
|
144
214
|
const close = (): void => {
|
|
145
215
|
if (closed) return;
|
|
146
216
|
closed = true;
|
|
217
|
+
clearInterval(keepAlive);
|
|
147
218
|
controller.close();
|
|
148
219
|
};
|
|
149
220
|
|
|
@@ -157,6 +228,9 @@ export function renderStreamHtml(
|
|
|
157
228
|
return;
|
|
158
229
|
}
|
|
159
230
|
|
|
231
|
+
keepAlive = setInterval(() => write(STREAM_KEEPALIVE_CHUNK), keepAliveMs);
|
|
232
|
+
keepAlive.unref?.();
|
|
233
|
+
|
|
160
234
|
const settle = (): void => {
|
|
161
235
|
pending -= 1;
|
|
162
236
|
if (pending === 0) {
|
|
@@ -170,46 +244,65 @@ export function renderStreamHtml(
|
|
|
170
244
|
// its rejection, or its deadline. A late resolve after the deadline writes nothing —
|
|
171
245
|
// the placeholder it would fill was replaced by the fallback and the document is closed.
|
|
172
246
|
let revealed = false;
|
|
173
|
-
|
|
247
|
+
// The hole's own lifetime: the response's, cut short by its deadline. The document closes
|
|
248
|
+
// only once every hole settled or missed its deadline, so nothing is left to stop at close,
|
|
249
|
+
// and a hole that finished keeps an un-aborted signal (`render-stream.test.ts` pins it).
|
|
250
|
+
const own = new AbortController();
|
|
251
|
+
const signal = AbortSignal.any([holes.signal, own.signal]);
|
|
252
|
+
const reveal = (html: string, after = ''): void => {
|
|
174
253
|
if (revealed) return;
|
|
175
254
|
revealed = true;
|
|
176
255
|
if (timer !== undefined) clearTimeout(timer);
|
|
177
|
-
write(revealChunk(hole.id, html));
|
|
256
|
+
write(revealChunk(hole.id, html, after));
|
|
178
257
|
settle();
|
|
179
258
|
};
|
|
259
|
+
const failed = (): string => hole.error ?? errorFallback(hole.id);
|
|
180
260
|
const timer =
|
|
181
261
|
timeoutMs === null
|
|
182
262
|
? undefined
|
|
183
263
|
: setTimeout(() => {
|
|
184
264
|
logger.warn('render.stream.hole_deadline', { hole: hole.id, timeoutMs });
|
|
185
|
-
|
|
265
|
+
const missed = holeDeadlineMissed(hole.id, timeoutMs);
|
|
266
|
+
// A client that left is nobody's fault: the abort below stopped the work on purpose.
|
|
267
|
+
if (!signal.aborted) reportError(missed, { source: 'http' });
|
|
268
|
+
own.abort(missed);
|
|
269
|
+
// `after` on every path: the error markup may carry an island of its own.
|
|
270
|
+
reveal(failed(), afterOf(hole, 'failed'));
|
|
186
271
|
}, timeoutMs);
|
|
187
272
|
// A response nobody is reading must not hold the process open until its deadline.
|
|
188
273
|
timer?.unref?.();
|
|
189
274
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
hole
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
275
|
+
// `after` inside the chain: a throw there is this hole's failure, never an unhandled
|
|
276
|
+
// rejection that holds the document to its deadline.
|
|
277
|
+
void startHole(hole, signal)
|
|
278
|
+
// Asked only for a reveal that will write: a hole past its deadline already answered.
|
|
279
|
+
.then((html) => ({ html, after: revealed || closed ? '' : afterOf(hole, 'filled') }))
|
|
280
|
+
.then(
|
|
281
|
+
({ html, after }) => {
|
|
282
|
+
reveal(html, after);
|
|
283
|
+
},
|
|
284
|
+
(error: unknown) => {
|
|
285
|
+
// `renderThrowable`, never `.message`/`String()`: the value is whatever the hole threw,
|
|
286
|
+
// and a read that raises here skips the `reveal` below — the hole never fills and the
|
|
287
|
+
// response is held to its deadline for a failure that was already handled.
|
|
288
|
+
// A FIELD, never the message: `logger` redacts fields and never `msg`, so a hole's
|
|
289
|
+
// failure text interpolated into the message reached the log past every redactor.
|
|
290
|
+
logger.warn('render.stream.hole_rejected', {
|
|
291
|
+
hole: hole.id,
|
|
292
|
+
error: renderThrowable(error),
|
|
293
|
+
});
|
|
294
|
+
// Aborted means its deadline or the client already answered for it.
|
|
295
|
+
if (!signal.aborted) reportError(error, { source: 'http' });
|
|
296
|
+
reveal(failed(), afterOf(hole, 'failed'));
|
|
297
|
+
},
|
|
298
|
+
);
|
|
207
299
|
}
|
|
208
300
|
},
|
|
209
301
|
|
|
210
302
|
/** The client went away. Stop enqueueing, and stop the work that was going to be enqueued. */
|
|
211
303
|
cancel(reason: unknown) {
|
|
212
304
|
closed = true;
|
|
305
|
+
clearInterval(keepAlive);
|
|
213
306
|
holes.abort(reason);
|
|
214
307
|
},
|
|
215
308
|
});
|
package/src/route-status.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The one way a loader answers a response STATUS while still rendering the route's own page.
|
|
2
2
|
//
|
|
3
|
-
// Measured in
|
|
3
|
+
// Measured in one adopter, 2026-09-07: `/fleet/nope` — a host id the fleet does not have — rendered
|
|
4
4
|
// the app's own "Not found" page inside its shell, the right page, and answered **200**. The only
|
|
5
5
|
// route to a 404 was throwing, and a throw renders the framework's error page OUTSIDE the shell.
|
|
6
6
|
// So an app that did the right thing for its visitor could not do the right thing for a crawler,
|
package/src/route.ts
CHANGED
|
@@ -78,8 +78,7 @@ export interface RevalidateConfig {
|
|
|
78
78
|
* The query parameters this page varies on. ONLY these are part of the stored page's key and
|
|
79
79
|
* of the `url` / `query` that `load`, `meta` and the page are given; every other parameter is
|
|
80
80
|
* dropped before either, so `?utm_source=…` is the same stored page and no visitor can mint one.
|
|
81
|
-
* `[]
|
|
82
|
-
* visitor can then mint stored pages (`x verify` says so) — until the next major makes it `[]`.
|
|
81
|
+
* OMITTED is `[]`, none: list every parameter the page reads (`x verify` warns on one it can see).
|
|
83
82
|
*/
|
|
84
83
|
readonly query?: readonly string[];
|
|
85
84
|
}
|
package/src/sass-cache.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A content-addressed disk cache for one Sass compilation: keyed by the compiler, the file and its
|
|
2
2
|
// source, and valid only while every file that compilation READ still hashes the same. Sass has no
|
|
3
3
|
// cache across compilations, so every `.module.scss` re-parses `@ultimat3/ui/tokens` — measured on
|
|
4
|
-
//
|
|
4
|
+
// one adopter's app (140 modules), 3.7 s wall and 12 s CPU of `x manifest --check`'s 6.5 s wall.
|
|
5
5
|
|
|
6
6
|
// why: `compileStylesheet` is synchronous — Bun's loader `onLoad` path calls it inline — and Bun
|
|
7
7
|
// ships no synchronous file read, write or rename; `node:fs` is the only sync file API.
|
package/src/server.ts
CHANGED
|
@@ -38,8 +38,8 @@ export {
|
|
|
38
38
|
} from './module-loader';
|
|
39
39
|
export { enumeratePrerenderEntries } from './prerender-enumerate';
|
|
40
40
|
// ---- the render pipeline: one entry point per mode ----------------------------------------------
|
|
41
|
-
export type { RenderHtmlOptions } from './render-html';
|
|
42
|
-
export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
|
|
41
|
+
export type { HoleEnter, HoleSink, HoleSinkOptions, RenderHtmlOptions } from './render-html';
|
|
42
|
+
export { holeSink, ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
|
|
43
43
|
export {
|
|
44
44
|
DEFAULT_ISR_REGENERATE_DEADLINE_MS,
|
|
45
45
|
invalidateAndRevalidate,
|
|
@@ -80,11 +80,13 @@ export type { StreamHole, StreamOptions, StreamPlan } from './render-stream';
|
|
|
80
80
|
export {
|
|
81
81
|
collectStream,
|
|
82
82
|
DEFAULT_HOLE_TIMEOUT_MS,
|
|
83
|
+
DEFAULT_STREAM_KEEPALIVE_MS,
|
|
83
84
|
holeId,
|
|
84
85
|
holeMarker,
|
|
85
86
|
REVEAL_SCRIPT,
|
|
86
87
|
renderStreamHtml,
|
|
87
88
|
revealChunk,
|
|
89
|
+
STREAM_KEEPALIVE_CHUNK,
|
|
88
90
|
streamResult,
|
|
89
91
|
} from './render-stream';
|
|
90
92
|
// ---- the Sass compile cache: where it lives, and how often this process ran Sass instead --------
|
package/src/type-pins.tsx
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// apps, so the `<ContactSales />` below is checked against the SAME `JSX.Element` a page is —
|
|
9
9
|
// which is the only way a regression to `IslandNode` fails a build rather than a reader's review.
|
|
10
10
|
|
|
11
|
+
import { Hole } from './hole';
|
|
11
12
|
import type { IslandComponent } from './island';
|
|
12
13
|
import { island } from './island';
|
|
13
14
|
import type { JsonValue } from './island-props';
|
|
@@ -126,3 +127,10 @@ type UnprojectedBudgetKey = Exclude<keyof RouteBudget, BudgetKeyOf<keyof RouteDe
|
|
|
126
127
|
export type _EveryBudgetKeyIsProjected = Assert<
|
|
127
128
|
[UnprojectedBudgetKey] extends [never] ? true : false
|
|
128
129
|
>;
|
|
130
|
+
|
|
131
|
+
/** `<Hole>` is an ordinary JSX component, and its children are a thunk: `unknown`, as a shell's are. */
|
|
132
|
+
export const _HoleIsJsx = (): unknown => (
|
|
133
|
+
<Hole fallback={<p>waiting</p>} error={<p>failed</p>}>
|
|
134
|
+
{async () => <p>filled</p>}
|
|
135
|
+
</Hole>
|
|
136
|
+
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The detected-zone script: one inline cookie write from `Intl`'s own zone, on `app/` documents
|
|
2
|
+
// only (never `site/`, axiom 6), so a document navigation — which sends no `x-timezone` — still
|
|
3
|
+
// tells the next request where the visitor is. The first paint stays the default; the second is right.
|
|
4
|
+
|
|
5
|
+
import { serializeSetCookie } from '@ultimat3/core';
|
|
6
|
+
import { DEFAULT_TZ_CONFIG } from '@ultimat3/http';
|
|
7
|
+
import type { HeadTag } from './head';
|
|
8
|
+
|
|
9
|
+
/** A script past this is no longer "one cookie write"; the test holds the body under it. */
|
|
10
|
+
export const ZONE_SCRIPT_MAX_BYTES = 200;
|
|
11
|
+
|
|
12
|
+
/** A year: the zone a device is in changes rarely, and every load rewrites it anyway. */
|
|
13
|
+
const MAX_AGE_SECONDS = 31_536_000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The body alone, so the CSP hash and the tag come from one string (`themeScriptBody`'s rule).
|
|
17
|
+
* The name is `@ultimat3/http`'s `detectedCookie` — the one `resolvePreferences` reads, ranked
|
|
18
|
+
* below the chosen cookie — and never the chosen one: a guess written there would erase a pick.
|
|
19
|
+
* The attributes are core's one serializer's, split around an empty value the browser fills with
|
|
20
|
+
* the zone: `httpOnly: false` because a script writes it, and an IANA name is all cookie octets.
|
|
21
|
+
*/
|
|
22
|
+
export function zoneScriptBody(): string {
|
|
23
|
+
const line = serializeSetCookie(DEFAULT_TZ_CONFIG.detectedCookie, '', {
|
|
24
|
+
maxAge: MAX_AGE_SECONDS,
|
|
25
|
+
httpOnly: false,
|
|
26
|
+
});
|
|
27
|
+
const at = line.indexOf(';');
|
|
28
|
+
return (
|
|
29
|
+
`try{document.cookie=${JSON.stringify(line.slice(0, at))}+` +
|
|
30
|
+
'Intl.DateTimeFormat().resolvedOptions().timeZone+' +
|
|
31
|
+
`${JSON.stringify(line.slice(at))}}catch(e){}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** As a `HeadTag`, deduped by its key like the theme flip. */
|
|
36
|
+
export function zoneScript(): HeadTag {
|
|
37
|
+
return { kind: 'script', key: 'script:zone', content: zoneScriptBody() };
|
|
38
|
+
}
|