@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.
@@ -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,
@@ -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 key carries the whole query, so `/blog?x=1`, `/blog?x=2`, … are each a
23
- * miss, a render and a stored entry: any visitor can make an `isr` route render on demand and push
24
- * real pages out of the bounded store, and `utm_*` / `fbclid` / `gclid` split one page into as
25
- * many entries as there are campaigns — each of which a withdrawal then has to reach. An `isr`
26
- * document is a shared artifact, so what it may vary on is the route's to declare, never the
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: what `isrRequestUrl` drops — or, for a
50
- * route that declared nothing (`null`), what a visitor just used to mint a stored page.
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
  }
@@ -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 `<Suspense>` boundary becomes a hole that is filled out of order, in
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 `<Suspense>` boundary that
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 { BUILD_ID_HEADER, escapeHtml, finiteCount, logger, renderThrowable } from '@ultimat3/core';
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 (the `<Suspense fallback>`). */
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
- const reveal = (html: string): void => {
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
- reveal(errorFallback(hole.id));
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
- void hole.resolve(holes.signal).then(
191
- (html) => {
192
- reveal(html);
193
- },
194
- (error: unknown) => {
195
- // `renderThrowable`, never `.message`/`String()`: the value is whatever the hole threw,
196
- // and a read that raises here skips the `reveal` below — the hole never fills and the
197
- // response is held to its deadline for a failure that was already handled.
198
- // A FIELD, never the message: `logger` redacts fields and never `msg`, so a hole's
199
- // failure text interpolated into the message reached the log past every redactor.
200
- logger.warn('render.stream.hole_rejected', {
201
- hole: hole.id,
202
- error: renderThrowable(error),
203
- });
204
- reveal(errorFallback(hole.id));
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
  });
@@ -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 ai-maxxing, 2026-09-07: `/fleet/nope` — a host id the fleet does not have — rendered
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
- * `[]` is "none", and what most pages want. OMITTED, the key is the whole query string — any
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
- // notificado.co (140 modules), 3.7 s wall and 12 s CPU of `x manifest --check`'s 6.5 s wall.
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
+ }