@ultimat3/render 27.7.0 → 27.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -77,7 +77,7 @@ axiom 6). Never `cli` (upward).
77
77
  | "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below `modes.ts` and `render-isr.ts`. |
78
78
  | A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts`. `bun run error-render` cannot see a value laundered through a local helper. |
79
79
  | `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `pathnameOf` never throws on a relative `ctx.url`. |
80
- | The ISR key | `isrKey(url, locale)` — pathname, the negotiated locale in the reserved `__x_locale` parameter, then the sorted query. The locale is REQUIRED. The time zone is deliberately not a dimension. `toResult` emits `vary: accept-language`, and `surrogate-key`/`cache-tag` from `@ultimat3/cache`'s `surrogateKeys()` over the route's `revalidateTags` — kept only while the response stays shared: http's `applyCacheHeaders` strips both on a rewrite to `private`/`no-store`. `registry.ts`'s `assertPurgeableTags` screens the tags at registration (`X_ROUTE_MODE_INVALID`); the rest of the shared key is `@ultimat3/http`'s `cache-headers` stage. The one shipped emitter of purge keys; file and mounted routes are both screened, so `toResult` never refuses on the request path |
80
+ | The ISR key | `isrKey(url, locale)` — the path in http's `canonicalPath` spelling (no base path), the locale in the reserved `__x_locale` parameter, the sorted query. A key no route owns is never stored. The locale is REQUIRED. The time zone is deliberately not a dimension. `toResult` emits `vary: accept-language`, and `surrogate-key`/`cache-tag` from `@ultimat3/cache`'s `surrogateKeys()` over the route's `revalidateTags` — kept only while the response stays shared: http's `applyCacheHeaders` strips both on a rewrite to `private`/`no-store`. `registry.ts`'s `assertPurgeableTags` screens the tags at registration (`X_ROUTE_MODE_INVALID`); the rest of the shared key is `@ultimat3/http`'s `cache-headers` stage. File and mounted routes are both screened |
81
81
  | A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set`. `registerPath` runs BEFORE the render. |
82
82
  | ISR regeneration | `@ultimat3/core`'s `singleFlight`, `deadlineMs` = `regenerateDeadlineMs` (`DEFAULT_ISR_REGENERATE_DEADLINE_MS`, 30 s; `finiteCount` ≥ 1, no opt-out), `schedule` injectable. Past it the PATH is free; the hung render runs on, and `latest` (by identity) drops its result once a newer run started. `render-isr-flight.test.ts`, on a hand-fired scheduler. |
83
83
  | Which route a stored ISR path is | exact path first, else the MOST SPECIFIC pattern: `route-pattern.ts`'s `compilePattern` (never a second compiler), sorted on `CompiledPattern.specificity`. Never table order: `describePages()` sorts `*` before `:`. |
package/README.md CHANGED
@@ -557,6 +557,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
557
557
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
558
558
  | `renderStatic`†, `enumeratePrerender`† | build-time render, content hashing |
559
559
  | `isrController`†, `invalidateAndRevalidate`† | SWR + single-flight + tag triggers |
560
+ | `isrKey`†, `isrRequestUrl`†, `undeclaredQuery`†, `isrRouteFor`† | the one derivation of an `isr` store key — the path in `@ultimat3/http`'s `canonicalPath` spelling, so every spelling the router answers with a page is that page's one entry — the request URL narrowed to `revalidate.query`, and the route a key reads back as (a key no route owns is never stored) |
561
+ | `noStore`, `routeNoStoreOf` | a loader's "do not keep this answer" — a mark on the data, like `withStatus`; read by `isr` |
560
562
  | `renderSsr`†, `streamResult`† | the per-request modes |
561
563
  | `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
562
564
  | `claimStylesheets`† | a package claims its own directory's stylesheets for ONE surface, so the other surface's documents never carry them |
@@ -591,6 +593,20 @@ a job boundary the class is gone and the `code` is what survives — match on th
591
593
  `*catch-all`, per segment), never the first in table order. The route is matched on the path
592
594
  with its query and its routed locale prefix removed (`unlocalizedPath`), as the router matches
593
595
  it — the key keeps both, so `/en/blog/a` is its own entry under `/blog/:slug`'s TTL and tags.
