@webjsdev/server 0.8.88 → 0.8.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ssr/render.js CHANGED
@@ -1,9 +1,9 @@
1
- import { resolve } from 'node:path';
2
1
  import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized } from '@webjsdev/core';
3
2
  import { vendorPreloadTargets } from '../importmap.js';
4
3
  import {
5
- componentPreloads, deduplicatedPreloads, reachedVendorSpecifiers, toUrlPath,
4
+ componentPreloads, pagePreloadHints, toUrlPath,
6
5
  } from './preloads.js';
6
+ import { shippedBootModules } from './shipped-modules.js';
7
7
  import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from '../action-seed.js';
8
8
  import {
9
9
  readRevalidate,
@@ -830,7 +830,7 @@ async function ssrBoundaryHtml(file, heading, opts) {
830
830
  moduleUrls = [];
831
831
  }
832
832
  }
833
- const nonce = opts.req ? getNonce(opts.req) : undefined;
833
+ const nonce = getNonce();
834
834
  return wrapInDocument(body, {
835
835
  metadata: { title: heading.replace(/^\d+:\s*/, '') },
836
836
  moduleUrls,
@@ -900,7 +900,7 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
900
900
  moduleUrls = [];
901
901
  }
902
902
  }
903
- const nonce = opts.req ? getNonce(opts.req) : undefined;
903
+ const nonce = getNonce();
904
904
  return wrapInDocument(body, {
905
905
  metadata: { title: 'Not found' },
906
906
  moduleUrls,
@@ -1113,37 +1113,19 @@ export async function ssrPage(route, params, url, opts) {
1113
1113
  // lazy loader instead of imported, exactly as a shipping module's served
1114
1114
  // source defers its side-effect import of one, so the boot never fetches
1115
1115
  // it up front and a tag revealed later still loads on first sight.
1116
- const inert = opts.inertRouteModules;
1117
- const importOnly = opts.importOnlyRouteModules;
1116
+ //
1117
+ // The assembly itself lives in `shipped-modules.js` (#1644), shared with
1118
+ // the Node listener's 103 Early Hints and memoized per route object and
1119
+ // build epoch; nothing below mutates `moduleUrls`, which is what lets the
1120
+ // memoized array be handed out as is.
1118
1121
  const lazyFiles = opts.lazyComponentFiles;
1119
- const moduleUrls = [];
1120
- /** @type {Record<string, string>} */
1121
- const frontierLazy = {};
1122
- {
1123
- const seen = new Set();
1124
- const push = (abs) => {
1125
- const u = toUrlPath(abs, opts.appDir);
1126
- const tags = lazyFiles && lazyFiles.get(abs);
1127
- if (tags) { for (const t of tags) frontierLazy[t] = u; return; }
1128
- if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
1129
- };
1130
- for (const f of [route.file, ...route.layouts]) {
1131
- if (inert && inert.has(f)) continue;
1132
- const emit = importOnly && importOnly.get(f);
1133
- if (emit) emit.forEach(push);
1134
- else push(f);
1135
- }
1136
- }
1137
- // instrumentation-client.{js,ts} (#848): import it FIRST in the client boot
1138
- // so it runs before app modules (Next's instrumentation-client ordering).
1139
- // It ships even on an otherwise-static page (the app opted into client work),
1140
- // so it is prepended AFTER the component/page modules are collected.
1141
- if (opts.instrumentationClient) {
1142
- const u = toUrlPath(opts.instrumentationClient, opts.appDir);
1143
- const i = moduleUrls.indexOf(u);
1144
- if (i !== -1) moduleUrls.splice(i, 1);
1145
- moduleUrls.unshift(u);
1146
- }
1122
+ const { moduleUrls, frontierLazy } = shippedBootModules(route, {
1123
+ appDir: opts.appDir,
1124
+ inert: opts.inertRouteModules,
1125
+ importOnly: opts.importOnlyRouteModules,
1126
+ lazyFiles,
1127
+ instrumentationClient: opts.instrumentationClient,
1128
+ });
1147
1129
  // Emit <link rel="modulepreload"> for every custom element that
1148
1130
  // actually rendered PLUS their transitive dependencies (from the
1149
1131
  // module graph). URLs are deduplicated so the browser never sees
@@ -1153,48 +1135,28 @@ export async function ssrPage(route, params, url, opts) {
1153
1135
  const { eager: eagerComponents, lazy: renderedLazy } =
1154
1136
  componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents, lazyFiles);
1155
1137
  const lazyComponents = { ...frontierLazy, ...renderedLazy };
1156
- // The preload walks skip a lazy component and the subtree reachable only
1157
- // through it (#1524), the same way they skip an elided one: the browser
1158
- // does not fetch it until the loader asks.
1159
- const preloadSkip = lazyFiles && lazyFiles.size
1160
- ? new Set([...(opts.elidableComponents || []), ...lazyFiles.keys()])
1161
- : opts.elidableComponents;
1162
- // The walk roots for BOTH preload passes are the BOOT's actually-shipped
1163
- // module set (`moduleUrls`, which already drops inert page/layout modules and
1164
- // substitutes an import-only page with its components), NOT the raw route
1165
- // entries `[route.file, ...route.layouts]`. Rooting at the raw entries would
1166
- // walk a dropped page's SSR-only subtree (a direct import OR a relative
1167
- // helper) and hint a `modulepreload` for a module nothing that ships imports,
1168
- // an over-fetch (#780, the app-module analog of the #754 vendor over-fetch).
1169
- const shippedRoots = moduleUrls.map((u) =>
1170
- resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
1171
- const preloads = deduplicatedPreloads(
1172
- eagerComponents,
1138
+ // Both preload passes (the app-module hints and the vendor hints, #754)
1139
+ // root at the BOOT's shipped set plus the rendered components and skip a
1140
+ // lazy component (#1524) or an elided one and the subtree only it reaches;
1141
+ // `pagePreloadHints` walks that closure ONCE for both (#1644), where the
1142
+ // two passes used to walk it twice. The over-fetch rules (#780, #754) are
1143
+ // documented on the helper.
1144
+ const hints = pagePreloadHints({
1145
+ graph: opts.moduleGraph,
1146
+ appDir: opts.appDir,
1173
1147
  moduleUrls,
1174
- opts.moduleGraph,
1175
- shippedRoots,
1176
- opts.appDir,
1177
- opts.serverFiles,
1178
- preloadSkip,
1179
- );
1148
+ eagerComponents,
1149
+ elidableComponents: opts.elidableComponents,
1150
+ lazyFiles,
1151
+ serverFiles: opts.serverFiles,
1152
+ });
1153
+ const preloads = hints.preloads;
1180
1154
  // Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
1181
- // vendor URLs the page's SHIPPED modules actually import, fetched in parallel
1182
- // instead of discovered level by level. Same shipped-module roots as the
1183
- // app-module walk above, so a vendor reached ONLY through a dropped module
1184
- // (a page's SSR-only direct import OR its SSR-only relative helper) is never
1185
- // preloaded (no over-fetch).
1186
- const vendorPreloads = vendorPreloadTargets(
1187
- reachedVendorSpecifiers(
1188
- opts.moduleGraph,
1189
- shippedRoots,
1190
- eagerComponents,
1191
- opts.appDir,
1192
- preloadSkip,
1193
- opts.serverFiles,
1194
- ),
1195
- );
1155
+ // vendor URLs the page's SHIPPED modules actually import, fetched in
1156
+ // parallel instead of discovered level by level.
1157
+ const vendorPreloads = vendorPreloadTargets(hints.vendorSpecifiers);
1196
1158
  // Extract CSP nonce from request headers (if present).
1197
- const nonce = opts.req ? getNonce(opts.req) : undefined;
1159
+ const nonce = getNonce();
1198
1160
  const wrapOpts = {
1199
1161
  metadata,
1200
1162
  moduleUrls,
@@ -1342,7 +1304,7 @@ export async function ssrPage(route, params, url, opts) {
1342
1304
  // Error paths still need to honor the request's CSP nonce so the
1343
1305
  // error page's boot scripts (when moduleUrls is non-empty) and
1344
1306
  // the meta csp-nonce tag both pass strict-CSP enforcement.
1345
- const errNonce = opts.req ? getNonce(opts.req) : undefined;
1307
+ const errNonce = getNonce();
1346
1308
  // One dedup set for the whole 500 path: the walk below and the
1347
1309
  // global-error attempt after it collapse REPEATS of the same cause (a
1348
1310
  // shared layout that fails every attempt) while still reporting a
@@ -16,19 +16,15 @@ import { escapeAttr, escapeHtml } from './escape.js';
16
16
  * scope. Delegates to `cspNonce()`, which returns the per-request nonce
17
17
  * the handler MINTED when CSP is enabled (issue #233), or, as a fallback,
18
18
  * the nonce parsed from an inbound `Content-Security-Policy` request
19
- * header (the legacy consume-only path). Using the same source as the
19
+ * header (a proxy that mints its own). Using the same source as the
20
20
  * `Content-Security-Policy` response header is what guarantees the inline
21
21
  * boot script, the importmap, the modulepreload hints, and the header all
22
- * carry the EXACT same nonce: one minted value, no drift.
22
+ * carry the EXACT same nonce: one minted value, no drift. The value comes
23
+ * from the request-scoped AsyncLocalStorage store, so it takes no request.
23
24
  *
24
- * `req` is accepted (and ignored) so existing call sites stay unchanged;
25
- * the value comes from the request-scoped AsyncLocalStorage store, not
26
- * the argument.
27
- *
28
- * @param {Request} [_req]
29
25
  * @returns {string | undefined}
30
26
  */
31
- export function getNonce(_req) {
27
+ export function getNonce() {
32
28
  return cspNonce() || undefined;
33
29
  }
34
30
 
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The boot's shipped-module assembly, shared by the SSR render and the 103
3
+ * Early Hints (#1644).
4
+ *
5
+ * For a matched route the client boot imports the page and its layouts MINUS
6
+ * the inert ones (a module that does no client work, #170), with an
7
+ * IMPORT-ONLY module (#605) replaced by the component frontier it exists to
8
+ * import, and a `static lazy` component in that frontier (#1524) diverted to
9
+ * the lazy loader instead of imported. `ssr/render.js` derived that list per
10
+ * request, and the Node listener's `routeFor` (the 103 Early Hints) carried a
11
+ * second copy of the same loop that had drifted: it knew nothing of lazy
12
+ * files, so a lazy frontier component was early-hinted although the boot
13
+ * never imports it.
14
+ *
15
+ * One function now owns the assembly, memoized per ROUTE OBJECT in a WeakMap
16
+ * (the route table is rebuilt per build, so a stale entry dies with its
17
+ * route, and a dynamic route cannot grow the memo the way a pathname key
18
+ * would) and validated against the build epoch (the inert / import-only /
19
+ * lazy sets are per-build inputs). The hinted url list, the same modules
20
+ * through `withBasePath` then `withAssetHash`, is memoized beside it so a
21
+ * warm GET costs one WeakMap lookup.
22
+ */
23
+ import { buildEpoch } from '../build-epoch.js';
24
+ import { withBasePath } from '../base-path.js';
25
+ import { withAssetHash, assetHashMisses } from '../asset-hash.js';
26
+ import { toUrlPath } from './preloads.js';
27
+
28
+ /**
29
+ * @typedef {object} ShippedDeps
30
+ * @property {string} appDir
31
+ * @property {Set<string> | undefined} inert inert page / layout files (never shipped)
32
+ * @property {Map<string, string[]> | undefined} importOnly import-only module -> the component files emitted in its place
33
+ * @property {Map<string, string[]> | undefined} lazyFiles lazy component file -> its tags
34
+ * @property {string | undefined} instrumentationClient `instrumentation-client.*`, imported first when present
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} ShippedModules
39
+ * @property {string[]} moduleUrls url paths the boot imports, in import order
40
+ * @property {Record<string, string>} frontierLazy tag -> url for lazy components diverted out of the frontier
41
+ */
42
+
43
+ /** @type {WeakMap<object, { epoch: number, value: ShippedModules, hinted: string[] | null, hintedBasePath: string }>} */
44
+ const MEMO = new WeakMap();
45
+
46
+ /**
47
+ * The modules the client boot imports for `route`, plus the lazy frontier it
48
+ * registers instead. Same object back for the same route within a build.
49
+ *
50
+ * @param {{ file: string, layouts: string[] }} route the matched route (its object identity is the memo key)
51
+ * @param {ShippedDeps} deps
52
+ * @returns {ShippedModules}
53
+ */
54
+ export function shippedBootModules(route, deps) {
55
+ const hit = MEMO.get(route);
56
+ if (hit && hit.epoch === buildEpoch()) return hit.value;
57
+ const { appDir, inert, importOnly, lazyFiles, instrumentationClient } = deps;
58
+ /** @type {string[]} */
59
+ const moduleUrls = [];
60
+ /** @type {Record<string, string>} */
61
+ const frontierLazy = {};
62
+ const seen = new Set();
63
+ const push = (/** @type {string} */ abs) => {
64
+ const u = toUrlPath(abs, appDir);
65
+ const tags = lazyFiles && lazyFiles.get(abs);
66
+ if (tags) { for (const t of tags) frontierLazy[t] = u; return; }
67
+ if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
68
+ };
69
+ for (const f of [route.file, ...route.layouts]) {
70
+ if (inert && inert.has(f)) continue;
71
+ const emit = importOnly && importOnly.get(f);
72
+ if (emit) emit.forEach(push);
73
+ else push(f);
74
+ }
75
+ // instrumentation-client.{js,ts} (#848): import it FIRST in the client boot
76
+ // so it runs before app modules (Next's instrumentation-client ordering).
77
+ // It ships even on an otherwise-static page (the app opted into client work).
78
+ if (instrumentationClient) {
79
+ const u = toUrlPath(instrumentationClient, appDir);
80
+ const i = moduleUrls.indexOf(u);
81
+ if (i !== -1) moduleUrls.splice(i, 1);
82
+ moduleUrls.unshift(u);
83
+ }
84
+ const value = { moduleUrls, frontierLazy };
85
+ MEMO.set(route, { epoch: buildEpoch(), value, hinted: null, hintedBasePath: '' });
86
+ return value;
87
+ }
88
+
89
+ /**
90
+ * The 103 Early Hints url list for `route`: the SAME modules the boot
91
+ * imports, through `withBasePath` then `withAssetHash`, so the hint warms
92
+ * exactly the url the body requests (#243). Memoized beside the assembly.
93
+ *
94
+ * @param {{ file: string, layouts: string[] }} route
95
+ * @param {ShippedDeps} deps
96
+ * @param {string} basePath the normalized `webjs.basePath` (`''` at a root mount)
97
+ * @returns {string[]}
98
+ */
99
+ export function hintedBootModules(route, deps, basePath) {
100
+ const { moduleUrls } = shippedBootModules(route, deps);
101
+ const entry = /** @type {NonNullable<ReturnType<typeof MEMO.get>>} */ (MEMO.get(route));
102
+ if (entry.hinted && entry.hintedBasePath === basePath) return entry.hinted;
103
+ // Not stored when a hash read failed transiently while computing it
104
+ // (`assetHashMisses` moved): the list is served with the un-fingerprinted
105
+ // url, and the next request re-attempts the read (see `asset-hash.js`).
106
+ const missesBefore = assetHashMisses();
107
+ const hinted = moduleUrls.map((u) => withAssetHash(withBasePath(u, basePath), basePath));
108
+ if (assetHashMisses() === missesBefore) {
109
+ entry.hinted = hinted;
110
+ entry.hintedBasePath = basePath;
111
+ }
112
+ return hinted;
113
+ }