@ultimat3/render 23.0.0 → 25.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/registry.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * The route table — the single source of route truth. File path → URL conventions for
3
- * `site/`, `app/` and `api/`, plus `describeRoutes()`, the serializable projection that
3
+ * `site/`, `app/` and `api/`, plus `describePages()`, the serializable projection that
4
4
  * `x.manifest.json`, the `/_x` routes panel, the sitemap and `sw.js` are all generated
5
5
  * from. Nothing downstream may keep its own list of routes.
6
6
  */
7
7
 
8
+ import type { CacheTag } from '@ultimat3/cache';
9
+ import { serializeTag, surrogateKeys } from '@ultimat3/cache';
8
10
  import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
9
- import { finiteCount } from '@ultimat3/core';
11
+ import { finiteCount, isUltimateError, renderFixLiteral } from '@ultimat3/core';
10
12
  import { byCodeUnit } from './code-unit-order';
11
13
  import {
12
14
  RouteDuplicateError,
13
15
  RouteFileInvalidError,
16
+ RouteModeInvalidError,
14
17
  RouteUnnormalizedError,
15
18
  SurfaceBoundaryError,
16
19
  } from './errors';
@@ -24,8 +27,10 @@ import {
24
27
  setMountedRoutes,
25
28
  } from './mounted-routes';
26
29
  import type { RouteConfig, RouteData } from './route';
27
- import { isRouteConfig, tagKeys } from './route';
30
+ import { isRouteConfig, routeTagKeys } from './route';
28
31
  import type { RouteComponent } from './route-component';
32
+ import type { CompiledPattern } from './route-pattern';
33
+ import { compilePattern } from './route-pattern';
29
34
  import type { Surface } from './surfaces';
30
35
  import { locateSurface } from './surfaces';
31
36
 
@@ -90,7 +95,6 @@ export interface RouteDescriptor {
90
95
  */
91
96
  readonly islandSources: readonly string[];
92
97
  readonly budgetJs: string | null;
93
- readonly budgetLcp: number | null;
94
98
  /**
95
99
  * Present on a route NO surface file declares: who mounted it and every permission that gates
96
100
  * it. Absent on a file route — so a reader never has to ask whether `file` is a path.
@@ -100,14 +104,6 @@ export interface RouteDescriptor {
100
104
 
101
105
  export type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
102
106
 
103
- export interface CompiledPattern {
104
- readonly source: string;
105
- readonly regex: RegExp;
106
- readonly keys: readonly string[];
107
- /** Higher wins when two patterns match the same pathname. */
108
- readonly specificity: number;
109
- }
110
-
111
107
  /**
112
108
  * `apps/web/site/blog/[slug]/page.tsx` → `{ surface: 'site', path: '/blog/:slug' }`.
113
109
  *
@@ -209,36 +205,8 @@ function toUrlSegment(segment: string): string {
209
205
  return segment;
210
206
  }
211
207
 
212
- export function compilePattern(path: string): CompiledPattern {
213
- const keys: string[] = [];
214
- let specificity = 0;
215
- const segments = path.split('/').filter((s) => s.length > 0);
216
-
217
- const parts = segments.map((segment) => {
218
- if (segment.startsWith('*')) {
219
- keys.push(segment.slice(1));
220
- specificity += 1;
221
- return '(.*)';
222
- }
223
- if (segment.startsWith(':')) {
224
- keys.push(segment.slice(1));
225
- specificity += 10;
226
- return '([^/]+)';
227
- }
228
- specificity += 100;
229
- return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
230
- });
231
-
232
- return {
233
- source: path,
234
- regex: new RegExp(`^/${parts.join('/')}/?$`),
235
- keys,
236
- specificity,
237
- };
238
- }
239
-
240
208
  const routes = new Map<string, RouteEntry>();
241
- /** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
209
+ /** The table `describePages()` last built, dropped whenever a route registers or the registry clears. */
242
210
  let described: readonly RouteDescriptor[] | undefined;
243
211
 
