@ultimat3/render 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +15 -6
- package/README.md +23 -5
- package/package.json +7 -6
- package/src/asset.ts +5 -2
- package/src/client-path-style-tag.ts +25 -0
- package/src/css-modules.ts +58 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +2 -2
- package/src/head.ts +9 -10
- package/src/html.ts +13 -19
- package/src/hydrate.ts +3 -3
- package/src/index.ts +8 -2
- package/src/island-collector.ts +2 -1
- package/src/island.ts +1 -1
- package/src/jsx.ts +24 -3
- package/src/modes.ts +14 -3
- package/src/module-loader.ts +48 -8
- package/src/mounted-routes.ts +74 -0
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-rules.ts +22 -6
- 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 +25 -53
- package/src/registry.ts +107 -55
- package/src/render-html.ts +4 -4
- package/src/render-isr.ts +127 -65
- package/src/render-static.ts +20 -6
- package/src/render-stream.ts +6 -5
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +10 -18
- package/src/sass-cache.ts +22 -3
- package/src/server.ts +4 -0
- package/src/static-path.ts +15 -2
- package/src/type-pins.tsx +4 -3
- package/src/navigation-controller-fixture.ts +0 -91
- package/src/navigation-dom-fixture.ts +0 -424
package/src/render-isr.ts
CHANGED
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
registerDependent,
|
|
14
14
|
registerRevalidator,
|
|
15
15
|
sampleFence,
|
|
16
|
+
surrogateKeys,
|
|
16
17
|
unregisterDependent,
|
|
17
18
|
} from '@ultimat3/cache';
|
|
18
|
-
import {
|
|
19
|
+
import type { Scheduler } from '@ultimat3/core';
|
|
20
|
+
import { createSingleFlight, finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
19
21
|
import { parseTtlMs } from './duration';
|
|
20
22
|
import { finiteStatus, isRenderStatus } from './finite-status';
|
|
21
23
|
import type { RouteDescriptor } from './registry';
|
|
@@ -24,6 +26,7 @@ import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
|
|
|
24
26
|
import { memoryIsrStore } from './render-isr-store';
|
|
25
27
|
import { contentHash, staticHeaders } from './render-static';
|
|
26
28
|
import type { RenderResult } from './route';
|
|
29
|
+
import { compilePattern } from './route-pattern';
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
|
|
@@ -105,8 +108,25 @@ export interface IsrControllerOptions {
|
|
|
105
108
|
readonly routes?: () => readonly RouteDescriptor[];
|
|
106
109
|
/** ISR-route dependents for a tag set; defaults to `@ultimat3/cache`'s graph. */
|
|
107
110
|
readonly isrDependents?: (tags: readonly CacheTag[]) => readonly string[];
|
|
111
|
+
/**
|
|
112
|
+
* How long one regeneration may hold its path, in whole ms (≥ 1). Defaults to
|
|
113
|
+
* `DEFAULT_ISR_REGENERATE_DEADLINE_MS`. There is no "forever": that is the bug it bounds.
|
|
114
|
+
*/
|
|
115
|
+
readonly regenerateDeadlineMs?: number | undefined;
|
|
116
|
+
/** Injected so the deadline is provable without waiting one out. */
|
|
117
|
+
readonly schedule?: Scheduler | undefined;
|
|
108
118
|
}
|
|
109
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The ceiling on one regeneration's hold on its path — `@ultimat3/http`'s default
|
|
122
|
+
* `requestTimeoutMs`, so the request that started a render has been cut off by then. Without it,
|
|
123
|
+
* one `render()` that never settled pinned its path for the life of the process: a missed page
|
|
124
|
+
* hung every later request, a stale one was served stale forever, and only a restart cleared it.
|
|
125
|
+
* Eviction frees the PATH; the hung render is not cancellable from here, and its late result is
|
|
126
|
+
* dropped if a newer regeneration has started since (`latest`).
|
|
127
|
+
*/
|
|
128
|
+
export const DEFAULT_ISR_REGENERATE_DEADLINE_MS = 30_000;
|
|
129
|
+
|
|
110
130
|
export interface IsrController {
|
|
111
131
|
serve(path: string, render: IsrRenderFn): Promise<IsrServeResult>;
|
|
112
132
|
/** Single-flight: concurrent callers for the same path share one render. */
|
|
@@ -140,7 +160,22 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
140
160
|
const isrDependents =
|
|
141
161
|
options.isrDependents ?? ((tags: readonly CacheTag[]) => dependentsOfKind(tags, 'isr-route'));
|
|
142
162
|
const buildId = options.buildId ?? 'dev';
|
|
143
|
-
const
|
|
163
|
+
const flight = createSingleFlight({
|
|
164
|
+
deadlineMs: finiteCount(
|
|
165
|
+
'createIsrController',
|
|
166
|
+
'regenerateDeadlineMs',
|
|
167
|
+
options.regenerateDeadlineMs ?? DEFAULT_ISR_REGENERATE_DEADLINE_MS,
|
|
168
|
+
1,
|
|
169
|
+
),
|
|
170
|
+
schedule: options.schedule,
|
|
171
|
+
});
|
|
172
|
+
/**
|
|
173
|
+
* The newest regeneration STARTED per path, by identity. Once a deadline can evict a flight, two
|
|
174
|
+
* renders of one path can overlap, and the older one settling last would publish its HTML over
|
|
175
|
+
* the newer page with a fresh `generatedAt`. Cleared by the run that still owns it; a run that
|
|
176
|
+
* never settles leaves one entry, replaced by the next regeneration of that path.
|
|
177
|
+
*/
|
|
178
|
+
const latest = new Map<string, object>();
|
|
144
179
|
const registered = new Set<string>();
|
|
145
180
|
|
|
146
181
|
function descriptorFor(key: string): RouteDescriptor | undefined {
|
|
@@ -149,6 +184,10 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
149
184
|
return table.find((r) => r.path === path) ?? matchersOf(table).find((m) => m.test(path))?.route;
|
|
150
185
|
}
|
|
151
186
|
|
|
187
|
+
/** The route's `revalidate.tags` for a store key — what its document is purged by at the edge. */
|
|
188
|
+
const tagsOf = (key: string): readonly CacheTag[] =>
|
|
189
|
+
(descriptorFor(key)?.revalidateTags ?? []).map(parseWireTag);
|
|
190
|
+
|
|
152
191
|
/**
|
|
153
192
|
* A rendered page joins the invalidation graph under its route's tags, so `/blog/a` and
|
|
154
193
|
* `/blog/b` are separately addressable and a `tag.post` bust does not touch `/team`.
|
|
@@ -184,48 +223,53 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
184
223
|
return now() - entry.generatedAt < ttlMs;
|
|
185
224
|
}
|
|
186
225
|
|
|
226
|
+
/** One regeneration of `path`; `led` is told when THIS call started it rather than joined one. */
|
|
227
|
+
function regenerateOnce(path: string, render: IsrRenderFn, led?: () => void): Promise<IsrEntry> {
|
|
228
|
+
return flight.run(path, async (): Promise<IsrEntry> => {
|
|
229
|
+
led?.();
|
|
230
|
+
const run = {};
|
|
231
|
+
latest.set(path, run);
|
|
232
|
+
const descriptor = descriptorFor(path);
|
|
233
|
+
try {
|
|
234
|
+
// BEFORE the render, not after: `revalidateByTags` reads the graph, so a bust arriving
|
|
235
|
+
// while a cold path's first render was in flight could not see the page it was
|
|
236
|
+
// invalidating — which is the window in which the bust that matters most arrives.
|
|
237
|
+
registerPath(path, descriptor);
|
|
238
|
+
// Sampled before the render for the reason `@ultimat3/cache`'s read-through fill samples
|
|
239
|
+
// before its `load()` (`tiers.ts`): the HTML below is built from rows read in the past,
|
|
240
|
+
// and a `markStale` landing in between was then ERASED by `store.set({ stale: false })`.
|
|
241
|
+
// For a tag-only route `isFresh` is true forever, so the process went on serving pre-write
|
|
242
|
+
// HTML for the rest of its life. One mechanism, not a second one grown here.
|
|
243
|
+
const fence = sampleFence({
|
|
244
|
+
key: path,
|
|
245
|
+
tags: (descriptor?.revalidateTags ?? []).map(parseWireTag),
|
|
246
|
+
});
|
|
247
|
+
const { html, status } = renderedOf(await render(path));
|
|
248
|
+
const entry: IsrEntry = {
|
|
249
|
+
path,
|
|
250
|
+
html,
|
|
251
|
+
hash: contentHash(html),
|
|
252
|
+
generatedAt: now(),
|
|
253
|
+
ttlMs: parseTtlMs(descriptor?.revalidateTtl),
|
|
254
|
+
stale: false,
|
|
255
|
+
// Screened at generation, the one place a status enters the store: a `NaN` written here
|
|
256
|
+
// would be served for the whole TTL as a `RangeError` on every hit.
|
|
257
|
+
status: finiteStatus('IsrRenderFn', status),
|
|
258
|
+
};
|
|
259
|
+
// Refused, never published stale-flagged: the next request re-renders from rows that now
|
|
260
|
+
// include the write, where a stored-but-stale entry would serve this pre-write body once
|
|
261
|
+
// more before doing the same thing. Refused too once a newer run started (`latest`).
|
|
262
|
+
if (fence.isValid() && latest.get(path) === run) store.set(entry);
|
|
263
|
+
forgetEvictedPaths();
|
|
264
|
+
return entry;
|
|
265
|
+
} finally {
|
|
266
|
+
if (latest.get(path) === run) latest.delete(path);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
187
271
|
function regenerate(path: string, render: IsrRenderFn): Promise<IsrEntry> {
|
|
188
|
-
|
|
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
|
|
|
@@ -336,27 +383,28 @@ interface RouteMatcher {
|
|
|
336
383
|
/** One compiled set per route TABLE — `describeRoutes()` hands out one array per registry change. */
|
|
337
384
|
const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
|
|
338
385
|
|
|
339
|
-
/**
|
|
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-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 { 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. */
|
package/src/render-stream.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
15
15
|
import { finiteStatus } from './finite-status';
|
|
16
|
-
import {
|
|
16
|
+
import { escapeHtml } from './html';
|
|
17
17
|
import type { RenderResult } from './route';
|
|
18
18
|
import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
|
|
19
19
|
|
|
@@ -54,7 +54,7 @@ export function holeId(id: string): string {
|
|
|
54
54
|
* from a param. `fallback` is already-rendered HTML and stays verbatim.
|
|
55
55
|
*/
|
|
56
56
|
export function holeMarker(id: string, fallback: string): string {
|
|
57
|
-
return `<x-hole id="${
|
|
57
|
+
return `<x-hole id="${escapeHtml(holeId(id))}">${fallback}</x-hole>`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
@@ -64,10 +64,10 @@ export function holeMarker(id: string, fallback: string): string {
|
|
|
64
64
|
export const REVEAL_SCRIPT = `<script>${REVEAL_BODY}</script>`;
|
|
65
65
|
|
|
66
66
|
export function revealChunk(id: string, html: string): string {
|
|
67
|
-
// The id reaches markup ONLY through `
|
|
67
|
+
// The id reaches markup ONLY through `escapeHtml` — never a script — so a quote in it
|
|
68
68
|
// cannot close anything, and no per-hole body exists for a CSP to fail to list.
|
|
69
69
|
return (
|
|
70
|
-
`<template data-x-hole="${
|
|
70
|
+
`<template data-x-hole="${escapeHtml(holeId(id))}">${html}</template>` +
|
|
71
71
|
`<script>${REVEAL_CALL}</script>`
|
|
72
72
|
);
|
|
73
73
|
}
|
|
@@ -117,7 +117,8 @@ export function renderStreamHtml(
|
|
|
117
117
|
const encoder = new TextEncoder();
|
|
118
118
|
const tail = plan.tail ?? '</body></html>';
|
|
119
119
|
const errorFallback =
|
|
120
|
-
|
|
120
|
+
// Escaped as `holeMarker` escapes the same id: it lands inside an attribute either way.
|
|
121
|
+
options.errorFallback ?? ((id) => `<div data-x-hole-error="${escapeHtml(id)}" hidden></div>`);
|
|
121
122
|
// A deadline is the bound a non-finite value does not disable but MOVES: `setTimeout(fn, NaN)`
|
|
122
123
|
// is `setTimeout(fn, 0)`, so every hole would miss a deadline nobody set and the document would
|
|
123
124
|
// be all fallbacks. `null` is the declared opt-out; there is no spelling that means "immediately".
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A route pattern (`/docs/:id`, `/docs/*path`) as the regex a pathname is tested with, and the
|
|
3
|
+
* order two matching patterns are ranked in. Split from `registry.ts`, which registers routes; this
|
|
4
|
+
* file only reads a pattern string.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { routeRank } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
export interface CompiledPattern {
|
|
10
|
+
readonly source: string;
|
|
11
|
+
readonly regex: RegExp;
|
|
12
|
+
readonly keys: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Higher wins when two patterns match the same pathname — `@ultimat3/core`'s `routeRank`, the
|
|
15
|
+
* ONE ordering rule: ISR, the sitemap, the admin and `@ultimat3/pwa`'s worker all sort on it.
|
|
16
|
+
*/
|
|
17
|
+
readonly specificity: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A regex metacharacter, matched as itself. */
|
|
21
|
+
const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A literal segment as a pathname may spell it: each character raw OR percent-encoded, either hex
|
|
25
|
+
* case. A route file is named in Unicode (`precios-españa`) and `URL.pathname` encodes it
|
|
26
|
+
* (`precios-espa%C3%B1a`), so a literal-only pattern never matched the key it was asked about.
|
|
27
|
+
*/
|
|
28
|
+
function literalPattern(segment: string): string {
|
|
29
|
+
let out = '';
|
|
30
|
+
for (const char of segment) {
|
|
31
|
+
const encoded = [...new TextEncoder().encode(char)]
|
|
32
|
+
.map((byte) => {
|
|
33
|
+
const hex = byte.toString(16).toUpperCase().padStart(2, '0');
|
|
34
|
+
const digit = (d: string): string => (/[A-F]/.test(d) ? `[${d}${d.toLowerCase()}]` : d);
|
|
35
|
+
return `%${digit(hex[0] ?? '0')}${digit(hex[1] ?? '0')}`;
|
|
36
|
+
})
|
|
37
|
+
.join('');
|
|
38
|
+
out += `(?:${escapeRegex(char)}|${encoded})`;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function compilePattern(path: string): CompiledPattern {
|
|
44
|
+
const keys: string[] = [];
|
|
45
|
+
const segments = path.split('/').filter((s) => s.length > 0);
|
|
46
|
+
|
|
47
|
+
// Each part carries its own leading `/`, so a catch-all can own its separator: an empty rest is
|
|
48
|
+
// the bare prefix (`/docs`), which is the path `static-path.ts` writes for it.
|
|
49
|
+
const parts = segments.map((segment) => {
|
|
50
|
+
if (segment.startsWith('*')) {
|
|
51
|
+
keys.push(segment.slice(1));
|
|
52
|
+
return '(?:/(.*))?';
|
|
53
|
+
}
|
|
54
|
+
if (segment.startsWith(':')) {
|
|
55
|
+
keys.push(segment.slice(1));
|
|
56
|
+
return '/([^/]+)';
|
|
57
|
+
}
|
|
58
|
+
return `/${literalPattern(segment)}`;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
source: path,
|
|
63
|
+
regex: new RegExp(`^${parts.join('')}/?$`),
|
|
64
|
+
keys,
|
|
65
|
+
specificity: routeRank(path),
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/route.ts
CHANGED
|
@@ -51,17 +51,15 @@ export interface RevalidateConfig {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* What a route promises to stay under. **
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* What a route promises to stay under. **One key, and it is MEASURED** — a key here that nothing
|
|
55
|
+
* weighs is a number an author writes and nothing can ever compare against. `type-pins.tsx` makes
|
|
56
|
+
* an unprojected key a build error, and `assertModeInvariants` refuses one that arrives anyway.
|
|
57
57
|
*
|
|
58
|
-
* `css`, `cls` and `tbt`
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* half does not exist either, and a knob that fails silently in the permissive direction is the
|
|
64
|
-
* framework's most repeated defect.
|
|
58
|
+
* `css`, `cls` and `tbt` went in 12.0.0 and `lcp` after them: each was accepted, stored and
|
|
59
|
+
* ignored while the gate that exists to enforce budgets reported green, because nothing in the
|
|
60
|
+
* framework observes a paint — the build emits static HTML. Deleted rather than wired for
|
|
61
|
+
* `PwaConfig.installPrompt`'s reason: the measuring half does not exist, and a knob that fails
|
|
62
|
+
* silently in the permissive direction is the framework's most repeated defect.
|
|
65
63
|
*/
|
|
66
64
|
export interface RouteBudget {
|
|
67
65
|
/**
|
|
@@ -72,12 +70,6 @@ export interface RouteBudget {
|
|
|
72
70
|
* `As of 2026-09-05`; `packages/cli/src/budgets.ts`'s `measureDocumentJs` is the one reader.
|
|
73
71
|
*/
|
|
74
72
|
readonly js?: string;
|
|
75
|
-
/**
|
|
76
|
-
* Milliseconds. PUBLISHED, not enforced: it reaches `x.manifest.json` and `x routes`, and no
|
|
77
|
-
* step of the gate compares it — nothing in the framework produces an `lcp` measurement, which
|
|
78
|
-
* `packages/cli/src/templates/route.ts` states at the one place an author would write one.
|
|
79
|
-
*/
|
|
80
|
-
readonly lcp?: number;
|
|
81
73
|
}
|
|
82
74
|
|
|
83
75
|
/**
|
|
@@ -299,7 +291,7 @@ export function defineRoute<TData = RouteData>(
|
|
|
299
291
|
}
|
|
300
292
|
if (!OFFLINE_STRATEGIES.includes(def.offline)) {
|
|
301
293
|
throw new RouteOfflineMissingError(
|
|
302
|
-
`offline: ${
|
|
294
|
+
`offline: ${renderCauseValue(def.offline)} is not a known strategy`,
|
|
303
295
|
`use one of ${OFFLINE_STRATEGIES.join(' | ')}`,
|
|
304
296
|
);
|
|
305
297
|
}
|
|
@@ -312,7 +304,7 @@ export function defineRoute<TData = RouteData>(
|
|
|
312
304
|
|
|
313
305
|
if (def.load !== undefined && typeof def.load !== 'function') {
|
|
314
306
|
throw new RouteLoadInvalidError(
|
|
315
|
-
`load: ${
|
|
307
|
+
`load: ${renderCauseValue(def.load)} is not a function`,
|
|
316
308
|
'make load a function of ({ params, url }) returning the page data, or remove it',
|
|
317
309
|
);
|
|
318
310
|
}
|
package/src/sass-cache.ts
CHANGED
|
@@ -9,7 +9,12 @@ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'no
|
|
|
9
9
|
// why: Bun ships no path-join primitive.
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Relative to the process's cwd — the app root for every `x` command. `.x/` is gitignored, and in
|
|
14
|
+
* a container it is the STATE directory a topology mounts a tmpfs over, so an image's own entries
|
|
15
|
+
* live elsewhere: the image build points this cache at its prebuilt store through
|
|
16
|
+
* `setSassCacheDir`, and the container's boot points it back at the same place.
|
|
17
|
+
*/
|
|
13
18
|
export const SASS_CACHE_DIR = join('.x', 'cache', 'sass');
|
|
14
19
|
|
|
15
20
|
/** Bumped when the entry shape changes, so an old entry is a miss and never a misread. */
|
|
@@ -153,11 +158,25 @@ export function cachedSassCompile(
|
|
|
153
158
|
assetsValid: AssetCheck = () => false,
|
|
154
159
|
): SassOutput {
|
|
155
160
|
const dir = cacheDir();
|
|
156
|
-
if (dir === null) return compile
|
|
161
|
+
if (dir === null) return counted(compile);
|
|
157
162
|
const file = join(dir, `${sha256(key)}.json`);
|
|
158
163
|
const hit = readHit(file, assetsValid);
|
|
159
164
|
if (hit !== undefined) return hit;
|
|
160
|
-
const output = compile
|
|
165
|
+
const output = counted(compile);
|
|
161
166
|
store(dir, file, output);
|
|
162
167
|
return output;
|
|
163
168
|
}
|
|
169
|
+
|
|
170
|
+
let compilations = 0;
|
|
171
|
+
|
|
172
|
+
const counted = (compile: () => SassOutput): SassOutput => {
|
|
173
|
+
compilations += 1;
|
|
174
|
+
return compile();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How many times this process ran Sass rather than reading a stored result. A container booted
|
|
179
|
+
* from a prebuilt image answers 0; anything else is a boot that compiled what the image build
|
|
180
|
+
* should have, and the number is what its log line reports.
|
|
181
|
+
*/
|
|
182
|
+
export const sassCompilations = (): number => compilations;
|
package/src/server.ts
CHANGED
|
@@ -26,6 +26,7 @@ export {
|
|
|
26
26
|
// ---- the two Bun loaders: `.tsx` → the server JSX factory, `.scss` → css + a class map ----------
|
|
27
27
|
export type { Stylesheet } from './module-loader';
|
|
28
28
|
export {
|
|
29
|
+
claimStylesheets,
|
|
29
30
|
clearStylesheets,
|
|
30
31
|
installRenderLoader,
|
|
31
32
|
loadStylesheet,
|
|
@@ -47,6 +48,7 @@ export type {
|
|
|
47
48
|
} from './render-isr';
|
|
48
49
|
export {
|
|
49
50
|
createIsrController,
|
|
51
|
+
DEFAULT_ISR_REGENERATE_DEADLINE_MS,
|
|
50
52
|
ISR_LOCALE_PARAM,
|
|
51
53
|
invalidateAndRevalidate,
|
|
52
54
|
isrKey,
|
|
@@ -76,3 +78,5 @@ export {
|
|
|
76
78
|
revealChunk,
|
|
77
79
|
streamResult,
|
|
78
80
|
} from './render-stream';
|
|
81
|
+
// ---- the Sass compile cache: where it lives, and how often this process ran Sass instead --------
|
|
82
|
+
export { sassCompilations, setSassCacheDir } from './sass-cache';
|
package/src/static-path.ts
CHANGED
|
@@ -35,6 +35,19 @@ const checked = (pattern: string, param: string, segment: string): string => {
|
|
|
35
35
|
return segment;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A param as text, `undefined` when absent. Typed `string` but filled from app data at runtime:
|
|
40
|
+
* `{ id: 7 }` reached `.split()` and `for…of` as a number and left the build as a bare `TypeError`.
|
|
41
|
+
*/
|
|
42
|
+
const paramText = (pattern: string, name: string, params: RouteParams): string | undefined => {
|
|
43
|
+
if (typeof params !== 'object' || params === null) {
|
|
44
|
+
return refuse(pattern, name, params, 'is missing — the params are not an object');
|
|
45
|
+
}
|
|
46
|
+
const value: unknown = Object.hasOwn(params, name) ? params[name] : undefined;
|
|
47
|
+
if (value === undefined || typeof value === 'string') return value;
|
|
48
|
+
return refuse(pattern, name, value, 'is not text — a path segment is a string');
|
|
49
|
+
};
|
|
50
|
+
|
|
38
51
|
/**
|
|
39
52
|
* The raw (decoded) segments, validated. `:name` must be present and one segment; `*name` may span
|
|
40
53
|
* several (`a/b`) and may be absent, but none of its parts may be a dot segment or unsafe.
|
|
@@ -45,7 +58,7 @@ export function filledSegments(pattern: string, params: RouteParams): readonly s
|
|
|
45
58
|
if (segment === '') continue;
|
|
46
59
|
if (segment.startsWith(':')) {
|
|
47
60
|
const name = segment.slice(1);
|
|
48
|
-
const value =
|
|
61
|
+
const value = paramText(pattern, name, params);
|
|
49
62
|
if (value === undefined || value === '') {
|
|
50
63
|
return refuse(
|
|
51
64
|
pattern,
|
|
@@ -59,7 +72,7 @@ export function filledSegments(pattern: string, params: RouteParams): readonly s
|
|
|
59
72
|
}
|
|
60
73
|
if (segment.startsWith('*')) {
|
|
61
74
|
const name = segment.slice(1);
|
|
62
|
-
const value =
|
|
75
|
+
const value = paramText(pattern, name, params) ?? '';
|
|
63
76
|
for (const part of value.split('/')) if (part !== '') out.push(checked(pattern, name, part));
|
|
64
77
|
continue;
|
|
65
78
|
}
|
package/src/type-pins.tsx
CHANGED
|
@@ -110,9 +110,10 @@ export function _IslandIsAJsxComponent(): unknown {
|
|
|
110
110
|
/**
|
|
111
111
|
* Every key a route may declare a BUDGET for is a key the descriptor projects.
|
|
112
112
|
*
|
|
113
|
-
* Both sides are derived, so neither can move alone: `budgetJs
|
|
114
|
-
*
|
|
115
|
-
* `budget.css`, `budget.cls` and
|
|
113
|
+
* Both sides are derived, so neither can move alone: `budgetJs` on `RouteDescriptor` gives `'js'`,
|
|
114
|
+
* and anything `RouteBudget` declares beyond that fails this line by name. `budget.lcp` left the
|
|
115
|
+
* contract with its descriptor field, measured by nothing. `budget.css`, `budget.cls` and
|
|
116
|
+
* `budget.tbt` were declared on the contract for four majors, were
|
|
116
117
|
* flattened away by `registerRoute`, and reached no reader anywhere — so an author writing
|
|
117
118
|
* `budget: { cls: 0.1 }` was ignored in silence while `x verify`'s `budgets` step, the one thing
|
|
118
119
|
* that exists to enforce a budget, reported green. `scripts/declaration-readers.ts` is the rule
|