596
+ - **`revalidate` decides three more things** (`As of 2026-10-10`; the tables are the wiki's
597
+ Routes page). `onInvalidate: 'purge'` (needs `tags` and `ttl`) deletes a route's stored pages on
598
+ a tag bust and evicts their renders in flight, where the default `'stale'` answers the old copy
599
+ once more: the next request on this replica and on each one the broadcast reaches; a replica
600
+ that misses it serves its copy until `ttl`. `maxStale` bounds how long past its `ttl` a copy is
601
+ served (`0`: never; the default under `'purge'`). `query: ['page']` (or `[]`) is the only part
602
+ of the query string in the key and in the URL `load` is given (`isrRequestUrl`†); a route that
603
+ declares none still keys on the whole query string, and `x verify` says so. **Never stored:** a
604
+ 5xx (the last good page keeps answering, stale; a 1 s cooldown stops a render per request), a
605
+ render whose `load` returned `noStore(data)`, one invalidated while it ran, a throw, a redirect.
606
+ - **A store two processes write needs `IsrStore.tagFence`**† — `sample` / `bump` / an atomic
607
+ `setIfCurrent` over a generation per tag entity — or a replica whose render began before a
608
+ purge writes the page back. `memoryIsrStore` has it; a bust also asks the store which keys it
609
+ holds under the tags, so a page another controller wrote is reached too.
594
610
  - **A tag-revalidated ISR document carries its purge keys while it is shared-cacheable**,
595
611
  `As of 2026-10-02` — `@ultimat3/http`'s `cache-headers` stage rewrites the response to `private`
596
612
  for a signed-in visitor and strips both headers: `Surrogate-Key`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "27.7.0",
3
+ "version": "27.8.1",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,11 +40,11 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/cache": "27.7.0",
44
- "@ultimat3/core": "27.7.0",
45
- "@ultimat3/http": "27.7.0",
46
- "@ultimat3/i18n": "27.7.0",
47
- "@ultimat3/seo": "27.7.0",
43
+ "@ultimat3/cache": "27.8.1",
44
+ "@ultimat3/core": "27.8.1",
45
+ "@ultimat3/http": "27.8.1",
46
+ "@ultimat3/i18n": "27.8.1",
47
+ "@ultimat3/seo": "27.8.1",
48
48
  "sass": "1.105.1"
49
49
  }
50
50
  }
package/src/duration.ts CHANGED
@@ -29,6 +29,20 @@ export function parseTtlMs(ttl: string | number | null | undefined): number | nu
29
29
  return factor === undefined ? null : positiveMs(Number(amount) * factor);
30
30
  }
31
31
 
32
+ /**
33
+ * `revalidate.maxStale`'s reader: the one duration that may be ZERO — "never answer a copy past
34
+ * its ttl" is a bound, where a zero-length TTL is no trigger at all. `null` is "not a duration".
35
+ */
36
+ export function parseStaleMs(value: string | number | null | undefined): number | null {
37
+ if (
38
+ value === 0 ||
39
+ (typeof value === 'string' && /^0(?:\.0+)?(?:ms|s|m|h|d)$/.test(value.trim()))
40
+ ) {
41
+ return 0;
42
+ }
43
+ return parseTtlMs(value);
44
+ }
45
+
32
46
  /**
33
47
  * Both arms end here, so `'0s'` and `0` cannot disagree: a zero-length TTL registered, and
34
48
  * `entryTtlMs` then read the stored `0` as tag-only — a page declared to expire never did.
package/src/index.ts CHANGED
@@ -180,6 +180,7 @@ export {
180
180
  routePathFromFile,
181
181
  } from './registry';
182
182
  export type {
183
+ InvalidateMode,
183
184
  LoadRequirement,
184
185
  PrerenderFn,
185
186
  RenderResult,
@@ -211,7 +212,7 @@ export { pageComponentOf } from './route-component';
211
212
  export { metaContextFor, routeDataFor } from './route-data';
212
213
  export type { CompiledPattern } from './route-pattern';
213
214
  export { compilePattern } from './route-pattern';
214
- export { routeStatusOf, withStatus } from './route-status';
215
+ export { noStore, routeNoStoreOf, routeStatusOf, withStatus } from './route-status';
215
216
  export type { SpeculationRules } from './speculation-rules';
216
217
  export {
217
218
  SPECULATION_RULES_TYPE,
package/src/modes.ts CHANGED
@@ -9,6 +9,7 @@ import type { HydrateStrategy, RenderMode } from '@ultimat3/core';
9
9
  import { HYDRATE_STRATEGIES, RENDER_MODES, renderCauseValue } from '@ultimat3/core';
10
10
  import { parseTtlMs } from './duration';
11
11
  import { RouteModeInvalidError } from './errors';
12
+ import { assertRevalidateShape } from './revalidate-shape';
12
13
  import type { RouteConfig } from './route';
13
14
  import type { RouteSurface, Surface } from './surfaces';
14
15
  import { SURFACE_SPECS, surfaceAllows } from './surfaces';
@@ -132,6 +133,9 @@ export function assertModeShape(config: RouteShape): void {
132
133
  "add revalidate: { tags: [tag.post] } or revalidate: { ttl: '5m' }",
133
134
  );
134
135
  }
136
+ if (config.render === 'isr' && config.revalidate !== undefined) {
137
+ assertRevalidateShape(config.revalidate);
138
+ }
135
139
 
136
140
  // cache: the `ssr` mode's key alone. `static` and `isr` are content-hashed / TTL'd documents
137
141
  // whose headers ARE the mode, and a `stream` is always `private, no-store` — a declaration on
package/src/registry.ts CHANGED
@@ -5,15 +5,12 @@
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';
10
8
  import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
11
- import { finiteCount, isUltimateError, renderFixLiteral } from '@ultimat3/core';
9
+ import { finiteCount } from '@ultimat3/core';
12
10
  import { byCodeUnit } from './code-unit-order';
13
11
  import {
14
12
  RouteDuplicateError,
15
13
  RouteFileInvalidError,
16
- RouteModeInvalidError,
17
14
  RouteUnnormalizedError,
18
15
  SurfaceBoundaryError,
19
16
  } from './errors';
@@ -27,7 +24,8 @@ import {
27
24
  mountedRoutes,
28
25
  setMountedRoutes,
29
26
  } from './mounted-routes';
30
- import type { RouteConfig, RouteData } from './route';
27
+ import { assertPurgeableTags } from './revalidate-shape';
28
+ import type { InvalidateMode, RouteConfig, RouteData } from './route';
31
29
  import { isRouteConfig, routeTagKeys } from './route';
32
30
  import type { RouteComponent } from './route-component';
33
31
  import type { CompiledPattern } from './route-pattern';
@@ -70,6 +68,17 @@ export interface RouteDescriptor {
70
68
  readonly hydrate: HydrateStrategy;
71
69
  readonly revalidateTags: readonly string[];
72
70
  readonly revalidateTtl: string | number | null;
71
+ /**
72
+ * The three below are optional so a descriptor built by hand before they existed still
73
+ * typechecks; the registry always writes them. Absent reads as `'stale'`, unbounded, undeclared.
74
+ */
75
+ readonly revalidateOnInvalidate?: InvalidateMode;
76
+ readonly revalidateMaxStale?: string | number | null;
77
+ /**
78
+ * `revalidate.query`, sorted: the only query parameters an `isr` page of this route keys on.
79
+ * `null` when the route declared none — its pages then key on the WHOLE query string.
80
+ */
81
+ readonly revalidateQuery?: readonly string[] | null;
73
82
  readonly prerenderable: boolean;
74
83
  readonly dynamic: boolean;
75
84
  readonly hasPolicy: boolean;
@@ -237,27 +246,6 @@ export interface RegisterRouteInput<TData = RouteData> {
237
246
  readonly component?: RouteComponent;
238
247
  }
239
248
 
240
- /**
241
- * A `revalidate.tags` entry goes out as a purge key on every response of the route
242
- * (`render-isr.ts`), so one a CDN would split is refused HERE, naming the file. Asked of
243
- * `@ultimat3/cache`'s own screen rather than restated: found at serve time it is a 500 on a
244
- * public page, found at purge time it is a document nothing can clear.
245
- */
246
- function assertPurgeableTags(file: string, tags: readonly CacheTag[] | undefined): void {
247
- for (const owned of tags ?? []) {
248
- try {
249
- surrogateKeys([owned], file);
250
- } catch (error) {
251
- if (!isUltimateError(error) || error.code !== 'X_CACHE_PURGE_FAILED') throw error;
252
- const wire = renderFixLiteral(serializeTag(owned), '<the tag>');
253
- throw new RouteModeInvalidError(
254
- `${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`,
255
- `edit revalidate.tags in ${file}: replace ${wire} with a tag whose entity and id carry no whitespace or comma`,
256
- );
257
- }
258
- }
259
- }
260
-
261
249
  /** Register a route and enforce every invariant that needs the surrounding module. */
262
250
  export function registerRoute<TData = RouteData>(
263
251
  input: RegisterRouteInput<TData>,
@@ -461,6 +449,10 @@ const descriptorOf = (
461
449
  hydrate: config.hydrate,
462
450
  revalidateTags: routeTagKeys(config.revalidate?.tags),
463
451
  revalidateTtl: config.revalidate?.ttl ?? null,
452
+ revalidateOnInvalidate: config.revalidate?.onInvalidate ?? 'stale',
453
+ revalidateMaxStale: config.revalidate?.maxStale ?? null,
454
+ revalidateQuery:
455
+ config.revalidate?.query === undefined ? null : [...new Set(config.revalidate.query)].sort(),
464
456
  prerenderable: config.prerender !== undefined,
465
457
  dynamic: params > 0,
466
458
  hasPolicy: config.policy !== undefined,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A short memory of the keys whose last render FAILED (a 5xx). `isr` never stores a failure, so
3
+ * without this every request for a page whose read is down is its own render: a crawler walking
4
+ * the route while the database is unreachable is one failing query per request, back to back.
5
+ * For the cooldown the same answer is repeated instead. Bounded, oldest out first.
6
+ */
7
+
8
+ /** How long one failed render answers for. Short: it bounds retries, it is not a cache. */
9
+ export const DEFAULT_ISR_FAILURE_COOLDOWN_MS = 1_000;
10
+
11
+ const MAX_REMEMBERED = 1_000;
12
+
13
+ export interface FailureCooldown<T> {
14
+ /** The failure still answering for `key`, or `undefined` once its cooldown has passed. */
15
+ get(key: string): T | undefined;
16
+ remember(key: string, failure: T): void;
17
+ clear(key: string): void;
18
+ }
19
+
20
+ export function failureCooldown<T>(cooldownMs: number, now: () => number): FailureCooldown<T> {
21
+ const held = new Map<string, { readonly until: number; readonly failure: T }>();
22
+ return {
23
+ get(key) {
24
+ const kept = held.get(key);
25
+ if (kept === undefined) return undefined;
26
+ if (now() < kept.until) return kept.failure;
27
+ held.delete(key);
28
+ return undefined;
29
+ },
30
+ remember(key, failure) {
31
+ if (cooldownMs <= 0) return;
32
+ held.delete(key);
33
+ held.set(key, { until: now() + cooldownMs, failure });
34
+ while (held.size > MAX_REMEMBERED) {
35
+ const oldest = held.keys().next();
36
+ if (oldest.done === true) break;
37
+ held.delete(oldest.value);
38
+ }
39
+ },
40
+ clear: (key) => {
41
+ held.delete(key);
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * What an `isr` entry is keyed by: the request URL narrowed to the query the route declared, the
3
+ * negotiated locale, and the way back from a key to the route table. Split from `render-isr.ts`,
4
+ * the controller, which only ever holds the key this file derived.
5
+ */
6
+
7
+ import { canonicalPath } from '@ultimat3/http';
8
+ import { unlocalizedPath } from '@ultimat3/i18n';
9
+
10
+ /**
11
+ * The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
12
+ * because `routePathOf` splits a key at its `?`: a `es:/blog` key would match no route, so
13
+ * `descriptorFor` would answer `undefined` and a declared `revalidate: { ttl }` would silently
14
+ * become tag-only. Reserved spelling, so an app's own `?locale=` stays its own dimension.
15
+ */
16
+ export const ISR_LOCALE_PARAM = '__x_locale';
17
+
18
+ /**
19
+ * The URL an `isr` route is rendered FOR: the request's, keeping only the query parameters the
20
+ * route declared in `revalidate.query`, one value each, in sorted order.
21
+ *
22
+ * Undeclared (`null`), the key carries the whole query, so `/blog?x=1`, `/blog?x=2`, … are each a
23
+ * miss, a render and a stored entry: any visitor can make an `isr` route render on demand and push
24
+ * real pages out of the bounded store, and `utm_*` / `fbclid` / `gclid` split one page into as
25
+ * many entries as there are campaigns — each of which a withdrawal then has to reach. An `isr`
26
+ * document is a shared artifact, so what it may vary on is the route's to declare, never the
27
+ * visitor's to choose. `null` is kept only because flipping it changes what a page that reads an
28
+ * undeclared parameter renders, and that waits for a major; `query: []` is the safe declaration.
29
+ *
30
+ * The SAME narrowed URL is what `load`, `meta` and the page are given (`data.url`, `props.query`),
31
+ * so a parameter that is not in the key cannot change the document stored under it. Nothing is
32
+ * redirected: `/blog?utm_source=x` is answered with the `/blog` entry, whose `canonical` is `/blog`.
33
+ * A repeated parameter keeps its FIRST value — `?p=1&p=2` and `?p=1&p=3` are one entry, not two.
34
+ */
35
+ export function isrRequestUrl(url: URL, query: readonly string[] | null): URL {
36
+ if (query === null) return url;
37
+ const kept = new URLSearchParams();
38
+ for (const name of [...new Set(query)].sort()) {
39
+ const value = url.searchParams.get(name);
40
+ if (value !== null) kept.set(name, value);
41
+ }
42
+ const narrowed = new URL(url);
43
+ narrowed.search = kept.toString();
44
+ narrowed.hash = '';
45
+ return narrowed;
46
+ }
47
+
48
+ /**
49
+ * The request's parameter names the route did not declare: what `isrRequestUrl` drops — or, for a
50
+ * route that declared nothing (`null`), what a visitor just used to mint a stored page.
51
+ */
52
+ export function undeclaredQuery(url: URL, query: readonly string[] | null): readonly string[] {
53
+ const declared = new Set(query ?? []);
54
+ return [...new Set(url.searchParams.keys())].filter((name) => !declared.has(name)).sort();
55
+ }
56
+
57
+ /**
58
+ * The ISR store key for one URL: pathname, the negotiated LOCALE, and the query, **params
59
+ * sorted**. The URL is `isrRequestUrl`'s — already narrowed to the route's declared parameters.
60
+ *
61
+ * The pathname is keyed in its ONE spelling — `@ultimat3/http`'s `canonicalPath`, built from the
62
+ * functions the router segments and decodes a request with, never a second normaliser. The router
63
+ * answers `/blog//a/`, `/blog/%61` and `/blog/a` with one page; keyed raw, each was an entry of
64
+ * its own (a 20-letter slug has a million percent-spellings, and the store holds 1,000), and one
65
+ * the route table could not read back (`/blog//a`) was stored with no ttl and no tags, which no
66
+ * bust reached: a withdrawn page stayed up at that spelling for the life of the process.
67
+ *
68
+ * Exported because deriving it is the caller's job and there may only be ONE derivation — a
69
+ * server that keyed on `url.pathname` while the store believed it held a whole URL is the shape
70
+ * of #171. Sorting makes `?a=1&b=2` and `?b=2&a=1` one entry rather than two renders of one page.
71
+ *
72
+ * The locale is REQUIRED, and required as an argument rather than read from the ambient context so
73
+ * that every call site has to answer: a document is rendered with `<html lang>` and every `t()` in
74
+ * the request's own locale, so one entry per path served visitor 2 the document negotiated for
75
+ * visitor 1 — for the whole TTL, and with `s-maxage` telling the CDN to do the same. The time zone
76
+ * is deliberately NOT a dimension: it is unbounded where a locale set is declared, and an `isr`
77
+ * page is a shared artifact, so a date on one belongs in an explicit zone the page itself names.
78
+ */
79
+ export function isrKey(url: URL, locale: string): string {
80
+ const params = new URLSearchParams(url.search);
81
+ params.set(ISR_LOCALE_PARAM, locale);
82
+ params.sort();
83
+ return `${canonicalPath(url.pathname)}?${params.toString()}`;
84
+ }
85
+
86
+ /**
87
+ * The path the ROUTE TABLE knows a key by. Every lookup that asks the table a question — the
88
+ * descriptor, and therefore the TTL and the tags — has to undo what the key carries beyond the
89
+ * route, in the order the router does before it matches (`@ultimat3/http`'s `routing` stage):
90
+ *
91
+ * - the query: `descriptorFor('/blog?page=2')` matches no route;
92
+ * - the routed locale prefix: `/en/blog/a` is the `/blog/:slug` route in English. The key KEEPS the
93
+ * prefix — it is the URL the document was rendered for, `canonical` and all — and the table has
94
+ * never heard of it. Left on, a prefixed page matched no route: `ttlMs: null` and no tag edge, so
95
+ * it was fresh forever while the default locale's copy of the same page went stale on schedule.
96
+ *
97
+ * `unlocalizedPath` is i18n's, the one the router's own split is built on: a segment that names no
98
+ * routed locale is left alone, so `/fr/blog` is still no route.
99
+ */
100
+ export function routePathOf(key: string): string {
101
+ const query = key.indexOf('?');
102
+ return unlocalizedPath(query === -1 ? key : key.slice(0, query));
103
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * An `isr` entry as a response: its status, and the headers that tell a shared cache how long it
3
+ * may keep the document and which keys purge it. Split from `render-isr.ts`, the controller.
4
+ */
5
+
6
+ import { surrogateKeys } from '@ultimat3/cache';
7
+ import { logger } from '@ultimat3/core';
8
+ import { isRenderStatus } from './finite-status';
9
+ import type { IsrPolicy } from './render-isr-routes';
10
+ import type { IsrEntry } from './render-isr-store';
11
+ import { staticHeaders } from './render-static';
12
+ import type { RenderResult } from './route';
13
+
14
+ /** Tag-only routes have no clock of their own; a tag bust reaches the CDN through the fanout. */
15
+ const TAG_ONLY_S_MAX_AGE_SECONDS = 60;
16
+
17
+ /** What a shared cache may serve stale for when the route bounds nothing — a day, as before. */
18
+ const DEFAULT_STALE_WHILE_REVALIDATE_SECONDS = 86_400;
19
+
20
+ /**
21
+ * `IsrStore` is a driver seam, so an entry can come back from an app's own store — one backed by
22
+ * Redis round-trips it through JSON, where a `ttlMs` that was never written reads back as
23
+ * `undefined` and `entry.ttlMs === null` is then false. Two failures follow from that one value
24
+ * and neither raises: `now - generatedAt < NaN` is false, so the page is NEVER fresh and every
25
+ * request regenerates it, and the CDN is handed `s-maxage=NaN` — an unparseable directive a
26
+ * conforming cache IGNORES, dropping the page to heuristic caching rather than to the declared
27
+ * age. Read on the request path, so it is TOTAL rather than a throw: a ttl that is not a positive
28
+ * finite number of milliseconds is the tag-only `null` `parseTtlMs` would have answered for it.
29
+ */
30
+ export function entryTtlMs(entry: IsrEntry): number | null {
31
+ const ttlMs = entry.ttlMs;
32
+ if (ttlMs === null || (Number.isFinite(ttlMs) && ttlMs > 0)) return ttlMs;
33
+ logger.warn('isr.entry_ttl_invalid', { path: entry.path, ttlMs: String(ttlMs) });
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * The declared TTL is the route's own contract with the CDN: a shared cache must not hold the
39
+ * page longer than the app said it stays true. A flat `s-maxage=60` made `revalidate: { ttl:
40
+ * '5m' }` a lie in one direction and `ttl: '30s'` a lie in the other.
41
+ *
42
+ * `stale-while-revalidate` is the same contract for the copy PAST its age, so it follows the
43
+ * route's own bound: none at all under `onInvalidate: 'purge'` (a document that must come down may
44
+ * not be answered stale by the edge for a day after the origin deleted it), `maxStale` when one is
45
+ * declared, a day otherwise.
46
+ */
47
+ function cacheControl(ttlMs: number | null, policy: IsrPolicy, ageMs: number): string {
48
+ const sMaxAge = ttlMs === null ? TAG_ONLY_S_MAX_AGE_SECONDS : Math.round(ttlMs / 1_000);
49
+ if (policy.onInvalidate === 'purge') {
50
+ // What is LEFT of the ttl, not the whole of it: the ttl bounds a replica that missed a purge,
51
+ // and a hit answered at its last second with the full age would hand the edge a second ttl.
52
+ // TOTAL, like every reader here: an age that is not a number (a clock that answered `NaN`)
53
+ // is the whole ttl, never `s-maxage=NaN` — a directive a conforming cache ignores.
54
+ const left = ttlMs === null ? sMaxAge : Math.max(0, Math.ceil((ttlMs - ageMs) / 1_000));
55
+ return `public, max-age=0, s-maxage=${Number.isFinite(left) ? Math.min(sMaxAge, left) : sMaxAge}`;
56
+ }
57
+ const base = `public, max-age=0, s-maxage=${sMaxAge}`;
58
+ const stale =
59
+ policy.maxStaleMs === null
60
+ ? DEFAULT_STALE_WHILE_REVALIDATE_SECONDS
61
+ : Math.round(policy.maxStaleMs / 1_000);
62
+ return stale === 0 ? base : `${base}, stale-while-revalidate=${stale}`;
63
+ }
64
+
65
+ /**
66
+ * `entryTtlMs`'s reason, one field over: a store may hand back an entry with no `status`, or one
67
+ * that JSON turned into something else, on the request path. Absent is 200 — the only value any
68
+ * entry carried before the field existed — and anything the range refuses is 200 with a warning,
69
+ * because a stored number must not 500 the page for its whole TTL.
70
+ */
71
+ function entryStatus(entry: IsrEntry): number {
72
+ const status = entry.status;
73
+ if (status === undefined) return 200;
74
+ if (isRenderStatus(status)) return status;
75
+ logger.warn('isr.entry_status_invalid', { path: entry.path, status: String(status) });
76
+ return 200;
77
+ }
78
+
79
+ export interface IsrResultOptions {
80
+ readonly buildId: string;
81
+ readonly policy: IsrPolicy;
82
+ readonly servedStale?: boolean;
83
+ /** How long ago the entry was generated, by the controller's clock; 0 for one just rendered. */
84
+ readonly ageMs: number;
85
+ /**
86
+ * False for a render the store did not keep — a 5xx, one whose `load` said `noStore()`, or one
87
+ * invalidated while it rendered. It is then `private, no-store` with no purge key: an answer
88
+ * nothing here kept must not be kept by the edge either, or one upstream blip is a 503 — and one
89
+ * purge raced by a render is the withdrawn page — for the route's whole `s-maxage`.
90
+ */
91
+ readonly cacheable?: boolean;
92
+ }
93
+
94
+ export function isrResult(entry: IsrEntry, options: IsrResultOptions): RenderResult {
95
+ const { buildId, policy } = options;
96
+ // The store keys on the locale; a shared cache in front of it has to as well, or the CDN
97
+ // repeats the bug this entry was split to fix. `ssrHeaders`' own line, for the same reason.
98
+ // The rest of the shared key — the cookie, the zone — is added by `@ultimat3/http`'s
99
+ // `cache-headers` stage, which sees the actor this function cannot.
100
+ const vary = 'accept-language';
101
+ if (options.cacheable === false) {
102
+ const { etag: _etag, ...rest } = staticHeaders(entry.hash, buildId);
103
+ return {
104
+ status: entryStatus(entry),
105
+ headers: { ...rest, 'cache-control': 'private, no-store', vary },
106
+ body: entry.html,
107
+ };
108
+ }
109
+ const headers: Record<string, string> = {
110
+ ...staticHeaders(entry.hash, buildId),
111
+ 'cache-control': cacheControl(entryTtlMs(entry), policy, options.ageMs),
112
+ vary,
113
+ };
114
+ // The keys an edge purges this document by — `@ultimat3/cache`'s list, never a second one, so
115
+ // what a bust sends and what the page carries cannot drift. Absent, an `invalidates` cleared
116
+ // every tier but the CDN, which held the document for its whole `s-maxage`. The tags were
117
+ // screened when the route registered (`registry.ts`), so this cannot refuse on the request path.
118
+ const keys = surrogateKeys(policy.tags, 'isr');
119
+ if (keys.length > 0) {
120
+ headers['surrogate-key'] = keys.join(' ');
121
+ headers['cache-tag'] = keys.join(',');
122
+ }
123
+ if (options.servedStale === true) headers['x-ultimate-isr'] = 'stale';
124
+ return { status: entryStatus(entry), headers, body: entry.html };
125
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Which route a stored `isr` key belongs to, and what that route declared about it — its tags, its
3
+ * TTL, what an invalidation does and how long past its TTL a copy may still be served. Split from
4
+ * `render-isr.ts`: the controller asks, this file reads the route table.
5
+ */
6
+
7
+ import type { CacheTag } from '@ultimat3/cache';
8
+ import { parseTag } from '@ultimat3/cache';
9
+ import { parseStaleMs, parseTtlMs } from './duration';
10
+ import type { RouteDescriptor } from './registry';
11
+ import { routePathOf } from './render-isr-key';
12
+ import type { InvalidateMode } from './route';
13
+ import { compilePattern } from './route-pattern';
14
+
15
+ /** What a route declared for the entries stored under it; the defaults for a key no route owns. */
16
+ export interface IsrPolicy {
17
+ readonly tags: readonly CacheTag[];
18
+ readonly ttlMs: number | null;
19
+ /** `'stale'`: a bust keeps the copy for one more serve. `'purge'`: a bust deletes it. */
20
+ readonly onInvalidate: InvalidateMode;
21
+ /**
22
+ * How long past its TTL an entry may still be served stale: `null` is unbounded, `0` never.
23
+ * A `'purge'` route that declares none gets `0` — its `ttl` is what bounds a replica that
24
+ * missed the purge, and a copy answered stale past it would move that bound by one request.
25
+ */
26
+ readonly maxStaleMs: number | null;
27
+ }
28
+
29
+ export function isrPolicyOf(descriptor: RouteDescriptor | undefined): IsrPolicy {
30
+ const onInvalidate = descriptor?.revalidateOnInvalidate ?? 'stale';
31
+ const declared = descriptor?.revalidateMaxStale ?? null;
32
+ return {
33
+ tags: (descriptor?.revalidateTags ?? []).map(parseTag),
34
+ ttlMs: parseTtlMs(descriptor?.revalidateTtl),
35
+ onInvalidate,
36
+ maxStaleMs: declared === null ? (onInvalidate === 'purge' ? 0 : null) : parseStaleMs(declared),
37
+ };
38
+ }
39
+
40
+ interface RouteMatcher {
41
+ readonly route: RouteDescriptor;
42
+ test(storedPath: string): boolean;
43
+ }
44
+
45
+ /** One compiled set per route TABLE — `describePages()` hands out one array per registry change. */
46
+ const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
47
+
48
+ /**
49
+ * The route each key resolved to, per table. A bust asks this of EVERY stored key, and a pattern
50
+ * match per key per bust is the store's size in regex tests on the event loop. Bounded: emptied
51
+ * when it outgrows a store several times the default, never grown without limit by request keys.
52
+ */
53
+ const resolvedKeys = new WeakMap<
54
+ readonly RouteDescriptor[],
55
+ Map<string, RouteDescriptor | undefined>
56
+ >();
57
+ const MAX_RESOLVED_KEYS = 8_192;
58
+
59
+ /**
60
+ * A stored path belongs to a route when the route's pattern matches it — `route-pattern.ts`'s one
61
+ * pattern compiler, never a second. Ordered MOST SPECIFIC FIRST, as `@ultimat3/http`'s trie
62
+ * resolves a request: segment by segment, a static beats a `:param` beats a `*catch-all`. In table
63
+ * order `/docs/*path` sorts before `/docs/:id`, so the first match gave `/docs/7` the catch-all's
64
+ * TTL and tags — a page rendered by one route and expired by another's clock.
65
+ */
66
+ function matchersOf(table: readonly RouteDescriptor[]): readonly RouteMatcher[] {
67
+ const cached = compiledTables.get(table);
68
+ if (cached !== undefined) return cached;
69
+ // Static routes too: `/precios-españa` is the route's path and `/precios-espa%C3%B1a` the key's,
70
+ // so only the pattern (raw or encoded, per character) finds it.
71
+ const compiled = table
72
+ .map((route): RouteMatcher & { readonly specificity: number } => {
73
+ const { regex, specificity } = compilePattern(route.path);
74
+ return { route, specificity, test: (stored) => regex.test(stored) };
75
+ })
76
+ .sort((a, b) => b.specificity - a.specificity);
77
+ compiledTables.set(table, compiled);
78
+ return compiled;
79
+ }
80
+
81
+ /** The route a store key was rendered by, or `undefined` for a key no route in `table` owns. */
82
+ export function isrRouteFor(
83
+ table: readonly RouteDescriptor[],
84
+ key: string,
85
+ ): RouteDescriptor | undefined {
86
+ let resolved = resolvedKeys.get(table);
87
+ if (resolved === undefined) {
88
+ resolved = new Map();
89
+ resolvedKeys.set(table, resolved);
90
+ }
91
+ if (resolved.has(key)) return resolved.get(key);
92
+ const path = routePathOf(key);
93
+ const route =
94
+ table.find((r) => r.path === path) ?? matchersOf(table).find((m) => m.test(path))?.route;
95
+ if (resolved.size >= MAX_RESOLVED_KEYS) resolved.clear();
96
+ resolved.set(key, route);
97
+ return route;
98
+ }