@webjsdev/server 0.8.89 → 0.8.91

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, assembleBootModules } from './shipped-modules.js';
7
7
  import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from '../action-seed.js';
8
8
  import {
9
9
  readRevalidate,
@@ -674,34 +674,21 @@ function reportBoundaryLayoutError(err, opts, cfg = {}) {
674
674
  * @returns {string[]}
675
675
  */
676
676
  function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
677
- /** @type {string[]} */
678
- const urls = [];
679
- const seen = new Set();
680
- const push = (abs) => {
681
- const u = toUrlPath(abs, opts.appDir);
682
- if (!seen.has(u)) { seen.add(u); urls.push(u); }
683
- };
684
- for (const f of (boundaryFile ? [boundaryFile, ...wrapLayouts] : [...wrapLayouts])) {
685
- if (opts.inertRouteModules && opts.inertRouteModules.has(f)) continue;
686
- const emit = opts.importOnlyRouteModules && opts.importOnlyRouteModules.get(f);
687
- if (emit) emit.forEach(push);
688
- else push(f);
689
- }
690
- // instrumentation-client.{js,ts} (#848) rides the SAME first-import contract
691
- // it has on the happy path: it runs before app modules so the app's client
692
- // error reporting is installed before anything can throw. A boundary page is
693
- // where that matters most, so it must not be the one place it is missing.
694
- // The set is never empty by the time we get here (the boundary file is
695
- // always pushed above, since only pages and layouts are fed to the elision
696
- // analysis, so a boundary is never inert and never import-only), which is
697
- // why this needs no non-empty guard.
698
- if (opts.instrumentationClient) {
699
- const u = toUrlPath(opts.instrumentationClient, opts.appDir);
700
- const i = urls.indexOf(u);
701
- if (i !== -1) urls.splice(i, 1);
702
- urls.unshift(u);
703
- }
704
- return urls;
677
+ // The happy path's own assembly (`shipped-modules.js`), un-memoized because a
678
+ // boundary's file list is built per render. instrumentation-client.{js,ts}
679
+ // (#848) rides the SAME first-import contract it has on the happy path: it
680
+ // runs before app modules so the app's client error reporting is installed
681
+ // before anything can throw, and a boundary page is where that matters most.
682
+ // `lazyFiles` is deliberately not passed: a boundary render has no lazy map
683
+ // of its own to register a diverted frontier component with, so a `static
684
+ // lazy` component imported by a boundary is imported eagerly, as before.
685
+ return assembleBootModules(boundaryFile ? [boundaryFile, ...wrapLayouts] : wrapLayouts, {
686
+ appDir: opts.appDir,
687
+ inert: opts.inertRouteModules,
688
+ importOnly: opts.importOnlyRouteModules,
689
+ lazyFiles: undefined,
690
+ instrumentationClient: opts.instrumentationClient,
691
+ }).moduleUrls;
705
692
  }
706
693
 
707
694
  /**
@@ -1113,37 +1100,19 @@ export async function ssrPage(route, params, url, opts) {
1113
1100
  // lazy loader instead of imported, exactly as a shipping module's served
1114
1101
  // source defers its side-effect import of one, so the boot never fetches
1115
1102
  // it up front and a tag revealed later still loads on first sight.
1116
- const inert = opts.inertRouteModules;
1117
- const importOnly = opts.importOnlyRouteModules;
1103
+ //
1104
+ // The assembly itself lives in `shipped-modules.js` (#1644), shared with
1105
+ // the Node listener's 103 Early Hints and memoized per route object and
1106
+ // build epoch; nothing below mutates `moduleUrls`, which is what lets the
1107
+ // memoized array be handed out as is.
1118
1108
  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
- }
1109
+ const { moduleUrls, frontierLazy } = shippedBootModules(route, {
1110
+ appDir: opts.appDir,
1111
+ inert: opts.inertRouteModules,
1112
+ importOnly: opts.importOnlyRouteModules,
1113
+ lazyFiles,
1114
+ instrumentationClient: opts.instrumentationClient,
1115
+ });
1147
1116
  // Emit <link rel="modulepreload"> for every custom element that
1148
1117
  // actually rendered PLUS their transitive dependencies (from the
1149
1118
  // module graph). URLs are deduplicated so the browser never sees
@@ -1153,46 +1122,26 @@ export async function ssrPage(route, params, url, opts) {
1153
1122
  const { eager: eagerComponents, lazy: renderedLazy } =
1154
1123
  componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents, lazyFiles);
1155
1124
  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,
1125
+ // Both preload passes (the app-module hints and the vendor hints, #754)
1126
+ // root at the BOOT's shipped set plus the rendered components and skip a
1127
+ // lazy component (#1524) or an elided one and the subtree only it reaches;
1128
+ // `pagePreloadHints` walks that closure ONCE for both (#1644), where the
1129
+ // two passes used to walk it twice. The over-fetch rules (#780, #754) are
1130
+ // documented on the helper.
1131
+ const hints = pagePreloadHints({
1132
+ graph: opts.moduleGraph,
1133
+ appDir: opts.appDir,
1173
1134
  moduleUrls,
1174
- opts.moduleGraph,
1175
- shippedRoots,
1176
- opts.appDir,
1177
- opts.serverFiles,
1178
- preloadSkip,
1179
- );
1135
+ eagerComponents,
1136
+ elidableComponents: opts.elidableComponents,
1137
+ lazyFiles,
1138
+ serverFiles: opts.serverFiles,
1139
+ });
1140
+ const preloads = hints.preloads;
1180
1141
  // 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
- );
1142
+ // vendor URLs the page's SHIPPED modules actually import, fetched in
1143
+ // parallel instead of discovered level by level.
1144
+ const vendorPreloads = vendorPreloadTargets(hints.vendorSpecifiers);
1196
1145
  // Extract CSP nonce from request headers (if present).
1197
1146
  const nonce = getNonce();
1198
1147
  const wrapOpts = {
@@ -0,0 +1,129 @@
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 value = assembleBootModules([route.file, ...route.layouts], deps);
58
+ MEMO.set(route, { epoch: buildEpoch(), value, hinted: null, hintedBasePath: '' });
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * The assembly itself, un-memoized: the one loop behind `shippedBootModules`
64
+ * (a matched page and its layouts) and the boundary render in `ssr/render.js`
65
+ * (an error / not-found / forbidden / unauthorized file and the layouts it is
66
+ * wrapped in, a per-render list with no stable object to key a memo on). One
67
+ * function so the two cannot drift, which is how the 103 Early Hints lost the
68
+ * lazy-frontier rule before #1644.
69
+ *
70
+ * @param {string[]} files the route modules in import order (page or boundary first, then its layouts)
71
+ * @param {ShippedDeps} deps
72
+ * @returns {ShippedModules}
73
+ */
74
+ export function assembleBootModules(files, deps) {
75
+ const { appDir, inert, importOnly, lazyFiles, instrumentationClient } = deps;
76
+ /** @type {string[]} */
77
+ const moduleUrls = [];
78
+ /** @type {Record<string, string>} */
79
+ const frontierLazy = {};
80
+ const seen = new Set();
81
+ const push = (/** @type {string} */ abs) => {
82
+ const u = toUrlPath(abs, appDir);
83
+ const tags = lazyFiles && lazyFiles.get(abs);
84
+ if (tags) { for (const t of tags) frontierLazy[t] = u; return; }
85
+ if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
86
+ };
87
+ for (const f of files) {
88
+ if (inert && inert.has(f)) continue;
89
+ const emit = importOnly && importOnly.get(f);
90
+ if (emit) emit.forEach(push);
91
+ else push(f);
92
+ }
93
+ // instrumentation-client.{js,ts} (#848): import it FIRST in the client boot
94
+ // so it runs before app modules (Next's instrumentation-client ordering).
95
+ // It ships even on an otherwise-static page (the app opted into client work).
96
+ if (instrumentationClient) {
97
+ const u = toUrlPath(instrumentationClient, appDir);
98
+ const i = moduleUrls.indexOf(u);
99
+ if (i !== -1) moduleUrls.splice(i, 1);
100
+ moduleUrls.unshift(u);
101
+ }
102
+ return { moduleUrls, frontierLazy };
103
+ }
104
+
105
+ /**
106
+ * The 103 Early Hints url list for `route`: the SAME modules the boot
107
+ * imports, through `withBasePath` then `withAssetHash`, so the hint warms
108
+ * exactly the url the body requests (#243). Memoized beside the assembly.
109
+ *
110
+ * @param {{ file: string, layouts: string[] }} route
111
+ * @param {ShippedDeps} deps
112
+ * @param {string} basePath the normalized `webjs.basePath` (`''` at a root mount)
113
+ * @returns {string[]}
114
+ */
115
+ export function hintedBootModules(route, deps, basePath) {
116
+ const { moduleUrls } = shippedBootModules(route, deps);
117
+ const entry = /** @type {NonNullable<ReturnType<typeof MEMO.get>>} */ (MEMO.get(route));
118
+ if (entry.hinted && entry.hintedBasePath === basePath) return entry.hinted;
119
+ // Not stored when a hash read failed transiently while computing it
120
+ // (`assetHashMisses` moved): the list is served with the un-fingerprinted
121
+ // url, and the next request re-attempts the read (see `asset-hash.js`).
122
+ const missesBefore = assetHashMisses();
123
+ const hinted = moduleUrls.map((u) => withAssetHash(withBasePath(u, basePath), basePath));
124
+ if (assetHashMisses() === missesBefore) {
125
+ entry.hinted = hinted;
126
+ entry.hintedBasePath = basePath;
127
+ }
128
+ return hinted;
129
+ }