@ultimat3/render 1.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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The route table — the single source of route truth. File path → URL conventions for
3
+ * `site/`, `app/` and `api/`, plus `describeRoutes()`, the serializable projection that
4
+ * `x.manifest.json`, the `/_x` routes panel, the sitemap and `sw.js` are all generated
5
+ * from. Nothing downstream may keep its own list of routes.
6
+ */
7
+
8
+ import {
9
+ RouteDuplicateError,
10
+ RouteFileInvalidError,
11
+ RouteUnnormalizedError,
12
+ SurfaceBoundaryError,
13
+ } from './errors';
14
+ import { assertModeInvariants } from './modes';
15
+ import type {
16
+ HydrateStrategy,
17
+ OfflineStrategy,
18
+ RenderMode,
19
+ RouteConfig,
20
+ RouteData,
21
+ RouteParams,
22
+ } from './route';
23
+ import { isRouteConfig, tagKeys } from './route';
24
+ import type { Surface } from './surfaces';
25
+ import { surfaceOf } from './surfaces';
26
+
27
+ /**
28
+ * The one filename a route may carry, per surface. `shared/` is absent on purpose: it is a leaf
29
+ * of helpers with no URL, so a route file there has nowhere to resolve to.
30
+ */
31
+ export const ROUTE_FILENAME: Readonly<Partial<Record<Surface, string>>> = Object.freeze({
32
+ site: 'page.tsx',
33
+ app: 'page.tsx',
34
+ api: 'route.ts',
35
+ });
36
+
37
+ /** Stems that already meant "this directory", so the repair is a rename in place, not a new folder. */
38
+ const DIRECTORY_STEMS = new Set(['index', 'page', 'route']);
39
+
40
+ const API_PREFIX = '/api';
41
+
42
+ export interface RouteEntry<TData = RouteData> {
43
+ readonly file: string;
44
+ readonly path: string;
45
+ readonly surface: Surface;
46
+ readonly config: RouteConfig<TData>;
47
+ readonly suspenseBoundaries: number;
48
+ readonly islands: readonly string[];
49
+ readonly pattern: CompiledPattern;
50
+ }
51
+
52
+ export interface RouteDescriptor {
53
+ readonly path: string;
54
+ readonly file: string;
55
+ readonly surface: Surface;
56
+ readonly mode: RenderMode;
57
+ readonly offline: OfflineStrategy;
58
+ readonly hydrate: HydrateStrategy;
59
+ readonly revalidateTags: readonly string[];
60
+ readonly revalidateTtl: string | number | null;
61
+ readonly prerenderable: boolean;
62
+ readonly dynamic: boolean;
63
+ readonly hasPolicy: boolean;
64
+ readonly islands: readonly string[];
65
+ readonly budgetJs: string | null;
66
+ readonly budgetLcp: number | null;
67
+ }
68
+
69
+ export interface CompiledPattern {
70
+ readonly source: string;
71
+ readonly regex: RegExp;
72
+ readonly keys: readonly string[];
73
+ /** Higher wins when two patterns match the same pathname. */
74
+ readonly specificity: number;
75
+ }
76
+
77
+ /**
78
+ * `apps/web/site/blog/[slug]/page.tsx` → `{ surface: 'site', path: '/blog/:slug' }`.
79
+ *
80
+ * The URL is the **directory** path under the surface; the filename names the kind of file, never
81
+ * a URL segment. Anything else is `X_ROUTE_FILE_INVALID` — one spelling per surface, so an agent
82
+ * reading a folder knows which file is the route without opening any of them.
83
+ *
84
+ * | file | path |
85
+ * |---|---|
86
+ * | `site/page.tsx` | `/` |
87
+ * | `site/pricing/page.tsx` | `/pricing` |
88
+ * | `site/(marketing)/about/page.tsx` | `/about` |
89
+ * | `site/blog/[slug]/page.tsx` | `/blog/:slug` |
90
+ * | `site/docs/[...path]/page.tsx` | `/docs/*path` |
91
+ * | `app/dashboard/page.tsx` | `/dashboard` |
92
+ * | `api/posts/route.ts` | `/api/posts` |
93
+ */
94
+ export function routePathFromFile(file: string): { surface: Surface; path: string } {
95
+ const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
96
+ const surface = surfaceOf(normalized);
97
+ if (surface === null) {
98
+ throw new SurfaceBoundaryError(
99
+ `${file} is not inside a surface directory, so it has no URL and no bundle graph`,
100
+ `move ${file} under site/, app/ or api/`,
101
+ );
102
+ }
103
+
104
+ const afterSurface = normalized.slice(normalized.indexOf(`${surface}/`) + surface.length + 1);
105
+ const rawSegments = afterSurface.split('/').filter((s) => s.length > 0);
106
+ assertRouteFilename(normalized, surface, rawSegments[rawSegments.length - 1]);
107
+
108
+ const urlSegments = rawSegments
109
+ .slice(0, -1)
110
+ .filter((s) => !(s.startsWith('(') && s.endsWith(')')))
111
+ .map(toUrlSegment);
112
+
113
+ const base = surface === 'api' ? API_PREFIX : '';
114
+ const path = `${base}/${urlSegments.join('/')}`.replace(/\/+$/, '') || '/';
115
+ return { surface, path };
116
+ }
117
+
118
+ /**
119
+ * POSIX single-quotes a filesystem-derived operand for a `fix:` command: close the quote, escape
120
+ * an embedded quote as `'\''`, reopen it. A `fix:` is copied and run verbatim (axiom 4), so a route
121
+ * filename carrying a space, an apostrophe or a shell metacharacter must not change what runs.
122
+ */
123
+ const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
124
+
125
+ /**
126
+ * Enforced rather than documented (axiom 3): a convention that is not a build error is not a
127
+ * convention. The fix is the move that makes the file a route, spelled out — the directory the
128
+ * author already meant, plus the one filename that surface accepts.
129
+ */
130
+ function assertRouteFilename(file: string, surface: Surface, basename: string | undefined): void {
131
+ const expected = ROUTE_FILENAME[surface];
132
+ if (expected === undefined) {
133
+ throw new RouteFileInvalidError(
134
+ `${file} is under shared/, which is a leaf of helpers with no URL — a route cannot live there`,
135
+ `move ${file} under site/, app/ or api/, named page.tsx (site/, app/) or route.ts (api/)`,
136
+ );
137
+ }
138
+ if (basename === expected) return;
139
+
140
+ // The directory the author meant is the file's own path minus its extension: `site/pricing.tsx`
141
+ // was always trying to be `/pricing`, so `site/pricing/page.tsx` is the move, not a guess.
142
+ // `index`, `page` and `route` are the exception — each already means "this directory", so the
143
+ // rename happens in place and no directory is created.
144
+ const stem = file.replace(/\.(tsx|ts|jsx|js)$/, '');
145
+ const dir = stem.slice(0, stem.lastIndexOf('/'));
146
+ const inPlace = DIRECTORY_STEMS.has(stem.slice(dir.length + 1));
147
+ const target = inPlace ? `${dir}/${expected}` : `${stem}/${expected}`;
148
+ throw new RouteFileInvalidError(
149
+ `${file} is a route on the ${surface} surface, so it must be named ${expected}: the URL is the ` +
150
+ 'directory path and the filename names the kind of file',
151
+ inPlace
152
+ ? `git mv -- ${shellQuote(file)} ${shellQuote(target)}`
153
+ : `mkdir -p -- ${shellQuote(stem)} && git mv -- ${shellQuote(file)} ${shellQuote(target)}`,
154
+ );
155
+ }
156
+
157
+ function toUrlSegment(segment: string): string {
158
+ const catchAll = /^\[\.\.\.(.+)\]$/.exec(segment);
159
+ if (catchAll?.[1] !== undefined) return `*${catchAll[1]}`;
160
+ const dynamic = /^\[(.+)\]$/.exec(segment);
161
+ if (dynamic?.[1] !== undefined) return `:${dynamic[1]}`;
162
+ return segment;
163
+ }
164
+
165
+ export function compilePattern(path: string): CompiledPattern {
166
+ const keys: string[] = [];
167
+ let specificity = 0;
168
+ const segments = path.split('/').filter((s) => s.length > 0);
169
+
170
+ const parts = segments.map((segment) => {
171
+ if (segment.startsWith('*')) {
172
+ keys.push(segment.slice(1));
173
+ specificity += 1;
174
+ return '(.*)';
175
+ }
176
+ if (segment.startsWith(':')) {
177
+ keys.push(segment.slice(1));
178
+ specificity += 10;
179
+ return '([^/]+)';
180
+ }
181
+ specificity += 100;
182
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
183
+ });
184
+
185
+ return {
186
+ source: path,
187
+ regex: new RegExp(`^/${parts.join('/')}/?$`),
188
+ keys,
189
+ specificity,
190
+ };
191
+ }
192
+
193
+ const routes = new Map<string, RouteEntry>();
194
+
195
+ export interface RegisterRouteInput<TData = RouteData> {
196
+ readonly file: string;
197
+ readonly config: RouteConfig<TData>;
198
+ /** Counted from the module's JSX by the build; `stream` requires >= 1. */
199
+ readonly suspenseBoundaries?: number;
200
+ readonly islands?: readonly string[];
201
+ /** Override the convention (locale roots, rewrites). Rarely needed. */
202
+ readonly path?: string;
203
+ }
204
+
205
+ /** Register a route and enforce every invariant that needs the surrounding module. */
206
+ export function registerRoute<TData = RouteData>(
207
+ input: RegisterRouteInput<TData>,
208
+ ): RouteEntry<TData> {
209
+ // The type already refuses a declaration; this catches the JS caller and the cast. Without it a
210
+ // raw declaration registers, and `describeRoutes()` is where it surfaces — as a bare TypeError
211
+ // on `config.budget.js`, one build step away from the file that caused it.
212
+ if (!isRouteConfig(input.config)) {
213
+ throw new RouteUnnormalizedError(
214
+ `${input.file} registered a route declaration, not a descriptor: defineRoute normalizes ` +
215
+ '`meta` and `budget`, and the route table has no other normalizer',
216
+ `wrap the declaration in ${input.file}: registerRoute({ file, config: defineRoute({ … }) })`,
217
+ );
218
+ }
219
+
220
+ const derived = routePathFromFile(input.file);
221
+ const path = input.path ?? derived.path;
222
+ const suspenseBoundaries = input.suspenseBoundaries ?? 0;
223
+
224
+ assertModeInvariants(input.config, {
225
+ file: input.file,
226
+ path,
227
+ surface: derived.surface,
228
+ suspenseBoundaries,
229
+ });
230
+
231
+ const existing = routes.get(path);
232
+ if (existing !== undefined && existing.file !== input.file) {
233
+ throw new RouteDuplicateError(
234
+ `${path} is claimed by both ${existing.file} and ${input.file}`,
235
+ `rename or delete one of them — the route table is keyed by URL`,
236
+ );
237
+ }
238
+
239
+ const entry: RouteEntry<TData> = {
240
+ file: input.file,
241
+ path,
242
+ surface: derived.surface,
243
+ config: input.config,
244
+ suspenseBoundaries,
245
+ islands: input.islands ?? [],
246
+ pattern: compilePattern(path),
247
+ };
248
+ routes.set(path, entry as RouteEntry);
249
+ return entry;
250
+ }
251
+
252
+ export function clearRoutes(): void {
253
+ routes.clear();
254
+ }
255
+
256
+ export function routeCount(): number {
257
+ return routes.size;
258
+ }
259
+
260
+ export function routeEntries(): readonly RouteEntry[] {
261
+ return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
262
+ }
263
+
264
+ export function routeFor(path: string): RouteEntry | undefined {
265
+ return routes.get(path);
266
+ }
267
+
268
+ /**
269
+ * The manifest projection: JSON-safe, sorted by path, identical for identical input.
270
+ * Determinism matters because `sw.js` and the sitemap are diffed across deploys.
271
+ */
272
+ export function describeRoutes(): readonly RouteDescriptor[] {
273
+ return routeEntries().map((entry) => ({
274
+ path: entry.path,
275
+ file: entry.file,
276
+ surface: entry.surface,
277
+ mode: entry.config.render,
278
+ offline: entry.config.offline,
279
+ hydrate: entry.config.hydrate,
280
+ revalidateTags: tagKeys(entry.config.revalidate?.tags),
281
+ revalidateTtl: entry.config.revalidate?.ttl ?? null,
282
+ prerenderable: entry.config.prerender !== undefined,
283
+ dynamic: entry.pattern.keys.length > 0,
284
+ hasPolicy: entry.config.policy !== undefined,
285
+ islands: entry.islands,
286
+ budgetJs: entry.config.budget.js ?? null,
287
+ budgetLcp: entry.config.budget.lcp ?? null,
288
+ }));
289
+ }
290
+
291
+ export interface RouteMatch {
292
+ readonly entry: RouteEntry;
293
+ readonly params: RouteParams;
294
+ }
295
+
296
+ /** Most specific pattern wins: static segments > dynamic > catch-all. */
297
+ export function matchRoute(pathname: string): RouteMatch | null {
298
+ const candidates = routeEntries()
299
+ .slice()
300
+ .sort((a, b) => b.pattern.specificity - a.pattern.specificity);
301
+
302
+ for (const entry of candidates) {
303
+ const match = entry.pattern.regex.exec(pathname);
304
+ if (match === null) continue;
305
+ const params: Record<string, string> = {};
306
+ entry.pattern.keys.forEach((key, index) => {
307
+ const value = match[index + 1];
308
+ if (value !== undefined) params[key] = decodeURIComponent(value);
309
+ });
310
+ return { entry, params };
311
+ }
312
+ return null;
313
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * `isr` — static output plus background regeneration. Three things make it safe:
3
+ * stale-while-revalidate (a stale page is served instantly, never a spinner),
4
+ * single-flight regeneration (a traffic burst on a stale page renders once, not N times),
5
+ * and tag-driven staleness (an action's `invalidates` marks exactly the dependent routes).
6
+ */
7
+
8
+ import type { CacheTag } from '@ultimat3/cache';
9
+ import {
10
+ dependentsOfKind,
11
+ invalidateTags,
12
+ registerDependent,
13
+ registerRevalidator,
14
+ unregisterDependent,
15
+ } from '@ultimat3/cache';
16
+ import { logger } from '@ultimat3/core';
17
+ import type { RouteDescriptor } from './registry';
18
+ import { describeRoutes } from './registry';
19
+ import { contentHash, staticHeaders } from './render-static';
20
+ import type { RenderResult } from './route';
21
+
22
+ export type IsrState = 'miss' | 'hit' | 'stale';
23
+
24
+ export interface IsrEntry {
25
+ readonly path: string;
26
+ readonly html: string;
27
+ readonly hash: string;
28
+ readonly generatedAt: number;
29
+ readonly ttlMs: number | null;
30
+ /** Set by a tag invalidation; independent of the TTL clock. */
31
+ readonly stale: boolean;
32
+ }
33
+
34
+ export interface IsrStore {
35
+ get(path: string): IsrEntry | undefined;
36
+ set(entry: IsrEntry): void;
37
+ delete(path: string): void;
38
+ paths(): readonly string[];
39
+ }
40
+
41
+ export function memoryIsrStore(): IsrStore {
42
+ const map = new Map<string, IsrEntry>();
43
+ return {
44
+ get: (path) => map.get(path),
45
+ set: (entry) => {
46
+ map.set(entry.path, entry);
47
+ },
48
+ delete: (path) => {
49
+ map.delete(path);
50
+ },
51
+ paths: () => [...map.keys()].sort(),
52
+ };
53
+ }
54
+
55
+ const DURATION_UNITS: Readonly<Record<string, number>> = {
56
+ ms: 1,
57
+ s: 1_000,
58
+ m: 60_000,
59
+ h: 3_600_000,
60
+ d: 86_400_000,
61
+ };
62
+
63
+ /** `'5m'` → 300000. Numbers pass through as milliseconds. */
64
+ export function parseTtlMs(ttl: string | number | null | undefined): number | null {
65
+ if (ttl === null || ttl === undefined) return null;
66
+ if (typeof ttl === 'number') return Number.isFinite(ttl) && ttl > 0 ? ttl : null;
67
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(ttl.trim());
68
+ const amount = match?.[1];
69
+ const unit = match?.[2];
70
+ if (amount === undefined || unit === undefined) return null;
71
+ const factor = DURATION_UNITS[unit];
72
+ return factor === undefined ? null : Number(amount) * factor;
73
+ }
74
+
75
+ export type IsrRenderFn = (path: string) => string | Promise<string>;
76
+
77
+ export interface IsrServeResult {
78
+ readonly state: IsrState;
79
+ readonly entry: IsrEntry;
80
+ readonly result: RenderResult;
81
+ /** True when this request started a background regeneration. */
82
+ readonly regenerating: boolean;
83
+ }
84
+
85
+ export interface IsrControllerOptions {
86
+ readonly store?: IsrStore;
87
+ readonly buildId?: string;
88
+ readonly now?: () => number;
89
+ /** Route table provider — defaults to the real registry. */
90
+ readonly routes?: () => readonly RouteDescriptor[];
91
+ /** ISR-route dependents for a tag set; defaults to `@ultimat3/cache`'s graph. */
92
+ readonly isrDependents?: (tags: readonly CacheTag[]) => readonly string[];
93
+ }
94
+
95
+ export interface IsrController {
96
+ serve(path: string, render: IsrRenderFn): Promise<IsrServeResult>;
97
+ /** Single-flight: concurrent callers for the same path share one render. */
98
+ regenerate(path: string, render: IsrRenderFn): Promise<IsrEntry>;
99
+ markStale(path: string): boolean;
100
+ /** Mark every ISR page the cache graph says depends on these tags. */
101
+ revalidateByTags(tags: readonly CacheTag[]): readonly string[];
102
+ inflight(): number;
103
+ store(): IsrStore;
104
+ /**
105
+ * Register this controller as the framework's revalidator, so
106
+ * `action({ cache: { invalidates: [tag.post] } })` reaches ISR in the same hop as
107
+ * memo, LRU, Redis and the CDN. Returns a detach function for tests and reloads.
108
+ */
109
+ attach(): () => void;
110
+ }
111
+
112
+ export function createIsrController(options: IsrControllerOptions = {}): IsrController {
113
+ const store = options.store ?? memoryIsrStore();
114
+ const now = options.now ?? (() => Date.now());
115
+ const routes = options.routes ?? describeRoutes;
116
+ const isrDependents =
117
+ options.isrDependents ?? ((tags: readonly CacheTag[]) => dependentsOfKind(tags, 'isr-route'));
118
+ const buildId = options.buildId ?? 'dev';
119
+ const pending = new Map<string, Promise<IsrEntry>>();
120
+ const registered = new Set<string>();
121
+
122
+ function descriptorFor(path: string): RouteDescriptor | undefined {
123
+ const table = routes();
124
+ return table.find((r) => r.path === path) ?? table.find((r) => matchesRoute(path, r.path));
125
+ }
126
+
127
+ /**
128
+ * A rendered page joins the invalidation graph under its route's tags, so `/blog/a` and
129
+ * `/blog/b` are separately addressable and a `tag.post` bust does not touch `/team`.
130
+ */
131
+ function registerPath(path: string, descriptor: RouteDescriptor | undefined): void {
132
+ if (descriptor === undefined || registered.has(path)) return;
133
+ if (descriptor.revalidateTags.length === 0) return;
134
+ registerDependent(descriptor.revalidateTags.map(parseWireTag), { kind: 'isr-route', id: path });
135
+ registered.add(path);
136
+ }
137
+
138
+ function isFresh(entry: IsrEntry): boolean {
139
+ if (entry.stale) return false;
140
+ if (entry.ttlMs === null) return true; // tag-only revalidation: fresh until invalidated
141
+ return now() - entry.generatedAt < entry.ttlMs;
142
+ }
143
+
144
+ function regenerate(path: string, render: IsrRenderFn): Promise<IsrEntry> {
145
+ const existing = pending.get(path);
146
+ if (existing !== undefined) return existing;
147
+
148
+ const descriptor = descriptorFor(path);
149
+ const work = (async (): Promise<IsrEntry> => {
150
+ const html = await render(path);
151
+ const entry: IsrEntry = {
152
+ path,
153
+ html,
154
+ hash: contentHash(html),
155
+ generatedAt: now(),
156
+ ttlMs: parseTtlMs(descriptor?.revalidateTtl),
157
+ stale: false,
158
+ };
159
+ store.set(entry);
160
+ registerPath(path, descriptor);
161
+ return entry;
162
+ })();
163
+
164
+ pending.set(path, work);
165
+ void work.catch(() => undefined).finally(() => pending.delete(path));
166
+ return work;
167
+ }
168
+
169
+ function markStale(path: string): boolean {
170
+ const entry = store.get(path);
171
+ if (entry === undefined) return false;
172
+ store.set({ ...entry, stale: true });
173
+ return true;
174
+ }
175
+
176
+ return {
177
+ store: () => store,
178
+ inflight: () => pending.size,
179
+ regenerate,
180
+ markStale,
181
+
182
+ async serve(path, render) {
183
+ const cached = store.get(path);
184
+
185
+ if (cached === undefined) {
186
+ const entry = await regenerate(path, render);
187
+ return { state: 'miss', entry, result: toResult(entry, buildId), regenerating: false };
188
+ }
189
+
190
+ if (isFresh(cached)) {
191
+ return {
192
+ state: 'hit',
193
+ entry: cached,
194
+ result: toResult(cached, buildId),
195
+ regenerating: false,
196
+ };
197
+ }
198
+
199
+ // stale-while-revalidate: answer from the stale copy now, refresh behind the request.
200
+ const already = pending.has(path);
201
+ void regenerate(path, render).catch((error: unknown) => {
202
+ logger.warn('isr.regenerate.failed', {
203
+ path,
204
+ error: error instanceof Error ? error.message : String(error),
205
+ });
206
+ });
207
+ return {
208
+ state: 'stale',
209
+ entry: cached,
210
+ result: toResult(cached, buildId, true),
211
+ regenerating: !already,
212
+ };
213
+ },
214
+
215
+ revalidateByTags(tags) {
216
+ const affected = new Set<string>();
217
+ for (const path of isrDependents(tags)) {
218
+ markStale(path);
219
+ affected.add(path);
220
+ }
221
+ return [...affected].sort();
222
+ },
223
+
224
+ attach() {
225
+ // The cache fanout owns the trigger; render owns only "what does stale mean here".
226
+ registerRevalidator((path) => {
227
+ markStale(path);
228
+ });
229
+ return () => {
230
+ for (const path of registered) unregisterDependent({ kind: 'isr-route', id: path });
231
+ registered.clear();
232
+ };
233
+ },
234
+ };
235
+ }
236
+
237
+ /**
238
+ * The whole loop, in one call: `action({ cache: { invalidates: [tag.post] } })` fans out
239
+ * across memo, LRU, Redis and the CDN, and the same hop returns the ISR pages that were
240
+ * marked stale — because the controller registered them in the same graph. Nobody lists
241
+ * pages by hand, so nobody forgets one.
242
+ */
243
+ export async function invalidateAndRevalidate(
244
+ tags: readonly CacheTag[],
245
+ ): Promise<readonly string[]> {
246
+ const report = await invalidateTags(tags);
247
+ return report.isr;
248
+ }
249
+
250
+ /** `post` / `post:123` → `{ entity, id? }`. Mirrors `@ultimat3/cache`'s wire form. */
251
+ function parseWireTag(wire: string): CacheTag {
252
+ const split = wire.indexOf(':');
253
+ if (split === -1) return { entity: wire };
254
+ return { entity: wire.slice(0, split), id: wire.slice(split + 1) };
255
+ }
256
+
257
+ /** A stored path belongs to a route when the route's pattern matches it. */
258
+ function matchesRoute(storedPath: string, routePath: string): boolean {
259
+ if (!routePath.includes(':') && !routePath.includes('*')) return storedPath === routePath;
260
+ const parts = routePath.split('/').map(segmentPattern);
261
+ return new RegExp(`^${parts.join('/')}/?$`).test(storedPath);
262
+ }
263
+
264
+ function segmentPattern(segment: string): string {
265
+ if (segment.startsWith(':')) return '([^/]+)';
266
+ if (segment.startsWith('*')) return '(.*)';
267
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
268
+ }
269
+
270
+ /** Tag-only routes have no clock of their own; a tag bust reaches the CDN through the fanout. */
271
+ const TAG_ONLY_S_MAX_AGE_SECONDS = 60;
272
+
273
+ /**
274
+ * The declared TTL is the route's own contract with the CDN: a shared cache must not hold the
275
+ * page longer than the app said it stays true. A flat `s-maxage=60` made `revalidate: { ttl:
276
+ * '5m' }` a lie in one direction and `ttl: '30s'` a lie in the other.
277
+ */
278
+ function cacheControl(ttlMs: number | null): string {
279
+ const sMaxAge = ttlMs === null ? TAG_ONLY_S_MAX_AGE_SECONDS : Math.round(ttlMs / 1_000);
280
+ return `public, max-age=0, s-maxage=${sMaxAge}, stale-while-revalidate=86400`;
281
+ }
282
+
283
+ function toResult(entry: IsrEntry, buildId: string, servedStale = false): RenderResult {
284
+ const headers: Record<string, string> = {
285
+ ...staticHeaders(entry.hash, buildId),
286
+ 'cache-control': cacheControl(entry.ttlMs),
287
+ };
288
+ if (servedStale) headers['x-ultimate-isr'] = 'stale';
289
+ return { status: 200, headers, body: entry.html };
290
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `spa` — shell only, client fetches everything. For dashboards behind auth, where the
3
+ * shell is identical for every actor and therefore cacheable, and the data is not.
4
+ * `modes.ts` requires a `policy` on this mode: nothing is server-rendered, so the route
5
+ * itself is the only place authz can live.
6
+ */
7
+
8
+ import { RouteModeInvalidError } from './errors';
9
+ import type { RouteEntry } from './registry';
10
+ import { contentHash } from './render-static';
11
+ import type { RenderResult } from './route';
12
+
13
+ export const SPA_ROOT_ID = 'x-root';
14
+
15
+ export interface SpaShellInput {
16
+ readonly entry: RouteEntry;
17
+ readonly buildId: string;
18
+ /** Everything inside `<head>`, already merged by `head.ts`. */
19
+ readonly head: string;
20
+ /** Build-id-immutable chunk URLs to preload; order is preserved for determinism. */
21
+ readonly chunks: readonly string[];
22
+ readonly rootId?: string;
23
+ readonly lang: string;
24
+ readonly dir?: 'ltr' | 'rtl';
25
+ }
26
+
27
+ export interface SpaShell {
28
+ readonly html: string;
29
+ readonly hash: string;
30
+ }
31
+
32
+ export function renderSpaShell(input: SpaShellInput): SpaShell {
33
+ if (input.entry.config.policy === undefined) {
34
+ throw new RouteModeInvalidError(
35
+ `${input.entry.file} declares render: 'spa' with no policy, so the shell would be public`,
36
+ `add policy: can('…') to ${input.entry.file}`,
37
+ );
38
+ }
39
+
40
+ const rootId = input.rootId ?? SPA_ROOT_ID;
41
+ const preloads = input.chunks
42
+ .map((chunk) => `<link rel="modulepreload" href="${chunk}">`)
43
+ .join('');
44
+ const scripts = input.chunks
45
+ .map((chunk) => `<script type="module" src="${chunk}"></script>`)
46
+ .join('');
47
+
48
+ const html =
49
+ `<!doctype html><html lang="${input.lang}" dir="${input.dir ?? 'ltr'}">` +
50
+ `<head>${input.head}${preloads}` +
51
+ `<meta name="x-ultimate-build" content="${input.buildId}">` +
52
+ `</head><body><div id="${rootId}"></div>${scripts}</body></html>`;
53
+
54
+ return { html, hash: contentHash(html) };
55
+ }
56
+
57
+ /**
58
+ * The shell is identical for every actor, so it is cache-first in `sw.js` and
59
+ * revalidate-on-navigation over HTTP. The build id in the document is what
60
+ * `@ultimat3/pwa`'s skew detection compares against the server's.
61
+ */
62
+ export function renderSpa(input: SpaShellInput): RenderResult {
63
+ const shell = renderSpaShell(input);
64
+ return {
65
+ status: 200,
66
+ headers: {
67
+ 'content-type': 'text/html; charset=utf-8',
68
+ 'cache-control': 'private, max-age=0, must-revalidate',
69
+ etag: `"${shell.hash}"`,
70
+ 'x-ultimate-build': input.buildId,
71
+ },
72
+ body: shell.html,
73
+ };
74
+ }