@webjsdev/server 0.8.91 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/index.d.ts +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +112 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +191 -133
- package/src/dev/server.js +109 -37
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +322 -78
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev-styles.js +64 -6
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +150 -69
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +3 -8
- package/src/component-elision.js +0 -1852
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/ssr/render.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized } from '@webjsdev/core';
|
|
2
|
+
import { headerSafeLocation } from '../redirect-location.js';
|
|
2
3
|
import { vendorPreloadTargets } from '../importmap.js';
|
|
3
4
|
import {
|
|
4
5
|
componentPreloads, pagePreloadHints, toUrlPath,
|
|
@@ -8,10 +9,11 @@ import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from '
|
|
|
8
9
|
import {
|
|
9
10
|
readRevalidate,
|
|
10
11
|
readHtmlCache,
|
|
12
|
+
htmlCacheGeneration,
|
|
11
13
|
HTML_CACHE_MARKER,
|
|
12
14
|
} from '../html-cache.js';
|
|
13
15
|
import { requestedFrameId, extractFrameSubtree } from '../frame-render.js';
|
|
14
|
-
import { makeThenable } from '../thenable-params.js';
|
|
16
|
+
import { makeThenable, searchParamsRecord } from '../thenable-params.js';
|
|
15
17
|
import { buildDocumentParts, wrapInDocument, layoutSegmentPath, pageSegmentPath, boundarySegmentPath, layoutsForBoundary, regionRouteKey, wrapWithChildrenMarker } from './document.js';
|
|
16
18
|
import { escapeHtml } from './escape.js';
|
|
17
19
|
import {
|
|
@@ -19,6 +21,9 @@ import {
|
|
|
19
21
|
} from './responses.js';
|
|
20
22
|
import { devImport } from '../dev-import.js';
|
|
21
23
|
import { applySeoDefaults } from './seo.js';
|
|
24
|
+
import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from '../action-error.js';
|
|
25
|
+
import { readFlash, encodeFlash, FLASH_COOKIE } from '../action-flash.js';
|
|
26
|
+
import { requestCookieStore } from '../context.js';
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
/**
|
|
@@ -123,11 +128,25 @@ function nearest(arr) {
|
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
/**
|
|
131
|
+
* Collect and merge metadata across the route's layouts (outermost first) and
|
|
132
|
+
* its page, Next.js style. `generateMetadata(props, parent)` and
|
|
133
|
+
* `generateViewport(props, parent)` receive `parent`, a promise of the
|
|
134
|
+
* metadata (or viewport) resolved from the segments above, with `title`
|
|
135
|
+
* normalized to Next's `{ absolute, template }`.
|
|
136
|
+
*
|
|
137
|
+
* A thrown `notFound()` / `redirect()` / `forbidden()` / `unauthorized()`
|
|
138
|
+
* propagates, so it answers the request exactly as it would from the page. Any
|
|
139
|
+
* other error is logged and reported through `onError`, and that segment's
|
|
140
|
+
* metadata is skipped, so a broken `generateMetadata` never fails the page.
|
|
141
|
+
*
|
|
126
142
|
* @param {import('../router.js').PageRoute} route
|
|
127
143
|
* @param {Record<string,unknown>} ctx
|
|
128
|
-
* @param {boolean}
|
|
144
|
+
* @param {{ dev?: boolean, onError?: (e: unknown) => void }} opts
|
|
129
145
|
*/
|
|
130
|
-
async function collectMetadata(route, ctx,
|
|
146
|
+
async function collectMetadata(route, ctx, opts) {
|
|
147
|
+
const dev = !!opts.dev;
|
|
148
|
+
/** @type {Record<string, any>} */
|
|
149
|
+
let viewportSoFar = {};
|
|
131
150
|
/** @type {Record<string, any>} */
|
|
132
151
|
let meta = {};
|
|
133
152
|
// Carry the title template forward across layers. Once an outer layout
|
|
@@ -141,7 +160,7 @@ async function collectMetadata(route, ctx, dev) {
|
|
|
141
160
|
const mod = await loadModule(file, dev);
|
|
142
161
|
let m = null;
|
|
143
162
|
if (typeof mod.generateMetadata === 'function') {
|
|
144
|
-
m = await mod.generateMetadata(ctx);
|
|
163
|
+
m = await mod.generateMetadata(ctx, Promise.resolve(resolvedParent(meta, titleTemplate)));
|
|
145
164
|
} else if (mod.metadata) {
|
|
146
165
|
m = mod.metadata;
|
|
147
166
|
}
|
|
@@ -152,14 +171,17 @@ async function collectMetadata(route, ctx, dev) {
|
|
|
152
171
|
// working. Likewise for `themeColor` and `colorScheme`.
|
|
153
172
|
let vp = null;
|
|
154
173
|
if (typeof mod.generateViewport === 'function') {
|
|
155
|
-
vp = await mod.generateViewport(ctx);
|
|
174
|
+
vp = await mod.generateViewport(ctx, Promise.resolve({ ...viewportSoFar }));
|
|
156
175
|
} else if (mod.viewport) {
|
|
157
176
|
vp = mod.viewport;
|
|
158
177
|
}
|
|
159
178
|
if (vp && typeof vp === 'object') {
|
|
179
|
+
viewportSoFar = { ...viewportSoFar, ...vp };
|
|
160
180
|
m = { ...(m || {}), _viewport: { ...(m && m._viewport), ...vp } };
|
|
161
181
|
// Allow `themeColor` / `colorScheme` to live on the viewport export.
|
|
162
|
-
|
|
182
|
+
// `themeColor` may be a string, a `{ color, media }` descriptor, or an
|
|
183
|
+
// array of them (Next's shape); head.js emits one <meta> per entry.
|
|
184
|
+
if (vp.themeColor && !(m && m.themeColor)) {
|
|
163
185
|
m.themeColor = vp.themeColor;
|
|
164
186
|
}
|
|
165
187
|
if (typeof vp.colorScheme === 'string') m.colorScheme = vp.colorScheme;
|
|
@@ -175,6 +197,11 @@ async function collectMetadata(route, ctx, dev) {
|
|
|
175
197
|
// { template, default, absolute }: `absolute` overrides everything;
|
|
176
198
|
// `template` is captured for deeper layers; `default` is the value
|
|
177
199
|
// used when no deeper layer supplies a plain title string.
|
|
200
|
+
// A layer's own template applies to the layers BELOW it, never to
|
|
201
|
+
// its own `default`, which goes through the closest PARENT template
|
|
202
|
+
// as Next.js documents ("augments title.template from the closest
|
|
203
|
+
// parent segment", #1686).
|
|
204
|
+
const parentTemplate = titleTemplate;
|
|
178
205
|
if (typeof t.template === 'string') titleTemplate = t.template;
|
|
179
206
|
if (typeof t.absolute === 'string') {
|
|
180
207
|
resolved.title = t.absolute;
|
|
@@ -182,15 +209,22 @@ async function collectMetadata(route, ctx, dev) {
|
|
|
182
209
|
// it for deeper segments below, but the *current* segment is
|
|
183
210
|
// rendered absolute.
|
|
184
211
|
} else if (typeof t.default === 'string') {
|
|
185
|
-
resolved.title = t.default;
|
|
212
|
+
resolved.title = parentTemplate ? parentTemplate.replace('%s', t.default) : t.default;
|
|
186
213
|
} else {
|
|
187
214
|
delete resolved.title;
|
|
188
215
|
}
|
|
189
216
|
}
|
|
190
217
|
}
|
|
191
218
|
meta = { ...meta, ...resolved };
|
|
192
|
-
} catch {
|
|
193
|
-
//
|
|
219
|
+
} catch (e) {
|
|
220
|
+
// Control flow answers the request, as it would from the page.
|
|
221
|
+
if (isControlFlowThrow(e)) throw e;
|
|
222
|
+
// Anything else is a bug in that segment's metadata: say so, report it,
|
|
223
|
+
// and render the page with the metadata the other segments produced.
|
|
224
|
+
console.error(`[webjs] metadata failed in ${file}:`, e);
|
|
225
|
+
if (typeof opts.onError === 'function') {
|
|
226
|
+
try { opts.onError(e); } catch { /* a throwing sink must not affect the response */ }
|
|
227
|
+
}
|
|
194
228
|
}
|
|
195
229
|
}
|
|
196
230
|
try {
|
|
@@ -200,6 +234,19 @@ async function collectMetadata(route, ctx, dev) {
|
|
|
200
234
|
}
|
|
201
235
|
}
|
|
202
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The `parent` metadata a `generateMetadata` receives: a shallow snapshot of
|
|
239
|
+
* what the outer segments resolved, `title` in Next's `{ absolute, template }`
|
|
240
|
+
* shape so `(await parent).title.absolute` works as it does in Next.
|
|
241
|
+
* @param {Record<string, any>} meta
|
|
242
|
+
* @param {string | null} template
|
|
243
|
+
*/
|
|
244
|
+
function resolvedParent(meta, template) {
|
|
245
|
+
const snap = { ...meta };
|
|
246
|
+
snap.title = { absolute: typeof meta.title === 'string' ? meta.title : '', template };
|
|
247
|
+
return snap;
|
|
248
|
+
}
|
|
249
|
+
|
|
203
250
|
/**
|
|
204
251
|
* Like layoutSegmentPath but for `loading.{js,ts}` files. Strips the
|
|
205
252
|
* `loading.ext` filename from the URL path under app/.
|
|
@@ -470,7 +517,11 @@ async function defaultBoundaryInChain(raw, opts) {
|
|
|
470
517
|
* @returns {{ _$webjs: 'template', strings: string[], values: unknown[] }}
|
|
471
518
|
*/
|
|
472
519
|
function rawTemplate(raw) {
|
|
473
|
-
|
|
520
|
+
// `raw` marks the strings as the framework's own markup, which is what the
|
|
521
|
+
// renderers require of a template result (a tagged template's strings have it).
|
|
522
|
+
const strings = [raw];
|
|
523
|
+
Object.defineProperty(strings, 'raw', { value: [raw] });
|
|
524
|
+
return { _$webjs: 'template', strings, values: [] };
|
|
474
525
|
}
|
|
475
526
|
|
|
476
527
|
/**
|
|
@@ -486,6 +537,35 @@ function wrapLayoutsFor(route, boundaryFile) {
|
|
|
486
537
|
return layoutsForBoundary(route.layouts, boundarySegmentPath(boundaryFile));
|
|
487
538
|
}
|
|
488
539
|
|
|
540
|
+
/**
|
|
541
|
+
* The metadata of a 404 / 403 / 401 page, resolved like a page's (#1686): the
|
|
542
|
+
* layouts that wrap the boundary, then the boundary file's own `metadata` or
|
|
543
|
+
* `generateMetadata`, so the title runs through the layouts' template and a
|
|
544
|
+
* layout's description and openGraph carry over, as in Next.js. The
|
|
545
|
+
* framework's default page (no boundary file) keeps its own `fallbackTitle`;
|
|
546
|
+
* a boundary file that names no title takes the layouts' default.
|
|
547
|
+
* @param {string | null} file
|
|
548
|
+
* @param {string} fallbackTitle
|
|
549
|
+
* @param {{ dev: boolean, route?: any, ctx?: Record<string, unknown>,
|
|
550
|
+
* params?: Record<string, string>, url?: URL, onError?: (e: unknown) => void }} opts
|
|
551
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
552
|
+
*/
|
|
553
|
+
async function boundaryMetadata(file, fallbackTitle, opts) {
|
|
554
|
+
const layouts = opts.route ? (file ? wrapLayoutsFor(opts.route, file) : opts.route.layouts || []) : [];
|
|
555
|
+
const metadataFiles = file ? [...layouts, file] : layouts;
|
|
556
|
+
const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
|
|
557
|
+
/** @type {Record<string, any>} */
|
|
558
|
+
let meta = {};
|
|
559
|
+
try {
|
|
560
|
+
meta = await collectMetadata(/** @type any */ ({ metadataFiles }), ctx, { dev: opts.dev, onError: opts.onError });
|
|
561
|
+
} catch {
|
|
562
|
+
// A control-flow throw from a layout's metadata cannot re-route an error
|
|
563
|
+
// page that is already the answer; the fallback title stands.
|
|
564
|
+
}
|
|
565
|
+
if (!file || meta.title === undefined) meta = { ...meta, title: file && meta.title !== undefined ? meta.title : fallbackTitle };
|
|
566
|
+
return meta;
|
|
567
|
+
}
|
|
568
|
+
|
|
489
569
|
/**
|
|
490
570
|
* Marker for a throw that came from the LAYOUT-wrapping phase of a boundary
|
|
491
571
|
* render, as opposed to the boundary's own tree (#1298). Non-enumerable, so it
|
|
@@ -652,24 +732,17 @@ function reportBoundaryLayoutError(err, opts, cfg = {}) {
|
|
|
652
732
|
* The boot module set for a boundary response: what actually RENDERED, the
|
|
653
733
|
* boundary module plus the layouts wrapping it (#1298).
|
|
654
734
|
*
|
|
655
|
-
* Applies the SAME
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
* (legal, it never loads client-side)
|
|
659
|
-
* boundary page's boot on the throw-at-load stub, killing the
|
|
660
|
-
* component registrations in the same module script.
|
|
661
|
-
*
|
|
662
|
-
* The substitution applies to the boundary file harmlessly: only page and
|
|
663
|
-
* layout files are fed to the elision analysis, so a boundary is never inert
|
|
664
|
-
* and never import-only, gets no verdict, and is pushed as-is. Every boundary
|
|
665
|
-
* kind is a `browserEntryFiles` entry (browser-entries.js), so each of these
|
|
666
|
-
* URLs is servable through the auth gate.
|
|
735
|
+
* Applies the SAME frontier substitution as the happy-path boot, so a
|
|
736
|
+
* boundary response never ships a route module (#1686): the boundary and each
|
|
737
|
+
* wrapping layout are replaced by the components they import. Shipped whole, a
|
|
738
|
+
* layout with a bare `.server.*` import (legal, it never loads client-side)
|
|
739
|
+
* would crash the boundary page's boot on the throw-at-load stub, killing the
|
|
740
|
+
* sibling component registrations in the same module script.
|
|
667
741
|
*
|
|
668
742
|
* @param {string | null} boundaryFile Null for the framework's default body,
|
|
669
743
|
* which has no module of its own; only the layouts boot then.
|
|
670
744
|
* @param {string[]} wrapLayouts
|
|
671
|
-
* @param {{ appDir: string,
|
|
672
|
-
* importOnlyRouteModules?: Map<string, string[]>,
|
|
745
|
+
* @param {{ appDir: string, routeFrontiers?: Map<string, string[]>,
|
|
673
746
|
* instrumentationClient?: string }} opts
|
|
674
747
|
* @returns {string[]}
|
|
675
748
|
*/
|
|
@@ -682,10 +755,10 @@ function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
|
|
|
682
755
|
// `lazyFiles` is deliberately not passed: a boundary render has no lazy map
|
|
683
756
|
// of its own to register a diverted frontier component with, so a `static
|
|
684
757
|
// lazy` component imported by a boundary is imported eagerly, as before.
|
|
685
|
-
|
|
758
|
+
// Nesting order, as on the happy path: the wrapping layouts, then the boundary.
|
|
759
|
+
return assembleBootModules(boundaryFile ? [...wrapLayouts, boundaryFile] : wrapLayouts, {
|
|
686
760
|
appDir: opts.appDir,
|
|
687
|
-
|
|
688
|
-
importOnly: opts.importOnlyRouteModules,
|
|
761
|
+
frontiers: opts.routeFrontiers,
|
|
689
762
|
lazyFiles: undefined,
|
|
690
763
|
instrumentationClient: opts.instrumentationClient,
|
|
691
764
|
}).moduleUrls;
|
|
@@ -703,12 +776,39 @@ function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
|
|
|
703
776
|
function boundaryCtx(params, url, actionData) {
|
|
704
777
|
return {
|
|
705
778
|
params: makeThenable(params || {}),
|
|
706
|
-
searchParams: makeThenable(url ?
|
|
779
|
+
searchParams: makeThenable(url ? searchParamsRecord(url.searchParams) : {}),
|
|
707
780
|
url: url ? url.toString() : '',
|
|
708
781
|
actionData,
|
|
709
782
|
};
|
|
710
783
|
}
|
|
711
784
|
|
|
785
|
+
/**
|
|
786
|
+
* The markup a streamed Suspense boundary renders when its content throws
|
|
787
|
+
* notFound(), forbidden() or unauthorized() after the 200 went out (#1686):
|
|
788
|
+
* the nearest matching boundary file, else the default heading. Null for any
|
|
789
|
+
* other throw, which keeps the streamed-error behaviour.
|
|
790
|
+
* @param {unknown} e
|
|
791
|
+
* @param {any} route
|
|
792
|
+
* @param {Record<string, unknown>} ctx
|
|
793
|
+
* @param {{ dev: boolean, globalNotFound?: string | null, onError?: (e: unknown) => void }} opts
|
|
794
|
+
* @returns {Promise<string | null>}
|
|
795
|
+
*/
|
|
796
|
+
async function streamedAccessFallback(e, route, ctx, opts) {
|
|
797
|
+
const kind = isNotFound(e) ? 'notFounds' : isForbidden(e) ? 'forbiddens' : isUnauthorized(e) ? 'unauthorizeds' : null;
|
|
798
|
+
if (!kind) return null;
|
|
799
|
+
const heading = kind === 'notFounds' ? '404: Not found' : kind === 'forbiddens' ? '403: Forbidden' : '401: Unauthorized';
|
|
800
|
+
const file = nearest(route[kind]) || (kind === 'notFounds' ? opts.globalNotFound : null);
|
|
801
|
+
if (!file) return `<h1>${heading}</h1>`;
|
|
802
|
+
try {
|
|
803
|
+
const mod = await loadModule(file, opts.dev);
|
|
804
|
+
if (typeof mod.default !== 'function') return `<h1>${heading}</h1>`;
|
|
805
|
+
return await renderToString(await mod.default(ctx), { ssr: true, dev: opts.dev });
|
|
806
|
+
} catch (err) {
|
|
807
|
+
reportBoundaryLayoutError(err, opts, { what: STAGE_BOUNDARY, overlay: true });
|
|
808
|
+
return `<h1>${heading}</h1>`;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
712
812
|
/**
|
|
713
813
|
* Render a simple boundary page (forbidden / unauthorized, #848) at the given
|
|
714
814
|
* default heading. Loads the nearest boundary module when one exists, else
|
|
@@ -729,6 +829,9 @@ async function ssrBoundaryHtml(file, heading, opts) {
|
|
|
729
829
|
let body = `<h1>${heading}</h1>`;
|
|
730
830
|
/** @type {string[]} */
|
|
731
831
|
let moduleUrls = [];
|
|
832
|
+
// Whether the boundary module imported, so a broken one is reported once
|
|
833
|
+
// (by the render below) and not again by the metadata read.
|
|
834
|
+
let loaded = false;
|
|
732
835
|
// No boundary FILE, but a route matched: the framework's own default page is
|
|
733
836
|
// what renders, and it belongs inside the layouts just as a custom one does.
|
|
734
837
|
// This is the COMMON case for 403 / 401, since most apps ship no
|
|
@@ -743,6 +846,7 @@ async function ssrBoundaryHtml(file, heading, opts) {
|
|
|
743
846
|
const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
|
|
744
847
|
try {
|
|
745
848
|
const mod = await loadModule(file, opts.dev);
|
|
849
|
+
loaded = true;
|
|
746
850
|
if (mod.default) {
|
|
747
851
|
const tree = await mod.default(ctx);
|
|
748
852
|
try {
|
|
@@ -819,7 +923,7 @@ async function ssrBoundaryHtml(file, heading, opts) {
|
|
|
819
923
|
}
|
|
820
924
|
const nonce = getNonce();
|
|
821
925
|
return wrapInDocument(body, {
|
|
822
|
-
metadata:
|
|
926
|
+
metadata: await boundaryMetadata(loaded ? file : null, heading.replace(/^\d+:\s*/, ''), opts),
|
|
823
927
|
moduleUrls,
|
|
824
928
|
dev: opts.dev,
|
|
825
929
|
nonce,
|
|
@@ -840,6 +944,7 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
840
944
|
let body = '<h1>404: Not found</h1>';
|
|
841
945
|
/** @type {string[]} */
|
|
842
946
|
let moduleUrls = [];
|
|
947
|
+
let loaded = false;
|
|
843
948
|
if (!notFoundFile && opts.route) {
|
|
844
949
|
const wrapped = await defaultBoundaryInChain('<h1>404: Not found</h1>', opts);
|
|
845
950
|
if (wrapped) { body = wrapped.body; moduleUrls = wrapped.moduleUrls; }
|
|
@@ -848,6 +953,7 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
848
953
|
const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
|
|
849
954
|
try {
|
|
850
955
|
const mod = await loadModule(notFoundFile, opts.dev);
|
|
956
|
+
loaded = true;
|
|
851
957
|
if (mod.default) {
|
|
852
958
|
const tree = await mod.default(ctx);
|
|
853
959
|
try {
|
|
@@ -889,13 +995,28 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
889
995
|
}
|
|
890
996
|
const nonce = getNonce();
|
|
891
997
|
return wrapInDocument(body, {
|
|
892
|
-
metadata
|
|
998
|
+
// The boundary's own `metadata` / `generateMetadata` over its layouts', as
|
|
999
|
+
// a `not-found.tsx` resolves in Next; "Not found" is only the fallback.
|
|
1000
|
+
metadata: await boundaryMetadata(loaded ? notFoundFile : null, 'Not found', opts),
|
|
893
1001
|
moduleUrls,
|
|
894
1002
|
dev: opts.dev,
|
|
895
1003
|
nonce,
|
|
896
1004
|
});
|
|
897
1005
|
}
|
|
898
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Whether a flash belongs to the page at `url`: the path its success redirect
|
|
1009
|
+
* named, compared without a trailing slash. A flash with no recorded path (a
|
|
1010
|
+
* cookie from before the path was recorded) is shown as before.
|
|
1011
|
+
* @param {{ path?: string }} flash
|
|
1012
|
+
* @param {URL} url
|
|
1013
|
+
*/
|
|
1014
|
+
function flashTargets(flash, url) {
|
|
1015
|
+
if (!flash.path) return true;
|
|
1016
|
+
const norm = (/** @type {string} */ p) => (p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p);
|
|
1017
|
+
return norm(flash.path) === norm(url.pathname);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
899
1020
|
/**
|
|
900
1021
|
* SSR a matched page route to a Response.
|
|
901
1022
|
*
|
|
@@ -910,10 +1031,38 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
910
1031
|
* @param {import('../router.js').PageRoute} route
|
|
911
1032
|
* @param {Record<string,string>} params
|
|
912
1033
|
* @param {URL} url
|
|
913
|
-
* @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
|
|
1034
|
+
* @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, renderError?: unknown, onError?: (error: unknown) => void }} opts
|
|
1035
|
+
* `renderError`: a form action's throw (form-dispatch.js), answered with the
|
|
1036
|
+
* page's nearest error boundary and never from the HTML cache.
|
|
914
1037
|
* @returns {Promise<Response>}
|
|
915
1038
|
*/
|
|
916
1039
|
export async function ssrPage(route, params, url, opts) {
|
|
1040
|
+
// A form action's result carried across its 303 (action-flash.js): shown
|
|
1041
|
+
// once as actionData on the page the redirect lands on, then marked shown
|
|
1042
|
+
// so a reload does not repeat it (it stays the next submission's prevState).
|
|
1043
|
+
// Read BEFORE the HTML-cache lookup, since a page carrying actionData is
|
|
1044
|
+
// never served from or written to the cache. A speculative prefetch never
|
|
1045
|
+
// consumes it.
|
|
1046
|
+
// Set when this render shows a flash: its body then carries one visitor's
|
|
1047
|
+
// action result, so the response must never reach a shared cache (below).
|
|
1048
|
+
// Shown only on the page the success redirect named (flash.path), and never
|
|
1049
|
+
// on an error render. Another page or tab rendered inside the 60 seconds, or
|
|
1050
|
+
// a <webjs-frame> loading another path, leaves it for the right one. A frame
|
|
1051
|
+
// render OF that path is the landing of a form submitted inside the frame
|
|
1052
|
+
// (the redirect is followed with x-webjs-frame), so it shows it.
|
|
1053
|
+
let showsFlash = false;
|
|
1054
|
+
if (!opts.actionData && opts.renderError === undefined && opts.req
|
|
1055
|
+
&& opts.req.headers.get('x-webjs-prefetch') !== '1') {
|
|
1056
|
+
const flash = await readFlash(opts.req);
|
|
1057
|
+
if (flash && !flash.shown && flashTargets(flash, url)) {
|
|
1058
|
+
showsFlash = true;
|
|
1059
|
+
opts = { ...opts, actionData: flash.result };
|
|
1060
|
+
try {
|
|
1061
|
+
const shown = await encodeFlash({ ...flash, shown: true });
|
|
1062
|
+
if (shown) requestCookieStore().set(FLASH_COOKIE, shown, { httpOnly: true, sameSite: 'lax', maxAge: 60 });
|
|
1063
|
+
} catch { /* no request scope (a direct ssrPage call): nothing to rewrite */ }
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
917
1066
|
// Server HTML response cache (ISR for no-build, #241). OPT-IN: only a page
|
|
918
1067
|
// that declares `export const revalidate = N` is ever cached (the page
|
|
919
1068
|
// module export is the single trigger). The page module is loaded ONCE up
|
|
@@ -924,7 +1073,16 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
924
1073
|
// no double behaviour) for the form-action re-render (actionData / a non-200
|
|
925
1074
|
// status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
|
|
926
1075
|
// on the request and must not be shared under the full-URL key.
|
|
1076
|
+
// The cache generation this render starts under (#1689). A dev rebuild, or
|
|
1077
|
+
// a `revalidateAll()`, during the render makes its body describe the source
|
|
1078
|
+
// before the bump, so the funnel must not store it under the bumped key.
|
|
1079
|
+
// Read before the first await, so the whole render counts.
|
|
1080
|
+
const cacheGeneration = htmlCacheGeneration();
|
|
1081
|
+
// A failed form action's error render (`renderError`, form-dispatch.js) is
|
|
1082
|
+
// never a cache read: a warm entry would answer 200 with the page and the
|
|
1083
|
+
// throw would never reach the error boundary or onError.
|
|
927
1084
|
const cacheEligible =
|
|
1085
|
+
opts.renderError === undefined &&
|
|
928
1086
|
!opts.actionData &&
|
|
929
1087
|
!opts.status &&
|
|
930
1088
|
!opts.pageModule &&
|
|
@@ -960,7 +1118,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
960
1118
|
// `params.id` still works, `await params` also works (Next 15/16 parity).
|
|
961
1119
|
// The `then` is non-enumerable so spread / JSON / Object.keys are unchanged.
|
|
962
1120
|
params: makeThenable(params),
|
|
963
|
-
searchParams: makeThenable(
|
|
1121
|
+
searchParams: makeThenable(searchParamsRecord(url.searchParams)),
|
|
964
1122
|
url: url.toString(),
|
|
965
1123
|
// Populated only when this render is the re-render after a failed page
|
|
966
1124
|
// `action` submission (#244). The page function and every layout receive
|
|
@@ -971,9 +1129,23 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
971
1129
|
};
|
|
972
1130
|
|
|
973
1131
|
// Collect metadata across layouts (outermost first) then page.
|
|
974
|
-
|
|
1132
|
+
// A control-flow throw from generateMetadata (notFound(), redirect(), ...)
|
|
1133
|
+
// is rethrown inside the try below, so it answers exactly as a page throw.
|
|
1134
|
+
/** @type {Record<string, any>} */
|
|
1135
|
+
let metadata = {};
|
|
1136
|
+
/** @type {unknown} */
|
|
1137
|
+
let metadataThrow = null;
|
|
1138
|
+
try {
|
|
1139
|
+
metadata = await collectMetadata(route, ctx, { dev: opts.dev, onError: opts.onError });
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
metadataThrow = e;
|
|
1142
|
+
}
|
|
975
1143
|
|
|
976
1144
|
try {
|
|
1145
|
+
if (metadataThrow) throw metadataThrow;
|
|
1146
|
+
// A form action that threw (form-dispatch.js): the page answers with its
|
|
1147
|
+
// nearest error boundary exactly as a failed render does (#1686).
|
|
1148
|
+
if (opts.renderError !== undefined) throw opts.renderError;
|
|
977
1149
|
const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set(), dev: opts.dev };
|
|
978
1150
|
// Parse the partial-nav "have" header from the client. The server walks
|
|
979
1151
|
// the target route's layout chain innermost → outermost and
|
|
@@ -1108,8 +1280,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1108
1280
|
const lazyFiles = opts.lazyComponentFiles;
|
|
1109
1281
|
const { moduleUrls, frontierLazy } = shippedBootModules(route, {
|
|
1110
1282
|
appDir: opts.appDir,
|
|
1111
|
-
|
|
1112
|
-
importOnly: opts.importOnlyRouteModules,
|
|
1283
|
+
frontiers: opts.routeFrontiers,
|
|
1113
1284
|
lazyFiles,
|
|
1114
1285
|
instrumentationClient: opts.instrumentationClient,
|
|
1115
1286
|
});
|
|
@@ -1120,11 +1291,11 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1120
1291
|
// preloads and instead loaded via IntersectionObserver when they
|
|
1121
1292
|
// enter the viewport.
|
|
1122
1293
|
const { eager: eagerComponents, lazy: renderedLazy } =
|
|
1123
|
-
componentPreloads(suspenseCtx.usedComponents, opts.appDir,
|
|
1294
|
+
componentPreloads(suspenseCtx.usedComponents, opts.appDir, lazyFiles);
|
|
1124
1295
|
const lazyComponents = { ...frontierLazy, ...renderedLazy };
|
|
1125
1296
|
// Both preload passes (the app-module hints and the vendor hints, #754)
|
|
1126
1297
|
// root at the BOOT's shipped set plus the rendered components and skip a
|
|
1127
|
-
// lazy component (#1524)
|
|
1298
|
+
// lazy component (#1524) and the subtree only it reaches;
|
|
1128
1299
|
// `pagePreloadHints` walks that closure ONCE for both (#1644), where the
|
|
1129
1300
|
// two passes used to walk it twice. The over-fetch rules (#780, #754) are
|
|
1130
1301
|
// documented on the helper.
|
|
@@ -1133,7 +1304,6 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1133
1304
|
appDir: opts.appDir,
|
|
1134
1305
|
moduleUrls,
|
|
1135
1306
|
eagerComponents,
|
|
1136
|
-
elidableComponents: opts.elidableComponents,
|
|
1137
1307
|
lazyFiles,
|
|
1138
1308
|
serverFiles: opts.serverFiles,
|
|
1139
1309
|
});
|
|
@@ -1153,6 +1323,8 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1153
1323
|
vendorPreloads,
|
|
1154
1324
|
lazyComponents,
|
|
1155
1325
|
nonce,
|
|
1326
|
+
// The page's own URL, which hoisted stylesheet hrefs are keyed against.
|
|
1327
|
+
pageUrl: url.href,
|
|
1156
1328
|
};
|
|
1157
1329
|
// buildDocumentParts picks up a user-supplied <!doctype><html>…</html>
|
|
1158
1330
|
// shell from the body when present; otherwise auto-emits the framework
|
|
@@ -1208,12 +1380,17 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1208
1380
|
metadata,
|
|
1209
1381
|
nonce,
|
|
1210
1382
|
opts.dev,
|
|
1383
|
+
(e) => streamedAccessFallback(e, route, ctx, opts),
|
|
1211
1384
|
);
|
|
1212
1385
|
// Dev-only seeding diagnostics (#1309). A miss is indistinguishable from a
|
|
1213
1386
|
// hit from the outside, so an app whose seeding silently broke looks exactly
|
|
1214
1387
|
// like one where it works. Dev only: a production header would publish how
|
|
1215
1388
|
// many server calls a page made, for no benefit.
|
|
1216
1389
|
if (opts.dev) res.headers.set('X-Webjs-Seed', seedHeader);
|
|
1390
|
+
// A 200 takes the page's metadata.cacheControl, but a render showing a
|
|
1391
|
+
// flash is per-visitor (like the 422 re-render, which is never cached), so
|
|
1392
|
+
// a page marked public must not hand this body to a shared cache.
|
|
1393
|
+
if (showsFlash) res.headers.set('cache-control', 'private, no-store');
|
|
1217
1394
|
// REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
|
|
1218
1395
|
// outer-layout chrome, so these bytes are only valid for a request that
|
|
1219
1396
|
// sent a matching `have`. Left shared-cacheable, a CDN edge could store the
|
|
@@ -1243,7 +1420,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1243
1420
|
// and strips this internal marker. The CSP guard is decided here (the SSR
|
|
1244
1421
|
// side knows whether a nonce was stamped into the body).
|
|
1245
1422
|
if (revalidateSeconds !== null && !opts.cspEnabled) {
|
|
1246
|
-
res.headers.set(HTML_CACHE_MARKER,
|
|
1423
|
+
res.headers.set(HTML_CACHE_MARKER, `${revalidateSeconds}:${cacheGeneration}`);
|
|
1247
1424
|
}
|
|
1248
1425
|
return res;
|
|
1249
1426
|
} catch (err) {
|
|
@@ -1252,9 +1429,9 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1252
1429
|
// A redirect thrown during a GET page/layout render is a GET-to-GET
|
|
1253
1430
|
// navigation (an auth bounce, a gate). 302 Found is the conventional
|
|
1254
1431
|
// code there, so it is the default when the caller did not pick one. An
|
|
1255
|
-
// explicit `redirect(url, status)` overrides it. (
|
|
1256
|
-
// POST,
|
|
1257
|
-
return new Response(null, { status: e.status || 302, headers: { location: e.url } });
|
|
1432
|
+
// explicit `redirect(url, status)` overrides it. (A form action's
|
|
1433
|
+
// redirect, a POST, always answers 303 in form-dispatch.js.)
|
|
1434
|
+
return new Response(null, { status: e.status || 302, headers: { location: headerSafeLocation(e.url) } });
|
|
1258
1435
|
}
|
|
1259
1436
|
if (isNotFound(err)) {
|
|
1260
1437
|
// Nearest not-found.{js,ts} in the page's chain wins (#848 fix: previously
|
|
@@ -1280,6 +1457,21 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1280
1457
|
// rendering the boundary, so the sink gets the ORIGINAL error even if the
|
|
1281
1458
|
// boundary itself swallows or transforms it. notFound / redirect are
|
|
1282
1459
|
// sentinels (control flow), not errors, so they are excluded above.
|
|
1460
|
+
//
|
|
1461
|
+
// Production sanitizes what the boundary sees (Next.js parity): error.ts
|
|
1462
|
+
// and global-error.ts receive a generic message plus a `digest`, never the
|
|
1463
|
+
// raw message (a driver error, a path, a connection string), and the full
|
|
1464
|
+
// error is logged under the same digest so a user-reported digest maps
|
|
1465
|
+
// back to it. The sinks get the original error, carrying the digest too.
|
|
1466
|
+
// Dev passes the real error through unchanged.
|
|
1467
|
+
let boundaryErr = err;
|
|
1468
|
+
if (!opts.dev) {
|
|
1469
|
+
let digest;
|
|
1470
|
+
try { digest = await errorDigest(err); } catch { digest = crypto.randomUUID().replace(/-/g, '').slice(0, 10); }
|
|
1471
|
+
try { if (err && typeof err === 'object' && /** @type {any} */ (err).digest == null) /** @type {any} */ (err).digest = digest; } catch { /* frozen */ }
|
|
1472
|
+
console.error(`[webjs] render error [digest=${digest}]:`, err);
|
|
1473
|
+
boundaryErr = Object.assign(new Error(GENERIC_ERROR_MESSAGE), { digest });
|
|
1474
|
+
}
|
|
1283
1475
|
if (typeof opts.onError === 'function') {
|
|
1284
1476
|
try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
|
|
1285
1477
|
}
|
|
@@ -1312,7 +1504,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1312
1504
|
try {
|
|
1313
1505
|
const mod = await loadModule(route.errors[i], opts.dev);
|
|
1314
1506
|
if (!mod.default) continue;
|
|
1315
|
-
const tree = await mod.default({ ...ctx, error:
|
|
1507
|
+
const tree = await mod.default({ ...ctx, error: boundaryErr });
|
|
1316
1508
|
// The layouts that wrap THIS boundary: its own segment and every
|
|
1317
1509
|
// ancestor (#1298). A layout deeper than the boundary never rendered.
|
|
1318
1510
|
const wrapLayouts = layoutsForBoundary(route.layouts, boundarySegmentPath(route.errors[i]));
|
|
@@ -1325,11 +1517,10 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1325
1517
|
// needed (a throw out of renderToString is not attributable to one
|
|
1326
1518
|
// layout anyway, so tracking would be absent exactly when it mattered).
|
|
1327
1519
|
const body = await renderBoundaryInChain(tree, route, route.errors[i], ctx, opts.dev);
|
|
1328
|
-
// Apply the SAME
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
// load here whole and crash the error page's boot on the throw-at-load
|
|
1520
|
+
// Apply the SAME frontier substitution as the happy-path boot
|
|
1521
|
+
// (#1686): the error page must not ship a route module. Shipped whole,
|
|
1522
|
+
// one with a bare `.server.*` import (legal, it never loads
|
|
1523
|
+
// client-side) would crash the error page's boot on the throw-at-load
|
|
1333
1524
|
// stub, killing the sibling component registrations in the same
|
|
1334
1525
|
// module script.
|
|
1335
1526
|
//
|
|
@@ -1337,10 +1528,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1337
1528
|
// the layouts wrapping it. It used to be the page module plus every
|
|
1338
1529
|
// layout, so a boundary shipped the page (which never ran) and any
|
|
1339
1530
|
// deeper layout (which never ran either), while omitting the boundary
|
|
1340
|
-
// itself, and a component the boundary rendered never upgraded.
|
|
1341
|
-
// substitution applies to the boundary file harmlessly: only page and
|
|
1342
|
-
// layout files are fed to the elision analysis, so a boundary is never
|
|
1343
|
-
// inert and never import-only, gets no verdict, and is pushed as-is.
|
|
1531
|
+
// itself, and a component the boundary rendered never upgraded.
|
|
1344
1532
|
const errModuleUrls = boundaryModuleUrls(route.errors[i], wrapLayouts, opts);
|
|
1345
1533
|
const html = wrapInDocument(body, { metadata, moduleUrls: errModuleUrls, dev: opts.dev, nonce: errNonce });
|
|
1346
1534
|
return htmlResponse(html, 500, opts.req, url);
|
|
@@ -1378,7 +1566,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1378
1566
|
try {
|
|
1379
1567
|
const mod = await loadModule(opts.globalError, opts.dev);
|
|
1380
1568
|
if (mod.default) {
|
|
1381
|
-
const tree = await mod.default({ error:
|
|
1569
|
+
const tree = await mod.default({ error: boundaryErr });
|
|
1382
1570
|
const body = await renderToString(tree, { ssr: true, dev: opts.dev });
|
|
1383
1571
|
return htmlResponse(body, 500, opts.req, url);
|
|
1384
1572
|
}
|
|
@@ -1396,10 +1584,10 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1396
1584
|
}
|
|
1397
1585
|
}
|
|
1398
1586
|
// Default: dev shows stack, prod shows a terse message (no stack trace leaks).
|
|
1399
|
-
console.error('[webjs] unhandled render error:', err);
|
|
1587
|
+
if (opts.dev) console.error('[webjs] unhandled render error:', err);
|
|
1400
1588
|
const body = opts.dev
|
|
1401
1589
|
? `<h1>Server error</h1><pre style="white-space:pre-wrap">${escapeHtml(safeErrorText(err))}</pre>`
|
|
1402
|
-
: `<h1>Server error</h1><p>Something went wrong. Please try again.</p>`;
|
|
1590
|
+
: `<h1>Server error</h1><p>Something went wrong. Please try again.</p><p><small>Digest: ${escapeHtml(String(/** @type {any} */ (boundaryErr).digest || ''))}</small></p>`;
|
|
1403
1591
|
return htmlResponse(
|
|
1404
1592
|
wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev, nonce: errNonce }),
|
|
1405
1593
|
500,
|