@ultimat3/render 20.2.1 → 22.0.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/src/hydrate.ts CHANGED
@@ -135,9 +135,14 @@ function hush(){}
135
135
  // `hush` above: `boot` rethrows, so every runtime below has to terminate the chain it starts or
136
136
  // the page reports an unhandled rejection for a failure it already recorded on the element.
137
137
 
138
+ // `idle` hydrates on the browser's schedule, not the visitor's, so its server-rendered controls
139
+ // are pressable for the idle wait plus the chunk's download — up to IDLE_HYDRATE_TIMEOUT_MS and
140
+ // more. A press in that window reached a node with no handler and was lost, which is every app's
141
+ // first click. It catches up exactly as `interaction` does, through the one `catchUp` below; the
142
+ // only difference left between the two is that `idle` also boots when the browser goes idle.
138
143
  const RUNTIME_IDLE = `
139
144
  each('[data-x-hydrate="idle"]',function(el){
140
- var go=function(){boot(el).catch(hush)};
145
+ var go=catchUp(el);
141
146
  if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:${IDLE_HYDRATE_TIMEOUT_MS}});else setTimeout(go,1)})
142
147
  `.trim();
143
148
 
@@ -171,7 +176,16 @@ io.observe(el)})
171
176
  // root is never visited. The root is the last resort, not the repair: it is where an event with no
172
177
  // coordinates goes (a `keydown` has no `clientX`), and where a hit landing outside this island goes
173
178
  // — synthesizing a click on an element the visitor never pressed is worse than losing the replay.
174
- // `typeof` and not `ev.clientX||ev.clientY`, because (0, 0) is a coordinate.
179
+ // `typeof` and not `ev.clientX||ev.clientY`, because (0, 0) is a coordinate — but not when
180
+ // `ev.detail` is 0: a keyboard-activated or scripted `click()` fires at (0, 0) and names no point,
181
+ // so hit-testing the page's corner found nothing of the island and the press went to the root.
182
+ //
183
+ // Between the hit test and the root sits the STRUCTURAL answer: `path` records, when the event is
184
+ // caught, the child-element indices from the root to the target plus the target's tag, and `aim`
185
+ // walks the same indices in the mounted tree. Same tag at the same place is the island's own render
186
+ // of the control that was pressed — which is what a keyboard press, a `keydown`, and a hit landing
187
+ // on a sticky header all lack otherwise. A different tag there is a different control and falls
188
+ // through to the root, for the stranger rule above.
175
189
  //
176
190
  // `off` is BOTH arms of the `then`, and the rejection arm is the reason it is a named function.
177
191
  // `boot` rethrows on purpose (see the prelude), so `el.__x` holds a rejected promise from the
@@ -181,18 +195,32 @@ io.observe(el)})
181
195
  // grew by one retained `Event` — each holding a live `target` — per click, for an island that
182
196
  // will never mount. Swallowing here loses no signal: the DOM already carries the failure as
183
197
  // `data-x-failed`, which is the documented observable.
184
- const RUNTIME_INTERACTION = `
185
- function aim(el,ev){var t=ev.target;if(t&&el.contains(t))return t;
186
- var x=ev.clientX,h=typeof x==='number'?document.elementFromPoint(x,ev.clientY):null;
187
- return h&&el.contains(h)?h:el}
188
- each('[data-x-hydrate="interaction"]',function(el){
189
- var evs=(el.getAttribute('data-x-events')||'click').split(' ');
198
+ //
199
+ // `catchUp(el)` attaches the capture listeners and answers `go`, the one boot-then-flush both
200
+ // strategies call: a caught event calls it, and `idle` calls it again from its idle callback. Every
201
+ // `go` chains on the same `el.__x`, and the first flush empties `q` and sets `done`, so however many
202
+ // ran, each caught event is replayed exactly once and an untouched island still lets go of its
203
+ // listeners at mount — a listener left behind would replay every later click a second time.
204
+ const RUNTIME_CATCH_UP = `
205
+ function path(el,t){var p=[t&&t.tagName];
206
+ for(;t&&t!==el&&t.parentNode;t=t.parentNode)p.unshift(Array.prototype.indexOf.call(t.parentNode.children,t));
207
+ return t===el?p:null}
208
+ function aim(el,ev,p){var t=ev.target;if(t&&el.contains(t))return t;
209
+ var x=ev.clientX,h=typeof x==='number'&&ev.detail!==0?document.elementFromPoint(x,ev.clientY):null;
210
+ if(h&&el.contains(h))return h;
211
+ for(var n=el,i=0;p&&n&&i<p.length-1;i++)n=n.children[p[i]];
212
+ return p&&n&&n.tagName===p[p.length-1]?n:el}
213
+ function catchUp(el){var evs=(el.getAttribute('data-x-events')||'click').split(' ');
190
214
  var q=[],done=false;
191
215
  var off=function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});q=[]};
