@ultimat3/render 27.8.0 → 27.9.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/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
@@ -26,6 +26,14 @@ export const config = defineRoute({
26
26
  export function Page(props: { data: Post }) { /* the SAME object meta was given */ }
27
27
  ```
28
28
 
29
+ `prerender()` answers the pages to build: a bare string (a one-param route), a params object, or an
30
+ entry — `{ params, lastmod?, locales? }` (`PrerenderEntry`) — mixed freely. The build reads `params`
31
+ alone and renders every routed locale; `lastmod` (a `Date` or ISO 8601 with a zone) and `locales`
32
+ are what the page says of itself for the sitemap: its `<lastmod>`, and the locales it is listed and
33
+ cross-linked in. `enumeratePrerender()` answers params, `enumeratePrerenderEntries()` the entries.
34
+ A key outside the three, a `lastmod` that is neither a string nor a `Date`, or `locales` that is
35
+ not a list of names is `X_PRERENDER_FAILED`, naming the route file.
36
+
29
37
  ## `load` is the one server-side data seam
30
38
 
31
39
  Optional, and the only way a page gets data. It runs **once per render** and the result is handed
@@ -557,7 +565,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
557
565
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
558
566
  | `renderStatic`†, `enumeratePrerender`† | build-time render, content hashing |
559
567
  | `isrController`†, `invalidateAndRevalidate`† | SWR + single-flight + tag triggers |
560
- | `isrKey`†, `isrRequestUrl`†, `undeclaredQuery`† | the one derivation of an `isr` store key, and the request URL narrowed to `revalidate.query` |
568
+ | `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
569
  | `noStore`, `routeNoStoreOf` | a loader's "do not keep this answer" — a mark on the data, like `withStatus`; read by `isr` |
562
570
  | `renderSsr`†, `streamResult`† | the per-request modes |
563
571
  | `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "27.8.0",
3
+ "version": "27.9.0",
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.8.0",
44
- "@ultimat3/core": "27.8.0",
45
- "@ultimat3/http": "27.8.0",
46
- "@ultimat3/i18n": "27.8.0",
47
- "@ultimat3/seo": "27.8.0",
43
+ "@ultimat3/cache": "27.9.0",
44
+ "@ultimat3/core": "27.9.0",
45
+ "@ultimat3/http": "27.9.0",
46
+ "@ultimat3/i18n": "27.9.0",
47
+ "@ultimat3/seo": "27.9.0",
48
48
  "sass": "1.105.1"
49
49
  }
50
50
  }
package/src/index.ts CHANGED
@@ -182,7 +182,9 @@ export {
182
182
  export type {
183
183
  InvalidateMode,
184
184
  LoadRequirement,
185
+ PrerenderEntry,
185
186
  PrerenderFn,
187
+ PrerenderItem,
186
188
  RenderResult,
187
189
  RevalidateConfig,
188
190
  RouteAlternate,
@@ -0,0 +1,108 @@
1
+ // Single responsibility: what a route's `prerender()` answered, normalised — every page as its
2
+ // params, plus what the page said about itself for a sitemap (`lastmod`, `locales`).
3
+
4
+ import { renderCauseValue, renderThrowable } from '@ultimat3/core';
5
+ import { PrerenderFailedError } from './errors';
6
+ import type { RouteEntry } from './registry';
7
+ import type { PrerenderEntry, RouteParams } from './route';
8
+
9
+ const ENTRY_KEYS: readonly string[] = ['params', 'lastmod', 'locales'];
10
+
11
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
12
+ typeof value === 'object' && value !== null && !Array.isArray(value);
13
+
14
+ /**
15
+ * Is `item` the ENTRY form — `{ params: { … }, lastmod?, locales? }` — rather than a params object?
16
+ * Decided by `params` holding an object: a param VALUE is always a string, so `{ params: 'a' }` on a
17
+ * route whose own dynamic segment is `[params]` stays the plain params it has always been.
18
+ */
19
+ const isEntry = (item: Readonly<Record<string, unknown>>): boolean => isRecord(item['params']);
20
+
21
+ function entryOf(entry: RouteEntry, item: Readonly<Record<string, unknown>>): PrerenderEntry {
22
+ const refuse = (problem: string): never => {
23
+ throw new PrerenderFailedError(
24
+ `prerender() for ${entry.path} returned an entry ${problem}`,
25
+ `x build --target static --json # after prerender in ${entry.file} returns { params, lastmod?: Date | ISO 8601 string, locales?: string[] }`,
26
+ );
27
+ };
28
+ const extra = Object.keys(item).find((key) => !ENTRY_KEYS.includes(key));
29
+ if (extra !== undefined) {
30
+ refuse(`with the key ${JSON.stringify(extra)}, which is not one of ${ENTRY_KEYS.join(', ')}`);
31
+ }
32
+ const { lastmod, locales } = item;
33
+ if (lastmod !== undefined && typeof lastmod !== 'string' && !(lastmod instanceof Date)) {
34
+ refuse(`whose lastmod is ${renderCauseValue(lastmod)}, neither a Date nor an ISO 8601 string`);
35
+ }
36
+ if (
37
+ locales !== undefined &&
38
+ !(Array.isArray(locales) && locales.every((locale) => typeof locale === 'string'))
39
+ ) {
40
+ refuse(`whose locales is ${renderCauseValue(locales)}, not a list of locale names`);
41
+ }
42
+ return {
43
+ params: item['params'] as RouteParams,
44
+ ...(lastmod === undefined ? {} : { lastmod: lastmod as string | Date }),
45
+ ...(locales === undefined ? {} : { locales: locales as readonly string[] }),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Every page `prerender()` enumerates, as an entry. A bare string fills the route's one dynamic
51
+ * param; a params object is an entry with nothing to add. `lastmod` is carried AS WRITTEN — whether
52
+ * it names an instant is the sitemap's question (`@ultimat3/seo`, `X_SITEMAP_ENTRY_INVALID`).
53
+ */
54
+ export async function enumeratePrerenderEntries(
55
+ entry: RouteEntry,
56
+ ): Promise<readonly PrerenderEntry[]> {
57
+ const prerender = entry.config.prerender;
58
+ if (prerender === undefined) {
59
+ return entry.pattern.keys.length === 0 ? [{ params: {} }] : [];
60
+ }
61
+
62
+ let produced: unknown;
63
+ try {
64
+ produced = await prerender();
65
+ } catch (error) {
66
+ throw new PrerenderFailedError(
67
+ // `renderThrowable`, never `.message`/`String()`: `prerender` is app code and may throw a
68
+ // value whose read raises in turn — this frame is what makes the build failure a coded one.
69
+ `prerender() for ${entry.path} threw: ${renderThrowable(error)}`,
70
+ `fix prerender in ${entry.file} — it runs at build time with no request context`,
71
+ );
72
+ }
73
+
74
+ if (!Array.isArray(produced)) {
75
+ throw new PrerenderFailedError(
76
+ `prerender() for ${entry.path} returned ${typeof produced}, expected an array`,
77
+ `return an array of params from prerender in ${entry.file}`,
78
+ );
79
+ }
80
+
81
+ const keys = entry.pattern.keys;
82
+ return produced.map((item: unknown): PrerenderEntry => {
83
+ if (typeof item === 'string') {
84
+ const only = keys[0];
85
+ if (keys.length !== 1 || only === undefined) {
86
+ throw new PrerenderFailedError(
87
+ `prerender() for ${entry.path} returned the bare string ${renderCauseValue(item)} ` +
88
+ `but the route has ${keys.length} dynamic params (${keys.join(', ')})`,
89
+ `return objects from prerender in ${entry.file}, e.g. { ${keys.join(': …, ')}: … }`,
90
+ );
91
+ }
92
+ return { params: { [only]: item } };
93
+ }
94
+ // A `null` reached `Object.hasOwn` in `static-path.ts` and left the build as a bare `TypeError`.
95
+ if (!isRecord(item)) {
96
+ throw new PrerenderFailedError(
97
+ `prerender() for ${entry.path} returned ${renderCauseValue(item)} as one of its items, which is neither a params object nor a string`,
98
+ `return an array of params objects (or strings, for a one-param route) from prerender in ${entry.file}`,
99
+ );
100
+ }
101
+ return isEntry(item) ? entryOf(entry, item) : { params: item as RouteParams };
102
+ });
103
+ }
104
+
105
+ /** The params of every page `prerender()` enumerates — what a build renders, one document each. */
106
+ export async function enumeratePrerender(entry: RouteEntry): Promise<readonly RouteParams[]> {
107
+ return (await enumeratePrerenderEntries(entry)).map((page) => page.params);
108
+ }
@@ -4,6 +4,7 @@
4
4
  * the controller, which only ever holds the key this file derived.
5
5
  */
6
6
 
7
+ import { canonicalPath } from '@ultimat3/http';
7
8
  import { unlocalizedPath } from '@ultimat3/i18n';
8
9
 
9
10
  /**
@@ -57,6 +58,13 @@ export function undeclaredQuery(url: URL, query: readonly string[] | null): read
57
58
  * The ISR store key for one URL: pathname, the negotiated LOCALE, and the query, **params
58
59
  * sorted**. The URL is `isrRequestUrl`'s — already narrowed to the route's declared parameters.
59
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
+ *
60
68
  * Exported because deriving it is the caller's job and there may only be ONE derivation — a
61
69
  * server that keyed on `url.pathname` while the store believed it held a whole URL is the shape
62
70
  * of #171. Sorting makes `?a=1&b=2` and `?b=2&a=1` one entry rather than two renders of one page.
@@ -72,7 +80,7 @@ export function isrKey(url: URL, locale: string): string {
72
80
  const params = new URLSearchParams(url.search);
73
81
  params.set(ISR_LOCALE_PARAM, locale);
74
82
  params.sort();
75
- return `${url.pathname}?${params.toString()}`;
83
+ return `${canonicalPath(url.pathname)}?${params.toString()}`;
76
84
  }
77
85
 
78
86
  /**
package/src/render-isr.ts CHANGED
@@ -108,6 +108,24 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
108
108
 
109
109
  const descriptorFor = (key: string): RouteDescriptor | undefined => isrRouteFor(routes(), key);
110
110
 
111
+ /**
112
+ * A key NO route owns is rendered for the request that asked and never stored. Its ttl and tags
113
+ * are its route's, so stored it had neither: fresh for ever, on no tag edge, and found by no
114
+ * bust — the one page a purge could not take down. `@ultimat3/cli` keys every request on the
115
+ * path the router matched, so this is a caller that derived a key some other way. Told once.
116
+ */
117
+ let toldUnowned = false;
118
+ function tellUnowned(path: string): void {
119
+ if (toldUnowned) return;
120
+ toldUnowned = true;
121
+ logger.warn('isr.key.unowned', {
122
+ path,
123
+ cause:
124
+ 'no route in the table matches this key, so it has no ttl and no tags to be revalidated by — it was rendered and not stored',
125
+ fix: 'isrKey(url, locale) // key on the URL the router matched, base path removed; never on a hand-built string',
126
+ });
127
+ }
128
+
111
129
  /**
112
130
  * A rendered page joins the invalidation graph under its route's tags, so `/blog/a` and
113
131
  * `/blog/b` are separately addressable and a `tag.post` bust does not touch `/team`.
@@ -192,7 +210,8 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
192
210
  status: finiteStatus('IsrRenderFn', rendered.status),
193
211
  };
194
212
  const current = latest.get(path) === run;
195
- const unstorable = isServerError(rendered.status) || rendered.noStore === true;
213
+ const unstorable =
214
+ descriptor === undefined || isServerError(rendered.status) || rendered.noStore === true;
196
215
  // Kept only when nothing invalidated the page while it rendered — in this process (the
197
216
  // fence) or in any other (the store's) — and no newer run started. Refused, never
198
217
  // published stale-flagged: the next request re-renders from rows that include the write.
@@ -203,6 +222,8 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
203
222
  // store holds stays (still stale, so the next request tries again); a miss has none, and
204
223
  // answers this 5xx to its own request only.
205
224
  logger.warn('isr.render.unstored', { path, status: String(rendered.status) });
225
+ } else if (descriptor === undefined) {
226
+ tellUnowned(path);
206
227
  } else if (rendered.noStore === true) {
207
228
  // The loader's answer is not a failure, so the copy held is no longer the page.
208
229
  if (current) store.delete(path);
@@ -331,7 +352,9 @@ export function isrController(options: IsrControllerOptions = {}): IsrController
331
352
  ageMs: Math.max(0, now() - entry.generatedAt),
332
353
  });
333
354
  let cached = store.get(path);
334
- if (cached !== undefined && pastMaxStale(cached, policy)) {
355
+ // A copy under a key no route owns (a shared store an earlier release wrote) is not served:
356
+ // nothing could have invalidated it since.
357
+ if (cached !== undefined && (descriptor === undefined || pastMaxStale(cached, policy))) {
335
358
  store.delete(path);
336
359
  cached = undefined;
337
360
  }
@@ -4,12 +4,16 @@
4
4
  * the precache revision in `sw.js`, and the asset filename suffix.
5
5
  */
6
6
 
7
- import { BUILD_ID_HEADER, renderCauseValue, renderThrowable, useContext } from '@ultimat3/core';
7
+ import { BUILD_ID_HEADER, renderThrowable, useContext } from '@ultimat3/core';
8
8
  import { PrerenderFailedError, RouteModeInvalidError } from './errors';
9
+ import { enumeratePrerender } from './prerender-enumerate';
9
10
  import type { RouteEntry } from './registry';
10
11
  import type { RenderResult, RouteParams } from './route';
11
12
  import { filePathOf, filledSegments, urlPathOf } from './static-path';
12
13
 
14
+ // Re-exported where it has always been imported from; the enumeration is `prerender-enumerate.ts`'s.
15
+ export { enumeratePrerender } from './prerender-enumerate';
16
+
13
17
  export interface StaticArtifact {
14
18
  readonly path: string;
15
19
  readonly params: RouteParams;
@@ -61,59 +65,6 @@ export function assertNoPerRequestState(file: string): void {
61
65
  }
62
66
  }
63
67
 
64
- /** Normalize `prerender()` output. A bare string fills the route's one dynamic param. */
65
- export async function enumeratePrerender(entry: RouteEntry): Promise<readonly RouteParams[]> {
66
- const prerender = entry.config.prerender;
67
- if (prerender === undefined) {
68
- return entry.pattern.keys.length === 0 ? [{}] : [];
69
- }
70
-
71
- let produced: readonly (string | RouteParams)[];
72
- try {
73
- produced = await prerender();
74
- } catch (error) {
75
- throw new PrerenderFailedError(
76
- // `renderThrowable`, never `.message`/`String()`: `prerender` is app code and may throw a
77
- // value whose read raises in turn — this frame is what makes the build failure a coded one.
78
- `prerender() for ${entry.path} threw: ${renderThrowable(error)}`,
79
- `fix prerender in ${entry.file} — it runs at build time with no request context`,
80
- );
81
- }
82
-
83
- if (!Array.isArray(produced)) {
84
- throw new PrerenderFailedError(
85
- `prerender() for ${entry.path} returned ${typeof produced}, expected an array`,
86
- `return an array of params from prerender in ${entry.file}`,
87
- );
88
- }
89
-
90
- const keys = entry.pattern.keys;
91
- return produced.map((item: unknown) => {
92
- if (typeof item !== 'string') return paramsOf(entry, item);
93
- const only = keys[0];
94
- if (keys.length !== 1 || only === undefined) {
95
- throw new PrerenderFailedError(
96
- `prerender() for ${entry.path} returned the bare string ${renderCauseValue(item)} ` +
97
- `but the route has ${keys.length} dynamic params (${keys.join(', ')})`,
98
- `return objects from prerender in ${entry.file}, e.g. { ${keys.join(': …, ')}: … }`,
99
- );
100
- }
101
- return { [only]: item };
102
- });
103
- }
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
-
117
68
  export interface StaticBuildOptions {
118
69
  readonly buildId: string;
119
70
  /** Extension-less output files get `/index.html` appended. */
package/src/route.ts CHANGED
@@ -186,10 +186,29 @@ export type RouteMetaAsyncFn<TData = RouteData> = (
186
186
  ctx: RouteMetaContext<TData>,
187
187
  ) => Promise<RouteMeta>;
188
188
 
189
- /** Returns the params to build at deploy time. Bare strings fill a single dynamic param. */
190
- export type PrerenderFn = () =>
191
- | readonly (string | RouteParams)[]
192
- | Promise<readonly (string | RouteParams)[]>;
189
+ /**
190
+ * One page `prerender()` answers, with what the page says about itself for the SITEMAP. The build
191
+ * reads `params` alone: every routed locale's document is still rendered.
192
+ */
193
+ export interface PrerenderEntry {
194
+ readonly params: RouteParams;
195
+ /**
196
+ * When this page last changed — the sitemap's `<lastmod>` for it. A `Date`, or ISO 8601 with an
197
+ * explicit zone (`row.updatedAt`); a moment in the future is written as now.
198
+ */
199
+ readonly lastmod?: string | Date;
200
+ /**
201
+ * The locales this page is LISTED in: one sitemap `<url>` each, cross-linked with hreflang among
202
+ * themselves only. Omitted: every routed locale. Empty: built, and listed nowhere.
203
+ */
204
+ readonly locales?: readonly string[];
205
+ }
206
+
207
+ /** One item of a `prerender()` answer: a bare string, a params object, or an entry. */
208
+ export type PrerenderItem = string | RouteParams | PrerenderEntry;
209
+
210
+ /** Returns the pages to build at deploy time. Bare strings fill a single dynamic param. */
211
+ export type PrerenderFn = () => readonly PrerenderItem[] | Promise<readonly PrerenderItem[]>;
193
212
 
194
213
  /**
195
214
  * The rule that makes the no-`load` fallback true: a route that loads nothing renders
package/src/server.ts CHANGED
@@ -36,10 +36,10 @@ export {
36
36
  stylesheetsRevision,
37
37
  transformTsx,
38
38
  } from './module-loader';
39
+ export { enumeratePrerenderEntries } from './prerender-enumerate';
39
40
  // ---- the render pipeline: one entry point per mode ----------------------------------------------
40
41
  export type { RenderHtmlOptions } from './render-html';
41
42
  export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
42
-
43
43
  export {
44
44
  DEFAULT_ISR_REGENERATE_DEADLINE_MS,
45
45
  invalidateAndRevalidate,
@@ -47,6 +47,7 @@ export {
47
47
  } from './render-isr';
48
48
  export { DEFAULT_ISR_FAILURE_COOLDOWN_MS } from './render-isr-cooldown';
49
49
  export { ISR_LOCALE_PARAM, isrKey, isrRequestUrl, undeclaredQuery } from './render-isr-key';
50
+ export { isrRouteFor } from './render-isr-routes';
50
51
  export type {
51
52
  IsrEntry,
52
53
  IsrState,