244
212
  export interface RegisterRouteInput<TData = RouteData> {
@@ -256,12 +224,33 @@ export interface RegisterRouteInput<TData = RouteData> {
256
224
  readonly component?: RouteComponent;
257
225
  }
258
226
 
227
+ /**
228
+ * A `revalidate.tags` entry goes out as a purge key on every response of the route
229
+ * (`render-isr.ts`), so one a CDN would split is refused HERE, naming the file. Asked of
230
+ * `@ultimat3/cache`'s own screen rather than restated: found at serve time it is a 500 on a
231
+ * public page, found at purge time it is a document nothing can clear.
232
+ */
233
+ function assertPurgeableTags(file: string, tags: readonly CacheTag[] | undefined): void {
234
+ for (const owned of tags ?? []) {
235
+ try {
236
+ surrogateKeys([owned], file);
237
+ } catch (error) {
238
+ if (!isUltimateError(error) || error.code !== 'X_CACHE_PURGE_FAILED') throw error;
239
+ const wire = renderFixLiteral(serializeTag(owned), '<the tag>');
240
+ throw new RouteModeInvalidError(
241
+ `${file} declares revalidate tag ${wire}, which cannot be a CDN purge key — a key is split on whitespace and commas and capped at 1024 bytes, so every response of the route would carry one no purge can name`,
242
+ `edit revalidate.tags in ${file}: replace ${wire} with a tag whose entity and id carry no whitespace or comma`,
243
+ );
244
+ }
245
+ }
246
+ }
247
+
259
248
  /** Register a route and enforce every invariant that needs the surrounding module. */
260
249
  export function registerRoute<TData = RouteData>(
261
250
  input: RegisterRouteInput<TData>,
262
251
  ): RouteEntry<TData> {
263
252
  // The type already refuses a declaration; this catches the JS caller and the cast. Without it a
264
- // raw declaration registers, and `describeRoutes()` is where it surfaces — as a bare TypeError
253
+ // raw declaration registers, and `describePages()` is where it surfaces — as a bare TypeError
265
254
  // on `config.budget.js`, one build step away from the file that caused it.
266
255
  if (!isRouteConfig(input.config)) {
267
256
  throw new RouteUnnormalizedError(
@@ -292,6 +281,8 @@ export function registerRoute<TData = RouteData>(
292
281
  suspenseBoundaries,
293
282
  });
294
283
 
284
+ assertPurgeableTags(input.file, config.revalidate?.tags);
285
+
295
286
  const existing = routes.get(path);
296
287
  if (existing !== undefined && existing.file !== input.file) {
297
288
  throw new RouteDuplicateError(
@@ -359,6 +350,7 @@ export function registerMountedRoutes(
359
350
  for (const route of declared) {
360
351
  const file = routes.get(route.path)?.file;
361
352
  if (file !== undefined) throw mountCollision(route.path, file, mount);
353
+ assertPurgeableTags(mount.file, route.config.revalidate?.tags);
362
354
  }
363
355
  setMountedRoutes(mount, declared);
364
356
  described = undefined;
@@ -375,7 +367,7 @@ export function routeCount(): number {
375
367
  }
376
368
 
377
369
  export function routeEntries(): readonly RouteEntry[] {
378
- // Code units, never `localeCompare` — `describeRoutes()` below promises an order "identical for
370
+ // Code units, never `localeCompare` — `describePages()` below promises an order "identical for
379
371
  // identical input", and `localeCompare` with no locale argument reads the runtime's ICU default.
380
372
  return [...routes.values()].sort((a, b) => byCodeUnit(a.path, b.path));
381
373
  }
@@ -388,7 +380,7 @@ export function routeFor(path: string): RouteEntry | undefined {
388
380
  * The manifest projection: JSON-safe, sorted by path, identical for identical input.
389
381
  * Determinism matters because `sw.js` and the sitemap are diffed across deploys.
390
382
  */
391
- export function describeRoutes(): readonly RouteDescriptor[] {
383
+ export function describePages(): readonly RouteDescriptor[] {
392
384
  // Built once per registry change and handed out as the SAME frozen array: an ISR regeneration
393
385
  // looked its route up through this on every request, re-sorting the whole table each time, and
394
386
  // a stable identity is what lets `render-isr.ts` compile its matchers once per table.
@@ -430,7 +422,7 @@ const descriptorOf = (
430
422
  mode: config.render,
431
423
  offline: config.offline,
432
424
  hydrate: config.hydrate,
433
- revalidateTags: tagKeys(config.revalidate?.tags),
425
+ revalidateTags: routeTagKeys(config.revalidate?.tags),
434
426
  revalidateTtl: config.revalidate?.ttl ?? null,
435
427
  prerenderable: config.prerender !== undefined,
436
428
  dynamic: params > 0,
@@ -439,7 +431,6 @@ const descriptorOf = (
439
431
  islands: config.islands.map((spec) => spec.moduleId),
440
432
  islandSources: config.islands.map((spec) => spec.src),
441
433
  budgetJs: config.budget.js ?? null,
442
- budgetLcp: config.budget.lcp ?? null,
443
434
  });
444
435
 
445
436
  /** See `RouteDescriptor.personal`. */
@@ -4,14 +4,14 @@
4
4
  * component — `static` at build time, `ssr`/`stream` per request, all through `renderToHtml`.
5
5
  */
6
6
 
7
- import { renderThrowable } from '@ultimat3/core';
7
+ import { escapeHtml, renderFixShellArg, renderThrowable } from '@ultimat3/core';
8
8
  import {
9
9
  IslandInvalidError,
10
10
  IslandNotHydratedError,
11
11
  IslandPropsInvalidError,
12
12
  PrerenderFailedError,
13
13
  } from './errors';
14
- import { escapeText, renderAttributes, VOID_ELEMENTS } from './html';
14
+ import { renderAttributes, VOID_ELEMENTS } from './html';
15
15
  import { emitIslandAttributes, emitIslandProps } from './hydrate';
16
16
  import type { IslandNode } from './island';
17
17
  import { isIslandNode } from './island';
@@ -62,8 +62,8 @@ export interface RenderHtmlOptions {
62
62
  async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): Promise<string> {
63
63
  assertDepth(depth);
64
64
  if (value === null || value === undefined || value === false || value === true) return '';
65
- if (typeof value === 'string') return escapeText(value);
66
- if (typeof value === 'number' || typeof value === 'bigint') return escapeText(String(value));
65
+ if (typeof value === 'string') return escapeHtml(value);
66
+ if (typeof value === 'number' || typeof value === 'bigint') return escapeHtml(String(value));
67
67
  if (value instanceof Promise) return unwrap(await value, depth, walk);
68
68
  // Before the array branch, never after: an island node IS an array — the only object shape the
69
69
  // configured `JSX.Element` admits — so the generic branch would render its empty contents and
@@ -75,7 +75,7 @@ async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): P
75
75
  }
76
76
  if (isJsxNode(value)) return renderNode(value.type, value.props, depth, walk);
77
77
  if (typeof value === 'function') return unwrap((value as () => unknown)(), depth + 1, walk);
78
- return escapeText(String(value));
78
+ return escapeHtml(String(value));
79
79
  }
80
80
 
81
81
  /**
@@ -164,7 +164,9 @@ export async function renderComponent(
164
164
  // null-prototype object (`String()` raises) or an Error whose `message` getter does — and
165
165
  // this frame is the last thing between that and a build failure with no code at all.
166
166
  `rendering the component in ${file} threw: ${renderThrowable(error)}`,
167
- `run \`bun test ${file.replace(/\.tsx?$/, '.test.ts')}\` to reproduce, then fix ${file}`,
167
+ // The route file is author text and `$(…)` is a legal name: screened where it enters the
168
+ // reproduce command (security audit of plan 101 sweep 1c).
169
+ `run \`bun test ${renderFixShellArg(file.replace(/\.tsx?$/, '.test.ts'), "'<the route's test file>'")}\` to reproduce, then fix ${file}`,
168
170
  );
169
171
  }
170
172
  }
package/src/render-isr.ts CHANGED
@@ -13,17 +13,20 @@ import {
13
13
  registerDependent,
14
14
  registerRevalidator,
15
15
  sampleFence,
16
+ surrogateKeys,
16
17
  unregisterDependent,
17
18
  } from '@ultimat3/cache';
18
- import { logger, renderThrowable } from '@ultimat3/core';
19
+ import type { Scheduler } from '@ultimat3/core';
20
+ import { finiteCount, logger, renderThrowable, singleFlight } from '@ultimat3/core';
19
21
  import { parseTtlMs } from './duration';
20
22
  import { finiteStatus, isRenderStatus } from './finite-status';
21
23
  import type { RouteDescriptor } from './registry';
22
- import { describeRoutes } from './registry';
24
+ import { describePages } from './registry';
23
25
  import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
24
26
  import { memoryIsrStore } from './render-isr-store';
25
27
  import { contentHash, staticHeaders } from './render-static';
26
28
  import type { RenderResult } from './route';
29
+ import { compilePattern } from './route-pattern';
27
30
 
28
31
  /**
29
32
  * The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
@@ -105,8 +108,25 @@ export interface IsrControllerOptions {
105
108
  readonly routes?: () => readonly RouteDescriptor[];
106
109
  /** ISR-route dependents for a tag set; defaults to `@ultimat3/cache`'s graph. */
107
110
  readonly isrDependents?: (tags: readonly CacheTag[]) => readonly string[];
111
+ /**
112
+ * How long one regeneration may hold its path, in whole ms (≥ 1). Defaults to
113
+ * `DEFAULT_ISR_REGENERATE_DEADLINE_MS`. There is no "forever": that is the bug it bounds.
114
+ */
115
+ readonly regenerateDeadlineMs?: number | undefined;
116
+ /** Injected so the deadline is provable without waiting one out. */
117
+ readonly schedule?: Scheduler | undefined;
108
118
  }
109
119
 
120
+ /**
121
+ * The ceiling on one regeneration's hold on its path — `@ultimat3/http`'s default
122
+ * `requestTimeoutMs`, so the request that started a render has been cut off by then. Without it,
123
+ * one `render()` that never settled pinned its path for the life of the process: a missed page
124
+ * hung every later request, a stale one was served stale forever, and only a restart cleared it.
125
+ * Eviction frees the PATH; the hung render is not cancellable from here, and its late result is
126
+ * dropped if a newer regeneration has started since (`latest`).
127
+ */
128
+ export const DEFAULT_ISR_REGENERATE_DEADLINE_MS = 30_000;
129
+
110
130
  export interface IsrController {
111
131
  serve(path: string, render: IsrRenderFn): Promise<IsrServeResult>;
112
132
  /** Single-flight: concurrent callers for the same path share one render. */
@@ -133,14 +153,29 @@ let installedRevalidator: Revalidator | undefined;
133
153
  /** What `registerRevalidator` is handed on detach: the framework's "nothing to revalidate". */
134
154
  const NO_REVALIDATION: Revalidator = () => undefined;
135
155
 
136
- export function createIsrController(options: IsrControllerOptions = {}): IsrController {
156
+ export function isrController(options: IsrControllerOptions = {}): IsrController {
137
157
  const store = options.store ?? memoryIsrStore();
138
158
  const now = options.now ?? (() => Date.now());
139
- const routes = options.routes ?? describeRoutes;
159
+ const routes = options.routes ?? describePages;
140
160
  const isrDependents =
141
161
  options.isrDependents ?? ((tags: readonly CacheTag[]) => dependentsOfKind(tags, 'isr-route'));
142
162
  const buildId = options.buildId ?? 'dev';
143
- const pending = new Map<string, Promise<IsrEntry>>();
163
+ const flight = singleFlight({
164
+ deadlineMs: finiteCount(
165
+ 'isrController',
166
+ 'regenerateDeadlineMs',
167
+ options.regenerateDeadlineMs ?? DEFAULT_ISR_REGENERATE_DEADLINE_MS,
168
+ 1,
169
+ ),
170
+ schedule: options.schedule,
171
+ });
172
+ /**
173
+ * The newest regeneration STARTED per path, by identity. Once a deadline can evict a flight, two
174
+ * renders of one path can overlap, and the older one settling last would publish its HTML over
175
+ * the newer page with a fresh `generatedAt`. Cleared by the run that still owns it; a run that
176
+ * never settles leaves one entry, replaced by the next regeneration of that path.
177
+ */
178
+ const latest = new Map<string, object>();
144
179
  const registered = new Set<string>();
145
180
 
146
181
  function descriptorFor(key: string): RouteDescriptor | undefined {
@@ -149,6 +184,10 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
149
184
  return table.find((r) => r.path === path) ?? matchersOf(table).find((m) => m.test(path))?.route;
150
185
  }
151
186
 
187
+ /** The route's `revalidate.tags` for a store key — what its document is purged by at the edge. */
188
+ const tagsOf = (key: string): readonly CacheTag[] =>
189
+ (descriptorFor(key)?.revalidateTags ?? []).map(parseWireTag);
190
+
152
191
  /**
153
192
  * A rendered page joins the invalidation graph under its route's tags, so `/blog/a` and
154
193
  * `/blog/b` are separately addressable and a `tag.post` bust does not touch `/team`.
@@ -184,48 +223,53 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
184
223
  return now() - entry.generatedAt < ttlMs;
185
224
  }
186
225
 
226
+ /** One regeneration of `path`; `led` is told when THIS call started it rather than joined one. */
227
+ function regenerateOnce(path: string, render: IsrRenderFn, led?: () => void): Promise<IsrEntry> {
228
+ return flight.run(path, async (): Promise<IsrEntry> => {
229
+ led?.();
230
+ const run = {};
231
+ latest.set(path, run);
232
+ const descriptor = descriptorFor(path);
233
+ try {
234
+ // BEFORE the render, not after: `revalidateByTags` reads the graph, so a bust arriving
235
+ // while a cold path's first render was in flight could not see the page it was
236
+ // invalidating — which is the window in which the bust that matters most arrives.
237
+ registerPath(path, descriptor);
238
+ // Sampled before the render for the reason `@ultimat3/cache`'s read-through fill samples
239
+ // before its `load()` (`tiers.ts`): the HTML below is built from rows read in the past,
240
+ // and a `markStale` landing in between was then ERASED by `store.set({ stale: false })`.
241
+ // For a tag-only route `isFresh` is true forever, so the process went on serving pre-write
242
+ // HTML for the rest of its life. One mechanism, not a second one grown here.
243
+ const fence = sampleFence({
244
+ key: path,
245
+ tags: (descriptor?.revalidateTags ?? []).map(parseWireTag),
246
+ });
247
+ const { html, status } = renderedOf(await render(path));
248
+ const entry: IsrEntry = {
249
+ path,
250
+ html,
251
+ hash: contentHash(html),
252
+ generatedAt: now(),
253
+ ttlMs: parseTtlMs(descriptor?.revalidateTtl),
254
+ stale: false,
255
+ // Screened at generation, the one place a status enters the store: a `NaN` written here
256
+ // would be served for the whole TTL as a `RangeError` on every hit.
257
+ status: finiteStatus('IsrRenderFn', status),
258
+ };
259
+ // Refused, never published stale-flagged: the next request re-renders from rows that now
260
+ // include the write, where a stored-but-stale entry would serve this pre-write body once
261
+ // more before doing the same thing. Refused too once a newer run started (`latest`).
262
+ if (fence.isValid() && latest.get(path) === run) store.set(entry);
263
+ forgetEvictedPaths();
264
+ return entry;
265
+ } finally {
266
+ if (latest.get(path) === run) latest.delete(path);
267
+ }
268
+ });
269
+ }
270
+
187
271
  function regenerate(path: string, render: IsrRenderFn): Promise<IsrEntry> {
188
- const existing = pending.get(path);
189
- if (existing !== undefined) return existing;
190
-
191
- const descriptor = descriptorFor(path);
192
- const work = (async (): Promise<IsrEntry> => {
193
- // BEFORE the render, not after: `revalidateByTags` reads the graph, so a bust arriving while
194
- // a cold path's first render was in flight could not see the page it was invalidating —
195
- // which is the window in which the bust that matters most arrives.
196
- registerPath(path, descriptor);
197
- // Sampled before the render for the reason `@ultimat3/cache`'s read-through fill samples
198
- // before its `load()` (`tiers.ts`): the HTML below is built from rows read in the past, and
199
- // a `markStale` landing in between was then ERASED by `store.set({ stale: false })`. For a
200
- // tag-only route `isFresh` is true forever, so the process went on serving pre-write HTML
201
- // for the rest of its life. One mechanism, not a second one grown here.
202
- const fence = sampleFence({
203
- key: path,
204
- tags: (descriptor?.revalidateTags ?? []).map(parseWireTag),
205
- });
206
- const { html, status } = renderedOf(await render(path));
207
- const entry: IsrEntry = {
208
- path,
209
- html,
210
- hash: contentHash(html),
211
- generatedAt: now(),
212
- ttlMs: parseTtlMs(descriptor?.revalidateTtl),
213
- stale: false,
214
- // Screened at generation, the one place a status enters the store: a `NaN` written here
215
- // would be served for the whole TTL as a `RangeError` on every hit.
216
- status: finiteStatus('IsrRenderFn', status),
217
- };
218
- // Refused, never published stale-flagged: the next request re-renders from rows that now
219
- // include the write, where a stored-but-stale entry would serve this pre-write body once
220
- // more before doing the same thing.
221
- if (fence.isValid()) store.set(entry);
222
- forgetEvictedPaths();
223
- return entry;
224
- })();
225
-
226
- pending.set(path, work);
227
- void work.catch(() => undefined).finally(() => pending.delete(path));
228
- return work;
272
+ return regenerateOnce(path, render);
229
273
  }
230
274
 
231
275
  function markStale(path: string): boolean {
@@ -238,7 +282,7 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
238
282
 
239
283
  return {
240
284
  store: () => store,
241
- inflight: () => pending.size,
285
+ inflight: () => flight.size,
242
286
  regenerate,
243
287
  markStale,
244
288
 
@@ -247,21 +291,24 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
247
291
 
248
292
  if (cached === undefined) {
249
293
  const entry = await regenerate(path, render);
250
- return { state: 'miss', entry, result: toResult(entry, buildId), regenerating: false };
294
+ const result = toResult(entry, buildId, tagsOf(path));
295
+ return { state: 'miss', entry, result, regenerating: false };
251
296
  }
252
297
 
253
298
  if (isFresh(cached)) {
254
299
  return {
255
300
  state: 'hit',
256
301
  entry: cached,
257
- result: toResult(cached, buildId),
302
+ result: toResult(cached, buildId, tagsOf(path)),
258
303
  regenerating: false,
259
304
  };
260
305
  }
261
306
 
262
307
  // stale-while-revalidate: answer from the stale copy now, refresh behind the request.
263
- const already = pending.has(path);
264
- void regenerate(path, render).catch((error: unknown) => {
308
+ let started = false;
309
+ void regenerateOnce(path, render, () => {
310
+ started = true;
311
+ }).catch((error: unknown) => {
265
312
  // `renderThrowable`, never `.message`/`String()`: this `.catch` is the last frame under a
266
313
  // route's own render function, and `String()` raises on a null-prototype object — the
267
314
  // handler that exists to REPORT the failure became a second, unhandled rejection.
@@ -270,8 +317,8 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
270
317
  return {
271
318
  state: 'stale',
272
319
  entry: cached,
273
- result: toResult(cached, buildId, true),
274
- regenerating: !already,
320
+ result: toResult(cached, buildId, tagsOf(path), true),
321
+ regenerating: started,
275
322
  };
276
323
  },
277
324
 
@@ -333,30 +380,31 @@ interface RouteMatcher {
333
380
  test(storedPath: string): boolean;
334
381
  }
335
382
 
336
- /** One compiled set per route TABLE — `describeRoutes()` hands out one array per registry change. */
383
+ /** One compiled set per route TABLE — `describePages()` hands out one array per registry change. */
337
384
  const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
338
385
 
339
- /** A stored path belongs to a route when the route's pattern matches it. */
386
+ /**
387
+ * A stored path belongs to a route when the route's pattern matches it — `route-pattern.ts`'s one
388
+ * pattern compiler, never a second. Ordered MOST SPECIFIC FIRST, as `@ultimat3/http`'s trie
389
+ * resolves a request: segment by segment, a static beats a `:param` beats a `*catch-all`. In table
390
+ * order `/docs/*path` sorts before `/docs/:id`, so the first match gave `/docs/7` the catch-all's
391
+ * TTL and tags — a page rendered by one route and expired by another's clock.
392
+ */
340
393
  function matchersOf(table: readonly RouteDescriptor[]): readonly RouteMatcher[] {
341
394
  const cached = compiledTables.get(table);
342
395
  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
- });
396
+ // Static routes too: `/precios-españa` is the route's path and `/precios-espa%C3%B1a` the key's,
397
+ // so only the pattern (raw or encoded, per character) finds it.
398
+ const compiled = table
399
+ .map((route): RouteMatcher & { readonly specificity: number } => {
400
+ const { regex, specificity } = compilePattern(route.path);
401
+ return { route, specificity, test: (stored) => regex.test(stored) };
402
+ })
403
+ .sort((a, b) => b.specificity - a.specificity);
350
404
  compiledTables.set(table, compiled);
351
405
  return compiled;
352
406
  }
353
407
 
354
- function segmentPattern(segment: string): string {
355
- if (segment.startsWith(':')) return '([^/]+)';
356
- if (segment.startsWith('*')) return '(.*)';
357
- return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
358
- }
359
-
360
408
  /** Tag-only routes have no clock of their own; a tag bust reaches the CDN through the fanout. */
361
409
  const TAG_ONLY_S_MAX_AGE_SECONDS = 60;
362
410
 
@@ -402,7 +450,12 @@ function entryStatus(entry: IsrEntry): number {
402
450
  return 200;
403
451
  }
404
452
 
405
- function toResult(entry: IsrEntry, buildId: string, servedStale = false): RenderResult {
453
+ function toResult(
454
+ entry: IsrEntry,
455
+ buildId: string,
456
+ tags: readonly CacheTag[],
457
+ servedStale = false,
458
+ ): RenderResult {
406
459
  const headers: Record<string, string> = {
407
460
  ...staticHeaders(entry.hash, buildId),
408
461
  'cache-control': cacheControl(entryTtlMs(entry)),
@@ -412,6 +465,15 @@ function toResult(entry: IsrEntry, buildId: string, servedStale = false): Render
412
465
  // `cache-headers` stage, which sees the actor this function cannot.
413
466
  vary: 'accept-language',
414
467
  };
468
+ // The keys an edge purges this document by — `@ultimat3/cache`'s list, never a second one, so
469
+ // what a bust sends and what the page carries cannot drift. Absent, an `invalidates` cleared
470
+ // every tier but the CDN, which held the document for its whole `s-maxage`. The tags were
471
+ // screened when the route registered (`registry.ts`), so this cannot refuse on the request path.
472
+ const keys = surrogateKeys(tags, 'isr');
473
+ if (keys.length > 0) {
474
+ headers['surrogate-key'] = keys.join(' ');
475
+ headers['cache-tag'] = keys.join(',');
476
+ }
415
477
  if (servedStale) headers['x-ultimate-isr'] = 'stale';
416
478
  return { status: entryStatus(entry), headers, body: entry.html };
417
479
  }
package/src/render-ssr.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Ctx } from '@ultimat3/core';
8
+ import { BUILD_ID_HEADER } from '@ultimat3/core';
8
9
  import { cacheControl } from '@ultimat3/http';
9
10
  import { finiteStatus } from './finite-status';
10
11
  import type { RouteEntry } from './registry';
@@ -81,6 +82,6 @@ export function ssrHeaders(
81
82
  ? PRIVATE_NO_STORE
82
83
  : 'public, max-age=0, s-maxage=30, stale-while-revalidate=300',
83
84
  vary: [...vary].sort().join(', '),
84
- 'x-ultimate-build': options.buildId,
85
+ [BUILD_ID_HEADER]: options.buildId,
85
86
  };
86
87
  }
@@ -4,7 +4,7 @@
4
4
  * the precache revision in `sw.js`, and the asset filename suffix.
5
5
  */
6
6
 
7
- import { renderThrowable, useContext } from '@ultimat3/core';
7
+ import { BUILD_ID_HEADER, renderCauseValue, 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';
@@ -31,9 +31,11 @@ export type StaticRenderFn = (input: {
31
31
  * 96 kB document against 21 µs here, and every static page, ISR regeneration and CSS module hashes
32
32
  * through it. xxHash32 is a SPECIFIED algorithm — the test pins its reference vectors — so the value
33
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.
34
+ * bust: every ETag and every scoped CSS class name changed once, in 22.0.0. Bytes hash to what
35
+ * their UTF-8 string does, so an icon's or an `asset()` file's ETag is this function too — the one
36
+ * xxHash32 call in the framework (`bun run flight-copies` refuses a second).
35
37
  */
36
- export function contentHash(input: string): string {
38
+ export function contentHash(input: string | Uint8Array): string {
37
39
  return Bun.hash.xxHash32(input).toString(16).padStart(8, '0');
38
40
  }
39
41
 
@@ -86,12 +88,12 @@ export async function enumeratePrerender(entry: RouteEntry): Promise<readonly Ro
86
88
  }
87
89
 
88
90
  const keys = entry.pattern.keys;
89
- return produced.map((item) => {
90
- if (typeof item !== 'string') return item;
91
+ return produced.map((item: unknown) => {
92
+ if (typeof item !== 'string') return paramsOf(entry, item);
91
93
  const only = keys[0];
92
94
  if (keys.length !== 1 || only === undefined) {
93
95
  throw new PrerenderFailedError(
94
- `prerender() for ${entry.path} returned the bare string ${JSON.stringify(item)} ` +
96
+ `prerender() for ${entry.path} returned the bare string ${renderCauseValue(item)} ` +
95
97
  `but the route has ${keys.length} dynamic params (${keys.join(', ')})`,
96
98
  `return objects from prerender in ${entry.file}, e.g. { ${keys.join(': …, ')}: … }`,
97
99
  );
@@ -100,6 +102,18 @@ export async function enumeratePrerender(entry: RouteEntry): Promise<readonly Ro
100
102
  });
101
103
  }
102
104
 
105
+ /**
106
+ * One `prerender()` item that is not a bare string: a params object, or a coded refusal. A `null`
107
+ * reached `Object.hasOwn` in `static-path.ts` and left the build as a bare `TypeError`.
108
+ */
109
+ function paramsOf(entry: RouteEntry, item: unknown): RouteParams {
110
+ if (typeof item === 'object' && item !== null && !Array.isArray(item)) return item as RouteParams;
111
+ throw new PrerenderFailedError(
112
+ `prerender() for ${entry.path} returned ${renderCauseValue(item)} as one of its items, which is neither a params object nor a string`,
113
+ `return an array of params objects (or strings, for a one-param route) from prerender in ${entry.file}`,
114
+ );
115
+ }
116
+
103
117
  export interface StaticBuildOptions {
104
118
  readonly buildId: string;
105
119
  /** Extension-less output files get `/index.html` appended. */
@@ -147,7 +161,7 @@ export function staticHeaders(hash: string, buildId: string): Readonly<Record<st
147
161
  // Revalidate cheaply: the HTML URL is stable, its content hash is not.
148
162
  'cache-control': 'public, max-age=0, must-revalidate',
149
163
  etag: `"${hash}"`,
150
- 'x-ultimate-build': buildId,
164
+ [BUILD_ID_HEADER]: buildId,
151
165
  };
152
166
  }
153
167