@ultimat3/render 1.1.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/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 { surfaceOf } from './surfaces';
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
- const surface = surfaceOf(normalized);
97
- if (surface === null) {
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 afterSurface = normalized.slice(normalized.indexOf(`${surface}/`) + surface.length + 1);
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
- readonly islands?: readonly string[];
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(input.config, {
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: input.config,
260
+ config,
244
261
  suspenseBoundaries,
245
- islands: input.islands ?? [],
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 !== undefined) params[key] = decodeURIComponent(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
- export function memoryIsrStore(): IsrStore {
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
- registerRevalidator((path) => {
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
  };
@@ -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
- readonly resolve: () => Promise<string>;
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
- controller.close();
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
- controller.close();
138
+ close();
100
139
  }
101
140
  };
102
141
 
103
142
  for (const hole of plan.holes) {
104
- void hole
105
- .resolve()
106
- .then(
107
- (html) => {
108
- write(revealChunk(hole.id, html));
109
- },
110
- (error: unknown) => {
111
- logger.warn(
112
- `stream hole ${hole.id} rejected: ${error instanceof Error ? error.message : String(error)}`,
113
- );
114
- write(revealChunk(hole.id, errorFallback(hole.id)));
115
- },
116
- )
117
- .then(settle, settle);
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
+ }