@webjsdev/server 0.8.63 → 0.8.65

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.
Files changed (47) hide show
  1. package/index.d.ts +7 -2
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +173 -26
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/router.js +11 -1
  30. package/src/ssr/client-router-flag.js +42 -0
  31. package/src/ssr/document.js +361 -0
  32. package/src/ssr/env-shim.js +51 -0
  33. package/src/ssr/escape.js +29 -0
  34. package/src/ssr/head.js +857 -0
  35. package/src/ssr/preloads.js +195 -0
  36. package/src/ssr/render.js +1481 -0
  37. package/src/ssr/responses.js +201 -0
  38. package/src/ssr.js +28 -2445
  39. package/src/vendor/audit.js +400 -0
  40. package/src/vendor/integrity.js +245 -0
  41. package/src/vendor/jspm.js +348 -0
  42. package/src/vendor/manifest.js +96 -0
  43. package/src/vendor/pins.js +892 -0
  44. package/src/vendor/providers.js +35 -0
  45. package/src/vendor/resolver.js +225 -0
  46. package/src/vendor/scanner.js +132 -0
  47. package/src/vendor.js +29 -2371
@@ -0,0 +1,1481 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { resolve } from 'node:path';
3
+ import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized } from '@webjsdev/core';
4
+ import { vendorPreloadTargets } from '../importmap.js';
5
+ import {
6
+ componentPreloads, deduplicatedPreloads, reachedVendorSpecifiers, toUrlPath,
7
+ } from './preloads.js';
8
+ import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from '../action-seed.js';
9
+ import {
10
+ readRevalidate,
11
+ readHtmlCache,
12
+ HTML_CACHE_MARKER,
13
+ } from '../html-cache.js';
14
+ import { requestedFrameId, extractFrameSubtree } from '../frame-render.js';
15
+ import { makeThenable } from '../thenable-params.js';
16
+ import { buildDocumentParts, wrapInDocument, layoutSegmentPath, pageSegmentPath, boundarySegmentPath, layoutsForBoundary, regionRouteKey, wrapWithChildrenMarker } from './document.js';
17
+ import { escapeHtml } from './escape.js';
18
+ import {
19
+ cachedHtmlResponse, getNonce, htmlResponse, streamingHtmlResponse,
20
+ } from './responses.js';
21
+
22
+
23
+ /**
24
+ * Downgrade a PARTIAL response so no shared cache can store it (#1140).
25
+ *
26
+ * A reduced `X-Webjs-Have` body is sliced by a REQUEST header, so it is valid
27
+ * only for a client that sent it. It is marked `Vary` for that header, but
28
+ * `Vary` is not a guarantee in practice: Cloudflare honours only
29
+ * `Accept-Encoding` and several other shared caches are just as selective.
30
+ * Since a reduced body otherwise INHERITS the page's `Cache-Control`, a page
31
+ * that opted into public caching was handing CDNs a chrome-less fragment under
32
+ * the full page's URL, to be served to whoever navigated there next. Measured
33
+ * on webjs.dev: 71,759 bytes for the document versus 47,375 for the fragment,
34
+ * identical `Cache-Control` on both.
35
+ *
36
+ * The reduced path is the ONLY caller. A `<webjs-frame>` subtree is sliced by a
37
+ * request header too, but its response is built without page metadata, so it is
38
+ * already `no-store` and needs no downgrade; that property is locked by a test
39
+ * rather than by a call here. A NEW partial-response shape does not get this
40
+ * treatment for free: call this from its response site.
41
+ *
42
+ * `private` fixes that at the source instead of relying on `Vary` being
43
+ * respected: no shared cache may store it, while the browser that asked for it
44
+ * still may. The freshness directives are preserved, so a returning client
45
+ * keeps whatever reuse the page declared. `Vary` stays too, as belt-and-braces
46
+ * for caches that do honour it.
47
+ *
48
+ * The response KEEPS its validator. `private` forbids shared storage, not
49
+ * validation, so the funnel still attaches an ETag (see conditional-get.js,
50
+ * which stopped excluding `private` for exactly this reason). That matters:
51
+ * the router fetches partials with `cache: 'no-cache'` (#1131), so without a
52
+ * validator every prefetch and soft navigation would re-download the whole
53
+ * fragment instead of being answered 304.
54
+ *
55
+ * @param {Response} res
56
+ */
57
+ export function privateFragment(res) {
58
+ const cc = res.headers.get('cache-control') || '';
59
+ // No header at all is not "nothing to do": a response with no Cache-Control
60
+ // is heuristically storable by a shared cache (RFC 9111), which is precisely
61
+ // the hazard here. Fail CLOSED.
62
+ if (!cc) {
63
+ res.headers.set('cache-control', 'private');
64
+ return;
65
+ }
66
+ // Split on commas that are NOT inside a quoted argument: a directive may
67
+ // carry one (`no-cache="Set-Cookie, X-Foo"`, `private="x-user"`), and a naive
68
+ // split tears those apart.
69
+ const directives = [];
70
+ let buf = '';
71
+ let inQuotes = false;
72
+ for (const ch of cc) {
73
+ if (ch === '"') inQuotes = !inQuotes;
74
+ if (ch === ',' && !inQuotes) { directives.push(buf.trim()); buf = ''; continue; }
75
+ buf += ch;
76
+ }
77
+ directives.push(buf.trim());
78
+
79
+ // `name` is everything before the first `=`, trimmed, so `s-maxage = 600`
80
+ // and `private="x-user"` are recognised as the directives they are.
81
+ const nameOf = (d) => d.split('=')[0].trim().toLowerCase();
82
+ // Already unshareable: leave it exactly as the author wrote it. Only a BARE
83
+ // `private` counts. The qualified form (`private="x-user"`) marks just the
84
+ // NAMED header fields private per RFC 9111, leaving the response itself
85
+ // storable by a shared cache, so it must still be downgraded.
86
+ const isBare = (d) => !d.includes('=');
87
+ if (directives.some((d) => nameOf(d) === 'no-store' || (nameOf(d) === 'private' && isBare(d)))) return;
88
+
89
+ // `private` joins the drop list because a bare one is prepended below: a
90
+ // qualified `private="x-user"` left in place would emit the directive twice,
91
+ // and a cache that resolves a repeat by taking the last occurrence would read
92
+ // the qualified form, which per RFC 9111 leaves the response shared-storable.
93
+ const SHARED_ONLY = new Set(['public', 's-maxage', 'proxy-revalidate', 'private']);
94
+ const kept = directives.filter((d) => d && !SHARED_ONLY.has(nameOf(d)));
95
+ kept.unshift('private');
96
+ res.headers.set('cache-control', kept.join(', '));
97
+ }
98
+
99
+ /**
100
+ * Import a route module. In prod the URL is stable so Node's module cache
101
+ * serves a single evaluation; in dev a cache-bust query forces a fresh
102
+ * evaluation so source edits take effect (which also re-runs the module's
103
+ * top-level side effects, the reason pages/layouts must keep their top level
104
+ * side-effect-free).
105
+ *
106
+ * @param {string} file
107
+ * @param {boolean} dev
108
+ */
109
+ async function loadModule(file, dev) {
110
+ const url = pathToFileURL(file).toString();
111
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
112
+ return import(url + bust);
113
+ }
114
+
115
+ /**
116
+ * Nearest-wins boundary file from a chain projected outermost -> innermost
117
+ * (the router builds these arrays via chainOf). The innermost (last) wins, the
118
+ * same "nearest boundary" rule error.js uses. Returns null for an empty chain.
119
+ * @param {string[] | undefined} files
120
+ * @returns {string | null}
121
+ */
122
+ function nearest(arr) {
123
+ return arr && arr.length ? arr[arr.length - 1] : null;
124
+ }
125
+
126
+ /**
127
+ * @param {import('../router.js').PageRoute} route
128
+ * @param {Record<string,unknown>} ctx
129
+ * @param {boolean} dev
130
+ */
131
+ async function collectMetadata(route, ctx, dev) {
132
+ /** @type {Record<string, any>} */
133
+ let meta = {};
134
+ // Carry the title template forward across layers. Once an outer layout
135
+ // sets `title: { template, default }`, every deeper layer that supplies
136
+ // a plain string title gets it transformed via the template: matching
137
+ // Next.js App Router semantics.
138
+ /** @type {string | null} */
139
+ let titleTemplate = null;
140
+ for (const file of route.metadataFiles) {
141
+ try {
142
+ const mod = await loadModule(file, dev);
143
+ let m = null;
144
+ if (typeof mod.generateMetadata === 'function') {
145
+ m = await mod.generateMetadata(ctx);
146
+ } else if (mod.metadata) {
147
+ m = mod.metadata;
148
+ }
149
+ // Next.js 14+ split `viewport` out of metadata into its own export
150
+ // (with `themeColor`, `colorScheme`). We support both: the new
151
+ // `export const viewport = {…}` shape merges into metadata.viewport
152
+ // as a string at emit time, and existing `metadata.viewport` keeps
153
+ // working. Likewise for `themeColor` and `colorScheme`.
154
+ let vp = null;
155
+ if (typeof mod.generateViewport === 'function') {
156
+ vp = await mod.generateViewport(ctx);
157
+ } else if (mod.viewport) {
158
+ vp = mod.viewport;
159
+ }
160
+ if (vp && typeof vp === 'object') {
161
+ m = { ...(m || {}), _viewport: { ...(m && m._viewport), ...vp } };
162
+ // Allow `themeColor` / `colorScheme` to live on the viewport export.
163
+ if (typeof vp.themeColor === 'string' && !(m && m.themeColor)) {
164
+ m.themeColor = vp.themeColor;
165
+ }
166
+ if (typeof vp.colorScheme === 'string') m.colorScheme = vp.colorScheme;
167
+ }
168
+ if (!m || typeof m !== 'object') continue;
169
+ // Pre-resolve the title for this layer using the inherited template.
170
+ const resolved = { ...m };
171
+ if (m.title !== undefined) {
172
+ const t = m.title;
173
+ if (typeof t === 'string') {
174
+ resolved.title = titleTemplate ? titleTemplate.replace('%s', t) : t;
175
+ } else if (t && typeof t === 'object') {
176
+ // { template, default, absolute }: `absolute` overrides everything;
177
+ // `template` is captured for deeper layers; `default` is the value
178
+ // used when no deeper layer supplies a plain title string.
179
+ if (typeof t.template === 'string') titleTemplate = t.template;
180
+ if (typeof t.absolute === 'string') {
181
+ resolved.title = t.absolute;
182
+ // `absolute` does NOT clear the template: Next.js propagates
183
+ // it for deeper segments below, but the *current* segment is
184
+ // rendered absolute.
185
+ } else if (typeof t.default === 'string') {
186
+ resolved.title = t.default;
187
+ } else {
188
+ delete resolved.title;
189
+ }
190
+ }
191
+ }
192
+ meta = { ...meta, ...resolved };
193
+ } catch {
194
+ // ignore: metadata collection never fails the request
195
+ }
196
+ }
197
+ return meta;
198
+ }
199
+
200
+ /**
201
+ * Like layoutSegmentPath but for `loading.{js,ts}` files. Strips the
202
+ * `loading.ext` filename from the URL path under app/.
203
+ *
204
+ * @param {string} loadingFile
205
+ * @returns {string}
206
+ */
207
+ function loadingSegmentPath(loadingFile) {
208
+ const p = loadingFile
209
+ .replace(/^.*\/app\//, '')
210
+ .replace(/\/?loading\.[jt]sx?$/, '');
211
+ return p === '' ? '/' : '/' + p;
212
+ }
213
+
214
+ /**
215
+ * Render each `loading.{js,ts}` in the route's chain into a hidden
216
+ * `<template id="wj-loading:<segment-path>">`. The client router clones
217
+ * the deepest matching template into the swap slot on nav-start, giving
218
+ * users an instant per-segment skeleton instead of stale content.
219
+ *
220
+ * Each loading file's segment path is the URL prefix it serves: same
221
+ * derivation as layoutSegmentPath but stripping `loading.ext` instead.
222
+ *
223
+ * Errors loading a single file are swallowed so a broken loading.ts in
224
+ * one segment doesn't break the whole response.
225
+ *
226
+ * @param {{ loadings?: string[] }} route
227
+ * @param {Record<string,unknown>} ctx
228
+ * @param {boolean} dev
229
+ * @returns {Promise<string>}
230
+ */
231
+ async function loadingTemplates(route, ctx, dev) {
232
+ if (!route.loadings || route.loadings.length === 0) return '';
233
+ /** @type {string[]} */
234
+ const parts = [];
235
+ for (const file of route.loadings) {
236
+ try {
237
+ const mod = await loadModule(file, dev);
238
+ if (!mod.default) continue;
239
+ const tree = await mod.default(ctx);
240
+ const html = await renderToString(tree, { ssr: true, dev });
241
+ const segmentPath = loadingSegmentPath(file);
242
+ parts.push(`<template id="wj-loading:${segmentPath}">${html}</template>`);
243
+ } catch { /* skip broken loading file */ }
244
+ }
245
+ return parts.join('');
246
+ }
247
+
248
+ /**
249
+ * Wrap a tree in a layout chain, emitting the KEYED boundary comment pair
250
+ * (#1015) around each layout's `${children}`.
251
+ *
252
+ * This is the ONE emitter: the happy path and every boundary render call it, so
253
+ * the segment ids and route-keys they produce cannot drift apart (#1298). Drift
254
+ * there is the worst available outcome, because the client router's scan then
255
+ * finds a mismatched pair and hard-loads anyway, which looks fixed in a diff.
256
+ *
257
+ * `have` is the X-Webjs-Have short-circuit. It is null on every boundary path,
258
+ * so a boundary response is structurally incapable of being reduced.
259
+ *
260
+ * @param {unknown} tree The inner tree (the page, or a boundary in its place).
261
+ * @param {string[]} layouts Layout files, outermost first.
262
+ * @param {Record<string, unknown>} ctx
263
+ * @param {boolean} dev
264
+ * @param {Record<string, string>} params Resolved route params for the keys.
265
+ * @param {Map<string, string> | null} have
266
+ * @returns {Promise<{ tree: unknown, reduced: boolean }>}
267
+ */
268
+ async function wrapLayoutChain(tree, layouts, ctx, dev, params, have) {
269
+ for (let i = layouts.length - 1; i >= 0; i--) {
270
+ const segmentPath = layoutSegmentPath(layouts[i]);
271
+ // Short-circuit ONLY when the client's copy of this layout was rendered
272
+ // for the SAME route-key: a param change at a dynamic layout must
273
+ // re-render the layout's own markup (#1015).
274
+ if (have && have.get(segmentPath) === regionRouteKey(segmentPath, params)) {
275
+ // REDUCED response (#1009): the outer layouts were skipped, so these
276
+ // bytes are only valid for a client that already HAS them. The caller
277
+ // marks the response `private` so no shared cache can store it, and
278
+ // additionally `Vary: X-Webjs-Have` for caches that honour it. The
279
+ // `private` is the guarantee, not the Vary: Cloudflare and others honour
280
+ // only `Accept-Encoding` (#1140).
281
+ return { tree: wrapWithChildrenMarker(tree, segmentPath, params), reduced: true };
282
+ }
283
+ const mod = await loadModule(layouts[i], dev);
284
+ if (!mod.default) continue;
285
+ tree = await mod.default({
286
+ ...ctx,
287
+ children: wrapWithChildrenMarker(tree, segmentPath, params),
288
+ });
289
+ }
290
+ return { tree, reduced: false };
291
+ }
292
+
293
+ /**
294
+ * Render a boundary tree in the PAGE's position, wrapped in the layouts that
295
+ * wrap that boundary, so the response carries the same keyed boundary comments
296
+ * a successful render of this URL would and the client router can soft-swap it
297
+ * (#1298). Before this, a boundary went straight to `wrapInDocument` with no
298
+ * layouts, so it carried no markers at all, the router's scan found no shared
299
+ * boundary, and every navigation into a failing page was a full document load.
300
+ *
301
+ * Buffered on purpose: no `suspenseCtx`, so a `<webjs-suspense>` inside a
302
+ * wrapped layout emits its fallback and never resolves (render-server/stream.js).
303
+ * That is the trade for a boundary response whose status and headers are final
304
+ * before the first byte, which matters more on a 500 than streaming does.
305
+ *
306
+ * A throw from a wrapped layout propagates to the CALLER. That is what makes a
307
+ * throwing layout fall through to the next boundary OUT instead of looping:
308
+ * each step outward wraps a strictly smaller set, so the walk converges.
309
+ *
310
+ * @param {unknown} tree The boundary module's rendered tree.
311
+ * @param {{ file: string, layouts: string[] }} route
312
+ * @param {string} boundaryFile
313
+ * @param {Record<string, unknown>} ctx
314
+ * @param {boolean} dev
315
+ * @returns {Promise<string>}
316
+ */
317
+ async function renderBoundaryInChain(tree, route, boundaryFile, ctx, dev) {
318
+ const params = { ...(/** @type {Record<string,string>} */ (ctx.params) || {}) };
319
+ const wrapLayouts = layoutsForBoundary(route.layouts, boundarySegmentPath(boundaryFile));
320
+ // The boundary occupies the PAGE's region, so it gets the page's own keyed
321
+ // pair, EXCEPT when the page's segment is also a LAYOUT's segment. Then the
322
+ // id is that layout's and emitting it here would be a lie in one of two ways.
323
+ //
324
+ // If that layout is the innermost wrapped one, its children slot already
325
+ // delimits this exact range and two boundaries under one id break keyed
326
+ // pairing: the happy path skips for the same reason.
327
+ //
328
+ // If that layout was EXCLUDED (the boundary sits above it, so it never
329
+ // rendered), the id is worse than redundant. The response would advertise a
330
+ // region under the excluded layout's id with none of its markup inside, the
331
+ // client would then send that id in `X-Webjs-Have`, and the next navigation
332
+ // into that layout's subtree would short-circuit on it and return a fragment
333
+ // that assumes chrome the page never had. The user lands on the next page
334
+ // with that layout missing entirely.
335
+ //
336
+ // So the SET of emitted ids is a subset of the happy path's, never a
337
+ // different set: an id only ever appears here if a layout at that segment
338
+ // rendered, or no layout owns that segment at all.
339
+ const pageSeg = pageSegmentPath(route.file);
340
+ const layoutSegs = new Set((route.layouts || []).map(layoutSegmentPath));
341
+ if (!layoutSegs.has(pageSeg)) tree = wrapWithChildrenMarker(tree, pageSeg, params);
342
+ // MARK a layout-phase failure. The caller has to tell "a wrapped layout
343
+ // threw" apart from "the boundary's own tree is broken", and it cannot infer
344
+ // it by re-rendering: when BOTH are broken the standalone attempt throws too,
345
+ // and an inference would report the layout error under the boundary's label
346
+ // and lose the tree's entirely. The phase is known exactly here, so it is
347
+ // recorded rather than guessed.
348
+ let chain;
349
+ try {
350
+ chain = await wrapLayoutChain(tree, wrapLayouts, ctx, dev, params, null);
351
+ } catch (e) {
352
+ throw markLayoutPhase(e);
353
+ }
354
+ return renderToString(chain.tree, { ssr: true, dev });
355
+ }
356
+
357
+ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
358
+ // Reuse a caller-supplied page module when present. The only producer is the
359
+ // HTML-cache path above, which loads the module to read `export const
360
+ // revalidate` and threads it back so this does not load it a second time.
361
+ // (The removed page `action` export used to be a second producer: it ran in
362
+ // the page module, so the 422 re-render could share that evaluation. A form
363
+ // action lives in a `.server.*` module instead, so the re-render loads the
364
+ // page module itself, exactly as a plain render does.)
365
+ const page = pageModule || await loadModule(route.file, dev);
366
+ if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
367
+ let tree = await page.default(ctx);
368
+
369
+ // If the route has a loading.ts file, wrap the page in a Suspense boundary
370
+ // with the loading content as the fallback. This mirrors NextJs's automatic
371
+ // Suspense wrapping when loading.tsx is present.
372
+ if (route.loadings && route.loadings.length > 0) {
373
+ // Use the innermost (closest) loading file
374
+ const loadingFile = route.loadings[route.loadings.length - 1];
375
+ try {
376
+ const loadingMod = await loadModule(loadingFile, dev);
377
+ if (loadingMod.default) {
378
+ const { Suspense } = await import('@webjsdev/core');
379
+ const fallback = await loadingMod.default(ctx);
380
+ tree = Suspense({ fallback, children: Promise.resolve(tree) });
381
+ }
382
+ } catch { /* loading file failed: skip, render page directly */ }
383
+ }
384
+
385
+ // Resolved route params drive every boundary's route-key. ctx.params is
386
+ // thenable (#848) but its `then` is non-enumerable, so a spread yields the
387
+ // plain param map the route-key derivation expects.
388
+ const params = { ...(/** @type {Record<string,string>} */ (ctx.params) || {}) };
389
+
390
+ // Page-level boundary (#1015). The page gets its own keyed boundary pair
391
+ // whose route-key is its full resolved path, so a dynamic-param change
392
+ // (`/blog/a` -> `/blog/b`) REPLACES (remounts) the page on the client (Next
393
+ // parity) while a shared parent layout (a shorter segment whose key did not
394
+ // change) is preserved. Skipped when the page's segment equals the innermost
395
+ // layout's: the layout's children-slot boundary already delimits that exact
396
+ // range, and two boundaries with one segment id would break keyed pairing.
397
+ const pageSeg = pageSegmentPath(route.file);
398
+ const innermostLayoutSeg = route.layouts && route.layouts.length
399
+ ? layoutSegmentPath(route.layouts[route.layouts.length - 1])
400
+ : null;
401
+ if (pageSeg !== innermostLayoutSeg) {
402
+ tree = wrapWithChildrenMarker(tree, pageSeg, params);
403
+ }
404
+
405
+ // Wrap each layout's `${children}` interpolation in the KEYED boundary
406
+ // comment pair (open `<!--wj:children:<segment>:<route-key>-->`, close
407
+ // `<!--/wj:children:<segment>-->`, #1015). The client router scans both the
408
+ // live and incoming DOM for these boundaries (strict id-matched pairing, no
409
+ // LIFO guessing) and applies the two-tier swap: a changed route-key
410
+ // REPLACES at the PARENT of the shallowest change, else the deepest shared
411
+ // boundary MORPHS. Outer
412
+ // layout DOM (and the scroll position of anything inside it: sidenavs,
413
+ // fixed headers, inner scroll containers) is preserved. Auto-derived from
414
+ // folder structure: no opt-in required from layout authors.
415
+ // X-Webjs-Have optimization: iterate from innermost → outermost and
416
+ // SHORT-CIRCUIT at the first layout whose segment path the client
417
+ // already has rendered. Wrap the accumulated inner tree in that
418
+ // layout's boundary pair (so the client can identify the splice
419
+ // target) and return: outer layouts are not rendered at all,
420
+ // saving CPU and wire bytes.
421
+ // The loop itself lives in wrapLayoutChain, shared with the boundary render
422
+ // path (#1298) so both emit identical segment ids and route-keys.
423
+ const chain = await wrapLayoutChain(tree, route.layouts, ctx, dev, params, have);
424
+ const body = await renderToString(chain.tree, { ssr: true, suspenseCtx });
425
+ return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: chain.reduced };
426
+ }
427
+
428
+ /**
429
+ * Render the framework's DEFAULT boundary body inside the matched route's
430
+ * layout chain (#1298), so a route with no boundary file of its own still
431
+ * keeps its chrome and still soft-navigates.
432
+ *
433
+ * Returns null when the chain cannot render, which degrades to the bare
434
+ * default the caller already holds: this is the last-resort page, so it must
435
+ * never be the thing that fails. A layout crash here is reported like any
436
+ * other rather than being swallowed.
437
+ *
438
+ * @param {string} raw The default body markup.
439
+ * @param {{ dev: boolean, appDir: string, route?: { file: string, layouts: string[] },
440
+ * ctx?: Record<string, unknown>, params?: Record<string, string>, url?: URL }} opts
441
+ * @returns {Promise<{ body: string, moduleUrls: string[] } | null>}
442
+ */
443
+ async function defaultBoundaryInChain(raw, opts) {
444
+ const route = opts.route;
445
+ if (!route || !route.layouts || route.layouts.length === 0) return null;
446
+ const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
447
+ try {
448
+ const params = { ...(/** @type {Record<string,string>} */ (ctx.params) || {}) };
449
+ const chain = await wrapLayoutChain(rawTemplate(raw), route.layouts, ctx, opts.dev, params, null);
450
+ return {
451
+ body: await renderToString(chain.tree, { ssr: true, dev: opts.dev }),
452
+ moduleUrls: boundaryModuleUrls(null, route.layouts, opts),
453
+ };
454
+ } catch (e) {
455
+ reportBoundaryLayoutError(e, opts, { overlay: true });
456
+ return null;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Wrap a raw HTML string as a synthetic TemplateResult so it can go through
462
+ * the layout chain. The string lives in `strings` (static template parts), not
463
+ * `values`, because a hole is HTML-escaped on render and this markup is the
464
+ * framework's own.
465
+ *
466
+ * @param {string} raw
467
+ * @returns {{ _$webjs: 'template', strings: string[], values: unknown[] }}
468
+ */
469
+ function rawTemplate(raw) {
470
+ return { _$webjs: 'template', strings: [raw], values: [] };
471
+ }
472
+
473
+ /**
474
+ * The layouts that will wrap a boundary FILE on this route (#1298). One place,
475
+ * so the render path and the boot set cannot disagree about what wrapped, and
476
+ * so the catch can ask whether anything wrapped at all.
477
+ *
478
+ * @param {{ layouts: string[] }} route
479
+ * @param {string} boundaryFile
480
+ * @returns {string[]}
481
+ */
482
+ function wrapLayoutsFor(route, boundaryFile) {
483
+ return layoutsForBoundary(route.layouts, boundarySegmentPath(boundaryFile));
484
+ }
485
+
486
+ /**
487
+ * Marker for a throw that came from the LAYOUT-wrapping phase of a boundary
488
+ * render, as opposed to the boundary's own tree (#1298). Non-enumerable, so it
489
+ * never shows up in a serialized error, and best-effort: a frozen or primitive
490
+ * throw simply goes unmarked and is treated as a tree failure, which reports it
491
+ * once under the boundary label rather than dropping it.
492
+ */
493
+ const LAYOUT_PHASE = Symbol('webjs.boundaryLayoutPhase');
494
+
495
+ /** @param {unknown} err @returns {unknown} */
496
+ function markLayoutPhase(err) {
497
+ if (err && typeof err === 'object') {
498
+ try { Object.defineProperty(err, LAYOUT_PHASE, { value: true, enumerable: false }); } catch { /* frozen */ }
499
+ }
500
+ return err;
501
+ }
502
+
503
+ /** @param {unknown} err @returns {boolean} */
504
+ function isLayoutPhase(err) {
505
+ return !!(err && typeof err === 'object' && /** @type {any} */ (err)[LAYOUT_PHASE]);
506
+ }
507
+
508
+ /**
509
+ * Render a thrown value as text for a DEV error page, safely.
510
+ *
511
+ * `String(err)` is not total: `String(Object.create(null))` throws on both
512
+ * runtimes, and a getter on a subclass can throw too. Every caller here is
513
+ * already inside a catch whose job is to keep the response alive, so an
514
+ * uncaught throw while FORMATTING the failure escapes the handler and takes
515
+ * the error page down with it, turning a handled 500 into an unhandled one.
516
+ *
517
+ * @param {unknown} err
518
+ * @returns {string}
519
+ */
520
+ function safeErrorText(err) {
521
+ try {
522
+ if (err instanceof Error) return String(err.stack || err.message);
523
+ return String(err);
524
+ } catch {
525
+ return '[unprintable value]';
526
+ }
527
+ }
528
+
529
+ /**
530
+ * A per-request dedup key for a secondary boundary failure: the STAGE it came
531
+ * from plus the error's name, message and construction site. Identity cannot
532
+ * be used, because a layout that
533
+ * constructs its error yields a fresh object each time it is re-run, and the
534
+ * whole point is to collapse exactly those repeats while letting a genuinely
535
+ * different failure through.
536
+ *
537
+ * The CONSTRUCTION SITE (the first stack frame), not the whole stack. The
538
+ * frames below it record how the throw was reached, and the same layout
539
+ * re-rendered around a different boundary is reached differently every time,
540
+ * so a full-stack key would never collapse the repeats it exists for.
541
+ *
542
+ * That leaves the construction site shared by two genuinely different
543
+ * failures that go through one helper, which is why the caller's STAGE is part
544
+ * of the key rather than the error alone: the boundary walk and the
545
+ * `global-error` attempt are different stages, so a shared helper failing in
546
+ * both is reported twice, while one layout failing repeatedly INSIDE the walk
547
+ * is reported once. The stage is what distinguishes them; the stack cannot.
548
+ *
549
+ * Returns null when no safe key can be derived, which means DO NOT DEDUPE.
550
+ * Failing open is the only acceptable direction here: a duplicate report is
551
+ * noise, a dropped one is a crash nobody hears about. That covers a non-Error
552
+ * throw (two unrelated plain objects would otherwise share `[object Object]`)
553
+ * and anything whose property access or stringification throws, which is a
554
+ * real shape: `String(Object.create(null))` throws, and this runs INSIDE the
555
+ * catch that is supposed to keep the response alive, so a throw here would
556
+ * escape `ssrPage` and take the 500 page with it.
557
+ *
558
+ * @param {unknown} err
559
+ * @param {string} stage Which attempt produced this throw. Part of the key.
560
+ * @returns {string | null}
561
+ */
562
+ function boundaryErrorKey(err, stage) {
563
+ if (!(err instanceof Error)) return null;
564
+ try {
565
+ const site = String(err.stack || '').split('\n')[1] || '';
566
+ return `${stage}\u0000${String(err.name)}:${String(err.message)}:${site.trim()}`;
567
+ } catch {
568
+ return null;
569
+ }
570
+ }
571
+
572
+ /** Stage labels, which are part of the dedup key (see boundaryErrorKey). */
573
+ const STAGE_WALK = 'an error boundary or its layout threw while handling a render error';
574
+ const STAGE_GLOBAL_ERROR = 'global-error threw while handling a render error';
575
+ const STAGE_BOUNDARY = 'a boundary module threw or failed to load';
576
+
577
+ /**
578
+ * Report a throw from a layout wrapped around a boundary page (#1298) to the
579
+ * same sinks a page-render error reaches, then let the caller degrade to the
580
+ * standalone render. Best-effort on both sinks: a throwing sink must never
581
+ * affect the response.
582
+ *
583
+ * A CONTROL-FLOW sentinel is not an error and is never reported. This is the
584
+ * same classification the page path makes before it calls `onError` (a
585
+ * `redirect()` / `notFound()` / `forbidden()` / `unauthorized()` is a routing
586
+ * decision, not a crash), and it matters more here: reporting one would fire
587
+ * the app's APM sink and paint a false dev error overlay OVER the 403 the user
588
+ * is looking at.
589
+ *
590
+ * The sentinel is not HONOURED either, and that is deliberate. The status is
591
+ * already decided by the time a boundary renders, and the boundary page IS the
592
+ * answer to this request; letting a layout redirect out of it would replace a
593
+ * 403 the app asked for with a bounce the app did not, on a path where the
594
+ * redirect target could itself be forbidden. So the render degrades to the
595
+ * standalone boundary body, exactly as it does for a real layout crash, and
596
+ * the status the boundary carries is preserved.
597
+ *
598
+ * Deduplicated per request by CAUSE, via the caller's `seen` set. The 500 path
599
+ * tries each boundary in the chain, and a throwing layout that is an ancestor
600
+ * of several of them fails EVERY attempt: `layoutsForBoundary` selects by
601
+ * segment ancestry rather than by boundary depth, so two boundaries at
602
+ * different segments can resolve to the same layout set. Without dedup a root
603
+ * layout that throws is reported once per boundary in the chain.
604
+ *
605
+ * The key is the error's name, message and throw site rather than its identity,
606
+ * because a layout that constructs its error (`throw new Error(...)`) yields a
607
+ * fresh object per attempt. Deduplicating on "any secondary failure already
608
+ * reported" instead would be wrong in the other direction: an inner boundary
609
+ * throwing a TypeError would silence a LATER, unrelated root-layout crash, and
610
+ * would make the `global-error` report below unreachable on every route whose
611
+ * chain contains a real `error.{js,ts}`, since reaching that block at all means
612
+ * an earlier attempt already threw. Distinct causes are each reported once.
613
+ *
614
+ * `overlay` says whether this failure may claim the DEV OVERLAY. The overlay
615
+ * holds one retained frame per URL and the dev handler's slot is last-write
616
+ * wins, so a secondary failure pushed after the original page error would
617
+ * replace the root cause with a symptom, and the replacement would survive a
618
+ * reconnect. So the 500 path passes false (the page error already claimed the
619
+ * slot and is what the developer needs to see), while the 403 / 401 / 404
620
+ * paths pass true, since nothing else claimed it there: their trigger is a
621
+ * control-flow sentinel, which is never reported.
622
+ *
623
+ * @param {unknown} err
624
+ * @param {{ onError?: (e: unknown) => void, onDevError?: (e: unknown) => void }} opts
625
+ * @param {{ what?: string, seen?: Set<string>, overlay?: boolean }} [cfg]
626
+ */
627
+ function reportBoundaryLayoutError(err, opts, cfg = {}) {
628
+ if (isRedirect(err) || isNotFound(err) || isForbidden(err) || isUnauthorized(err)) return;
629
+ if (cfg.seen) {
630
+ const key = boundaryErrorKey(err, cfg.what || '');
631
+ // A null key means no safe key could be derived, so report rather than
632
+ // risk dropping. See boundaryErrorKey.
633
+ if (key !== null) {
634
+ if (cfg.seen.has(key)) return;
635
+ cfg.seen.add(key);
636
+ }
637
+ }
638
+ const what = cfg.what || 'a layout threw while wrapping a boundary page';
639
+ if (typeof opts.onError === 'function') {
640
+ try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
641
+ }
642
+ if (cfg.overlay !== false && typeof opts.onDevError === 'function') {
643
+ try { opts.onDevError(err); } catch { /* a throwing sink must not affect the response */ }
644
+ }
645
+ console.error(`[webjs] ${what}:`, err);
646
+ }
647
+
648
+ /**
649
+ * The boot module set for a boundary response: what actually RENDERED, the
650
+ * boundary module plus the layouts wrapping it (#1298).
651
+ *
652
+ * Applies the SAME inert / import-only substitution as the happy-path boot
653
+ * (#963), so a boundary never ships a route module the normal render drops.
654
+ * Pre-substitution, an import-only layout with a bare `.server.*` import
655
+ * (legal, it never loads client-side) would load here whole and crash the
656
+ * boundary page's boot on the throw-at-load stub, killing the sibling
657
+ * component registrations in the same module script.
658
+ *
659
+ * The substitution applies to the boundary file harmlessly: only page and
660
+ * layout files are fed to the elision analysis, so a boundary is never inert
661
+ * and never import-only, gets no verdict, and is pushed as-is. Every boundary
662
+ * kind is a `browserEntryFiles` entry (browser-entries.js), so each of these
663
+ * URLs is servable through the auth gate.
664
+ *
665
+ * @param {string | null} boundaryFile Null for the framework's default body,
666
+ * which has no module of its own; only the layouts boot then.
667
+ * @param {string[]} wrapLayouts
668
+ * @param {{ appDir: string, inertRouteModules?: Set<string>,
669
+ * importOnlyRouteModules?: Map<string, string[]>,
670
+ * instrumentationClient?: string }} opts
671
+ * @returns {string[]}
672
+ */
673
+ function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
674
+ /** @type {string[]} */
675
+ const urls = [];
676
+ const seen = new Set();
677
+ const push = (abs) => {
678
+ const u = toUrlPath(abs, opts.appDir);
679
+ if (!seen.has(u)) { seen.add(u); urls.push(u); }
680
+ };
681
+ for (const f of (boundaryFile ? [boundaryFile, ...wrapLayouts] : [...wrapLayouts])) {
682
+ if (opts.inertRouteModules && opts.inertRouteModules.has(f)) continue;
683
+ const emit = opts.importOnlyRouteModules && opts.importOnlyRouteModules.get(f);
684
+ if (emit) emit.forEach(push);
685
+ else push(f);
686
+ }
687
+ // instrumentation-client.{js,ts} (#848) rides the SAME first-import contract
688
+ // it has on the happy path: it runs before app modules so the app's client
689
+ // error reporting is installed before anything can throw. A boundary page is
690
+ // where that matters most, so it must not be the one place it is missing.
691
+ // The set is never empty by the time we get here (the boundary file is
692
+ // always pushed above, since only pages and layouts are fed to the elision
693
+ // analysis, so a boundary is never inert and never import-only), which is
694
+ // why this needs no non-empty guard.
695
+ if (opts.instrumentationClient) {
696
+ const u = toUrlPath(opts.instrumentationClient, opts.appDir);
697
+ const i = urls.indexOf(u);
698
+ if (i !== -1) urls.splice(i, 1);
699
+ urls.unshift(u);
700
+ }
701
+ return urls;
702
+ }
703
+
704
+ /**
705
+ * The ctx a boundary module and its wrapped layouts receive (#1298). Same shape
706
+ * as the page render's, so a layout cannot tell a boundary render from a normal
707
+ * one. Boundary modules used to be called with an empty object, which left a
708
+ * wrapped layout with no `params` to build its own links from.
709
+ * @param {Record<string, string> | undefined} params
710
+ * @param {URL | undefined} url
711
+ * @param {unknown} actionData
712
+ */
713
+ function boundaryCtx(params, url, actionData) {
714
+ return {
715
+ params: makeThenable(params || {}),
716
+ searchParams: makeThenable(url ? Object.fromEntries(url.searchParams.entries()) : {}),
717
+ url: url ? url.toString() : '',
718
+ actionData,
719
+ };
720
+ }
721
+
722
+ /**
723
+ * Render a simple boundary page (forbidden / unauthorized, #848) at the given
724
+ * default heading. Loads the nearest boundary module when one exists, else
725
+ * emits the default heading. Mirrors ssrNotFoundHtml.
726
+ *
727
+ * When the caller supplies the matched `route` (#1298), the boundary renders
728
+ * inside the layouts wrapping it, so a 403 / 401 soft-swaps like any other
729
+ * response. Two bounded attempts, never a loop: a throwing layout degrades to
730
+ * the standalone render this has always produced.
731
+ *
732
+ * @param {string | null} file
733
+ * @param {string} heading e.g. '403: Forbidden'
734
+ * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL,
735
+ * route?: { file: string, layouts: string[] },
736
+ * ctx?: Record<string, unknown>, params?: Record<string, string> }} opts
737
+ */
738
+ async function ssrBoundaryHtml(file, heading, opts) {
739
+ let body = `<h1>${heading}</h1>`;
740
+ /** @type {string[]} */
741
+ let moduleUrls = [];
742
+ // No boundary FILE, but a route matched: the framework's own default page is
743
+ // what renders, and it belongs inside the layouts just as a custom one does.
744
+ // This is the COMMON case for 403 / 401, since most apps ship no
745
+ // forbidden.ts, and leaving it bare would mean the headline fix does not
746
+ // apply to them at all. Wrapped in the route's full chain, because the
747
+ // default boundary has no segment of its own and stands in for the page.
748
+ if (!file && opts.route) {
749
+ const wrapped = await defaultBoundaryInChain(`<h1>${heading}</h1>`, opts);
750
+ if (wrapped) { body = wrapped.body; moduleUrls = wrapped.moduleUrls; }
751
+ }
752
+ if (file) {
753
+ const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
754
+ try {
755
+ const mod = await loadModule(file, opts.dev);
756
+ if (mod.default) {
757
+ const tree = await mod.default(ctx);
758
+ try {
759
+ if (opts.route) {
760
+ body = await renderBoundaryInChain(tree, opts.route, file, ctx, opts.dev);
761
+ // The chain rendered, so its modules have to boot: chrome that
762
+ // paints but never hydrates is worse than no chrome, because its
763
+ // controls look live and are not (#1298).
764
+ moduleUrls = boundaryModuleUrls(file, wrapLayoutsFor(opts.route, file), opts);
765
+ } else {
766
+ body = await renderToString(tree, { ssr: true, dev: opts.dev });
767
+ }
768
+ } catch (layoutErr) {
769
+ // When NO LAYOUT actually wrapped this boundary, the try body already
770
+ // ran this exact `renderToString(tree)`: with no route there is no
771
+ // chain at all, and with a route whose wrap set is empty (a legal
772
+ // app with no root layout, since the framework emits the shell)
773
+ // renderBoundaryInChain reduces to the same call. Re-running it would
774
+ // execute the boundary tree a second time to learn nothing, and a
775
+ // tree that is not idempotent under re-render could even SUCCEED on
776
+ // the retry and be reported as a layout crash that never happened.
777
+ // Rethrow: the outer catch reports it once, under the boundary label.
778
+ if (!opts.route || wrapLayoutsFor(opts.route, file).length === 0) throw layoutErr;
779
+ // Always attempt the standalone render, which is the degradation
780
+ // this path has always produced. Its OUTCOME is half the diagnosis
781
+ // and the phase marker is the other half; neither alone is enough.
782
+ //
783
+ // The marker records a layout that threw when it was CALLED. It
784
+ // cannot record a layout that fails while being SERIALIZED (the
785
+ // idiomatic `html`<nav>${getNav()}</nav>${children}`` with a
786
+ // rejecting hole), because by then its output is fused into one tree
787
+ // with the boundary's and `renderToString` cannot say which half
788
+ // threw. That is what the fallback answers: if the boundary renders
789
+ // alone, the fault was layout-side whether or not it was marked.
790
+ let treeErr = null;
791
+ try {
792
+ body = await renderToString(tree, { ssr: true, dev: opts.dev });
793
+ moduleUrls = [];
794
+ } catch (e) {
795
+ treeErr = e;
796
+ }
797
+ if (treeErr === null) {
798
+ // The boundary is fine on its own, so the chain is what failed.
799
+ reportBoundaryLayoutError(layoutErr, opts, { overlay: true });
800
+ } else {
801
+ // The boundary's own tree is broken too. Report a MARKED layout
802
+ // failure as the separate thing it is, then hand the outer catch
803
+ // the tree error: it is what defeated the fallback and what the
804
+ // dev body should show. An UNMARKED one is not reported here,
805
+ // because it may be this same tree error arriving twice.
806
+ if (isLayoutPhase(layoutErr)) {
807
+ reportBoundaryLayoutError(layoutErr, opts, { overlay: false });
808
+ }
809
+ throw treeErr;
810
+ }
811
+ }
812
+ }
813
+ } catch (e) {
814
+ // The boundary module itself threw or failed to load. REPORT it: with
815
+ // the body sanitized below, this is otherwise completely silent in
816
+ // production, on a request that already returned a 4xx to a real user.
817
+ // Sanitizing without reporting moves a failure out of sight rather than
818
+ // out of the response, which is the opposite of the intent.
819
+ reportBoundaryLayoutError(e, opts, { what: STAGE_BOUNDARY, overlay: true });
820
+ // Dev shows the failure; prod shows only the heading. The 500 path has
821
+ // always drawn that line (a thrown error's message is not
822
+ // author-controlled and must not reach the client), and these pages were
823
+ // rendering it in production.
824
+ body = opts.dev
825
+ ? `<h1>${heading}</h1><pre>${escapeHtml(safeErrorText(e))}</pre>`
826
+ : `<h1>${heading}</h1>`;
827
+ moduleUrls = [];
828
+ }
829
+ }
830
+ const nonce = opts.req ? getNonce(opts.req) : undefined;
831
+ return wrapInDocument(body, {
832
+ metadata: { title: heading.replace(/^\d+:\s*/, '') },
833
+ moduleUrls,
834
+ dev: opts.dev,
835
+ nonce,
836
+ });
837
+ }
838
+
839
+ /**
840
+ * Render the 404 page. Same two-attempt wrapping contract as ssrBoundaryHtml
841
+ * (#1298). `opts.route` is absent when no route matched at all (an unrouted
842
+ * URL), and then there is no chain to wrap in, so the response stays the bare
843
+ * document it has always been.
844
+ * @param {string | null} notFoundFile
845
+ * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL,
846
+ * route?: { file: string, layouts: string[] },
847
+ * ctx?: Record<string, unknown>, params?: Record<string, string> }} opts
848
+ */
849
+ async function ssrNotFoundHtml(notFoundFile, opts) {
850
+ let body = '<h1>404: Not found</h1>';
851
+ /** @type {string[]} */
852
+ let moduleUrls = [];
853
+ if (!notFoundFile && opts.route) {
854
+ const wrapped = await defaultBoundaryInChain('<h1>404: Not found</h1>', opts);
855
+ if (wrapped) { body = wrapped.body; moduleUrls = wrapped.moduleUrls; }
856
+ }
857
+ if (notFoundFile) {
858
+ const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
859
+ try {
860
+ const mod = await loadModule(notFoundFile, opts.dev);
861
+ if (mod.default) {
862
+ const tree = await mod.default(ctx);
863
+ try {
864
+ if (opts.route) {
865
+ body = await renderBoundaryInChain(tree, opts.route, notFoundFile, ctx, opts.dev);
866
+ moduleUrls = boundaryModuleUrls(notFoundFile, wrapLayoutsFor(opts.route, notFoundFile), opts);
867
+ } else {
868
+ body = await renderToString(tree, { ssr: true, dev: opts.dev });
869
+ }
870
+ } catch (layoutErr) {
871
+ // Same diagnosis rule as ssrBoundaryHtml above, including the
872
+ // no-layout-wrapped short-circuit.
873
+ if (!opts.route || wrapLayoutsFor(opts.route, notFoundFile).length === 0) throw layoutErr;
874
+ let treeErr = null;
875
+ try {
876
+ body = await renderToString(tree, { ssr: true, dev: opts.dev });
877
+ moduleUrls = [];
878
+ } catch (e) {
879
+ treeErr = e;
880
+ }
881
+ if (treeErr === null) {
882
+ reportBoundaryLayoutError(layoutErr, opts, { overlay: true });
883
+ } else {
884
+ if (isLayoutPhase(layoutErr)) {
885
+ reportBoundaryLayoutError(layoutErr, opts, { overlay: false });
886
+ }
887
+ throw treeErr;
888
+ }
889
+ }
890
+ }
891
+ } catch (e) {
892
+ // Reported for the same reason as in ssrBoundaryHtml above.
893
+ reportBoundaryLayoutError(e, opts, { what: STAGE_BOUNDARY, overlay: true });
894
+ body = opts.dev
895
+ ? `<h1>404: Not found</h1><pre>${escapeHtml(safeErrorText(e))}</pre>`
896
+ : '<h1>404: Not found</h1>';
897
+ moduleUrls = [];
898
+ }
899
+ }
900
+ const nonce = opts.req ? getNonce(opts.req) : undefined;
901
+ return wrapInDocument(body, {
902
+ metadata: { title: 'Not found' },
903
+ moduleUrls,
904
+ dev: opts.dev,
905
+ nonce,
906
+ });
907
+ }
908
+
909
+ /**
910
+ * SSR a matched page route to a Response.
911
+ *
912
+ * Mirrors NextJs semantics:
913
+ * - Page + layout default exports can be async.
914
+ * - `metadata` named export on layouts/pages is merged (page > innermost layout > … > root).
915
+ * - `notFound()` and `redirect()` thrown anywhere in the chain are caught
916
+ * and converted to 404 or 3xx responses.
917
+ * - On a render error we walk up the chain looking for the nearest `error.js`
918
+ * and render that instead (falls back to a plain error page).
919
+ *
920
+ * @param {import('../router.js').PageRoute} route
921
+ * @param {Record<string,string>} params
922
+ * @param {URL} url
923
+ * @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('../module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string>, actionData?: unknown, status?: number, pageModule?: Record<string, unknown>, cspEnabled?: boolean }} opts
924
+ * @returns {Promise<Response>}
925
+ */
926
+ export async function ssrPage(route, params, url, opts) {
927
+ // Server HTML response cache (ISR for no-build, #241). OPT-IN: only a page
928
+ // that declares `export const revalidate = N` is ever cached (the page
929
+ // module export is the single trigger). The page module is loaded ONCE up
930
+ // front to read that window
931
+ // and is threaded back through `opts.pageModule` so renderChain reuses the
932
+ // same evaluation (no double-load). A cache HIT serves the stored HTML
933
+ // without re-running the page function. Skipped entirely (no opt-in read,
934
+ // no double behaviour) for the form-action re-render (actionData / a non-200
935
+ // status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
936
+ // on the request and must not be shared under the full-URL key.
937
+ const cacheEligible =
938
+ !opts.actionData &&
939
+ !opts.status &&
940
+ !opts.pageModule &&
941
+ !(opts.req && opts.req.headers.get('x-webjs-have'));
942
+ let revalidateSeconds = null;
943
+ if (cacheEligible) {
944
+ try {
945
+ const pageMod = await loadModule(route.file, opts.dev);
946
+ opts = { ...opts, pageModule: pageMod };
947
+ revalidateSeconds = readRevalidate(pageMod);
948
+ if (revalidateSeconds !== null) {
949
+ const hit = await readHtmlCache(url);
950
+ if (hit) {
951
+ const cached = cachedHtmlResponse(hit, opts.req, url);
952
+ // A cache hit returns before any seed work runs, so it would otherwise
953
+ // report nothing at all, which reads as "seeding is broken" when it is
954
+ // really the cache answering (#1309). The seed block rides INSIDE the
955
+ // cached bytes, so the seeds are exactly as fresh as the HTML.
956
+ if (opts.dev) cached.headers.set('X-Webjs-Seed', 'html-cache');
957
+ return cached;
958
+ }
959
+ }
960
+ } catch {
961
+ // A load / store failure falls through to a normal fresh render: the
962
+ // cache is an optimization, never a correctness dependency. Leave
963
+ // revalidateSeconds as read so the write path still applies when the
964
+ // page loaded but only the store lookup failed.
965
+ }
966
+ }
967
+
968
+ const ctx = {
969
+ // params / searchParams are awaitable AND synchronously readable (#848):
970
+ // `params.id` still works, `await params` also works (Next 15/16 parity).
971
+ // The `then` is non-enumerable so spread / JSON / Object.keys are unchanged.
972
+ params: makeThenable(params),
973
+ searchParams: makeThenable(Object.fromEntries(url.searchParams.entries())),
974
+ url: url.toString(),
975
+ // Populated only when this render is the re-render after a failed page
976
+ // `action` submission (#244). The page function and every layout receive
977
+ // it so they can surface field errors and repopulate inputs from the
978
+ // user's submitted values. Undefined on a normal GET render, so GET output
979
+ // is byte-identical to before this feature.
980
+ actionData: opts.actionData,
981
+ };
982
+
983
+ // Collect metadata across layouts (outermost first) then page.
984
+ const metadata = await collectMetadata(route, ctx, opts.dev);
985
+
986
+ try {
987
+ const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set(), dev: opts.dev };
988
+ // Parse the partial-nav "have" header from the client. The server walks
989
+ // the target route's layout chain innermost → outermost and
990
+ // SHORT-CIRCUITS at the first FULL match (segment AND route-key),
991
+ // returning only the content below that layout, wrapped in the matched
992
+ // layout's boundary pair. Real wire-byte savings: the outer layouts'
993
+ // HTML is never re-serialized for same-shell navigations.
994
+ const haveHeader = opts.req?.headers.get('x-webjs-have') || '';
995
+ // Entries are `<segment>:<route-key>` (#1015). The route-key is required
996
+ // for a correct short-circuit: a dynamic layout the client holds for
997
+ // OTHER params ('/[org]' rendered for org-a on an org-b navigation) must
998
+ // be re-rendered and re-shipped, or the client's parent-anchored REPLACE
999
+ // has no fresh layout markup to swap in. Split at the LAST ':' (encoded
1000
+ // route-keys contain no ':'; a hand-authored folder name that smuggles a
1001
+ // delimiter through the SEGMENT half can only fail to match, which
1002
+ // degrades to a full render: always correct). An entry with no key (a
1003
+ // malformed or legacy client) is ignored, degrading the same way.
1004
+ /** @type {Map<string, string> | null} */
1005
+ let have = null;
1006
+ if (haveHeader) {
1007
+ have = new Map();
1008
+ for (const entry of haveHeader.split(',')) {
1009
+ const e = entry.trim();
1010
+ if (!e) continue;
1011
+ const cut = e.lastIndexOf(':');
1012
+ if (cut <= 0 || cut === e.length - 1) continue;
1013
+ have.set(e.slice(0, cut), e.slice(cut + 1));
1014
+ }
1015
+ if (have.size === 0) have = null;
1016
+ }
1017
+ // SSR action-result seeding (#472). When enabled, run the whole render
1018
+ // inside an ambient seed collector so every `'use server'` action a
1019
+ // component awaits in `async render()` records its (args -> result) for the
1020
+ // hydration payload. Disabled -> the plain render, byte-identical to before.
1021
+ let seedCollector = null;
1022
+ let body;
1023
+ let reduced = false;
1024
+ if (seedingEnabled()) {
1025
+ const seeded = await collectSeeds(() =>
1026
+ renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule),
1027
+ );
1028
+ body = seeded.value.html;
1029
+ reduced = seeded.value.reduced;
1030
+ seedCollector = seeded.collector;
1031
+ } else {
1032
+ const chain = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
1033
+ body = chain.html;
1034
+ reduced = chain.reduced;
1035
+ }
1036
+
1037
+ // Frame subtree render (#253). A `<webjs-frame src>` self-load (or a
1038
+ // click-driven frame nav) sends `x-webjs-frame: <id>` and applies ONLY the
1039
+ // matching `<webjs-frame id>` subtree from the response, discarding the rest
1040
+ // of the page. So when that header is present AND the requested frame is in
1041
+ // the rendered output (the "isolable" case), return JUST that subtree: the
1042
+ // bytes are extracted verbatim from the same full render, so the result is
1043
+ // BYTE-EQUIVALENT to what the client would slice from a full-page response,
1044
+ // but the full document shell + all the other regions never go on the wire.
1045
+ // The frame swap path (applySwap in router-client.js) parses this body and
1046
+ // does `doc.querySelector('webjs-frame#<id>')`, which finds the lone
1047
+ // subtree exactly as it would in the full page. A streamed (Suspense)
1048
+ // render is skipped (its bytes are not yet final). When the frame id is NOT
1049
+ // found (an auth redirect to a login page, a route that dropped the frame),
1050
+ // we fall through to the normal full-page render, where the client's
1051
+ // existing `webjs:frame-missing` fallback handles the absence. A request
1052
+ // with NO `x-webjs-frame` header never reaches this branch, so a normal
1053
+ // page request is byte-identical to before this feature.
1054
+ const frameId = requestedFrameId(opts.req);
1055
+ if (frameId && suspenseCtx.pending.length === 0) {
1056
+ const subtree = extractFrameSubtree(body, frameId);
1057
+ if (subtree !== null) {
1058
+ const frameRes = htmlResponse(subtree, opts.status || 200, opts.req, url);
1059
+ // Positively mark the response as the SLICED subtree (#1407). This
1060
+ // branch is not the only answer to an `x-webjs-frame` request: a
1061
+ // streamed render skips it (the guard above) and a frame id absent from
1062
+ // the output falls through, and both send a whole document instead. The
1063
+ // client's speculative cache has to tell those apart before it stores a
1064
+ // body under a frame key, and it cannot infer it from the bytes (a page
1065
+ // whose first element is a frame looks exactly like a subtree). A
1066
+ // reduced `X-Webjs-Have` fragment is self-describing via its leading
1067
+ // boundary comment; a frame subtree carries no such marker, so the
1068
+ // server states it.
1069
+ frameRes.headers.set('x-webjs-frame', frameId);
1070
+ // The subtree is sliced by the x-webjs-frame REQUEST header, so a
1071
+ // shared cache must never serve it to a request that did not send
1072
+ // one (the same #1009 poisoning shape as the reduced-have case).
1073
+ frameRes.headers.append('vary', 'X-Webjs-Frame');
1074
+ // #1009: a subtree sliced from a REDUCED render inherits its variance.
1075
+ if (reduced) frameRes.headers.append('vary', 'X-Webjs-Have');
1076
+ // No privateFragment call here on purpose: this response is built
1077
+ // WITHOUT page metadata, so it is already `no-store` and the call
1078
+ // would be dead code with nothing to assert against. The property is
1079
+ // locked by a test instead, which fails if the frame response ever
1080
+ // starts carrying the page's cacheControl.
1081
+ return frameRes;
1082
+ }
1083
+ }
1084
+ // Module URLs for the page + every layout in its chain. These ride
1085
+ // the importmap; the browser fetches each file as it walks the
1086
+ // import graph. Combined with the modulepreload hints below, this
1087
+ // is the Rails 7+ / Hotwire pattern: per-file ESM, no bundling,
1088
+ // HTTP/2 multiplex on the wire.
1089
+ //
1090
+ // Inert route modules (a page or layout that does no client work, even
1091
+ // transitively) are dropped from the boot script: the browser never
1092
+ // downloads them. The SSR'd HTML is the complete output, and
1093
+ // progressive enhancement is unaffected, so a fully-static route ships
1094
+ // zero application JS. The analysis is conservative (anything that
1095
+ // touches the client router, a signal, an event, an npm import, or a
1096
+ // shipping component keeps shipping).
1097
+ //
1098
+ // Import-only route modules (#605) go one step further: a page / layout
1099
+ // whose only client relevance is importing shipping components is itself
1100
+ // dead weight on the client (it never hydrates), so it is dropped and its
1101
+ // component modules are emitted directly in its place. The component set
1102
+ // is the FRONTIER of the analyser's path-aware walk (#963): the shipping
1103
+ // components the module reaches without passing through another shipping
1104
+ // component. A component imported but only conditionally rendered still
1105
+ // registers; one nested behind an emitted component is absent here and
1106
+ // loads via its importer's own imports. Dedup so a component shared
1107
+ // across the page and a layout (or two layouts) is emitted once.
1108
+ const inert = opts.inertRouteModules;
1109
+ const importOnly = opts.importOnlyRouteModules;
1110
+ const moduleUrls = [];
1111
+ {
1112
+ const seen = new Set();
1113
+ const push = (abs) => {
1114
+ const u = toUrlPath(abs, opts.appDir);
1115
+ if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
1116
+ };
1117
+ for (const f of [route.file, ...route.layouts]) {
1118
+ if (inert && inert.has(f)) continue;
1119
+ const emit = importOnly && importOnly.get(f);
1120
+ if (emit) emit.forEach(push);
1121
+ else push(f);
1122
+ }
1123
+ }
1124
+ // instrumentation-client.{js,ts} (#848): import it FIRST in the client boot
1125
+ // so it runs before app modules (Next's instrumentation-client ordering).
1126
+ // It ships even on an otherwise-static page (the app opted into client work),
1127
+ // so it is prepended AFTER the component/page modules are collected.
1128
+ if (opts.instrumentationClient) {
1129
+ const u = toUrlPath(opts.instrumentationClient, opts.appDir);
1130
+ const i = moduleUrls.indexOf(u);
1131
+ if (i !== -1) moduleUrls.splice(i, 1);
1132
+ moduleUrls.unshift(u);
1133
+ }
1134
+ // Emit <link rel="modulepreload"> for every custom element that
1135
+ // actually rendered PLUS their transitive dependencies (from the
1136
+ // module graph). URLs are deduplicated so the browser never sees
1137
+ // the same preload twice. Lazy components are excluded from
1138
+ // preloads and instead loaded via IntersectionObserver when they
1139
+ // enter the viewport.
1140
+ const { eager: eagerComponents, lazy: lazyComponents } =
1141
+ componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents);
1142
+ // The walk roots for BOTH preload passes are the BOOT's actually-shipped
1143
+ // module set (`moduleUrls`, which already drops inert page/layout modules and
1144
+ // substitutes an import-only page with its components), NOT the raw route
1145
+ // entries `[route.file, ...route.layouts]`. Rooting at the raw entries would
1146
+ // walk a dropped page's SSR-only subtree (a direct import OR a relative
1147
+ // helper) and hint a `modulepreload` for a module nothing that ships imports,
1148
+ // an over-fetch (#780, the app-module analog of the #754 vendor over-fetch).
1149
+ const shippedRoots = moduleUrls.map((u) =>
1150
+ resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
1151
+ const preloads = deduplicatedPreloads(
1152
+ eagerComponents,
1153
+ moduleUrls,
1154
+ opts.moduleGraph,
1155
+ shippedRoots,
1156
+ opts.appDir,
1157
+ opts.serverFiles,
1158
+ opts.elidableComponents,
1159
+ );
1160
+ // Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
1161
+ // vendor URLs the page's SHIPPED modules actually import, fetched in parallel
1162
+ // instead of discovered level by level. Same shipped-module roots as the
1163
+ // app-module walk above, so a vendor reached ONLY through a dropped module
1164
+ // (a page's SSR-only direct import OR its SSR-only relative helper) is never
1165
+ // preloaded (no over-fetch).
1166
+ const vendorPreloads = vendorPreloadTargets(
1167
+ reachedVendorSpecifiers(
1168
+ opts.moduleGraph,
1169
+ shippedRoots,
1170
+ eagerComponents,
1171
+ opts.appDir,
1172
+ opts.elidableComponents,
1173
+ opts.serverFiles,
1174
+ ),
1175
+ );
1176
+ // Extract CSP nonce from request headers (if present).
1177
+ const nonce = opts.req ? getNonce(opts.req) : undefined;
1178
+ const wrapOpts = {
1179
+ metadata,
1180
+ moduleUrls,
1181
+ dev: opts.dev,
1182
+ streaming: suspenseCtx.pending.length > 0,
1183
+ preloads,
1184
+ vendorPreloads,
1185
+ lazyComponents,
1186
+ nonce,
1187
+ };
1188
+ // buildDocumentParts picks up a user-supplied <!doctype><html>…</html>
1189
+ // shell from the body when present; otherwise auto-emits the framework
1190
+ // shell. Either way the returned `prefix` ends just past the open <body>
1191
+ // and `closer` is the matching `</body></html>`.
1192
+ const { prefix, streamBody, closer } = buildDocumentParts(body, wrapOpts);
1193
+ // Append the SSR action-seed payload (#472) to the non-streamed body so the
1194
+ // client's first render reads it instead of re-issuing the RPC. Only for a
1195
+ // fully-buffered (non-streaming) render: a streamed page's deferred
1196
+ // boundaries resolve AFTER the first flush, so their seeds cannot ride this
1197
+ // block (those slow regions keep the stale-while-revalidate refetch). An
1198
+ // empty collector yields '' so the output stays byte-identical.
1199
+ let outBody = streamBody;
1200
+ // Dev-only seeding diagnostics (#1309). `off` (seeding disabled) is kept
1201
+ // DISTINCT from `collected=0` on purpose: the counting lives with the
1202
+ // collector rather than behind the seed gate, so a seeding-DISABLED app
1203
+ // never looks like a seeding-BROKEN one.
1204
+ let seedHeader = 'off';
1205
+ const streamed = suspenseCtx.pending.length > 0;
1206
+ if (seedCollector && streamed) {
1207
+ // A streamed render's deferred boundaries resolve AFTER the first flush,
1208
+ // so their results cannot ride this block and none is emitted in prod. In
1209
+ // DEV emit the marker alone, so the client reports the CAUSE instead of
1210
+ // leaving the developer to guess why every action call went to the network.
1211
+ seedHeader = `collected=${seedCollector.size}, emitted=0, streamed`;
1212
+ if (opts.dev) {
1213
+ const marker = await buildSeedScript(null, { dev: true, reason: 'streamed' });
1214
+ if (marker) outBody = streamBody + marker;
1215
+ }
1216
+ } else if (seedCollector) {
1217
+ const seedScript = await buildSeedScript(seedCollector, { dev: opts.dev });
1218
+ if (seedScript) outBody = streamBody + seedScript;
1219
+ // `emitted` differs from `collected` exactly when the serializer threw and
1220
+ // dropped the whole block, which is otherwise a completely invisible
1221
+ // failure. In DEV that throw still emits a marker-only block, so the
1222
+ // browser can name the cause; it carries no seeds, so it must NOT count as
1223
+ // emitted or the header would report a total success on the very failure
1224
+ // it exists to expose.
1225
+ const dropped = seedScript === SEED_DROP_BLOCK;
1226
+ seedHeader = `collected=${seedCollector.size}, emitted=${seedScript && !dropped ? seedCollector.size : 0}`;
1227
+ }
1228
+ const res = streamingHtmlResponse(
1229
+ prefix,
1230
+ outBody,
1231
+ closer,
1232
+ suspenseCtx,
1233
+ // Normally 200. After a failed form-action submission the caller passes
1234
+ // 422 (or another 4xx) so the re-rendered page with field errors carries
1235
+ // the right status for both the no-JS reload and the enhanced swap (#244).
1236
+ opts.status || 200,
1237
+ opts.req,
1238
+ url,
1239
+ metadata,
1240
+ nonce,
1241
+ opts.dev,
1242
+ );
1243
+ // Dev-only seeding diagnostics (#1309). A miss is indistinguishable from a
1244
+ // hit from the outside, so an app whose seeding silently broke looks exactly
1245
+ // like one where it works. Dev only: a production header would publish how
1246
+ // many server calls a page made, for no benefit.
1247
+ if (opts.dev) res.headers.set('X-Webjs-Seed', seedHeader);
1248
+ // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
1249
+ // outer-layout chrome, so these bytes are only valid for a request that
1250
+ // sent a matching `have`. Left shared-cacheable, a CDN edge could store the
1251
+ // reduced body under the URL and serve a chrome-less fragment to a fresh
1252
+ // full-page navigation (measured live: GET / was 73,534 bytes, GET / + have
1253
+ // was 57,035, byte-identical headers otherwise).
1254
+ //
1255
+ // TWO markings, and the order of trust matters (#1140). `privateFragment`
1256
+ // is the guarantee: it forbids SHARED storage outright, so no CDN can hold
1257
+ // this body at all. `Vary` is belt-and-braces for caches that honour it,
1258
+ // NOT the protection, because Cloudflare and others honour only
1259
+ // `Accept-Encoding`. Both are scoped to genuinely reduced responses, so a
1260
+ // normal page's headers and cache key are unchanged.
1261
+ //
1262
+ // The internal #241 revalidate cache is already safe by construction:
1263
+ // `cacheEligible` excludes any request that carries x-webjs-have, so a
1264
+ // reduced body is never stored under the URL-only key.
1265
+ if (reduced) {
1266
+ res.headers.append('vary', 'X-Webjs-Have');
1267
+ privateFragment(res);
1268
+ }
1269
+ // Server HTML cache write (#241). The page opted in via `revalidate`, so
1270
+ // FLAG this candidate for the response funnel rather than writing here: the
1271
+ // store decision must see the FINAL response (after segment middleware,
1272
+ // which may append a per-user Set-Cookie this code can't see yet). The
1273
+ // funnel re-checks every guard via isCacheableResponse, writes the cache,
1274
+ // and strips this internal marker. The CSP guard is decided here (the SSR
1275
+ // side knows whether a nonce was stamped into the body).
1276
+ if (revalidateSeconds !== null && !opts.cspEnabled) {
1277
+ res.headers.set(HTML_CACHE_MARKER, String(revalidateSeconds));
1278
+ }
1279
+ return res;
1280
+ } catch (err) {
1281
+ if (isRedirect(err)) {
1282
+ const e = /** @type any */ (err);
1283
+ // A redirect thrown during a GET page/layout render is a GET-to-GET
1284
+ // navigation (an auth bounce, a gate). 302 Found is the conventional
1285
+ // code there, so it is the default when the caller did not pick one. An
1286
+ // explicit `redirect(url, status)` overrides it. (Action redirects, a
1287
+ // POST, default to 307 in form-dispatch.js so the method is preserved.)
1288
+ return new Response(null, { status: e.status || 302, headers: { location: e.url } });
1289
+ }
1290
+ if (isNotFound(err)) {
1291
+ // Nearest not-found.{js,ts} in the page's chain wins (#848 fix: previously
1292
+ // this always rendered the bare default, ignoring even a root not-found);
1293
+ // fall back to a root global-not-found.{js,ts}, else the default page.
1294
+ // Pass the matched route and this render's ctx so the boundary renders
1295
+ // inside its layouts (#1298). A root-only global-not-found sits at '/',
1296
+ // so it wraps in the root layout only, which is right.
1297
+ const html = await ssrNotFoundHtml(
1298
+ nearest(route.notFounds) || opts.globalNotFound || null,
1299
+ { ...opts, url, route, ctx }
1300
+ );
1301
+ return htmlResponse(html, 404, opts.req, url);
1302
+ }
1303
+ // forbidden() / unauthorized() sentinels (#848, Next 15/16 parity): render
1304
+ // the nearest forbidden.{js,ts} / unauthorized.{js,ts} boundary (innermost
1305
+ // wins), else a default page, at 403 / 401.
1306
+ if (isForbidden(err)) return ssrForbidden(route, { ...opts, url, route, ctx });
1307
+ if (isUnauthorized(err)) return ssrUnauthorized(route, { ...opts, url, route, ctx });
1308
+ // APM / Sentry sink (issue #239): a page render error that becomes a 500
1309
+ // (an error.js boundary OR the default 500 page) is an unhandled error the
1310
+ // app should see in its error tracker. Report it best-effort BEFORE
1311
+ // rendering the boundary, so the sink gets the ORIGINAL error even if the
1312
+ // boundary itself swallows or transforms it. notFound / redirect are
1313
+ // sentinels (control flow), not errors, so they are excluded above.
1314
+ if (typeof opts.onError === 'function') {
1315
+ try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
1316
+ }
1317
+ // Dev error overlay (#264): push a rich frame to the open tab so the
1318
+ // overlay appears live. Dev-only + best-effort; never affects the response.
1319
+ if (typeof opts.onDevError === 'function') {
1320
+ try { opts.onDevError(err); } catch { /* a throwing sink must not affect the response */ }
1321
+ }
1322
+ // Error paths still need to honor the request's CSP nonce so the
1323
+ // error page's boot scripts (when moduleUrls is non-empty) and
1324
+ // the meta csp-nonce tag both pass strict-CSP enforcement.
1325
+ const errNonce = opts.req ? getNonce(opts.req) : undefined;
1326
+ // One dedup set for the whole 500 path: the walk below and the
1327
+ // global-error attempt after it collapse REPEATS of the same cause (a
1328
+ // shared layout that fails every attempt) while still reporting a
1329
+ // genuinely different failure, including global-error's own.
1330
+ //
1331
+ // SEEDED with the original error, which was just reported above. When a
1332
+ // LAYOUT is what threw, the walk re-runs that same layout around each
1333
+ // boundary it wraps (the root layout wraps every boundary), so the cause
1334
+ // that produced this 500 is about to arrive again as a secondary failure.
1335
+ // Without the seed the commonest layout crash of all is reported twice.
1336
+ const secondary = new Set();
1337
+ {
1338
+ const originalKey = boundaryErrorKey(err, STAGE_WALK);
1339
+ if (originalKey !== null) secondary.add(originalKey);
1340
+ }
1341
+ // Try nearest error.js (innermost → outermost).
1342
+ for (let i = route.errors.length - 1; i >= 0; i--) {
1343
+ try {
1344
+ const mod = await loadModule(route.errors[i], opts.dev);
1345
+ if (!mod.default) continue;
1346
+ const tree = await mod.default({ ...ctx, error: err });
1347
+ // The layouts that wrap THIS boundary: its own segment and every
1348
+ // ancestor (#1298). A layout deeper than the boundary never rendered.
1349
+ const wrapLayouts = layoutsForBoundary(route.layouts, boundarySegmentPath(route.errors[i]));
1350
+ // Render inside them, so the response carries the keyed wj:children
1351
+ // pairs and a client-router navigation into this page stays SOFT.
1352
+ // Inside the existing try on purpose: a layout that throws here is
1353
+ // caught by the `catch (nested)` below and the loop moves one boundary
1354
+ // OUT, whose wrapped set is strictly smaller. That bounded walk is how
1355
+ // a throwing layout resolves, and why no failure-point tracking is
1356
+ // needed (a throw out of renderToString is not attributable to one
1357
+ // layout anyway, so tracking would be absent exactly when it mattered).
1358
+ const body = await renderBoundaryInChain(tree, route, route.errors[i], ctx, opts.dev);
1359
+ // Apply the SAME inert / import-only substitution as the happy-path
1360
+ // boot (#963): the error page must not ship a page/layout module the
1361
+ // normal render drops. Pre-substitution, an import-only page with a
1362
+ // bare `.server.*` import (legal, it never loads client-side) would
1363
+ // load here whole and crash the error page's boot on the throw-at-load
1364
+ // stub, killing the sibling component registrations in the same
1365
+ // module script.
1366
+ //
1367
+ // The set is what actually RENDERED (#1298): the boundary module and
1368
+ // the layouts wrapping it. It used to be the page module plus every
1369
+ // layout, so a boundary shipped the page (which never ran) and any
1370
+ // deeper layout (which never ran either), while omitting the boundary
1371
+ // itself, and a component the boundary rendered never upgraded. The
1372
+ // substitution applies to the boundary file harmlessly: only page and
1373
+ // layout files are fed to the elision analysis, so a boundary is never
1374
+ // inert and never import-only, gets no verdict, and is pushed as-is.
1375
+ const errModuleUrls = boundaryModuleUrls(route.errors[i], wrapLayouts, opts);
1376
+ const html = wrapInDocument(body, { metadata, moduleUrls: errModuleUrls, dev: opts.dev, nonce: errNonce });
1377
+ return htmlResponse(html, 500, opts.req, url);
1378
+ } catch (nested) {
1379
+ // Fall through to the next error boundary out. Since #1298 this also
1380
+ // absorbs a throw from a WRAPPED LAYOUT, which is the case that makes a
1381
+ // throwing layout render the boundary outside it (Next parity: a
1382
+ // boundary sits inside its own segment's layout, so it cannot catch it).
1383
+ //
1384
+ // Report it on the way past. The fallthrough is the right BEHAVIOUR,
1385
+ // but it is not a reason to lose the error: whatever this response ends
1386
+ // up being, the sinks only ever hear about the ORIGINAL page error
1387
+ // (reported above), so a boundary or a layout that crashed while
1388
+ // handling it would vanish completely. A control-flow sentinel is
1389
+ // filtered out by the reporter, since a boundary throwing notFound() is
1390
+ // a routing decision rather than a crash.
1391
+ reportBoundaryLayoutError(nested, opts, {
1392
+ what: STAGE_WALK,
1393
+ seen: secondary,
1394
+ overlay: false,
1395
+ });
1396
+ }
1397
+ }
1398
+ // Root global-error.{js,ts} (#848): the app-wide catch-all, tried after the
1399
+ // nested error boundaries are exhausted. It renders the FULL document (its
1400
+ // own <!doctype><html><body>, like the root layout), since a root-layout
1401
+ // failure is exactly when it fires, so its rendered output is returned
1402
+ // verbatim without wrapInDocument. Status 500.
1403
+ //
1404
+ // Deliberately the one boundary #1298 does NOT wrap in layouts: it writes
1405
+ // its own shell (invariant 8 allows only one), wrapping it in the root
1406
+ // layout would re-run the code that just threw, and it ships no importmap
1407
+ // or boot script by design, so it could not soft-swap even if wrapped.
1408
+ if (opts.globalError) {
1409
+ try {
1410
+ const mod = await loadModule(opts.globalError, opts.dev);
1411
+ if (mod.default) {
1412
+ const tree = await mod.default({ error: err });
1413
+ const body = await renderToString(tree, { ssr: true, dev: opts.dev });
1414
+ return htmlResponse(body, 500, opts.req, url);
1415
+ }
1416
+ } catch (nested) {
1417
+ // Fall through to the default 500 page, but do not lose this. A broken
1418
+ // global-error.{js,ts} is the app's LAST-RESORT boundary failing, and
1419
+ // the developer would otherwise see only the generic default page and
1420
+ // the original error, with nothing naming the boundary as the thing
1421
+ // that failed.
1422
+ reportBoundaryLayoutError(nested, opts, {
1423
+ what: STAGE_GLOBAL_ERROR,
1424
+ seen: secondary,
1425
+ overlay: false,
1426
+ });
1427
+ }
1428
+ }
1429
+ // Default: dev shows stack, prod shows a terse message (no stack trace leaks).
1430
+ console.error('[webjs] unhandled render error:', err);
1431
+ const body = opts.dev
1432
+ ? `<h1>Server error</h1><pre style="white-space:pre-wrap">${escapeHtml(safeErrorText(err))}</pre>`
1433
+ : `<h1>Server error</h1><p>Something went wrong. Please try again.</p>`;
1434
+ return htmlResponse(
1435
+ wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev, nonce: errNonce }),
1436
+ 500,
1437
+ opts.req,
1438
+ url
1439
+ );
1440
+ }
1441
+ }
1442
+
1443
+ /**
1444
+ * 404 response for unmatched routes.
1445
+ * @param {string | null} notFoundFile
1446
+ * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL,
1447
+ * route?: import('../router.js').PageRoute,
1448
+ * ctx?: Record<string, unknown>, params?: Record<string, string> }} opts
1449
+ */
1450
+ export async function ssrNotFound(notFoundFile, opts) {
1451
+ const html = await ssrNotFoundHtml(notFoundFile, opts);
1452
+ return htmlResponse(html, 404, opts.req, opts.url);
1453
+ }
1454
+
1455
+ /**
1456
+ * 403 response for a thrown forbidden() (#848). Renders the nearest
1457
+ * forbidden.{js,ts} in the page's chain, else a default 403 page. Shared by the
1458
+ * page-render catch (ssr.js) and the form-action write path (form-dispatch.js) so
1459
+ * both behave identically.
1460
+ * @param {{ forbiddens?: string[] }} route
1461
+ * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL,
1462
+ * route?: import('../router.js').PageRoute,
1463
+ * ctx?: Record<string, unknown>, params?: Record<string, string> }} opts
1464
+ */
1465
+ export async function ssrForbidden(route, opts) {
1466
+ const html = await ssrBoundaryHtml(nearest(route.forbiddens), '403: Forbidden', opts);
1467
+ return htmlResponse(html, 403, opts.req, opts.url);
1468
+ }
1469
+
1470
+ /**
1471
+ * 401 response for a thrown unauthorized() (#848). Renders the nearest
1472
+ * unauthorized.{js,ts} in the page's chain, else a default 401 page.
1473
+ * @param {{ unauthorizeds?: string[] }} route
1474
+ * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL,
1475
+ * route?: import('../router.js').PageRoute,
1476
+ * ctx?: Record<string, unknown>, params?: Record<string, string> }} opts
1477
+ */
1478
+ export async function ssrUnauthorized(route, opts) {
1479
+ const html = await ssrBoundaryHtml(nearest(route.unauthorizeds), '401: Unauthorized', opts);
1480
+ return htmlResponse(html, 401, opts.req, opts.url);
1481
+ }