192
- var on=function(ev){if(done)return;q.push(ev);
193
- boot(el).then(function(){var r=q;off();
194
- r.forEach(function(ev){var c=new ev.constructor(ev.type,ev);aim(el,ev).dispatchEvent(c)})},off)};
195
- evs.forEach(function(n){el.addEventListener(n,on,true)})})
216
+ var go=function(){boot(el).then(function(){var r=q;off();
217
+ r.forEach(function(e){var ev=e[0],c=new ev.constructor(ev.type,ev);aim(el,ev,e[1]).dispatchEvent(c)})},off)};
218
+ var on=function(ev){if(done)return;q.push([ev,path(el,ev.target)]);go()};
219
+ evs.forEach(function(n){el.addEventListener(n,on,true)});return go}
220
+ `.trim();
221
+
222
+ const RUNTIME_INTERACTION = `
223
+ each('[data-x-hydrate="interaction"]',catchUp)
196
224
  `.trim();
197
225
 
198
226
  const RUNTIME_PARTS: Readonly<Record<Exclude<HydrateStrategy, 'never'>, string>> = {
@@ -220,6 +248,9 @@ const RUNTIME_ORDER: readonly Exclude<HydrateStrategy, 'never'>[] = HYDRATE_STRA
220
248
  const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): string =>
221
249
  [
222
250
  RUNTIME_PRELUDE,
251
+ // Emitted once for whichever of the two catch-up strategies the page uses; a `visible`-only
252
+ // page never pays for it.
253
+ ...(needed.has('idle') || needed.has('interaction') ? [RUNTIME_CATCH_UP] : []),
223
254
  ...RUNTIME_ORDER.filter((strategy) => needed.has(strategy)).map(
224
255
  (strategy) => RUNTIME_PARTS[strategy],
225
256
  ),
package/src/index.ts CHANGED
@@ -16,6 +16,23 @@ export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/cor
16
16
  // never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
17
17
  // table it prints against.
18
18
  export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
19
+ /** The `<meta name="ultimate-scope">` core's `pageClient()` reads, on private documents only. */
20
+ export {
21
+ CLIENT_PERSIST_META,
22
+ CLIENT_SCOPE_META,
23
+ clientPersistTags,
24
+ clientScopeTag,
25
+ documentCarriesScope,
26
+ } from './client-scope-tag';
27
+ /** Where the page's one socket dials, and the worker that hosts it (plan 101, slice 11). */
28
+ export type { ClientSyncHead } from './client-sync-tags';
29
+ export {
30
+ CLIENT_BUILD_META,
31
+ CLIENT_SYNC_META,
32
+ CLIENT_SYNC_WORKER_META,
33
+ clientBootTags,
34
+ clientSyncTags,
35
+ } from './client-sync-tags';
19
36
  export { parseTtlMs } from './duration';
20
37
  export type { RenderErrorCode } from './errors';
21
38
  export {
@@ -50,15 +67,13 @@ export {
50
67
  headFromMeta,
51
68
  mergeHead,
52
69
  renderHead,
53
- THEME_SCRIPT_MAX_BYTES,
54
70
  THEME_STORAGE_KEY,
55
71
  themeScript,
56
72
  themeScriptBody,
57
73
  } from './head';
58
- export { headTagKey, seoRenderers, toHeadTag } from './head-seo';
74
+ export { seoRenderers } from './head-seo';
59
75
  export type { IslandDirective } from './hydrate';
60
76
  export {
61
- DEFAULT_REPLAY_EVENTS,
62
77
  emitIslandAttributes,
63
78
  emitIslandProps,
64
79
  HYDRATE_RUNTIME_BODIES,
@@ -67,13 +82,10 @@ export {
67
82
  IDLE_HYDRATE_TIMEOUT_MS,
68
83
  ISLAND_FAILED_ATTRIBUTE,
69
84
  ISLAND_MOUNTED_ATTRIBUTE,
70
- requiredStrategies,
71
85
  } from './hydrate';
72
86
  export type { IslandComponent, IslandDeclaration, IslandNode, IslandSpec } from './island';
73
87
  export {
74
88
  ISLAND_EXTENSION,
75
- ISLAND_NODE,
76
- isEmittableSpecifier,
77
89
  isIslandNode,
78
90
  island,
79
91
  islandModuleId,
@@ -81,7 +93,7 @@ export {
81
93
  export type { IslandCollector, IslandCollectorInput } from './island-collector';
82
94
  export { createIslandCollector, islandModuleIds } from './island-collector';
83
95
  export type { IslandProps, JsonValue } from './island-props';
84
- export { checkIslandProps, ISLAND_PROPS_MAX_BYTES } from './island-props';
96
+ export { ISLAND_PROPS_MAX_BYTES } from './island-props';
85
97
  export { parseByteBudget } from './islands';
86
98
  export type { JsxComponent, JsxNode, JsxProps } from './jsx';
87
99
  export { Fragment, h, isJsxNode, JSX_NODE } from './jsx';
@@ -106,7 +118,6 @@ export {
106
118
  describeRoutes,
107
119
  ROUTE_FILENAME,
108
120
  registerRoute,
109
- routeCount,
110
121
  routeEntries,
111
122
  routeFor,
112
123
  routePathFromFile,
@@ -133,7 +144,8 @@ export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './r
133
144
  export type { RouteComponent } from './route-component';
134
145
  export { pageComponentOf } from './route-component';
135
146
  export { metaContextFor, routeDataFor } from './route-data';
136
- export { DEFAULT_ROUTE_STATUS, isErrorStatus, routeStatusOf, withStatus } from './route-status';
147
+ export { routeStatusOf, withStatus } from './route-status';
148
+ export { STREAM_REVEAL_BODIES } from './stream-scripts';
137
149
  export type {
138
150
  BoundaryRule,
139
151
  BoundaryViolation,
package/src/modes.ts CHANGED
@@ -225,14 +225,13 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
225
225
  * (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
226
226
  * any surface, because the allowance is measured above the baseline and not against it.
227
227
  *
228
- * The number: 17,797 (the heaviest island this repo actually ships) + 1,251 (`hydrateRuntimeBytes`
229
- * for one directive at `DEFAULT_ISLAND_HYDRATE`, which is `'interaction'` — `route.ts:33`, applied
230
- * at `:253` to any island route declaring no `hydrate`) = **19,048**. That is the worst case an
231
- * app reaches without writing a number down. 20,480 is NOT that rounded up — the next whole
232
- * kilobyte above it is 19,456 — it is one whole kB further, leaving 1,432 B of headroom and still
233
- * under 2x 19,048, so a route bundling the same island twice is refused. `island-budget.test.ts`
234
- * asserts all three. `idle` costs 774 and `visible` 846, so an island route that declares its
235
- * strategy pays less; the default is what the budget has to clear.
228
+ * The number: 17,797 (the heaviest island this repo actually ships) + 1,744 (`hydrateRuntimeBytes`
229
+ * for one `idle` directive — `defaultHydrate('app')`, and since 2026-09-22 the costlier of the two
230
+ * runtimes an island gets unasked; `DEFAULT_ISLAND_HYDRATE`'s `'interaction'` is 1,629) =
231
+ * **19,541**. That is the worst case an app reaches without writing a number down. 20,480 is the
232
+ * next whole kilobyte above it, leaving 939 B of headroom and still under 2x 19,541, so a route
233
+ * bundling the same island twice is refused. `island-budget.test.ts` asserts all three. `visible` costs 846, so an island route
234
+ * that declares it pays less; the default is what the budget has to clear.
236
235
  *
237
236
  * All three grew by 129 B on 2026-08-21 (from 881 / 615 / 687), when the prelude learned to mark a
238
237
  * mount's OUTCOME so `x shot` can tell an island that RAN from one that only started loading, and
@@ -240,8 +239,10 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
240
239
  * `interaction` — when each runtime learned to TERMINATE the promise chain `boot` starts rather
241
240
  * than emit one unhandled rejection per user event. `interaction` alone grew a third time on
242
241
  * 2026-08-25 (+184 B, `aim`), when the replay learned that the node it was dispatching at had been
243
- * detached by the mount it was waiting for. The headroom absorbed all three and the conclusion is
244
- * unchanged, which is the point of stating the
242
+ * detached by the mount it was waiting for. On 2026-09-22 `idle` learned the same replay through a
243
+ * shared `catchUp`, and both learned to aim a keyboard press by the pressed node's path (774 -> 1,744;
244
+ * `interaction` 1,251 -> 1,629), and `idle` became the worst case. The
245
+ * headroom absorbed all four and the conclusion is unchanged, which is the point of stating the
245
246
  * arithmetic here rather than the answer alone. It is not
246
247
  * derived from Solid's own size on purpose — this package may not import or name `solid-js`
247
248
  * (`CLAUDE.md`), so a constant tracking the runtime's version would be a dependency in a comment.
@@ -152,6 +152,9 @@ export function transformTsx(source: string): string {
152
152
  */
153
153
  export function loadStylesheet(path: string, source: string): string {
154
154
  const compiled = compileStylesheet(path, source);
155
+ // An EMPTY compile unregisters: under `x dev` an edit that deleted every rule left the old entry
156
+ // in place, serving rules the file no longer had until the process restarted.
157
+ if (compiled.css.length === 0 && stylesheets.delete(path)) revision += 1;
155
158
  if (compiled.css.length > 0) {
156
159
  if (stylesheets.get(path)?.css !== compiled.css) revision += 1;
157
160
  stylesheets.set(path, {
package/src/registry.ts CHANGED
@@ -211,6 +211,8 @@ export function compilePattern(path: string): CompiledPattern {
211
211
  }
212
212
 
213
213
  const routes = new Map<string, RouteEntry>();
214
+ /** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
215
+ let described: readonly RouteDescriptor[] | undefined;
214
216
 
215
217
  export interface RegisterRouteInput<TData = RouteData> {
216
218
  readonly file: string;
@@ -290,6 +292,7 @@ export function registerRoute<TData = RouteData>(
290
292
  ...(input.component === undefined ? {} : { component: input.component }),
291
293
  };
292
294
  routes.set(path, entry as RouteEntry);
295
+ described = undefined;
293
296
  return entry;
294
297
  }
295
298
 
@@ -316,6 +319,7 @@ function withIslandBudget<TData>(config: RouteConfig<TData>, surface: Surface):
316
319
 
317
320
  export function clearRoutes(): void {
318
321
  routes.clear();
322
+ described = undefined;
319
323
  }
320
324
 
321
325
  export function routeCount(): number {
@@ -337,6 +341,14 @@ export function routeFor(path: string): RouteEntry | undefined {
337
341
  * Determinism matters because `sw.js` and the sitemap are diffed across deploys.
338
342
  */
339
343
  export function describeRoutes(): readonly RouteDescriptor[] {
344
+ // Built once per registry change and handed out as the SAME frozen array: an ISR regeneration
345
+ // looked its route up through this on every request, re-sorting the whole table each time, and
346
+ // a stable identity is what lets `render-isr.ts` compile its matchers once per table.
347
+ described ??= Object.freeze(buildDescriptors());
348
+ return described;
349
+ }
350
+
351
+ function buildDescriptors(): RouteDescriptor[] {
340
352
  return routeEntries().map((entry) => ({
341
353
  path: entry.path,
342
354
  file: entry.file,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The ISR store: what an entry is, the driver seam an app may back with its own storage, and the
3
+ * bounded in-memory default. Split from `render-isr.ts`, which is the controller that reads it.
4
+ */
5
+
6
+ import { finiteCount } from '@ultimat3/core';
7
+
8
+ export type IsrState = 'miss' | 'hit' | 'stale';
9
+
10
+ export interface IsrEntry {
11
+ /**
12
+ * The store key: the request's pathname AND its query, params sorted. Not the route's pattern
13
+ * and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
14
+ * keying both as `/blog` served the second visitor the first one's HTML (#171).
15
+ */
16
+ readonly path: string;
17
+ readonly html: string;
18
+ readonly hash: string;
19
+ readonly generatedAt: number;
20
+ readonly ttlMs: number | null;
21
+ /** Set by a tag invalidation; independent of the TTL clock. */
22
+ readonly stale: boolean;
23
+ /**
24
+ * What the page answers, 200–599. Optional because an entry can come back from an app's own
25
+ * store, written before this field existed or JSON-round-tripped without it; absent reads as
26
+ * 200, the only status an entry ever had until `withStatus`.
27
+ */
28
+ readonly status?: number;
29
+ }
30
+
31
+ export interface IsrStore {
32
+ get(path: string): IsrEntry | undefined;
33
+ set(entry: IsrEntry): void;
34
+ /**
35
+ * Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
36
+ * not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
37
+ * store is entitled to order its eviction by that: the read-modify-write made the STALEST page
38
+ * the newest, so a tag bust protected exactly the pages that most needed regenerating.
39
+ */
40
+ markStale(path: string): boolean;
41
+ delete(path: string): void;
42
+ paths(): readonly string[];
43
+ }
44
+
45
+ /**
46
+ * How many rendered pages the default store holds. A route table supports `:params` and `*`, so
47
+ `/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
48
+ * included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
49
+ * the process.
50
+ */
51
+ export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
52
+
53
+ export interface MemoryIsrStoreOptions {
54
+ /** Pages retained. The least recently generated goes first. */
55
+ readonly maxEntries?: number;
56
+ }
57
+
58
+ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
59
+ // `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
60
+ // cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
61
+ const maxEntries = finiteCount(
62
+ 'memoryIsrStore',
63
+ 'maxEntries',
64
+ options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
65
+ );
66
+ const map = new Map<string, IsrEntry>();
67
+ return {
68
+ get: (path) => map.get(path),
69
+ set: (entry) => {
70
+ // Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
71
+ // the first key is the least recently generated page.
72
+ map.delete(entry.path);
73
+ map.set(entry.path, entry);
74
+ while (map.size > maxEntries) {
75
+ const oldest = map.keys().next();
76
+ if (oldest.done === true) break;
77
+ map.delete(oldest.value);
78
+ }
79
+ },
80
+ // In place: `map.set` on a key the Map already holds keeps its position, and that position is
81
+ // the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
82
+ markStale: (path) => {
83
+ const entry = map.get(path);
84
+ if (entry === undefined) return false;
85
+ map.set(path, { ...entry, stale: true });
86
+ return true;
87
+ },
88
+ delete: (path) => {
89
+ map.delete(path);
90
+ },
91
+ paths: () => [...map.keys()].sort(),
92
+ };
93
+ }
package/src/render-isr.ts CHANGED
@@ -15,101 +15,16 @@ import {
15
15
  sampleFence,
16
16
  unregisterDependent,
17
17
  } from '@ultimat3/cache';
