@ultimat3/render 1.2.0 → 2.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 +74 -0
- package/README.md +179 -11
- package/package.json +7 -4
- package/src/css-modules.ts +138 -0
- package/src/errors.ts +98 -0
- package/src/head.ts +49 -9
- package/src/html.ts +141 -0
- package/src/hydrate.ts +20 -4
- package/src/index.ts +57 -1
- package/src/island-collector.ts +148 -0
- package/src/island-props.ts +144 -0
- package/src/island.ts +218 -0
- package/src/islands.ts +8 -1
- package/src/jsx.ts +46 -0
- package/src/modes.ts +28 -3
- package/src/module-loader.ts +144 -0
- package/src/registry.ts +77 -11
- package/src/render-html.ts +149 -0
- package/src/render-isr.ts +64 -4
- package/src/render-stream.ts +80 -17
- package/src/route-component.ts +36 -0
- package/src/route-data.ts +59 -0
- package/src/route.ts +137 -15
- package/src/surfaces.ts +22 -3
- package/src/type-pins.tsx +101 -0
package/src/registry.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
RouteUnnormalizedError,
|
|
12
12
|
SurfaceBoundaryError,
|
|
13
13
|
} from './errors';
|
|
14
|
-
import { assertModeInvariants } from './modes';
|
|
14
|
+
import { assertModeInvariants, defaultIslandBudget } from './modes';
|
|
15
15
|
import type {
|
|
16
16
|
HydrateStrategy,
|
|
17
17
|
OfflineStrategy,
|
|
@@ -21,8 +21,9 @@ import type {
|
|
|
21
21
|
RouteParams,
|
|
22
22
|
} from './route';
|
|
23
23
|
import { isRouteConfig, tagKeys } from './route';
|
|
24
|
+
import type { RouteComponent } from './route-component';
|
|
24
25
|
import type { Surface } from './surfaces';
|
|
25
|
-
import {
|
|
26
|
+
import { locateSurface } from './surfaces';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The one filename a route may carry, per surface. `shared/` is absent on purpose: it is a leaf
|
|
@@ -47,6 +48,11 @@ export interface RouteEntry<TData = RouteData> {
|
|
|
47
48
|
readonly suspenseBoundaries: number;
|
|
48
49
|
readonly islands: readonly string[];
|
|
49
50
|
readonly pattern: CompiledPattern;
|
|
51
|
+
/**
|
|
52
|
+
* The module's page component. Absent for `api/` routes and for a module that exports none —
|
|
53
|
+
* a `spa` shell is the mode that legitimately has no server-rendered body.
|
|
54
|
+
*/
|
|
55
|
+
readonly component?: RouteComponent;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export interface RouteDescriptor {
|
|
@@ -93,16 +99,19 @@ export interface CompiledPattern {
|
|
|
93
99
|
*/
|
|
94
100
|
export function routePathFromFile(file: string): { surface: Surface; path: string } {
|
|
95
101
|
const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
// One reader of the surface segment, and it answers WHERE as well as WHICH. Slicing at
|
|
103
|
+
// `indexOf('app/')` instead matched inside `myapp/`, so `apps/myapp/app/page.tsx` resolved to
|
|
104
|
+
// `/app` rather than `/` — the surface came from an anchored regex and the URL from a substring.
|
|
105
|
+
const located = locateSurface(normalized);
|
|
106
|
+
if (located === null) {
|
|
98
107
|
throw new SurfaceBoundaryError(
|
|
99
108
|
`${file} is not inside a surface directory, so it has no URL and no bundle graph`,
|
|
100
109
|
`move ${file} under site/, app/ or api/`,
|
|
101
110
|
);
|
|
102
111
|
}
|
|
112
|
+
const surface = located.surface;
|
|
103
113
|
|
|
104
|
-
const
|
|
105
|
-
const rawSegments = afterSurface.split('/').filter((s) => s.length > 0);
|
|
114
|
+
const rawSegments = located.rest.split('/').filter((s) => s.length > 0);
|
|
106
115
|
assertRouteFilename(normalized, surface, rawSegments[rawSegments.length - 1]);
|
|
107
116
|
|
|
108
117
|
const urlSegments = rawSegments
|
|
@@ -197,9 +206,14 @@ export interface RegisterRouteInput<TData = RouteData> {
|
|
|
197
206
|
readonly config: RouteConfig<TData>;
|
|
198
207
|
/** Counted from the module's JSX by the build; `stream` requires >= 1. */
|
|
199
208
|
readonly suspenseBoundaries?: number;
|
|
200
|
-
|
|
209
|
+
// No `islands` key: an island is declared by `island()` and reaches the entry through
|
|
210
|
+
// `config.islands`. It was here, undocumented, passed by nothing, and read as
|
|
211
|
+
// `input.islands ?? []` — so the only thing a caller could do with it was un-weigh a
|
|
212
|
+
// declaration. One question, one answer.
|
|
201
213
|
/** Override the convention (locale roots, rewrites). Rarely needed. */
|
|
202
214
|
readonly path?: string;
|
|
215
|
+
/** The page component, resolved from the module by `pageComponentOf`. */
|
|
216
|
+
readonly component?: RouteComponent;
|
|
203
217
|
}
|
|
204
218
|
|
|
205
219
|
/** Register a route and enforce every invariant that needs the surrounding module. */
|
|
@@ -220,8 +234,11 @@ export function registerRoute<TData = RouteData>(
|
|
|
220
234
|
const derived = routePathFromFile(input.file);
|
|
221
235
|
const path = input.path ?? derived.path;
|
|
222
236
|
const suspenseBoundaries = input.suspenseBoundaries ?? 0;
|
|
237
|
+
// Explicit `<TData>`: `isRouteConfig` is a guard over the default `RouteData`, so inference off
|
|
238
|
+
// the narrowed argument would resolve the route's own data generic away here.
|
|
239
|
+
const config = withIslandBudget<TData>(input.config, derived.surface);
|
|
223
240
|
|
|
224
|
-
assertModeInvariants(
|
|
241
|
+
assertModeInvariants(config, {
|
|
225
242
|
file: input.file,
|
|
226
243
|
path,
|
|
227
244
|
surface: derived.surface,
|
|
@@ -240,15 +257,43 @@ export function registerRoute<TData = RouteData>(
|
|
|
240
257
|
file: input.file,
|
|
241
258
|
path,
|
|
242
259
|
surface: derived.surface,
|
|
243
|
-
config
|
|
260
|
+
config,
|
|
244
261
|
suspenseBoundaries,
|
|
245
|
-
|
|
262
|
+
// The declaration is the ONLY source. It was `input.islands ?? []`, which nothing ever passed,
|
|
263
|
+
// so `routeJsBytes`'s "what registration declared" half read `[]` on every route in the
|
|
264
|
+
// framework's history — and keeping the input as a fallback would be a second answer to one
|
|
265
|
+
// question that can only ever weaken it: a caller passing `[]` un-weighs a declared island.
|
|
266
|
+
islands: config.islands.map((spec) => spec.moduleId),
|
|
246
267
|
pattern: compilePattern(path),
|
|
268
|
+
// Spread, never assigned: `exactOptionalPropertyTypes` makes an explicit `undefined` a
|
|
269
|
+
// different answer from an absent key, and every reader tests presence.
|
|
270
|
+
...(input.component === undefined ? {} : { component: input.component }),
|
|
247
271
|
};
|
|
248
272
|
routes.set(path, entry as RouteEntry);
|
|
249
273
|
return entry;
|
|
250
274
|
}
|
|
251
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The half of the derivation `defineRoute` cannot make: a budget is only meaningful against a
|
|
278
|
+
* surface baseline, and the surface is a fact of the file path, which the route table is already
|
|
279
|
+
* the one reader of. `defineRoute` stays the normalizer of everything the declaration alone
|
|
280
|
+
* decides; this fills in the one value that needs the URL.
|
|
281
|
+
*
|
|
282
|
+
* Returns the descriptor untouched unless there is something to derive, so identity is preserved
|
|
283
|
+
* for every route that declared a budget or has no island.
|
|
284
|
+
*/
|
|
285
|
+
function withIslandBudget<TData>(config: RouteConfig<TData>, surface: Surface): RouteConfig<TData> {
|
|
286
|
+
// `'never'` is left bare on purpose: a route that ships no JavaScript has nothing to budget, and
|
|
287
|
+
// a derived ceiling there would paper over the one contradiction `X_ISLAND_NOT_HYDRATED` names.
|
|
288
|
+
if (config.hydrate === 'never') return config;
|
|
289
|
+
if (config.islands.length === 0 || config.budget.js !== undefined) return config;
|
|
290
|
+
const derived: RouteConfig<TData> = {
|
|
291
|
+
...config,
|
|
292
|
+
budget: { ...config.budget, js: defaultIslandBudget(surface) },
|
|
293
|
+
};
|
|
294
|
+
return Object.freeze(derived);
|
|
295
|
+
}
|
|
296
|
+
|
|
252
297
|
export function clearRoutes(): void {
|
|
253
298
|
routes.clear();
|
|
254
299
|
}
|
|
@@ -303,11 +348,32 @@ export function matchRoute(pathname: string): RouteMatch | null {
|
|
|
303
348
|
const match = entry.pattern.regex.exec(pathname);
|
|
304
349
|
if (match === null) continue;
|
|
305
350
|
const params: Record<string, string> = {};
|
|
351
|
+
let undecodable = false;
|
|
306
352
|
entry.pattern.keys.forEach((key, index) => {
|
|
307
353
|
const value = match[index + 1];
|
|
308
|
-
if (value
|
|
354
|
+
if (value === undefined) return;
|
|
355
|
+
const decoded = decodeSegment(value);
|
|
356
|
+
if (decoded === undefined) undecodable = true;
|
|
357
|
+
else params[key] = decoded;
|
|
309
358
|
});
|
|
359
|
+
// A segment that will not decode fails only the branch that would have decoded it, exactly as
|
|
360
|
+
// `@ultimat3/http`'s router already answers: a literal route matching the same text still wins,
|
|
361
|
+
// and a pathname nothing else claims is the 404 it always was.
|
|
362
|
+
if (undecodable) continue;
|
|
310
363
|
return { entry, params };
|
|
311
364
|
}
|
|
312
365
|
return null;
|
|
313
366
|
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* `undefined` for a malformed percent-escape. A pathname is whatever the client typed, and
|
|
370
|
+
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — which escaped
|
|
371
|
+
* `matchRoute` as a 500 and an error-monitor page for somebody's typo.
|
|
372
|
+
*/
|
|
373
|
+
function decodeSegment(value: string): string | undefined {
|
|
374
|
+
try {
|
|
375
|
+
return decodeURIComponent(value);
|
|
376
|
+
} catch {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tree walker: a JSX node in, an HTML string out. It is the one place that knows what a
|
|
3
|
+
* component call means on the server, so every render mode gets the same markup from the same
|
|
4
|
+
* component — `static` at build time, `ssr`/`stream` per request, all through `renderToHtml`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
IslandInvalidError,
|
|
9
|
+
IslandNotHydratedError,
|
|
10
|
+
IslandPropsInvalidError,
|
|
11
|
+
PrerenderFailedError,
|
|
12
|
+
} from './errors';
|
|
13
|
+
import { escapeText, renderAttributes, VOID_ELEMENTS } from './html';
|
|
14
|
+
import { emitIslandAttributes, emitIslandProps } from './hydrate';
|
|
15
|
+
import type { IslandNode } from './island';
|
|
16
|
+
import { isIslandNode } from './island';
|
|
17
|
+
import type { IslandCollector } from './island-collector';
|
|
18
|
+
import { islandWithoutCollector } from './island-collector';
|
|
19
|
+
import type { JsxComponent, JsxProps } from './jsx';
|
|
20
|
+
import { isJsxNode } from './jsx';
|
|
21
|
+
|
|
22
|
+
/** Depth is bounded so a component that renders itself fails with a cause instead of a stack trace. */
|
|
23
|
+
const MAX_DEPTH = 500;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What the walk carries besides depth. One object per render, never module-global: two concurrent
|
|
27
|
+
* requests render different params, and a shared collector would bill one page for the other's JS.
|
|
28
|
+
*/
|
|
29
|
+
export interface RenderHtmlOptions {
|
|
30
|
+
readonly islands?: IslandCollector;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const describe = (error: unknown): string =>
|
|
34
|
+
error instanceof Error ? error.message : String(error);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A thunk is called, not stringified. Solid's reactive reads are accessors (`count()`), and a
|
|
38
|
+
* `children` prop is routinely a function — evaluating it once is exactly the server's job.
|
|
39
|
+
*/
|
|
40
|
+
async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): Promise<string> {
|
|
41
|
+
if (value === null || value === undefined || value === false || value === true) return '';
|
|
42
|
+
if (typeof value === 'string') return escapeText(value);
|
|
43
|
+
if (typeof value === 'number' || typeof value === 'bigint') return escapeText(String(value));
|
|
44
|
+
if (value instanceof Promise) return unwrap(await value, depth, walk);
|
|
45
|
+
// Before the array branch, never after: an island node IS an array — the only object shape the
|
|
46
|
+
// configured `JSX.Element` admits — so the generic branch would render its empty contents and
|
|
47
|
+
// drop the island, its props script and its budget line without a word.
|
|
48
|
+
if (isIslandNode(value)) return renderIsland(value, depth, walk);
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
const parts = await Promise.all(value.map((item) => unwrap(item, depth + 1, walk)));
|
|
51
|
+
return parts.join('');
|
|
52
|
+
}
|
|
53
|
+
if (isJsxNode(value)) return renderNode(value.type, value.props, depth, walk);
|
|
54
|
+
if (typeof value === 'function') return unwrap((value as () => unknown)(), depth + 1, walk);
|
|
55
|
+
return escapeText(String(value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `innerHTML` is the one escape hatch that emits unescaped markup, and it is deliberate: the
|
|
60
|
+
* `<head>` renderers and the streaming reveal chunks are already-serialized HTML, and re-escaping
|
|
61
|
+
* them would print the tags. Nothing else in this file trusts a string.
|
|
62
|
+
*/
|
|
63
|
+
async function renderElement(
|
|
64
|
+
tag: string,
|
|
65
|
+
props: JsxProps,
|
|
66
|
+
depth: number,
|
|
67
|
+
walk: RenderHtmlOptions,
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
const open = `<${tag}${renderAttributes(props)}>`;
|
|
70
|
+
if (VOID_ELEMENTS.has(tag)) return open;
|
|
71
|
+
const raw = (props as { innerHTML?: unknown }).innerHTML;
|
|
72
|
+
const inner =
|
|
73
|
+
raw === undefined || raw === null
|
|
74
|
+
? await unwrap((props as { children?: unknown }).children, depth + 1, walk)
|
|
75
|
+
: String(raw);
|
|
76
|
+
return `${open}${inner}</${tag}>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* An island renders its SHELL on the server — the children the page wrote — and nothing of the
|
|
81
|
+
* client module: the specifier is data, so there is no import to follow and the static page's
|
|
82
|
+
* bundle graph never grows. The props script travels inside the wrapper so a document assembler
|
|
83
|
+
* has exactly one thing left to remember, the runtime.
|
|
84
|
+
*/
|
|
85
|
+
async function renderIsland(
|
|
86
|
+
node: IslandNode,
|
|
87
|
+
depth: number,
|
|
88
|
+
walk: RenderHtmlOptions,
|
|
89
|
+
): Promise<string> {
|
|
90
|
+
const collector = walk.islands;
|
|
91
|
+
if (collector === undefined) throw islandWithoutCollector(node.spec);
|
|
92
|
+
const directive = collector.record(node.spec, node.props);
|
|
93
|
+
const shell = await unwrap((node.props as { children?: unknown }).children, depth + 1, walk);
|
|
94
|
+
const tag = node.spec.tag;
|
|
95
|
+
return `<${tag} ${emitIslandAttributes(directive)}>${shell}${emitIslandProps(directive)}</${tag}>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function renderNode(
|
|
99
|
+
type: string | JsxComponent,
|
|
100
|
+
props: JsxProps,
|
|
101
|
+
depth: number,
|
|
102
|
+
walk: RenderHtmlOptions,
|
|
103
|
+
): Promise<string> {
|
|
104
|
+
if (depth > MAX_DEPTH) {
|
|
105
|
+
throw new PrerenderFailedError(
|
|
106
|
+
`component tree exceeded ${MAX_DEPTH} levels, so it renders itself`,
|
|
107
|
+
'remove the self-reference from the component that renders its own tag',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (typeof type === 'string') return renderElement(type, props, depth, walk);
|
|
111
|
+
return unwrap(type(props), depth + 1, walk);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render a component tree to HTML. Async throughout so a component may await its own data — the
|
|
116
|
+
* `ssr` mode's whole point — without a second sync renderer existing beside this one.
|
|
117
|
+
*/
|
|
118
|
+
export async function renderToHtml(
|
|
119
|
+
node: unknown,
|
|
120
|
+
options: RenderHtmlOptions = {},
|
|
121
|
+
): Promise<string> {
|
|
122
|
+
return unwrap(node, 0, options);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render a route's page component, naming the file in the failure. A component that throws is a
|
|
127
|
+
* build failure with a cause, never a blank body that looks like a routing problem.
|
|
128
|
+
*/
|
|
129
|
+
export async function renderComponent(
|
|
130
|
+
component: JsxComponent,
|
|
131
|
+
props: JsxProps,
|
|
132
|
+
file: string,
|
|
133
|
+
options: RenderHtmlOptions = {},
|
|
134
|
+
): Promise<string> {
|
|
135
|
+
try {
|
|
136
|
+
return await renderToHtml(component(props), options);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// An island failure already names the file, the prop and the edit. Wrapping it would replace
|
|
139
|
+
// three stable codes with one that says only "the component threw".
|
|
140
|
+
if (error instanceof PrerenderFailedError) throw error;
|
|
141
|
+
if (error instanceof IslandInvalidError) throw error;
|
|
142
|
+
if (error instanceof IslandPropsInvalidError) throw error;
|
|
143
|
+
if (error instanceof IslandNotHydratedError) throw error;
|
|
144
|
+
throw new PrerenderFailedError(
|
|
145
|
+
`rendering the component in ${file} threw: ${describe(error)}`,
|
|
146
|
+
`run \`bun test ${file.replace(/\.tsx?$/, '.test.ts')}\` to reproduce, then fix ${file}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/render-isr.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and tag-driven staleness (an action's `invalidates` marks exactly the dependent routes).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { CacheTag } from '@ultimat3/cache';
|
|
8
|
+
import type { CacheTag, Revalidator } from '@ultimat3/cache';
|
|
9
9
|
import {
|
|
10
10
|
dependentsOfKind,
|
|
11
11
|
invalidateTags,
|
|
@@ -38,12 +38,34 @@ export interface IsrStore {
|
|
|
38
38
|
paths(): readonly string[];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* How many rendered pages the default store holds. A route table supports `:params` and `*`, so
|
|
43
|
+
`/blog/:slug` has as many ISR paths as the blog has slugs — 404-shaped ones that still render
|
|
44
|
+
* included. Unbounded, a crawler over 100k slugs is 100k HTML strings resident for the life of
|
|
45
|
+
* the process.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_ISR_MAX_ENTRIES = 1_000;
|
|
48
|
+
|
|
49
|
+
export interface MemoryIsrStoreOptions {
|
|
50
|
+
/** Pages retained. The least recently generated goes first. */
|
|
51
|
+
readonly maxEntries?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
|
|
55
|
+
const maxEntries = options.maxEntries ?? DEFAULT_ISR_MAX_ENTRIES;
|
|
42
56
|
const map = new Map<string, IsrEntry>();
|
|
43
57
|
return {
|
|
44
58
|
get: (path) => map.get(path),
|
|
45
59
|
set: (entry) => {
|
|
60
|
+
// Re-inserted rather than overwritten, so the Map's iteration order IS generation order and
|
|
61
|
+
// the first key is the least recently generated page.
|
|
62
|
+
map.delete(entry.path);
|
|
46
63
|
map.set(entry.path, entry);
|
|
64
|
+
while (map.size > maxEntries) {
|
|
65
|
+
const oldest = map.keys().next();
|
|
66
|
+
if (oldest.done === true) break;
|
|
67
|
+
map.delete(oldest.value);
|
|
68
|
+
}
|
|
47
69
|
},
|
|
48
70
|
delete: (path) => {
|
|
49
71
|
map.delete(path);
|
|
@@ -109,6 +131,15 @@ export interface IsrController {
|
|
|
109
131
|
attach(): () => void;
|
|
110
132
|
}
|
|
111
133
|
|
|
134
|
+
/**
|
|
135
|
+
* `@ultimat3/cache` holds ONE revalidator and offers no read back, so detaching has to know
|
|
136
|
+
* whether the slot is still this controller's — a controller that attached after it owns it now.
|
|
137
|
+
*/
|
|
138
|
+
let installedRevalidator: Revalidator | undefined;
|
|
139
|
+
|
|
140
|
+
/** What `registerRevalidator` is handed on detach: the framework's "nothing to revalidate". */
|
|
141
|
+
const NO_REVALIDATION: Revalidator = () => undefined;
|
|
142
|
+
|
|
112
143
|
export function createIsrController(options: IsrControllerOptions = {}): IsrController {
|
|
113
144
|
const store = options.store ?? memoryIsrStore();
|
|
114
145
|
const now = options.now ?? (() => Date.now());
|
|
@@ -135,6 +166,23 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
135
166
|
registered.add(path);
|
|
136
167
|
}
|
|
137
168
|
|
|
169
|
+
/**
|
|
170
|
+
* A registration is only true while the store still holds the page. The store evicts silently
|
|
171
|
+
* and offers no callback — and a custom `IsrStore` need not have one at all — so the store's own
|
|
172
|
+
* `paths()` is the authority, reconciled after every generation. Left alone, `registered` and
|
|
173
|
+
* the cache graph behind it only ever grew: `/blog/:slug` retains one edge per slug ever
|
|
174
|
+
* requested, 404-shaped ones included, for the life of the process.
|
|
175
|
+
*/
|
|
176
|
+
function forgetEvictedPaths(): void {
|
|
177
|
+
if (registered.size === 0) return;
|
|
178
|
+
const live = new Set(store.paths());
|
|
179
|
+
for (const path of registered) {
|
|
180
|
+
if (live.has(path)) continue;
|
|
181
|
+
unregisterDependent({ kind: 'isr-route', id: path });
|
|
182
|
+
registered.delete(path);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
138
186
|
function isFresh(entry: IsrEntry): boolean {
|
|
139
187
|
if (entry.stale) return false;
|
|
140
188
|
if (entry.ttlMs === null) return true; // tag-only revalidation: fresh until invalidated
|
|
@@ -158,6 +206,7 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
158
206
|
};
|
|
159
207
|
store.set(entry);
|
|
160
208
|
registerPath(path, descriptor);
|
|
209
|
+
forgetEvictedPaths();
|
|
161
210
|
return entry;
|
|
162
211
|
})();
|
|
163
212
|
|
|
@@ -223,12 +272,23 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
|
|
|
223
272
|
|
|
224
273
|
attach() {
|
|
225
274
|
// The cache fanout owns the trigger; render owns only "what does stale mean here".
|
|
226
|
-
|
|
275
|
+
const revalidate: Revalidator = (path) => {
|
|
227
276
|
markStale(path);
|
|
228
|
-
}
|
|
277
|
+
};
|
|
278
|
+
registerRevalidator(revalidate);
|
|
279
|
+
installedRevalidator = revalidate;
|
|
229
280
|
return () => {
|
|
230
281
|
for (const path of registered) unregisterDependent({ kind: 'isr-route', id: path });
|
|
231
282
|
registered.clear();
|
|
283
|
+
// Left installed, this closure — and the whole store behind it — stayed reachable from
|
|
284
|
+
// the cache graph and kept receiving revalidations. `x dev`'s hot reload detached A and
|
|
285
|
+
// created B, and `invalidateTags` still called A's `markStale`: B's pages never went
|
|
286
|
+
// stale and A's store was never collected. Only if the slot is still OURS: a controller
|
|
287
|
+
// that attached after us owns it, and clearing that one is this bug pointed backwards.
|
|
288
|
+
if (installedRevalidator === revalidate) {
|
|
289
|
+
registerRevalidator(NO_REVALIDATION);
|
|
290
|
+
installedRevalidator = undefined;
|
|
291
|
+
}
|
|
232
292
|
};
|
|
233
293
|
},
|
|
234
294
|
};
|
package/src/render-stream.ts
CHANGED
|
@@ -19,7 +19,11 @@ export interface StreamHole {
|
|
|
19
19
|
readonly id: string;
|
|
20
20
|
/** Rendered synchronously into the first flush (the `<Suspense fallback>`). */
|
|
21
21
|
readonly fallback: string;
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* `signal` aborts when the response is cancelled — a client that disconnected mid-stream. A
|
|
24
|
+
* hole that ignores it still finishes; it just finishes into a document nobody reads.
|
|
25
|
+
*/
|
|
26
|
+
readonly resolve: (signal: AbortSignal) => Promise<string>;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export interface StreamPlan {
|
|
@@ -56,10 +60,25 @@ export function revealChunk(id: string, html: string): string {
|
|
|
56
60
|
return `<template data-x-hole="${key}">${html}</template><script>$X("${key}")</script>`;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* How long one hole may take before it is treated as failed. A hole is application code the
|
|
65
|
+
* framework `await`s — a query with no statement timeout, a fetch with no `AbortSignal.timeout` —
|
|
66
|
+
* and nothing else in the system bounds one: `settle` fires from the hole's own promise, so a
|
|
67
|
+
* promise that never settles holds the response, the socket and everything its closure retains
|
|
68
|
+
* for the life of the process. Long enough that a slow page still renders, short enough that a
|
|
69
|
+
* hung one is a fallback rather than a leak.
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_HOLE_TIMEOUT_MS = 15_000;
|
|
72
|
+
|
|
59
73
|
export interface StreamOptions {
|
|
60
74
|
readonly buildId: string;
|
|
61
75
|
/** Rendered into a hole whose promise rejected. Keep it a token-styled inline block. */
|
|
62
76
|
readonly errorFallback?: (holeId: string) => string;
|
|
77
|
+
/**
|
|
78
|
+
* Per-hole deadline in ms. `null` waits forever — the deliberate opt-out for a hole that owns
|
|
79
|
+
* its own timeout, never the default, because "forever" is not a deadline anyone chose.
|
|
80
|
+
*/
|
|
81
|
+
readonly holeTimeoutMs?: number | null;
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/**
|
|
@@ -75,20 +94,40 @@ export function renderStreamHtml(
|
|
|
75
94
|
const tail = plan.tail ?? '</body></html>';
|
|
76
95
|
const errorFallback =
|
|
77
96
|
options.errorFallback ?? ((id) => `<div data-x-hole-error="${id}" hidden></div>`);
|
|
97
|
+
const timeoutMs =
|
|
98
|
+
options.holeTimeoutMs === undefined ? DEFAULT_HOLE_TIMEOUT_MS : options.holeTimeoutMs;
|
|
99
|
+
/**
|
|
100
|
+
* The response's own lifetime. A client that disconnects mid-stream cancels the stream, and
|
|
101
|
+
* both halves of that have to be honoured: nothing more may be enqueued — `settle`'s
|
|
102
|
+
* `write(tail)`/`close()` on a cancelled controller threw out of a `void`ed promise, one
|
|
103
|
+
* unhandled rejection per response — and the holes still running must be told to stop doing
|
|
104
|
+
* their database work for a document nobody will read.
|
|
105
|
+
*/
|
|
106
|
+
const holes = new AbortController();
|
|
107
|
+
let closed = false;
|
|
78
108
|
|
|
79
109
|
return new ReadableStream<Uint8Array>({
|
|
80
110
|
start(controller) {
|
|
81
111
|
const write = (chunk: string): void => {
|
|
112
|
+
// `desiredSize` is null once the controller is closed or errored, which is the half a
|
|
113
|
+
// cancellation flag cannot see on its own.
|
|
114
|
+
if (closed || controller.desiredSize === null) return;
|
|
82
115
|
controller.enqueue(encoder.encode(chunk));
|
|
83
116
|
};
|
|
84
117
|
|
|
118
|
+
const close = (): void => {
|
|
119
|
+
if (closed) return;
|
|
120
|
+
closed = true;
|
|
121
|
+
controller.close();
|
|
122
|
+
};
|
|
123
|
+
|
|
85
124
|
// No holes, no reveal script: a page that streams nothing pays nothing.
|
|
86
125
|
write(plan.head + (plan.holes.length > 0 ? REVEAL_SCRIPT : '') + plan.shell);
|
|
87
126
|
|
|
88
127
|
let pending = plan.holes.length;
|
|
89
128
|
if (pending === 0) {
|
|
90
129
|
write(tail);
|
|
91
|
-
|
|
130
|
+
close();
|
|
92
131
|
return;
|
|
93
132
|
}
|
|
94
133
|
|
|
@@ -96,27 +135,51 @@ export function renderStreamHtml(
|
|
|
96
135
|
pending -= 1;
|
|
97
136
|
if (pending === 0) {
|
|
98
137
|
write(tail);
|
|
99
|
-
|
|
138
|
+
close();
|
|
100
139
|
}
|
|
101
140
|
};
|
|
102
141
|
|
|
103
142
|
for (const hole of plan.holes) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
143
|
+
// One hole reveals exactly once, whichever of the three finishes first: its own promise,
|
|
144
|
+
// its rejection, or its deadline. A late resolve after the deadline writes nothing —
|
|
145
|
+
// the placeholder it would fill was replaced by the fallback and the document is closed.
|
|
146
|
+
let revealed = false;
|
|
147
|
+
const reveal = (html: string): void => {
|
|
148
|
+
if (revealed) return;
|
|
149
|
+
revealed = true;
|
|
150
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
151
|
+
write(revealChunk(hole.id, html));
|
|
152
|
+
settle();
|
|
153
|
+
};
|
|
154
|
+
const timer =
|
|
155
|
+
timeoutMs === null
|
|
156
|
+
? undefined
|
|
157
|
+
: setTimeout(() => {
|
|
158
|
+
logger.warn(`stream hole ${hole.id} missed its ${timeoutMs}ms deadline`);
|
|
159
|
+
reveal(errorFallback(hole.id));
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
// A response nobody is reading must not hold the process open until its deadline.
|
|
162
|
+
timer?.unref?.();
|
|
163
|
+
|
|
164
|
+
void hole.resolve(holes.signal).then(
|
|
165
|
+
(html) => {
|
|
166
|
+
reveal(html);
|
|
167
|
+
},
|
|
168
|
+
(error: unknown) => {
|
|
169
|
+
logger.warn(
|
|
170
|
+
`stream hole ${hole.id} rejected: ${error instanceof Error ? error.message : String(error)}`,
|
|
171
|
+
);
|
|
172
|
+
reveal(errorFallback(hole.id));
|
|
173
|
+
},
|
|
174
|
+
);
|
|
118
175
|
}
|
|
119
176
|
},
|
|
177
|
+
|
|
178
|
+
/** The client went away. Stop enqueueing, and stop the work that was going to be enqueued. */
|
|
179
|
+
cancel(reason: unknown) {
|
|
180
|
+
closed = true;
|
|
181
|
+
holes.abort(reason);
|
|
182
|
+
},
|
|
120
183
|
});
|
|
121
184
|
}
|
|
122
185
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which export of a route module is its page. Named exports only (the repo forbids `default`), and
|
|
3
|
+
* the generators do not agree on one name — `Page`, `HomePage`, `DashboardPage`, `AdminHome` all
|
|
4
|
+
* ship today — so the rule is a fixed precedence, evaluated once, here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { JsxComponent } from './jsx';
|
|
8
|
+
|
|
9
|
+
/** The page component of a route module: a function of props, sync or async. */
|
|
10
|
+
export type RouteComponent = JsxComponent;
|
|
11
|
+
|
|
12
|
+
const isComponentExport = (name: string, value: unknown): value is RouteComponent =>
|
|
13
|
+
typeof value === 'function' && /^[A-Z]/.test(name);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `Page` first, because that is the name `examples/dummy` uses and the one the generators should
|
|
17
|
+
* converge on; then a single `…Page`; then a single capitalised function. Sorted before the last
|
|
18
|
+
* fallback so a module with two components resolves to the same one on every machine.
|
|
19
|
+
*/
|
|
20
|
+
export function pageComponentOf(
|
|
21
|
+
module: Readonly<Record<string, unknown>>,
|
|
22
|
+
): RouteComponent | undefined {
|
|
23
|
+
const components = Object.entries(module)
|
|
24
|
+
.filter(([name, value]) => isComponentExport(name, value))
|
|
25
|
+
.sort(([a], [b]) => a.localeCompare(b)) as readonly (readonly [string, RouteComponent])[];
|
|
26
|
+
if (components.length === 0) return undefined;
|
|
27
|
+
|
|
28
|
+
const exact = components.find(([name]) => name === 'Page');
|
|
29
|
+
if (exact !== undefined) return exact[1];
|
|
30
|
+
|
|
31
|
+
const suffixed = components.filter(([name]) => name.endsWith('Page'));
|
|
32
|
+
const onlySuffixed = suffixed.length === 1 ? suffixed[0] : undefined;
|
|
33
|
+
if (onlySuffixed !== undefined) return onlySuffixed[1];
|
|
34
|
+
|
|
35
|
+
return components[0]?.[1];
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a route's data, once per render. Every consumer that renders a document — `x dev`, the
|
|
3
|
+
* build's prerenderer, every render mode — MUST come through here, because `meta` and the page
|
|
4
|
+
* component have to be given the SAME object. Two resolutions is a `<title>` describing content
|
|
5
|
+
* the body does not contain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
9
|
+
import { useI18n } from '@ultimat3/i18n';
|
|
10
|
+
import { RouteLoadFailedError } from './errors';
|
|
11
|
+
import type { RouteConfig, RouteContext, RouteData, RouteMetaContext } from './route';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The route's data for one render.
|
|
15
|
+
*
|
|
16
|
+
* With no `load` the context itself is the data, which is exactly what `meta` received before
|
|
17
|
+
* `load` existed — so an untouched route keeps reading `data.url` and `data.params` and nothing
|
|
18
|
+
* that shipped has to change. Callers therefore never branch on whether a route declared one.
|
|
19
|
+
*/
|
|
20
|
+
export async function routeDataFor<TData = RouteData>(
|
|
21
|
+
config: RouteConfig<TData>,
|
|
22
|
+
ctx: RouteContext,
|
|
23
|
+
): Promise<TData> {
|
|
24
|
+
// The context IS the data, and `defineRoute`'s `LoadRequirement` is what makes that true: a
|
|
25
|
+
// route whose `meta` reads more than `{ params, url }` cannot compile without a `load`, so
|
|
26
|
+
// `RouteContext` satisfies `TData` on this line. One narrowing assertion backed by a build
|
|
27
|
+
// error, in place of the `as unknown as` that used to hand back any shape a caller named.
|
|
28
|
+
if (config.load === undefined) return ctx as RouteContext & TData;
|
|
29
|
+
try {
|
|
30
|
+
return await config.load(ctx);
|
|
31
|
+
} catch (cause) {
|
|
32
|
+
// Rethrown as-is when it is already one of ours: a loader that failed its OWN way — a policy
|
|
33
|
+
// denial, a missing row — has a better code and a better fix than anything this frame knows,
|
|
34
|
+
// and wrapping it would bury both behind a generic one. Read through core's brand, never a
|
|
35
|
+
// `code` property: an ENOENT off `Bun.file` carries `code: 'ENOENT'`, and the duck-type let
|
|
36
|
+
// every one of them out of here unwrapped — no fix line, and no mention of the route to fix.
|
|
37
|
+
if (isUltimateError(cause)) throw cause;
|
|
38
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
39
|
+
throw new RouteLoadFailedError(
|
|
40
|
+
`load() threw while rendering ${new URL(ctx.url).pathname}: ${detail}`,
|
|
41
|
+
`fix the load function for ${new URL(ctx.url).pathname}, or return a fallback so the page can render`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The argument `meta` is called with. Built here rather than at each call site so `x dev` and the
|
|
48
|
+
* build's prerenderer cannot disagree about what `meta` receives — two builders is a `<title>`
|
|
49
|
+
* that differs between the page a developer sees and the one a crawler gets.
|
|
50
|
+
*
|
|
51
|
+
* `t` is resolved per call from the ambient locale, never captured: a translator held across
|
|
52
|
+
* requests would render every visitor the first one's language.
|
|
53
|
+
*/
|
|
54
|
+
export function metaContextFor<TData = RouteData>(
|
|
55
|
+
ctx: RouteContext,
|
|
56
|
+
data: TData,
|
|
57
|
+
): RouteMetaContext<TData> {
|
|
58
|
+
return { data, params: ctx.params, url: ctx.url, t: useI18n() };
|
|
59
|
+
}
|