@ultimat3/render 22.15.0 → 24.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/render-isr.ts CHANGED
@@ -13,9 +13,11 @@ 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 { createSingleFlight, finiteCount, logger, renderThrowable } from '@ultimat3/core';
19
21
  import { parseTtlMs } from './duration';
20
22
  import { finiteStatus, isRenderStatus } from './finite-status';
21
23
  import type { RouteDescriptor } from './registry';
@@ -24,6 +26,7 @@ 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. */
@@ -140,7 +160,22 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
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 = createSingleFlight({
164
+ deadlineMs: finiteCount(
165
+ 'createIsrController',
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
 
@@ -336,27 +383,28 @@ interface RouteMatcher {
336
383
  /** One compiled set per route TABLE — `describeRoutes()` 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
  }
@@ -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 { 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. */
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
15
15
  import { finiteStatus } from './finite-status';
16
- import { escapeAttribute } from './html';
16
+ import { escapeHtml } from './html';
17
17
  import type { RenderResult } from './route';
18
18
  import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
19
19
 
@@ -54,7 +54,7 @@ export function holeId(id: string): string {
54
54
  * from a param. `fallback` is already-rendered HTML and stays verbatim.
55
55
  */
56
56
  export function holeMarker(id: string, fallback: string): string {
57
- return `<x-hole id="${escapeAttribute(holeId(id))}">${fallback}</x-hole>`;
57
+ return `<x-hole id="${escapeHtml(holeId(id))}">${fallback}</x-hole>`;
58
58
  }
59
59
 
60
60
  /**
@@ -64,10 +64,10 @@ export function holeMarker(id: string, fallback: string): string {
64
64
  export const REVEAL_SCRIPT = `<script>${REVEAL_BODY}</script>`;
65
65
 
66
66
  export function revealChunk(id: string, html: string): string {
67
- // The id reaches markup ONLY through `escapeAttribute` — never a script — so a quote in it
67
+ // The id reaches markup ONLY through `escapeHtml` — never a script — so a quote in it
68
68
  // cannot close anything, and no per-hole body exists for a CSP to fail to list.
69
69
  return (
70
- `<template data-x-hole="${escapeAttribute(holeId(id))}">${html}</template>` +
70
+ `<template data-x-hole="${escapeHtml(holeId(id))}">${html}</template>` +
71
71
  `<script>${REVEAL_CALL}</script>`
72
72
  );
73
73
  }
@@ -117,7 +117,8 @@ export function renderStreamHtml(
117
117
  const encoder = new TextEncoder();
118
118
  const tail = plan.tail ?? '</body></html>';
119
119
  const errorFallback =
120
- options.errorFallback ?? ((id) => `<div data-x-hole-error="${id}" hidden></div>`);
120
+ // Escaped as `holeMarker` escapes the same id: it lands inside an attribute either way.
121
+ options.errorFallback ?? ((id) => `<div data-x-hole-error="${escapeHtml(id)}" hidden></div>`);
121
122
  // A deadline is the bound a non-finite value does not disable but MOVES: `setTimeout(fn, NaN)`
122
123
  // is `setTimeout(fn, 0)`, so every hole would miss a deadline nobody set and the document would
123
124
  // be all fallbacks. `null` is the declared opt-out; there is no spelling that means "immediately".
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A route pattern (`/docs/:id`, `/docs/*path`) as the regex a pathname is tested with, and the
3
+ * order two matching patterns are ranked in. Split from `registry.ts`, which registers routes; this
4
+ * file only reads a pattern string.
5
+ */
6
+
7
+ import { routeRank } from '@ultimat3/core';
8
+
9
+ export interface CompiledPattern {
10
+ readonly source: string;
11
+ readonly regex: RegExp;
12
+ readonly keys: readonly string[];
13
+ /**
14
+ * Higher wins when two patterns match the same pathname — `@ultimat3/core`'s `routeRank`, the
15
+ * ONE ordering rule: ISR, the sitemap, the admin and `@ultimat3/pwa`'s worker all sort on it.
16
+ */
17
+ readonly specificity: number;
18
+ }
19
+
20
+ /** A regex metacharacter, matched as itself. */
21
+ const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
+
23
+ /**
24
+ * A literal segment as a pathname may spell it: each character raw OR percent-encoded, either hex
25
+ * case. A route file is named in Unicode (`precios-españa`) and `URL.pathname` encodes it
26
+ * (`precios-espa%C3%B1a`), so a literal-only pattern never matched the key it was asked about.
27
+ */
28
+ function literalPattern(segment: string): string {
29
+ let out = '';
30
+ for (const char of segment) {
31
+ const encoded = [...new TextEncoder().encode(char)]
32
+ .map((byte) => {
33
+ const hex = byte.toString(16).toUpperCase().padStart(2, '0');
34
+ const digit = (d: string): string => (/[A-F]/.test(d) ? `[${d}${d.toLowerCase()}]` : d);
35
+ return `%${digit(hex[0] ?? '0')}${digit(hex[1] ?? '0')}`;
36
+ })
37
+ .join('');
38
+ out += `(?:${escapeRegex(char)}|${encoded})`;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ export function compilePattern(path: string): CompiledPattern {
44
+ const keys: string[] = [];
45
+ const segments = path.split('/').filter((s) => s.length > 0);
46
+
47
+ // Each part carries its own leading `/`, so a catch-all can own its separator: an empty rest is
48
+ // the bare prefix (`/docs`), which is the path `static-path.ts` writes for it.
49
+ const parts = segments.map((segment) => {
50
+ if (segment.startsWith('*')) {
51
+ keys.push(segment.slice(1));
52
+ return '(?:/(.*))?';
53
+ }
54
+ if (segment.startsWith(':')) {
55
+ keys.push(segment.slice(1));
56
+ return '/([^/]+)';
57
+ }
58
+ return `/${literalPattern(segment)}`;
59
+ });
60
+
61
+ return {
62
+ source: path,
63
+ regex: new RegExp(`^${parts.join('')}/?$`),
64
+ keys,
65
+ specificity: routeRank(path),
66
+ };
67
+ }
package/src/route.ts CHANGED
@@ -51,17 +51,15 @@ export interface RevalidateConfig {
51
51
  }
52
52
 
53
53
  /**
54
- * What a route promises to stay under. **Two keys, and every one of them is PROJECTED** — a key
55
- * here that `RouteDescriptor` does not carry is a number an author writes and nothing can ever
56
- * compare against, which `type-pins.tsx` now makes a build error.
54
+ * What a route promises to stay under. **One key, and it is MEASURED** — a key here that nothing
55
+ * weighs is a number an author writes and nothing can ever compare against. `type-pins.tsx` makes
56
+ * an unprojected key a build error, and `assertModeInvariants` refuses one that arrives anyway.
57
57
  *
58
- * `css`, `cls` and `tbt` were three such keys and are gone in 12.0.0. `registerRoute` flattens a
59
- * budget to `budgetJs` + `budgetLcp`, and every reader downstream — `x verify`'s `budgets` step,
60
- * `x.manifest.json`, `x routes`, the dev dashboard — reads only those two, so `budget: { cls: 0.1 }`
61
- * was accepted, normalised, stored and ignored while the gate that exists to enforce budgets
62
- * reported green. Deleted rather than wired for `PwaConfig.installPrompt`'s reason: the measuring
63
- * half does not exist either, and a knob that fails silently in the permissive direction is the
64
- * framework's most repeated defect.
58
+ * `css`, `cls` and `tbt` went in 12.0.0 and `lcp` after them: each was accepted, stored and
59
+ * ignored while the gate that exists to enforce budgets reported green, because nothing in the
60
+ * framework observes a paint — the build emits static HTML. Deleted rather than wired for
61
+ * `PwaConfig.installPrompt`'s reason: the measuring half does not exist, and a knob that fails
62
+ * silently in the permissive direction is the framework's most repeated defect.
65
63
  */
66
64
  export interface RouteBudget {
67
65
  /**
@@ -72,12 +70,6 @@ export interface RouteBudget {
72
70
  * `As of 2026-09-05`; `packages/cli/src/budgets.ts`'s `measureDocumentJs` is the one reader.
73
71
  */
74
72
  readonly js?: string;
75
- /**
76
- * Milliseconds. PUBLISHED, not enforced: it reaches `x.manifest.json` and `x routes`, and no
77
- * step of the gate compares it — nothing in the framework produces an `lcp` measurement, which
78
- * `packages/cli/src/templates/route.ts` states at the one place an author would write one.
79
- */
80
- readonly lcp?: number;
81
73
  }
82
74
 
83
75
  /**
@@ -299,7 +291,7 @@ export function defineRoute<TData = RouteData>(
299
291
  }
300
292
  if (!OFFLINE_STRATEGIES.includes(def.offline)) {
301
293
  throw new RouteOfflineMissingError(
302
- `offline: ${JSON.stringify(def.offline)} is not a known strategy`,
294
+ `offline: ${renderCauseValue(def.offline)} is not a known strategy`,
303
295
  `use one of ${OFFLINE_STRATEGIES.join(' | ')}`,
304
296
  );
305
297
  }
@@ -312,7 +304,7 @@ export function defineRoute<TData = RouteData>(
312
304
 
313
305
  if (def.load !== undefined && typeof def.load !== 'function') {
314
306
  throw new RouteLoadInvalidError(
315
- `load: ${JSON.stringify(def.load)} is not a function`,
307
+ `load: ${renderCauseValue(def.load)} is not a function`,
316
308
  'make load a function of ({ params, url }) returning the page data, or remove it',
317
309
  );
318
310
  }
package/src/sass-cache.ts CHANGED
@@ -9,7 +9,12 @@ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'no
9
9
  // why: Bun ships no path-join primitive.
10
10
  import { join } from 'node:path';
11
11
 
12
- /** Relative to the process's cwd — the app root for every `x` command. `.x/` is gitignored. */
12
+ /**
13
+ * Relative to the process's cwd — the app root for every `x` command. `.x/` is gitignored, and in
14
+ * a container it is the STATE directory a topology mounts a tmpfs over, so an image's own entries
15
+ * live elsewhere: the image build points this cache at its prebuilt store through
16
+ * `setSassCacheDir`, and the container's boot points it back at the same place.
17
+ */
13
18
  export const SASS_CACHE_DIR = join('.x', 'cache', 'sass');
14
19
 
15
20
  /** Bumped when the entry shape changes, so an old entry is a miss and never a misread. */
@@ -153,11 +158,25 @@ export function cachedSassCompile(
153
158
  assetsValid: AssetCheck = () => false,
154
159
  ): SassOutput {
155
160
  const dir = cacheDir();
156
- if (dir === null) return compile();
161
+ if (dir === null) return counted(compile);
157
162
  const file = join(dir, `${sha256(key)}.json`);
158
163
  const hit = readHit(file, assetsValid);
159
164
  if (hit !== undefined) return hit;
160
- const output = compile();
165
+ const output = counted(compile);
161
166
  store(dir, file, output);
162
167
  return output;
163
168
  }
169
+
170
+ let compilations = 0;
171
+
172
+ const counted = (compile: () => SassOutput): SassOutput => {
173
+ compilations += 1;
174
+ return compile();
175
+ };
176
+
177
+ /**
178
+ * How many times this process ran Sass rather than reading a stored result. A container booted
179
+ * from a prebuilt image answers 0; anything else is a boot that compiled what the image build
180
+ * should have, and the number is what its log line reports.
181
+ */
182
+ export const sassCompilations = (): number => compilations;
package/src/server.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  // ---- the two Bun loaders: `.tsx` → the server JSX factory, `.scss` → css + a class map ----------
27
27
  export type { Stylesheet } from './module-loader';
28
28
  export {
29
+ claimStylesheets,
29
30
  clearStylesheets,
30
31
  installRenderLoader,
31
32
  loadStylesheet,
@@ -47,6 +48,7 @@ export type {
47
48
  } from './render-isr';
48
49
  export {
49
50
  createIsrController,
51
+ DEFAULT_ISR_REGENERATE_DEADLINE_MS,
50
52
  ISR_LOCALE_PARAM,
51
53
  invalidateAndRevalidate,
52
54
  isrKey,
@@ -76,3 +78,5 @@ export {
76
78
  revealChunk,
77
79
  streamResult,
78
80
  } from './render-stream';
81
+ // ---- the Sass compile cache: where it lives, and how often this process ran Sass instead --------
82
+ export { sassCompilations, setSassCacheDir } from './sass-cache';
@@ -35,6 +35,19 @@ const checked = (pattern: string, param: string, segment: string): string => {
35
35
  return segment;
36
36
  };
37
37
 
38
+ /**
39
+ * A param as text, `undefined` when absent. Typed `string` but filled from app data at runtime:
40
+ * `{ id: 7 }` reached `.split()` and `for…of` as a number and left the build as a bare `TypeError`.
41
+ */
42
+ const paramText = (pattern: string, name: string, params: RouteParams): string | undefined => {
43
+ if (typeof params !== 'object' || params === null) {
44
+ return refuse(pattern, name, params, 'is missing — the params are not an object');
45
+ }
46
+ const value: unknown = Object.hasOwn(params, name) ? params[name] : undefined;
47
+ if (value === undefined || typeof value === 'string') return value;
48
+ return refuse(pattern, name, value, 'is not text — a path segment is a string');
49
+ };
50
+
38
51
  /**
39
52
  * The raw (decoded) segments, validated. `:name` must be present and one segment; `*name` may span
40
53
  * several (`a/b`) and may be absent, but none of its parts may be a dot segment or unsafe.
@@ -45,7 +58,7 @@ export function filledSegments(pattern: string, params: RouteParams): readonly s
45
58
  if (segment === '') continue;
46
59
  if (segment.startsWith(':')) {
47
60
  const name = segment.slice(1);
48
- const value = Object.hasOwn(params, name) ? params[name] : undefined;
61
+ const value = paramText(pattern, name, params);
49
62
  if (value === undefined || value === '') {
50
63
  return refuse(
51
64
  pattern,
@@ -59,7 +72,7 @@ export function filledSegments(pattern: string, params: RouteParams): readonly s
59
72
  }
60
73
  if (segment.startsWith('*')) {
61
74
  const name = segment.slice(1);
62
- const value = Object.hasOwn(params, name) ? (params[name] ?? '') : '';
75
+ const value = paramText(pattern, name, params) ?? '';
63
76
  for (const part of value.split('/')) if (part !== '') out.push(checked(pattern, name, part));
64
77
  continue;
65
78
  }
package/src/type-pins.tsx CHANGED
@@ -110,9 +110,10 @@ export function _IslandIsAJsxComponent(): unknown {
110
110
  /**
111
111
  * Every key a route may declare a BUDGET for is a key the descriptor projects.
112
112
  *
113
- * Both sides are derived, so neither can move alone: `budgetJs`/`budgetLcp` on `RouteDescriptor`
114
- * give `'js' | 'lcp'`, and anything `RouteBudget` declares beyond that fails this line by name.
115
- * `budget.css`, `budget.cls` and `budget.tbt` were declared on the contract for four majors, were
113
+ * Both sides are derived, so neither can move alone: `budgetJs` on `RouteDescriptor` gives `'js'`,
114
+ * and anything `RouteBudget` declares beyond that fails this line by name. `budget.lcp` left the
115
+ * contract with its descriptor field, measured by nothing. `budget.css`, `budget.cls` and
116
+ * `budget.tbt` were declared on the contract for four majors, were
116
117
  * flattened away by `registerRoute`, and reached no reader anywhere — so an author writing
117
118
  * `budget: { cls: 0.1 }` was ignored in silence while `x verify`'s `budgets` step, the one thing
118
119
  * that exists to enforce a budget, reported green. `scripts/declaration-readers.ts` is the rule