18
- import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
18
+ import { logger, renderThrowable } from '@ultimat3/core';
19
19
  import { parseTtlMs } from './duration';
20
20
  import { finiteStatus, isRenderStatus } from './finite-status';
21
21
  import type { RouteDescriptor } from './registry';
22
22
  import { describeRoutes } from './registry';
23
+ import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
24
+ import { memoryIsrStore } from './render-isr-store';
23
25
  import { contentHash, staticHeaders } from './render-static';
24
26
  import type { RenderResult } from './route';
25
27
 
26
- export type IsrState = 'miss' | 'hit' | 'stale';
27
-
28
- export interface IsrEntry {
29
- /**
30
- * The store key: the request's pathname AND its query, params sorted. Not the route's pattern
31
- * and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
32
- * keying both as `/blog` served the second visitor the first one's HTML (#171).
33
- */
34
- readonly path: string;
35
- readonly html: string;
36
- readonly hash: string;
37
- readonly generatedAt: number;
38
- readonly ttlMs: number | null;
39
- /** Set by a tag invalidation; independent of the TTL clock. */
40
- readonly stale: boolean;
41
- /**
42
- * What the page answers, 200–599. Optional because an entry can come back from an app's own
43
- * store, written before this field existed or JSON-round-tripped without it; absent reads as
44
- * 200, the only status an entry ever had until `withStatus`.
45
- */
46
- readonly status?: number;
47
- }
48
-
49
- export interface IsrStore {
50
- get(path: string): IsrEntry | undefined;
51
- set(entry: IsrEntry): void;
52
- /**
53
- * Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
54
- * not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
55
- * store is entitled to order its eviction by that: the read-modify-write made the STALEST page
56
- * the newest, so a tag bust protected exactly the pages that most needed regenerating.
57
- */
58
- markStale(path: string): boolean;
59
- delete(path: string): void;
60
- paths(): readonly string[];
61
- }
62
-
63
- /**
64
- * How many rendered pages the default store holds. A route table supports `:params` and `*`, so
65
- `/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
66
- * included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
67
- * the process.
68
- */
69
- export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
70
-
71
- export interface MemoryIsrStoreOptions {
72
- /** Pages retained. The least recently generated goes first. */
73
- readonly maxEntries?: number;
74
- }
75
-
76
- export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
77
- // `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
78
- // cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
79
- const maxEntries = finiteCount(
80
- 'memoryIsrStore',
81
- 'maxEntries',
82
- options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
83
- );
84
- const map = new Map<string, IsrEntry>();
85
- return {
86
- get: (path) => map.get(path),
87
- set: (entry) => {
88
- // Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
89
- // the first key is the least recently generated page.
90
- map.delete(entry.path);
91
- map.set(entry.path, entry);
92
- while (map.size > maxEntries) {
93
- const oldest = map.keys().next();
94
- if (oldest.done === true) break;
95
- map.delete(oldest.value);
96
- }
97
- },
98
- // In place: `map.set` on a key the Map already holds keeps its position, and that position is
99
- // the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
100
- markStale: (path) => {
101
- const entry = map.get(path);
102
- if (entry === undefined) return false;
103
- map.set(path, { ...entry, stale: true });
104
- return true;
105
- },
106
- delete: (path) => {
107
- map.delete(path);
108
- },
109
- paths: () => [...map.keys()].sort(),
110
- };
111
- }
112
-
113
28
  /**
114
29
  * The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
115
30
  * because `routePathOf` splits a key at its `?`: a `es:/blog` key would match no route, so
@@ -231,7 +146,7 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
231
146
  function descriptorFor(key: string): RouteDescriptor | undefined {
232
147
  const path = routePathOf(key);
233
148
  const table = routes();
234
- return table.find((r) => r.path === path) ?? table.find((r) => matchesRoute(path, r.path));
149
+ return table.find((r) => r.path === path) ?? matchersOf(table).find((m) => m.test(path))?.route;
235
150
  }
236
151
 
237
152
  /**
@@ -413,11 +328,27 @@ function parseWireTag(wire: string): CacheTag {
413
328
  return { entity: wire.slice(0, split), id: wire.slice(split + 1) };
414
329
  }
415
330
 
331
+ interface RouteMatcher {
332
+ readonly route: RouteDescriptor;
333
+ test(storedPath: string): boolean;
334
+ }
335
+
336
+ /** One compiled set per route TABLE — `describeRoutes()` hands out one array per registry change. */
337
+ const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
338
+
416
339
  /** A stored path belongs to a route when the route's pattern matches it. */
417
- function matchesRoute(storedPath: string, routePath: string): boolean {
418
- if (!routePath.includes(':') && !routePath.includes('*')) return storedPath === routePath;
419
- const parts = routePath.split('/').map(segmentPattern);
420
- return new RegExp(`^${parts.join('/')}/?$`).test(storedPath);
340
+ function matchersOf(table: readonly RouteDescriptor[]): readonly RouteMatcher[] {
341
+ const cached = compiledTables.get(table);
342
+ if (cached !== undefined) return cached;
343
+ const compiled = table.map((route): RouteMatcher => {
344
+ if (!route.path.includes(':') && !route.path.includes('*')) {
345
+ return { route, test: (storedPath) => storedPath === route.path };
346
+ }
347
+ const pattern = new RegExp(`^${route.path.split('/').map(segmentPattern).join('/')}/?$`);
348
+ return { route, test: (storedPath) => pattern.test(storedPath) };
349
+ });
350
+ compiledTables.set(table, compiled);
351
+ return compiled;
421
352
  }
422
353
 
423
354
  function segmentPattern(segment: string): string {
@@ -8,12 +8,13 @@ import { renderThrowable, useContext } from '@ultimat3/core';
8
8
  import { PrerenderFailedError, RouteModeInvalidError } from './errors';
9
9
  import type { RouteEntry } from './registry';
10
10
  import type { RenderResult, RouteParams } from './route';
11
+ import { filePathOf, filledSegments, urlPathOf } from './static-path';
11
12
 
12
13
  export interface StaticArtifact {
13
14
  readonly path: string;
14
15
  readonly params: RouteParams;
15
16
  readonly html: string;
16
- /** FNV-1a of the HTML. Stable across machines and across Bun versions. */
17
+ /** `contentHash` (xxHash32) of the HTML. Stable across machines and across Bun versions. */
17
18
  readonly hash: string;
18
19
  /** Where the file lands on disk, relative to the build output root. */
19
20
  readonly outputPath: string;
@@ -25,14 +26,15 @@ export type StaticRenderFn = (input: {
25
26
  readonly params: RouteParams;
26
27
  }) => string | Promise<string>;
27
28
 
28
- /** Deterministic, dependency-free 32-bit FNV-1a, hex. */
29
+ /**
30
+ * xxHash32 (seed 0) of the UTF-8 bytes, 8 hex characters. Native: FNV-1a in JS measured 134 µs on a
31
+ * 96 kB document against 21 µs here, and every static page, ISR regeneration and CSS module hashes
32
+ * through it. xxHash32 is a SPECIFIED algorithm — the test pins its reference vectors — so the value
33
+ * is stable across machines and Bun versions, as the FNV one was. Switching was a one-time cache
34
+ * bust: every ETag and every scoped CSS class name changed once, in 22.0.0.
35
+ */
29
36
  export function contentHash(input: string): string {
30
- let hash = 0x811c9dc5;
31
- for (let i = 0; i < input.length; i += 1) {
32
- hash ^= input.charCodeAt(i);
33
- hash = Math.imul(hash, 0x01000193) >>> 0;
34
- }
35
- return hash.toString(16).padStart(8, '0');
37
+ return Bun.hash.xxHash32(input).toString(16).padStart(8, '0');
36
38
  }
37
39
 
38
40
  /**
@@ -115,7 +117,8 @@ export async function renderStatic(
115
117
 
116
118
  const artifacts: StaticArtifact[] = [];
117
119
  for (const params of paramSets) {
118
- const path = fillPath(entry.pattern.source, params);
120
+ const segments = filledSegments(entry.pattern.source, params);
121
+ const path = urlPathOf(segments);
119
122
  let html: string;
120
123
  try {
121
124
  html = await render({ path, params });
@@ -131,7 +134,7 @@ export async function renderStatic(
131
134
  params,
132
135
  html,
133
136
  hash,
134
- outputPath: `${path === '/' ? '' : path}/${indexFile}`.replace(/^\/+/, ''),
137
+ outputPath: filePathOf(segments, indexFile),
135
138
  headers: staticHeaders(hash, options.buildId),
136
139
  });
137
140
  }
@@ -152,17 +155,11 @@ export function staticResult(artifact: StaticArtifact): RenderResult {
152
155
  return { status: 200, headers: artifact.headers, body: artifact.html };
153
156
  }
154
157
 
155
- /** `/blog/:slug` + `{ slug: 'hello' }` → `/blog/hello`. */
158
+ /**
159
+ * `/blog/:slug` + `{ slug: 'hello' }` → `/blog/hello`, each segment percent-encoded. A missing
160
+ * param, a dot segment, a separator inside a `:param`, NUL, `?` and `#` are `X_PRERENDER_FAILED`
161
+ * (`static-path.ts`) — they wrote a `:slug` directory, or a file outside the build output.
162
+ */
156
163
  export function fillPath(pattern: string, params: RouteParams): string {
157
- return (
158
- pattern
159
- .split('/')
160
- .map((segment) => {
161
- if (segment.startsWith(':')) return params[segment.slice(1)] ?? segment;
162
- if (segment.startsWith('*')) return params[segment.slice(1)] ?? '';
163
- return segment;
164
- })
165
- .join('/')
166
- .replace(/\/+$/, '') || '/'
167
- );
164
+ return urlPathOf(filledSegments(pattern, params));
168
165
  }
@@ -13,8 +13,9 @@
13
13
 
14
14
  import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
15
15
  import { finiteStatus } from './finite-status';
16
- import { escapeAttribute, escapeRawTextContent } from './html';
16
+ import { escapeAttribute } from './html';
17
17
  import type { RenderResult } from './route';
18
+ import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
18
19
 
19
20
  export interface StreamHole {
20
21
  /** Stable within a response; becomes the DOM id, so keep it short. */
@@ -60,20 +61,14 @@ export function holeMarker(id: string, fallback: string): string {
60
61
  * The entire client half of out-of-order streaming. Inline, uncompressed, ~200 bytes; it
61
62
  * moves a late `<template>`'s content into the placeholder that is already on screen.
62
63
  */
63
- export const REVEAL_SCRIPT =
64
- "<script>window.$X=function(i){var t=document.querySelector('template[data-x-hole=\"'+i+'\"]')," +
65
- 's=document.getElementById(i);if(t&&s){s.replaceWith(t.content);t.remove()}}</script>';
64
+ export const REVEAL_SCRIPT = `<script>${REVEAL_BODY}</script>`;
66
65
 
67
66
  export function revealChunk(id: string, html: string): string {
68
- const key = holeId(id);
69
- // Two contexts, two encoders — the attribute takes `escapeAttribute`, and the script argument is
70
- // built by `JSON.stringify` so the id is a JS string LITERAL rather than text pasted between two
71
- // quotes: `a");alert(1);//` closed the call and ran on the page's own origin. `</script` inside
72
- // it would still end the element, so the raw-text rule applies over the top, as `html.ts` says.
73
- const argument = escapeRawTextContent(JSON.stringify(key));
67
+ // The id reaches markup ONLY through `escapeAttribute` — never a script — so a quote in it
68
+ // cannot close anything, and no per-hole body exists for a CSP to fail to list.
74
69
  return (
75
- `<template data-x-hole="${escapeAttribute(key)}">${html}</template>` +
76
- `<script>$X(${argument})</script>`
70
+ `<template data-x-hole="${escapeAttribute(holeId(id))}">${html}</template>` +
71
+ `<script>${REVEAL_CALL}</script>`
77
72
  );
78
73
  }
79
74
 
@@ -186,7 +181,7 @@ export function renderStreamHtml(
186
181
  timeoutMs === null
187
182
  ? undefined
188
183
  : setTimeout(() => {
189
- logger.warn(`stream hole ${hole.id} missed its ${timeoutMs}ms deadline`);
184
+ logger.warn('render.stream.hole_deadline', { hole: hole.id, timeoutMs });
190
185
  reveal(errorFallback(hole.id));
191
186
  }, timeoutMs);
192
187
  // A response nobody is reading must not hold the process open until its deadline.
@@ -200,7 +195,12 @@ export function renderStreamHtml(
200
195
  // `renderThrowable`, never `.message`/`String()`: the value is whatever the hole threw,
201
196
  // and a read that raises here skips the `reveal` below — the hole never fills and the
202
197
  // response is held to its deadline for a failure that was already handled.
203
- logger.warn(`stream hole ${hole.id} rejected: ${renderThrowable(error)}`);
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
204
  reveal(errorFallback(hole.id));
205
205
  },
206
206
  );