@webjsdev/server 0.8.62 → 0.8.64
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/index.d.ts +32 -3
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +1 -1
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/rate-limit.js +50 -5
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- 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
|
+
}
|