@webjsdev/server 0.7.3 → 0.8.1

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.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy } from '@webjsdev/core';
4
- import { importMapTag } from './importmap.js';
4
+ import { importMapTag, vendorIntegrityFor, importMapHash } from './importmap.js';
5
+ import { jsonForScriptTag } from './script-tag-json.js';
5
6
  import { readToken, newToken, cookieHeader } from './csrf.js';
6
7
  import { transitiveDeps } from './module-graph.js';
7
8
 
@@ -51,7 +52,18 @@ export async function ssrPage(route, params, url, opts) {
51
52
  // import graph. Combined with the modulepreload hints below, this
52
53
  // is the Rails 7+ / Hotwire pattern: per-file ESM, no bundling,
53
54
  // HTTP/2 multiplex on the wire.
54
- const moduleUrls = [route.file, ...route.layouts].map((f) => toUrlPath(f, opts.appDir));
55
+ //
56
+ // Inert route modules (a page or layout that does no client work, even
57
+ // transitively) are dropped from the boot script: the browser never
58
+ // downloads them. The SSR'd HTML is the complete output, and
59
+ // progressive enhancement is unaffected, so a fully-static route ships
60
+ // zero application JS. The analysis is conservative (anything that
61
+ // touches the client router, a signal, an event, an npm import, or a
62
+ // shipping component keeps shipping).
63
+ const inert = opts.inertRouteModules;
64
+ const moduleUrls = [route.file, ...route.layouts]
65
+ .filter((f) => !(inert && inert.has(f)))
66
+ .map((f) => toUrlPath(f, opts.appDir));
55
67
  // Emit <link rel="modulepreload"> for every custom element that
56
68
  // actually rendered PLUS their transitive dependencies (from the
57
69
  // module graph). URLs are deduplicated so the browser never sees
@@ -59,7 +71,7 @@ export async function ssrPage(route, params, url, opts) {
59
71
  // preloads and instead loaded via IntersectionObserver when they
60
72
  // enter the viewport.
61
73
  const { eager: eagerComponents, lazy: lazyComponents } =
62
- componentPreloads(suspenseCtx.usedComponents, opts.appDir);
74
+ componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents);
63
75
  const preloads = deduplicatedPreloads(
64
76
  eagerComponents,
65
77
  moduleUrls,
@@ -67,6 +79,7 @@ export async function ssrPage(route, params, url, opts) {
67
79
  [route.file, ...route.layouts],
68
80
  opts.appDir,
69
81
  opts.serverFiles,
82
+ opts.elidableComponents,
70
83
  );
71
84
  // Extract CSP nonce from request headers (if present).
72
85
  const nonce = opts.req ? getNonce(opts.req) : undefined;
@@ -93,6 +106,7 @@ export async function ssrPage(route, params, url, opts) {
93
106
  opts.req,
94
107
  url,
95
108
  metadata,
109
+ nonce,
96
110
  );
97
111
  } catch (err) {
98
112
  if (isRedirect(err)) {
@@ -103,6 +117,10 @@ export async function ssrPage(route, params, url, opts) {
103
117
  const html = await ssrNotFoundHtml(null, opts);
104
118
  return htmlResponse(html, 404, opts.req, url);
105
119
  }
120
+ // Error paths still need to honor the request's CSP nonce so the
121
+ // error page's boot scripts (when moduleUrls is non-empty) and
122
+ // the meta csp-nonce tag both pass strict-CSP enforcement.
123
+ const errNonce = opts.req ? getNonce(opts.req) : undefined;
106
124
  // Try nearest error.js (innermost → outermost).
107
125
  for (let i = route.errors.length - 1; i >= 0; i--) {
108
126
  try {
@@ -111,7 +129,7 @@ export async function ssrPage(route, params, url, opts) {
111
129
  const tree = await mod.default({ ...ctx, error: err });
112
130
  const body = await renderToString(tree);
113
131
  const moduleUrls = [route.file, ...route.layouts].map((f) => toUrlPath(f, opts.appDir));
114
- const html = wrapInDocument(body, { metadata, moduleUrls, dev: opts.dev });
132
+ const html = wrapInDocument(body, { metadata, moduleUrls, dev: opts.dev, nonce: errNonce });
115
133
  return htmlResponse(html, 500, opts.req, url);
116
134
  } catch (nested) {
117
135
  // fall through to next error boundary
@@ -125,7 +143,7 @@ export async function ssrPage(route, params, url, opts) {
125
143
  )}</pre>`
126
144
  : `<h1>Server error</h1><p>Something went wrong. Please try again.</p>`;
127
145
  return htmlResponse(
128
- wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev }),
146
+ wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev, nonce: errNonce }),
129
147
  500,
130
148
  opts.req,
131
149
  url
@@ -156,6 +174,11 @@ function htmlResponse(html, status, req, url, metadata) {
156
174
  // Default: no caching. Pages are dynamic by default: the developer
157
175
  // opts in to caching explicitly via metadata.cacheControl.
158
176
  headers.set('cache-control', metadata?.cacheControl || 'no-store');
177
+ // X-Webjs-Build carries the current importmap hash so the client
178
+ // router can detect post-deploy importmap changes on EVERY
179
+ // response, including the X-Webjs-Have partial responses that
180
+ // omit the head entirely. See router-client.js applySwap.
181
+ headers.set('x-webjs-build', importMapHash());
159
182
  if (req && !readToken(req)) {
160
183
  const secure = url ? url.protocol === 'https:' : false;
161
184
  headers.append('set-cookie', cookieHeader(newToken(), { secure }));
@@ -175,10 +198,12 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
175
198
  body = `<h1>404: Not found</h1><pre>${escapeHtml(String(e))}</pre>`;
176
199
  }
177
200
  }
201
+ const nonce = opts.req ? getNonce(opts.req) : undefined;
178
202
  return wrapInDocument(body, {
179
203
  metadata: { title: 'Not found' },
180
204
  moduleUrls: [],
181
205
  dev: opts.dev,
206
+ nonce,
182
207
  });
183
208
  }
184
209
 
@@ -632,11 +657,10 @@ export function publicEnvShim(opts) {
632
657
  }
633
658
  }
634
659
  env.NODE_ENV = opts.dev ? 'development' : 'production';
635
- const json = JSON.stringify(env).replace(/<\//g, '<\\/');
636
660
  const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
637
661
  return `<script${n}>`
638
662
  + `window.process=window.process||{};`
639
- + `window.process.env=Object.assign(window.process.env||{},${json});`
663
+ + `window.process.env=Object.assign(window.process.env||{},${jsonForScriptTag(env)});`
640
664
  + `</script>`;
641
665
  }
642
666
 
@@ -646,12 +670,12 @@ function wrapHead(opts) {
646
670
  // the request's CSP header by the caller.
647
671
  const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
648
672
 
649
- const imports = opts.moduleUrls.map((u) => `import ${JSON.stringify(u)};`).join('\n');
673
+ const imports = opts.moduleUrls.map((u) => `import ${jsonForScriptTag(u)};`).join('\n');
650
674
  const lazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
651
675
  ? opts.lazyComponents
652
676
  : null;
653
677
  const lazyBoot = lazyEntries
654
- ? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${JSON.stringify(lazyEntries)});`
678
+ ? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${jsonForScriptTag(lazyEntries)});`
655
679
  : '';
656
680
  const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
657
681
  const reload = opts.dev ? `<script type="module"${n} src="/__webjs/reload.js"></script>` : '';
@@ -910,11 +934,33 @@ function wrapHead(opts) {
910
934
  // module, then any custom `metadata.preload` entries (fonts, images, etc.)
911
935
  // (linkTags array was declared earlier so the metadata block above can
912
936
  // push icons / canonical / hreflang / archives / etc. into it.)
937
+ //
938
+ // Cross-origin URLs (vendor packages served from jspm.io etc.) MUST
939
+ // carry `crossorigin="anonymous"` on the preload link. Without it
940
+ // the browser either ignores the preload entirely or double-fetches
941
+ // (once for the preload as a non-CORS request, once for the actual
942
+ // module as a CORS request, defeating the optimization). Same-origin
943
+ // URLs get no attribute; adding `crossorigin=""` there would also
944
+ // double-fetch in some browsers because the preload becomes CORS
945
+ // but the import doesn't.
946
+ // CSP nonce on the preload link: under strict CSP (script-src
947
+ // 'nonce-...') the browser also gates modulepreload by the same
948
+ // policy. Without the attribute the preload is blocked and the
949
+ // import either falls back to a cold fetch or fails. Rails (via
950
+ // importmap-rails) applies nonce on every modulepreload tag for
951
+ // the same reason.
952
+ const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
913
953
  for (const url of opts.moduleUrls) {
914
- linkTags.push(`<link rel="modulepreload" href="${escapeAttr(url)}">`);
954
+ linkTags.push(
955
+ `<link rel="modulepreload" href="${escapeAttr(url)}"` +
956
+ `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
957
+ );
915
958
  }
916
959
  for (const url of opts.preloads || []) {
917
- linkTags.push(`<link rel="modulepreload" href="${escapeAttr(url)}">`);
960
+ linkTags.push(
961
+ `<link rel="modulepreload" href="${escapeAttr(url)}"` +
962
+ `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
963
+ );
918
964
  }
919
965
  if (Array.isArray(m.preload)) {
920
966
  for (const p of m.preload) {
@@ -1010,10 +1056,11 @@ function wrapHead(opts) {
1010
1056
  <html lang="en">
1011
1057
  <head>
1012
1058
  <meta charset="utf-8">
1059
+ ${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
1013
1060
  ${metaTags.join('\n')}
1014
1061
  <title>${escapeHtml(title)}</title>
1015
1062
  ${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}
1016
- ${importMapTag()}
1063
+ ${importMapTag({ nonce: opts.nonce })}
1017
1064
  ${linkTags.join('\n')}
1018
1065
  ${boot}
1019
1066
  ${reload}
@@ -1032,11 +1079,16 @@ ${suspenseBoot}
1032
1079
  * are NOT preloaded: they're loaded by the IntersectionObserver-based
1033
1080
  * lazy-loader when the element enters the viewport.
1034
1081
  *
1082
+ * Elidable (display-only) components are skipped entirely: their imports
1083
+ * are stripped from the served source, so preloading their module would
1084
+ * fetch JS the browser never executes.
1085
+ *
1035
1086
  * @param {Set<string>} usedTags
1036
1087
  * @param {string} appDir
1088
+ * @param {Set<string>} [elidable] absolute paths of elidable component files
1037
1089
  * @returns {{ eager: string[], lazy: Record<string, string> }}
1038
1090
  */
1039
- function componentPreloads(usedTags, appDir) {
1091
+ function componentPreloads(usedTags, appDir, elidable) {
1040
1092
  const eager = [];
1041
1093
  /** @type {Record<string, string>} */
1042
1094
  const lazy = {};
@@ -1046,6 +1098,7 @@ function componentPreloads(usedTags, appDir) {
1046
1098
  try {
1047
1099
  const abs = fileURLToPath(fileUrl);
1048
1100
  if (!abs.startsWith(appDir)) continue;
1101
+ if (elidable && elidable.has(abs)) continue;
1049
1102
  const url = toUrlPath(abs, appDir);
1050
1103
  if (isLazy(tag)) {
1051
1104
  lazy[tag] = url;
@@ -1066,9 +1119,10 @@ function componentPreloads(usedTags, appDir) {
1066
1119
  * @param {import('./module-graph.js').ModuleGraph | undefined} graph
1067
1120
  * @param {string[]} entryFiles absolute paths of page + layout files
1068
1121
  * @param {string} appDir
1122
+ * @param {Set<string>} [elidableComponents] absolute paths to skip in the walk
1069
1123
  * @returns {string[]}
1070
1124
  */
1071
- function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles) {
1125
+ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles, elidableComponents) {
1072
1126
  const seen = new Set(moduleUrls);
1073
1127
  const result = [];
1074
1128
 
@@ -1101,7 +1155,10 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD
1101
1155
  const abs = resolve(appDir, url.startsWith('/') ? url.slice(1) : url);
1102
1156
  allEntries.push(abs);
1103
1157
  }
1104
- const deps = transitiveDeps(graph, allEntries, appDir);
1158
+ // Skip elidable components and any subtree reachable only through
1159
+ // them: their imports are stripped from served source, so the
1160
+ // browser never fetches these modules.
1161
+ const deps = transitiveDeps(graph, allEntries, appDir, elidableComponents);
1105
1162
  for (const dep of deps) {
1106
1163
  if (byIndex(dep)) continue;
1107
1164
  const url = toUrlPath(dep, appDir);
@@ -1126,12 +1183,15 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD
1126
1183
  * @param {URL | undefined} url
1127
1184
  * @param {Record<string, any>} [metadata]
1128
1185
  */
1129
- function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata) {
1186
+ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce) {
1130
1187
  const encoder = new TextEncoder();
1131
1188
  const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
1132
1189
  // Default: no caching. Pages are dynamic by default: the developer
1133
1190
  // opts in to caching explicitly via metadata.cacheControl.
1134
1191
  headers.set('cache-control', metadata?.cacheControl || 'no-store');
1192
+ // See htmlResponse: build hash on every response for the client
1193
+ // router's importmap-mismatch detection on partial swaps.
1194
+ headers.set('x-webjs-build', importMapHash());
1135
1195
  if (req && !readToken(req)) {
1136
1196
  const secure = url ? url.protocol === 'https:' : false;
1137
1197
  headers.append('set-cookie', cookieHeader(newToken(), { secure }));
@@ -1169,9 +1229,16 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1169
1229
  // Emit just the <template>: the MutationObserver-based resolver
1170
1230
  // in the boot script detects it and swaps it into the placeholder.
1171
1231
  // Falls back to the __webjsResolve global for browsers without MO.
1232
+ // The fallback <script> carries the request's CSP nonce so
1233
+ // strict-CSP enforcement passes. Browsers that support
1234
+ // MutationObserver (all evergreen) handle the swap via the
1235
+ // boot script's observer and skip this fallback; the
1236
+ // <script> is here for legacy / extremely-restrictive
1237
+ // environments. Either way it must be nonce-signed.
1238
+ const scriptNonce = nonce ? ` nonce="${escapeAttr(nonce)}"` : '';
1172
1239
  const chunk =
1173
1240
  `<template data-webjs-resolve="${r.id}">${r.html}</template>` +
1174
- `<script>window.__webjsResolve&&__webjsResolve("${r.id}")</script>`;
1241
+ `<script${scriptNonce}>window.__webjsResolve&&__webjsResolve("${r.id}")</script>`;
1175
1242
  controller.enqueue(encoder.encode(chunk));
1176
1243
  }
1177
1244
  }
@@ -1208,6 +1275,17 @@ function toUrlPath(file, appDir) {
1208
1275
  /**
1209
1276
  * Extract a CSP nonce from the request's Content-Security-Policy header.
1210
1277
  * Matches `'nonce-<base64>'` in the script-src directive.
1278
+ *
1279
+ * The regex matches the first `nonce-...` token anywhere in the
1280
+ * header, regardless of which directive it sits under. This is
1281
+ * intentional: in practice every reasonable CSP uses the same
1282
+ * nonce across `script-src` and `style-src` (a per-request
1283
+ * single-nonce model), and webjs only emits `<script>` /
1284
+ * `<link rel="modulepreload">` tags, so reading the first match
1285
+ * is the right behaviour. A future caller that emits styled
1286
+ * inline content under a separate style nonce would need to
1287
+ * extend this to be directive-scoped.
1288
+ *
1211
1289
  * @param {Request} req
1212
1290
  * @returns {string | undefined}
1213
1291
  */
@@ -1226,6 +1304,44 @@ function escapeAttr(s) {
1226
1304
  return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
1227
1305
  }
1228
1306
 
1307
+ /**
1308
+ * Decide whether a `<link rel="modulepreload">` href needs a
1309
+ * `crossorigin="anonymous"` attribute. True for absolute URLs with
1310
+ * an http(s) scheme (vendor packages from jspm.io etc.); false for
1311
+ * same-origin paths like `/__webjs/core/index.js`. Browsers require
1312
+ * crossorigin on cross-origin module preload, else the preload is
1313
+ * wasted or double-fetched. Same-origin URLs must NOT have it for
1314
+ * the same reason in reverse.
1315
+ *
1316
+ * Exported for tests; production callers use it via documentParts.
1317
+ *
1318
+ * @param {string} url
1319
+ * @returns {string} either ` crossorigin="anonymous"` or empty
1320
+ */
1321
+ export function preloadCrossOriginAttr(url) {
1322
+ return /^https?:\/\//i.test(url) ? ' crossorigin="anonymous"' : '';
1323
+ }
1324
+
1325
+ /**
1326
+ * Look up the SRI integrity hash for a vendor URL and format it as a
1327
+ * `integrity="sha384-..."` attribute. Empty string for URLs without a
1328
+ * known hash (framework files, user code, vendor URLs in live-API
1329
+ * mode without a pin file).
1330
+ *
1331
+ * @param {string} url
1332
+ * @returns {string}
1333
+ */
1334
+ export function integrityAttr(url) {
1335
+ const hash = vendorIntegrityFor(url);
1336
+ // Belt and suspenders: readPinFile already validates the integrity
1337
+ // value end-to-end against /^sha(256|384|512)-[A-Za-z0-9+/=]+$/, so
1338
+ // a valid hash has no HTML-special chars and escapeAttr is a no-op.
1339
+ // But emission goes through the same attribute-injection-safe path
1340
+ // as everything else in the SSR pipeline so a future regression in
1341
+ // the validator doesn't bypass it.
1342
+ return hash ? ` integrity="${escapeAttr(hash)}"` : '';
1343
+ }
1344
+
1229
1345
  /**
1230
1346
  * Serialize a Next.js-shaped viewport object into the comma-separated
1231
1347
  * `content` string the meta tag expects. Recognised fields: