@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/CLAUDE.md +18 -11
- package/README.md +38 -15
- package/package.json +8 -8
- package/src/asset.ts +5 -2
- package/src/client-path-style-tag.ts +0 -3
- package/src/client-scope-tag.ts +0 -3
- package/src/client-sync-tags.ts +0 -3
- package/src/code-unit-order.ts +1 -1
- package/src/css-modules.ts +59 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +3 -3
- package/src/head.ts +12 -20
- package/src/html.ts +10 -22
- package/src/hydrate.ts +34 -13
- package/src/index.ts +12 -25
- package/src/island-collector.ts +13 -5
- package/src/island-hold.ts +56 -0
- package/src/island-props.ts +1 -1
- package/src/island.ts +6 -2
- package/src/modes.ts +14 -3
- package/src/module-loader.ts +19 -14
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-fetch.ts +12 -11
- package/src/navigation-rules.ts +37 -27
- package/src/navigation-scroll.ts +51 -0
- package/src/navigation-swap.ts +10 -2
- package/src/navigation-tabs.ts +58 -0
- package/src/navigation.ts +44 -63
- package/src/registry.ts +37 -46
- package/src/render-html.ts +8 -6
- package/src/render-isr.ts +131 -69
- package/src/render-ssr.ts +2 -1
- package/src/render-static.ts +21 -7
- package/src/render-stream.ts +7 -7
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +13 -19
- package/src/server.ts +2 -1
- package/src/static-path.ts +15 -2
- package/src/stylesheet-path.ts +34 -0
- package/src/type-pins.tsx +4 -3
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 `
|
|
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,
|
|
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 `
|
|
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 `
|
|
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` — `
|
|
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
|
|
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:
|
|
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`. */
|
package/src/render-html.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
66
|
-
if (typeof value === 'number' || typeof value === 'bigint') return
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
void
|
|
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:
|
|
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 — `
|
|
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
|
-
/**
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
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(
|
|
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
|
-
|
|
85
|
+
[BUILD_ID_HEADER]: options.buildId,
|
|
85
86
|
};
|
|
86
87
|
}
|
package/src/render-static.ts
CHANGED
|
@@ -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 ${
|
|
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
|
-
|
|
164
|
+
[BUILD_ID_HEADER]: buildId,
|
|
151
165
|
};
|
|
152
166
|
}
|
|
153
167
|
|