@ultimat3/render 20.2.1 → 22.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 +85 -88
- package/README.md +31 -9
- package/package.json +6 -6
- package/src/client-scope-tag.ts +49 -0
- package/src/client-sync-tags.ts +44 -0
- package/src/css-modules.ts +16 -3
- package/src/duration.ts +9 -8
- package/src/hydrate.ts +43 -12
- package/src/index.ts +21 -9
- package/src/modes.ts +11 -10
- package/src/module-loader.ts +3 -0
- package/src/registry.ts +12 -0
- package/src/render-isr-store.ts +93 -0
- package/src/render-isr.ts +24 -93
- package/src/render-static.ts +19 -22
- package/src/render-stream.ts +14 -14
- package/src/server.ts +2 -6
- package/src/static-path.ts +77 -0
- package/src/stream-scripts.ts +18 -0
- package/src/surfaces.ts +38 -24
package/src/hydrate.ts
CHANGED
|
@@ -135,9 +135,14 @@ function hush(){}
|
|
|
135
135
|
// `hush` above: `boot` rethrows, so every runtime below has to terminate the chain it starts or
|
|
136
136
|
// the page reports an unhandled rejection for a failure it already recorded on the element.
|
|
137
137
|
|
|
138
|
+
// `idle` hydrates on the browser's schedule, not the visitor's, so its server-rendered controls
|
|
139
|
+
// are pressable for the idle wait plus the chunk's download — up to IDLE_HYDRATE_TIMEOUT_MS and
|
|
140
|
+
// more. A press in that window reached a node with no handler and was lost, which is every app's
|
|
141
|
+
// first click. It catches up exactly as `interaction` does, through the one `catchUp` below; the
|
|
142
|
+
// only difference left between the two is that `idle` also boots when the browser goes idle.
|
|
138
143
|
const RUNTIME_IDLE = `
|
|
139
144
|
each('[data-x-hydrate="idle"]',function(el){
|
|
140
|
-
var go=
|
|
145
|
+
var go=catchUp(el);
|
|
141
146
|
if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:${IDLE_HYDRATE_TIMEOUT_MS}});else setTimeout(go,1)})
|
|
142
147
|
`.trim();
|
|
143
148
|
|
|
@@ -171,7 +176,16 @@ io.observe(el)})
|
|
|
171
176
|
// root is never visited. The root is the last resort, not the repair: it is where an event with no
|
|
172
177
|
// coordinates goes (a `keydown` has no `clientX`), and where a hit landing outside this island goes
|
|
173
178
|
// — synthesizing a click on an element the visitor never pressed is worse than losing the replay.
|
|
174
|
-
// `typeof` and not `ev.clientX||ev.clientY`, because (0, 0) is a coordinate
|
|
179
|
+
// `typeof` and not `ev.clientX||ev.clientY`, because (0, 0) is a coordinate — but not when
|
|
180
|
+
// `ev.detail` is 0: a keyboard-activated or scripted `click()` fires at (0, 0) and names no point,
|
|
181
|
+
// so hit-testing the page's corner found nothing of the island and the press went to the root.
|
|
182
|
+
//
|
|
183
|
+
// Between the hit test and the root sits the STRUCTURAL answer: `path` records, when the event is
|
|
184
|
+
// caught, the child-element indices from the root to the target plus the target's tag, and `aim`
|
|
185
|
+
// walks the same indices in the mounted tree. Same tag at the same place is the island's own render
|
|
186
|
+
// of the control that was pressed — which is what a keyboard press, a `keydown`, and a hit landing
|
|
187
|
+
// on a sticky header all lack otherwise. A different tag there is a different control and falls
|
|
188
|
+
// through to the root, for the stranger rule above.
|
|
175
189
|
//
|
|
176
190
|
// `off` is BOTH arms of the `then`, and the rejection arm is the reason it is a named function.
|
|
177
191
|
// `boot` rethrows on purpose (see the prelude), so `el.__x` holds a rejected promise from the
|
|
@@ -181,18 +195,32 @@ io.observe(el)})
|
|
|
181
195
|
// grew by one retained `Event` — each holding a live `target` — per click, for an island that
|
|
182
196
|
// will never mount. Swallowing here loses no signal: the DOM already carries the failure as
|
|
183
197
|
// `data-x-failed`, which is the documented observable.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
each
|
|
189
|
-
|
|
198
|
+
//
|
|
199
|
+
// `catchUp(el)` attaches the capture listeners and answers `go`, the one boot-then-flush both
|
|
200
|
+
// strategies call: a caught event calls it, and `idle` calls it again from its idle callback. Every
|
|
201
|
+
// `go` chains on the same `el.__x`, and the first flush empties `q` and sets `done`, so however many
|
|
202
|
+
// ran, each caught event is replayed exactly once and an untouched island still lets go of its
|
|
203
|
+
// listeners at mount — a listener left behind would replay every later click a second time.
|
|
204
|
+
const RUNTIME_CATCH_UP = `
|
|
205
|
+
function path(el,t){var p=[t&&t.tagName];
|
|
206
|
+
for(;t&&t!==el&&t.parentNode;t=t.parentNode)p.unshift(Array.prototype.indexOf.call(t.parentNode.children,t));
|
|
207
|
+
return t===el?p:null}
|
|
208
|
+
function aim(el,ev,p){var t=ev.target;if(t&&el.contains(t))return t;
|
|
209
|
+
var x=ev.clientX,h=typeof x==='number'&&ev.detail!==0?document.elementFromPoint(x,ev.clientY):null;
|
|
210
|
+
if(h&&el.contains(h))return h;
|
|
211
|
+
for(var n=el,i=0;p&&n&&i<p.length-1;i++)n=n.children[p[i]];
|
|
212
|
+
return p&&n&&n.tagName===p[p.length-1]?n:el}
|
|
213
|
+
function catchUp(el){var evs=(el.getAttribute('data-x-events')||'click').split(' ');
|
|
190
214
|
var q=[],done=false;
|
|
191
215
|
var off=function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});q=[]};
|
|
192
|
-
var
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
evs.forEach(function(n){el.addEventListener(n,on,true)})}
|
|
216
|
+
var go=function(){boot(el).then(function(){var r=q;off();
|
|
217
|
+
r.forEach(function(e){var ev=e[0],c=new ev.constructor(ev.type,ev);aim(el,ev,e[1]).dispatchEvent(c)})},off)};
|
|
218
|
+
var on=function(ev){if(done)return;q.push([ev,path(el,ev.target)]);go()};
|
|
219
|
+
evs.forEach(function(n){el.addEventListener(n,on,true)});return go}
|
|
220
|
+
`.trim();
|
|
221
|
+
|
|
222
|
+
const RUNTIME_INTERACTION = `
|
|
223
|
+
each('[data-x-hydrate="interaction"]',catchUp)
|
|
196
224
|
`.trim();
|
|
197
225
|
|
|
198
226
|
const RUNTIME_PARTS: Readonly<Record<Exclude<HydrateStrategy, 'never'>, string>> = {
|
|
@@ -220,6 +248,9 @@ const RUNTIME_ORDER: readonly Exclude<HydrateStrategy, 'never'>[] = HYDRATE_STRA
|
|
|
220
248
|
const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): string =>
|
|
221
249
|
[
|
|
222
250
|
RUNTIME_PRELUDE,
|
|
251
|
+
// Emitted once for whichever of the two catch-up strategies the page uses; a `visible`-only
|
|
252
|
+
// page never pays for it.
|
|
253
|
+
...(needed.has('idle') || needed.has('interaction') ? [RUNTIME_CATCH_UP] : []),
|
|
223
254
|
...RUNTIME_ORDER.filter((strategy) => needed.has(strategy)).map(
|
|
224
255
|
(strategy) => RUNTIME_PARTS[strategy],
|
|
225
256
|
),
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,23 @@ export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/cor
|
|
|
16
16
|
// never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
|
|
17
17
|
// table it prints against.
|
|
18
18
|
export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
|
|
19
|
+
/** The `<meta name="ultimate-scope">` core's `pageClient()` reads, on private documents only. */
|
|
20
|
+
export {
|
|
21
|
+
CLIENT_PERSIST_META,
|
|
22
|
+
CLIENT_SCOPE_META,
|
|
23
|
+
clientPersistTags,
|
|
24
|
+
clientScopeTag,
|
|
25
|
+
documentCarriesScope,
|
|
26
|
+
} from './client-scope-tag';
|
|
27
|
+
/** Where the page's one socket dials, and the worker that hosts it (plan 101, slice 11). */
|
|
28
|
+
export type { ClientSyncHead } from './client-sync-tags';
|
|
29
|
+
export {
|
|
30
|
+
CLIENT_BUILD_META,
|
|
31
|
+
CLIENT_SYNC_META,
|
|
32
|
+
CLIENT_SYNC_WORKER_META,
|
|
33
|
+
clientBootTags,
|
|
34
|
+
clientSyncTags,
|
|
35
|
+
} from './client-sync-tags';
|
|
19
36
|
export { parseTtlMs } from './duration';
|
|
20
37
|
export type { RenderErrorCode } from './errors';
|
|
21
38
|
export {
|
|
@@ -50,15 +67,13 @@ export {
|
|
|
50
67
|
headFromMeta,
|
|
51
68
|
mergeHead,
|
|
52
69
|
renderHead,
|
|
53
|
-
THEME_SCRIPT_MAX_BYTES,
|
|
54
70
|
THEME_STORAGE_KEY,
|
|
55
71
|
themeScript,
|
|
56
72
|
themeScriptBody,
|
|
57
73
|
} from './head';
|
|
58
|
-
export {
|
|
74
|
+
export { seoRenderers } from './head-seo';
|
|
59
75
|
export type { IslandDirective } from './hydrate';
|
|
60
76
|
export {
|
|
61
|
-
DEFAULT_REPLAY_EVENTS,
|
|
62
77
|
emitIslandAttributes,
|
|
63
78
|
emitIslandProps,
|
|
64
79
|
HYDRATE_RUNTIME_BODIES,
|
|
@@ -67,13 +82,10 @@ export {
|
|
|
67
82
|
IDLE_HYDRATE_TIMEOUT_MS,
|
|
68
83
|
ISLAND_FAILED_ATTRIBUTE,
|
|
69
84
|
ISLAND_MOUNTED_ATTRIBUTE,
|
|
70
|
-
requiredStrategies,
|
|
71
85
|
} from './hydrate';
|
|
72
86
|
export type { IslandComponent, IslandDeclaration, IslandNode, IslandSpec } from './island';
|
|
73
87
|
export {
|
|
74
88
|
ISLAND_EXTENSION,
|
|
75
|
-
ISLAND_NODE,
|
|
76
|
-
isEmittableSpecifier,
|
|
77
89
|
isIslandNode,
|
|
78
90
|
island,
|
|
79
91
|
islandModuleId,
|
|
@@ -81,7 +93,7 @@ export {
|
|
|
81
93
|
export type { IslandCollector, IslandCollectorInput } from './island-collector';
|
|
82
94
|
export { createIslandCollector, islandModuleIds } from './island-collector';
|
|
83
95
|
export type { IslandProps, JsonValue } from './island-props';
|
|
84
|
-
export {
|
|
96
|
+
export { ISLAND_PROPS_MAX_BYTES } from './island-props';
|
|
85
97
|
export { parseByteBudget } from './islands';
|
|
86
98
|
export type { JsxComponent, JsxNode, JsxProps } from './jsx';
|
|
87
99
|
export { Fragment, h, isJsxNode, JSX_NODE } from './jsx';
|
|
@@ -106,7 +118,6 @@ export {
|
|
|
106
118
|
describeRoutes,
|
|
107
119
|
ROUTE_FILENAME,
|
|
108
120
|
registerRoute,
|
|
109
|
-
routeCount,
|
|
110
121
|
routeEntries,
|
|
111
122
|
routeFor,
|
|
112
123
|
routePathFromFile,
|
|
@@ -133,7 +144,8 @@ export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './r
|
|
|
133
144
|
export type { RouteComponent } from './route-component';
|
|
134
145
|
export { pageComponentOf } from './route-component';
|
|
135
146
|
export { metaContextFor, routeDataFor } from './route-data';
|
|
136
|
-
export {
|
|
147
|
+
export { routeStatusOf, withStatus } from './route-status';
|
|
148
|
+
export { STREAM_REVEAL_BODIES } from './stream-scripts';
|
|
137
149
|
export type {
|
|
138
150
|
BoundaryRule,
|
|
139
151
|
BoundaryViolation,
|
package/src/modes.ts
CHANGED
|
@@ -225,14 +225,13 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
|
225
225
|
* (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
|
|
226
226
|
* any surface, because the allowance is measured above the baseline and not against it.
|
|
227
227
|
*
|
|
228
|
-
* The number: 17,797 (the heaviest island this repo actually ships) + 1,
|
|
229
|
-
* for one directive
|
|
230
|
-
*
|
|
231
|
-
* app reaches without writing a number down. 20,480 is
|
|
232
|
-
* kilobyte above it
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* strategy pays less; the default is what the budget has to clear.
|
|
228
|
+
* The number: 17,797 (the heaviest island this repo actually ships) + 1,744 (`hydrateRuntimeBytes`
|
|
229
|
+
* for one `idle` directive — `defaultHydrate('app')`, and since 2026-09-22 the costlier of the two
|
|
230
|
+
* runtimes an island gets unasked; `DEFAULT_ISLAND_HYDRATE`'s `'interaction'` is 1,629) =
|
|
231
|
+
* **19,541**. That is the worst case an app reaches without writing a number down. 20,480 is the
|
|
232
|
+
* next whole kilobyte above it, leaving 939 B of headroom and still under 2x 19,541, so a route
|
|
233
|
+
* bundling the same island twice is refused. `island-budget.test.ts` asserts all three. `visible` costs 846, so an island route
|
|
234
|
+
* that declares it pays less; the default is what the budget has to clear.
|
|
236
235
|
*
|
|
237
236
|
* All three grew by 129 B on 2026-08-21 (from 881 / 615 / 687), when the prelude learned to mark a
|
|
238
237
|
* mount's OUTCOME so `x shot` can tell an island that RAN from one that only started loading, and
|
|
@@ -240,8 +239,10 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
|
240
239
|
* `interaction` — when each runtime learned to TERMINATE the promise chain `boot` starts rather
|
|
241
240
|
* than emit one unhandled rejection per user event. `interaction` alone grew a third time on
|
|
242
241
|
* 2026-08-25 (+184 B, `aim`), when the replay learned that the node it was dispatching at had been
|
|
243
|
-
* detached by the mount it was waiting for.
|
|
244
|
-
*
|
|
242
|
+
* detached by the mount it was waiting for. On 2026-09-22 `idle` learned the same replay through a
|
|
243
|
+
* shared `catchUp`, and both learned to aim a keyboard press by the pressed node's path (774 -> 1,744;
|
|
244
|
+
* `interaction` 1,251 -> 1,629), and `idle` became the worst case. The
|
|
245
|
+
* headroom absorbed all four and the conclusion is unchanged, which is the point of stating the
|
|
245
246
|
* arithmetic here rather than the answer alone. It is not
|
|
246
247
|
* derived from Solid's own size on purpose — this package may not import or name `solid-js`
|
|
247
248
|
* (`CLAUDE.md`), so a constant tracking the runtime's version would be a dependency in a comment.
|
package/src/module-loader.ts
CHANGED
|
@@ -152,6 +152,9 @@ export function transformTsx(source: string): string {
|
|
|
152
152
|
*/
|
|
153
153
|
export function loadStylesheet(path: string, source: string): string {
|
|
154
154
|
const compiled = compileStylesheet(path, source);
|
|
155
|
+
// An EMPTY compile unregisters: under `x dev` an edit that deleted every rule left the old entry
|
|
156
|
+
// in place, serving rules the file no longer had until the process restarted.
|
|
157
|
+
if (compiled.css.length === 0 && stylesheets.delete(path)) revision += 1;
|
|
155
158
|
if (compiled.css.length > 0) {
|
|
156
159
|
if (stylesheets.get(path)?.css !== compiled.css) revision += 1;
|
|
157
160
|
stylesheets.set(path, {
|
package/src/registry.ts
CHANGED
|
@@ -211,6 +211,8 @@ export function compilePattern(path: string): CompiledPattern {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
const routes = new Map<string, RouteEntry>();
|
|
214
|
+
/** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
|
|
215
|
+
let described: readonly RouteDescriptor[] | undefined;
|
|
214
216
|
|
|
215
217
|
export interface RegisterRouteInput<TData = RouteData> {
|
|
216
218
|
readonly file: string;
|
|
@@ -290,6 +292,7 @@ export function registerRoute<TData = RouteData>(
|
|
|
290
292
|
...(input.component === undefined ? {} : { component: input.component }),
|
|
291
293
|
};
|
|
292
294
|
routes.set(path, entry as RouteEntry);
|
|
295
|
+
described = undefined;
|
|
293
296
|
return entry;
|
|
294
297
|
}
|
|
295
298
|
|
|
@@ -316,6 +319,7 @@ function withIslandBudget<TData>(config: RouteConfig<TData>, surface: Surface):
|
|
|
316
319
|
|
|
317
320
|
export function clearRoutes(): void {
|
|
318
321
|
routes.clear();
|
|
322
|
+
described = undefined;
|
|
319
323
|
}
|
|
320
324
|
|
|
321
325
|
export function routeCount(): number {
|
|
@@ -337,6 +341,14 @@ export function routeFor(path: string): RouteEntry | undefined {
|
|
|
337
341
|
* Determinism matters because `sw.js` and the sitemap are diffed across deploys.
|
|
338
342
|
*/
|
|
339
343
|
export function describeRoutes(): readonly RouteDescriptor[] {
|
|
344
|
+
// Built once per registry change and handed out as the SAME frozen array: an ISR regeneration
|
|
345
|
+
// looked its route up through this on every request, re-sorting the whole table each time, and
|
|
346
|
+
// a stable identity is what lets `render-isr.ts` compile its matchers once per table.
|
|
347
|
+
described ??= Object.freeze(buildDescriptors());
|
|
348
|
+
return described;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function buildDescriptors(): RouteDescriptor[] {
|
|
340
352
|
return routeEntries().map((entry) => ({
|
|
341
353
|
path: entry.path,
|
|
342
354
|
file: entry.file,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ISR store: what an entry is, the driver seam an app may back with its own storage, and the
|
|
3
|
+
* bounded in-memory default. Split from `render-isr.ts`, which is the controller that reads it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { finiteCount } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
export type IsrState = 'miss' | 'hit' | 'stale';
|
|
9
|
+
|
|
10
|
+
export interface IsrEntry {
|
|
11
|
+
/**
|
|
12
|
+
* The store key: the request's pathname AND its query, params sorted. Not the route's pattern
|
|
13
|
+
* and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
|
|
14
|
+
* keying both as `/blog` served the second visitor the first one's HTML (#171).
|
|
15
|
+
*/
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly html: string;
|
|
18
|
+
readonly hash: string;
|
|
19
|
+
readonly generatedAt: number;
|
|
20
|
+
readonly ttlMs: number | null;
|
|
21
|
+
/** Set by a tag invalidation; independent of the TTL clock. */
|
|
22
|
+
readonly stale: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* What the page answers, 200–599. Optional because an entry can come back from an app's own
|
|
25
|
+
* store, written before this field existed or JSON-round-tripped without it; absent reads as
|
|
26
|
+
* 200, the only status an entry ever had until `withStatus`.
|
|
27
|
+
*/
|
|
28
|
+
readonly status?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IsrStore {
|
|
32
|
+
get(path: string): IsrEntry | undefined;
|
|
33
|
+
set(entry: IsrEntry): void;
|
|
34
|
+
/**
|
|
35
|
+
* Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
|
|
36
|
+
* not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
|
|
37
|
+
* store is entitled to order its eviction by that: the read-modify-write made the STALEST page
|
|
38
|
+
* the newest, so a tag bust protected exactly the pages that most needed regenerating.
|
|
39
|
+
*/
|
|
40
|
+
markStale(path: string): boolean;
|
|
41
|
+
delete(path: string): void;
|
|
42
|
+
paths(): readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How many rendered pages the default store holds. A route table supports `:params` and `*`, so
|
|
47
|
+
`/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
|
|
48
|
+
* included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
|
|
49
|
+
* the process.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
|
|
52
|
+
|
|
53
|
+
export interface MemoryIsrStoreOptions {
|
|
54
|
+
/** Pages retained. The least recently generated goes first. */
|
|
55
|
+
readonly maxEntries?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
59
|
+
// `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
|
|
60
|
+
// cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
|
|
61
|
+
const maxEntries = finiteCount(
|
|
62
|
+
'memoryIsrStore',
|
|
63
|
+
'maxEntries',
|
|
64
|
+
options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
|
|
65
|
+
);
|
|
66
|
+
const map = new Map<string, IsrEntry>();
|
|
67
|
+
return {
|
|
68
|
+
get: (path) => map.get(path),
|
|
69
|
+
set: (entry) => {
|
|
70
|
+
// Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
|
|
71
|
+
// the first key is the least recently generated page.
|
|
72
|
+
map.delete(entry.path);
|
|
73
|
+
map.set(entry.path, entry);
|
|
74
|
+
while (map.size > maxEntries) {
|
|
75
|
+
const oldest = map.keys().next();
|
|
76
|
+
if (oldest.done === true) break;
|
|
77
|
+
map.delete(oldest.value);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
// In place: `map.set` on a key the Map already holds keeps its position, and that position is
|
|
81
|
+
// the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
|
|
82
|
+
markStale: (path) => {
|
|
83
|
+
const entry = map.get(path);
|
|
84
|
+
if (entry === undefined) return false;
|
|
85
|
+
map.set(path, { ...entry, stale: true });
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
delete: (path) => {
|
|
89
|
+
map.delete(path);
|
|
90
|
+
},
|
|
91
|
+
paths: () => [...map.keys()].sort(),
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/render-isr.ts
CHANGED
|
@@ -15,101 +15,16 @@ import {
|
|
|
15
15
|
sampleFence,
|
|
16
16
|
unregisterDependent,
|
|
17
17
|
} from '@ultimat3/cache';
|
|
18
|
-
import {
|
|
18
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
19
19
|
import { parseTtlMs } from './duration';
|
|
20
20
|
import { finiteStatus, isRenderStatus } from './finite-status';
|
|
21
21
|
import type { RouteDescriptor } from './registry';
|
|
22
22
|
import { describeRoutes } from './registry';
|
|
23
|
+
import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
|
|
24
|
+
import { memoryIsrStore } from './render-isr-store';
|
|
23
25
|
import { contentHash, staticHeaders } from './render-static';
|
|
24
26
|
import type { RenderResult } from './route';
|
|
25
27
|
|
|
26
|
-
export type IsrState = 'miss' | 'hit' | 'stale';
|
|
27
|
-
|
|
28
|
-
export interface IsrEntry {
|
|
29
|
-
/**
|
|
30
|
-
* The store key: the request's pathname AND its query, params sorted. Not the route's pattern
|
|
31
|
-
* and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
|
|
32
|
-
* keying both as `/blog` served the second visitor the first one's HTML (#171).
|
|
33
|
-
*/
|
|
34
|
-
readonly path: string;
|
|
35
|
-
readonly html: string;
|
|
36
|
-
readonly hash: string;
|
|
37
|
-
readonly generatedAt: number;
|
|
38
|
-
readonly ttlMs: number | null;
|
|
39
|
-
/** Set by a tag invalidation; independent of the TTL clock. */
|
|
40
|
-
readonly stale: boolean;
|
|
41
|
-
/**
|
|
42
|
-
* What the page answers, 200–599. Optional because an entry can come back from an app's own
|
|
43
|
-
* store, written before this field existed or JSON-round-tripped without it; absent reads as
|
|
44
|
-
* 200, the only status an entry ever had until `withStatus`.
|
|
45
|
-
*/
|
|
46
|
-
readonly status?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface IsrStore {
|
|
50
|
-
get(path: string): IsrEntry | undefined;
|
|
51
|
-
set(entry: IsrEntry): void;
|
|
52
|
-
/**
|
|
53
|
-
* Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
|
|
54
|
-
* not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
|
|
55
|
-
* store is entitled to order its eviction by that: the read-modify-write made the STALEST page
|
|
56
|
-
* the newest, so a tag bust protected exactly the pages that most needed regenerating.
|
|
57
|
-
*/
|
|
58
|
-
markStale(path: string): boolean;
|
|
59
|
-
delete(path: string): void;
|
|
60
|
-
paths(): readonly string[];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* How many rendered pages the default store holds. A route table supports `:params` and `*`, so
|
|
65
|
-
`/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
|
|
66
|
-
* included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
|
|
67
|
-
* the process.
|
|
68
|
-
*/
|
|
69
|
-
export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
|
|
70
|
-
|
|
71
|
-
export interface MemoryIsrStoreOptions {
|
|
72
|
-
/** Pages retained. The least recently generated goes first. */
|
|
73
|
-
readonly maxEntries?: number;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
77
|
-
// `map.size > NaN` is false for every size, so a cap that arrived non-finite is not a bigger
|
|
78
|
-
// cap — it is no cap, and this store is the one thing bounding a crawler over 100k slugs.
|
|
79
|
-
const maxEntries = finiteCount(
|
|
80
|
-
'memoryIsrStore',
|
|
81
|
-
'maxEntries',
|
|
82
|
-
options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES,
|
|
83
|
-
);
|
|
84
|
-
const map = new Map<string, IsrEntry>();
|
|
85
|
-
return {
|
|
86
|
-
get: (path) => map.get(path),
|
|
87
|
-
set: (entry) => {
|
|
88
|
-
// Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
|
|
89
|
-
// the first key is the least recently generated page.
|
|
90
|
-
map.delete(entry.path);
|
|
91
|
-
map.set(entry.path, entry);
|
|
92
|
-
while (map.size > maxEntries) {
|
|
93
|
-
const oldest = map.keys().next();
|
|
94
|
-
if (oldest.done === true) break;
|
|
95
|
-
map.delete(oldest.value);
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
// In place: `map.set` on a key the Map already holds keeps its position, and that position is
|
|
99
|
-
// the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
|
|
100
|
-
markStale: (path) => {
|
|
101
|
-
const entry = map.get(path);
|
|
102
|
-
if (entry === undefined) return false;
|
|
103
|
-
map.set(path, { ...entry, stale: true });
|
|
104
|
-
return true;
|
|
105
|
-
},
|
|
106
|
-
delete: (path) => {
|
|
107
|
-
map.delete(path);
|
|
108
|
-
},
|
|
109
|
-
paths: () => [...map.keys()].sort(),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
28
|
/**
|
|
114
29
|
* The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
|
|
115
30
|
* because `routePathOf` splits a key at its `?`: a `es:/blog` key would match no route, so
|
|
@@ -231,7 +146,7 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
231
146
|
function descriptorFor(key: string): RouteDescriptor | undefined {
|
|
232
147
|
const path = routePathOf(key);
|
|
233
148
|
const table = routes();
|
|
234
|
-
return table.find((r) => r.path === path) ?? table.find((
|
|
149
|
+
return table.find((r) => r.path === path) ?? matchersOf(table).find((m) => m.test(path))?.route;
|
|
235
150
|
}
|
|
236
151
|
|
|
237
152
|
/**
|
|
@@ -413,11 +328,27 @@ function parseWireTag(wire: string): CacheTag {
|
|
|
413
328
|
return { entity: wire.slice(0, split), id: wire.slice(split + 1) };
|
|
414
329
|
}
|
|
415
330
|
|
|
331
|
+
interface RouteMatcher {
|
|
332
|
+
readonly route: RouteDescriptor;
|
|
333
|
+
test(storedPath: string): boolean;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** One compiled set per route TABLE — `describeRoutes()` hands out one array per registry change. */
|
|
337
|
+
const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
|
|
338
|
+
|
|
416
339
|
/** A stored path belongs to a route when the route's pattern matches it. */
|
|
417
|
-
function
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
340
|
+
function matchersOf(table: readonly RouteDescriptor[]): readonly RouteMatcher[] {
|
|
341
|
+
const cached = compiledTables.get(table);
|
|
342
|
+
if (cached !== undefined) return cached;
|
|
343
|
+
const compiled = table.map((route): RouteMatcher => {
|
|
344
|
+
if (!route.path.includes(':') && !route.path.includes('*')) {
|
|
345
|
+
return { route, test: (storedPath) => storedPath === route.path };
|
|
346
|
+
}
|
|
347
|
+
const pattern = new RegExp(`^${route.path.split('/').map(segmentPattern).join('/')}/?$`);
|
|
348
|
+
return { route, test: (storedPath) => pattern.test(storedPath) };
|
|
349
|
+
});
|
|
350
|
+
compiledTables.set(table, compiled);
|
|
351
|
+
return compiled;
|
|
421
352
|
}
|
|
422
353
|
|
|
423
354
|
function segmentPattern(segment: string): string {
|
package/src/render-static.ts
CHANGED
|
@@ -8,12 +8,13 @@ import { 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';
|
|
11
|
+
import { filePathOf, filledSegments, urlPathOf } from './static-path';
|
|
11
12
|
|
|
12
13
|
export interface StaticArtifact {
|
|
13
14
|
readonly path: string;
|
|
14
15
|
readonly params: RouteParams;
|
|
15
16
|
readonly html: string;
|
|
16
|
-
/**
|
|
17
|
+
/** `contentHash` (xxHash32) of the HTML. Stable across machines and across Bun versions. */
|
|
17
18
|
readonly hash: string;
|
|
18
19
|
/** Where the file lands on disk, relative to the build output root. */
|
|
19
20
|
readonly outputPath: string;
|
|
@@ -25,14 +26,15 @@ export type StaticRenderFn = (input: {
|
|
|
25
26
|
readonly params: RouteParams;
|
|
26
27
|
}) => string | Promise<string>;
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* xxHash32 (seed 0) of the UTF-8 bytes, 8 hex characters. Native: FNV-1a in JS measured 134 µs on a
|
|
31
|
+
* 96 kB document against 21 µs here, and every static page, ISR regeneration and CSS module hashes
|
|
32
|
+
* through it. xxHash32 is a SPECIFIED algorithm — the test pins its reference vectors — so the value
|
|
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.
|
|
35
|
+
*/
|
|
29
36
|
export function contentHash(input: string): string {
|
|
30
|
-
|
|
31
|
-
for (let i = 0; i < input.length; i += 1) {
|
|
32
|
-
hash ^= input.charCodeAt(i);
|
|
33
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
34
|
-
}
|
|
35
|
-
return hash.toString(16).padStart(8, '0');
|
|
37
|
+
return Bun.hash.xxHash32(input).toString(16).padStart(8, '0');
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -115,7 +117,8 @@ export async function renderStatic(
|
|
|
115
117
|
|
|
116
118
|
const artifacts: StaticArtifact[] = [];
|
|
117
119
|
for (const params of paramSets) {
|
|
118
|
-
const
|
|
120
|
+
const segments = filledSegments(entry.pattern.source, params);
|
|
121
|
+
const path = urlPathOf(segments);
|
|
119
122
|
let html: string;
|
|
120
123
|
try {
|
|
121
124
|
html = await render({ path, params });
|
|
@@ -131,7 +134,7 @@ export async function renderStatic(
|
|
|
131
134
|
params,
|
|
132
135
|
html,
|
|
133
136
|
hash,
|
|
134
|
-
outputPath:
|
|
137
|
+
outputPath: filePathOf(segments, indexFile),
|
|
135
138
|
headers: staticHeaders(hash, options.buildId),
|
|
136
139
|
});
|
|
137
140
|
}
|
|
@@ -152,17 +155,11 @@ export function staticResult(artifact: StaticArtifact): RenderResult {
|
|
|
152
155
|
return { status: 200, headers: artifact.headers, body: artifact.html };
|
|
153
156
|
}
|
|
154
157
|
|
|
155
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* `/blog/:slug` + `{ slug: 'hello' }` → `/blog/hello`, each segment percent-encoded. A missing
|
|
160
|
+
* param, a dot segment, a separator inside a `:param`, NUL, `?` and `#` are `X_PRERENDER_FAILED`
|
|
161
|
+
* (`static-path.ts`) — they wrote a `:slug` directory, or a file outside the build output.
|
|
162
|
+
*/
|
|
156
163
|
export function fillPath(pattern: string, params: RouteParams): string {
|
|
157
|
-
return (
|
|
158
|
-
pattern
|
|
159
|
-
.split('/')
|
|
160
|
-
.map((segment) => {
|
|
161
|
-
if (segment.startsWith(':')) return params[segment.slice(1)] ?? segment;
|
|
162
|
-
if (segment.startsWith('*')) return params[segment.slice(1)] ?? '';
|
|
163
|
-
return segment;
|
|
164
|
-
})
|
|
165
|
-
.join('/')
|
|
166
|
-
.replace(/\/+$/, '') || '/'
|
|
167
|
-
);
|
|
164
|
+
return urlPathOf(filledSegments(pattern, params));
|
|
168
165
|
}
|
package/src/render-stream.ts
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
15
15
|
import { finiteStatus } from './finite-status';
|
|
16
|
-
import { escapeAttribute
|
|
16
|
+
import { escapeAttribute } from './html';
|
|
17
17
|
import type { RenderResult } from './route';
|
|
18
|
+
import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
|
|
18
19
|
|
|
19
20
|
export interface StreamHole {
|
|
20
21
|
/** Stable within a response; becomes the DOM id, so keep it short. */
|
|
@@ -60,20 +61,14 @@ export function holeMarker(id: string, fallback: string): string {
|
|
|
60
61
|
* The entire client half of out-of-order streaming. Inline, uncompressed, ~200 bytes; it
|
|
61
62
|
* moves a late `<template>`'s content into the placeholder that is already on screen.
|
|
62
63
|
*/
|
|
63
|
-
export const REVEAL_SCRIPT =
|
|
64
|
-
"<script>window.$X=function(i){var t=document.querySelector('template[data-x-hole=\"'+i+'\"]')," +
|
|
65
|
-
's=document.getElementById(i);if(t&&s){s.replaceWith(t.content);t.remove()}}</script>';
|
|
64
|
+
export const REVEAL_SCRIPT = `<script>${REVEAL_BODY}</script>`;
|
|
66
65
|
|
|
67
66
|
export function revealChunk(id: string, html: string): string {
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
// built by `JSON.stringify` so the id is a JS string LITERAL rather than text pasted between two
|
|
71
|
-
// quotes: `a");alert(1);//` closed the call and ran on the page's own origin. `</script` inside
|
|
72
|
-
// it would still end the element, so the raw-text rule applies over the top, as `html.ts` says.
|
|
73
|
-
const argument = escapeRawTextContent(JSON.stringify(key));
|
|
67
|
+
// The id reaches markup ONLY through `escapeAttribute` — never a script — so a quote in it
|
|
68
|
+
// cannot close anything, and no per-hole body exists for a CSP to fail to list.
|
|
74
69
|
return (
|
|
75
|
-
`<template data-x-hole="${escapeAttribute(
|
|
76
|
-
`<script>$
|
|
70
|
+
`<template data-x-hole="${escapeAttribute(holeId(id))}">${html}</template>` +
|
|
71
|
+
`<script>${REVEAL_CALL}</script>`
|
|
77
72
|
);
|
|
78
73
|
}
|
|
79
74
|
|
|
@@ -186,7 +181,7 @@ export function renderStreamHtml(
|
|
|
186
181
|
timeoutMs === null
|
|
187
182
|
? undefined
|
|
188
183
|
: setTimeout(() => {
|
|
189
|
-
logger.warn(
|
|
184
|
+
logger.warn('render.stream.hole_deadline', { hole: hole.id, timeoutMs });
|
|
190
185
|
reveal(errorFallback(hole.id));
|
|
191
186
|
}, timeoutMs);
|
|
192
187
|
// A response nobody is reading must not hold the process open until its deadline.
|
|
@@ -200,7 +195,12 @@ export function renderStreamHtml(
|
|
|
200
195
|
// `renderThrowable`, never `.message`/`String()`: the value is whatever the hole threw,
|
|
201
196
|
// and a read that raises here skips the `reveal` below — the hole never fills and the
|
|
202
197
|
// response is held to its deadline for a failure that was already handled.
|
|
203
|
-
logger
|
|
198
|
+
// A FIELD, never the message: `logger` redacts fields and never `msg`, so a hole's
|
|
199
|
+
// failure text interpolated into the message reached the log past every redactor.
|
|
200
|
+
logger.warn('render.stream.hole_rejected', {
|
|
201
|
+
hole: hole.id,
|
|
202
|
+
error: renderThrowable(error),
|
|
203
|
+
});
|
|
204
204
|
reveal(errorFallback(hole.id));
|
|
205
205
|
},
|
|
206
206
|
);
|