@reckona/mreact-router 0.0.201 → 0.0.203

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.
Files changed (93) hide show
  1. package/README.md +6 -3
  2. package/dist/actions.d.ts +2 -3
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +61 -41
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/cloudflare.d.ts +3 -1
  7. package/dist/adapters/cloudflare.d.ts.map +1 -1
  8. package/dist/adapters/cloudflare.js +74 -11
  9. package/dist/adapters/cloudflare.js.map +1 -1
  10. package/dist/adapters/node.d.ts +7 -0
  11. package/dist/adapters/node.d.ts.map +1 -1
  12. package/dist/adapters/node.js +7 -1
  13. package/dist/adapters/node.js.map +1 -1
  14. package/dist/adapters/static.d.ts.map +1 -1
  15. package/dist/adapters/static.js +14 -3
  16. package/dist/adapters/static.js.map +1 -1
  17. package/dist/build.d.ts +5 -0
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +137 -14
  20. package/dist/build.js.map +1 -1
  21. package/dist/built-runtime.d.ts +4 -1
  22. package/dist/built-runtime.d.ts.map +1 -1
  23. package/dist/built-runtime.js.map +1 -1
  24. package/dist/cache.d.ts +27 -1
  25. package/dist/cache.d.ts.map +1 -1
  26. package/dist/cache.js +83 -16
  27. package/dist/cache.js.map +1 -1
  28. package/dist/cli-options.d.ts +7 -0
  29. package/dist/cli-options.d.ts.map +1 -1
  30. package/dist/cli-options.js +27 -2
  31. package/dist/cli-options.js.map +1 -1
  32. package/dist/cli.js +3 -2
  33. package/dist/cli.js.map +1 -1
  34. package/dist/client.d.ts +5 -0
  35. package/dist/client.d.ts.map +1 -1
  36. package/dist/client.js +11 -2
  37. package/dist/client.js.map +1 -1
  38. package/dist/navigation-marker.d.ts +2 -0
  39. package/dist/navigation-marker.d.ts.map +1 -0
  40. package/dist/navigation-marker.js +294 -0
  41. package/dist/navigation-marker.js.map +1 -0
  42. package/dist/navigation-runtime.d.ts +1 -1
  43. package/dist/navigation-runtime.d.ts.map +1 -1
  44. package/dist/navigation-runtime.js +1 -1
  45. package/dist/navigation-runtime.js.map +1 -1
  46. package/dist/node-server.d.ts +6 -0
  47. package/dist/node-server.d.ts.map +1 -1
  48. package/dist/node-server.js +19 -1
  49. package/dist/node-server.js.map +1 -1
  50. package/dist/prerender-entry.d.ts +7 -0
  51. package/dist/prerender-entry.d.ts.map +1 -0
  52. package/dist/prerender-entry.js +99 -0
  53. package/dist/prerender-entry.js.map +1 -0
  54. package/dist/render.d.ts +20 -1
  55. package/dist/render.d.ts.map +1 -1
  56. package/dist/render.js +190 -110
  57. package/dist/render.js.map +1 -1
  58. package/dist/request-header-tracking.d.ts +44 -0
  59. package/dist/request-header-tracking.d.ts.map +1 -0
  60. package/dist/request-header-tracking.js +116 -0
  61. package/dist/request-header-tracking.js.map +1 -0
  62. package/dist/route-source.d.ts +16 -0
  63. package/dist/route-source.d.ts.map +1 -1
  64. package/dist/route-source.js +55 -1
  65. package/dist/route-source.js.map +1 -1
  66. package/dist/security-headers.d.ts +16 -0
  67. package/dist/security-headers.d.ts.map +1 -1
  68. package/dist/security-headers.js +37 -0
  69. package/dist/security-headers.js.map +1 -1
  70. package/dist/serve.d.ts +10 -0
  71. package/dist/serve.d.ts.map +1 -1
  72. package/dist/serve.js +132 -42
  73. package/dist/serve.js.map +1 -1
  74. package/package.json +11 -11
  75. package/src/actions.ts +72 -56
  76. package/src/adapters/cloudflare.ts +118 -16
  77. package/src/adapters/node.ts +14 -1
  78. package/src/adapters/static.ts +18 -5
  79. package/src/build.ts +176 -12
  80. package/src/built-runtime.ts +2 -2
  81. package/src/cache.ts +127 -15
  82. package/src/cli-options.ts +39 -2
  83. package/src/cli.ts +7 -1
  84. package/src/client.ts +15 -2
  85. package/src/navigation-marker.ts +353 -0
  86. package/src/navigation-runtime.ts +1 -0
  87. package/src/node-server.ts +27 -1
  88. package/src/prerender-entry.ts +129 -0
  89. package/src/render.ts +267 -121
  90. package/src/request-header-tracking.ts +161 -0
  91. package/src/route-source.ts +71 -0
  92. package/src/security-headers.ts +41 -0
  93. package/src/serve.ts +188 -48
package/src/build.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  buildNavigationRuntimeBundle,
47
47
  clientScriptForPath,
48
48
  routeIdForPath,
49
+ routeMarkerParts,
49
50
  type BuildClientRouteOutputOptions,
50
51
  } from "./navigation-runtime.js";
51
52
  import {
@@ -70,7 +71,12 @@ import {
70
71
  import type { ModuleMetadata } from "@reckona/mreact-compiler";
71
72
  import type { RouteCachePolicy } from "./cache.js";
72
73
  import { routeCachePolicyFromSource } from "./cache.js";
73
- import { bundleMiddlewareModuleCode, renderAppRequest } from "./render.js";
74
+ import {
75
+ bundleMiddlewareModuleCode,
76
+ prerenderVariantMarkerParts,
77
+ renderAppRequest,
78
+ type RenderAppRequestRuntimeOptions,
79
+ } from "./render.js";
74
80
  import { createAppRouterImportPolicyPlugin, type AppRouterImportPolicy } from "./import-policy.js";
75
81
  import {
76
82
  hasGenerateStaticParamsExport,
@@ -78,6 +84,7 @@ import {
78
84
  hasPrerenderExport,
79
85
  isStreamRouteSource,
80
86
  routeClosureMayUseAwaitBoundary,
87
+ routeClosureMayUseRequestInput,
81
88
  stripRouteBuildExports,
82
89
  stripRouteClientOnlyExports,
83
90
  stripRouteClientSource,
@@ -97,6 +104,11 @@ import { collectRouteCssFilesFromSources, collectSpecialBoundaryFiles } from "./
97
104
  import { existingRouteShellCandidates } from "./route-shells.js";
98
105
  import { sourceModuleCandidates } from "./source-modules.js";
99
106
  import { collectBuildInferredServerActions } from "./server-action-inference.js";
107
+ import {
108
+ isVisitorDependentResponse,
109
+ PRERENDERED_ROUTE_SCHEMA_VERSION,
110
+ storedPrerenderedRouteHeaders,
111
+ } from "./prerender-entry.js";
100
112
  import { prepareRouteServerActionPlaceholders } from "./actions.js";
101
113
  import { viteDefineCacheKey, vitePluginsCacheKey } from "./vite-plugin-cache-key.js";
102
114
  import { workspacePackageFile } from "./workspace-packages.js";
@@ -368,6 +380,7 @@ export interface BuiltRouteSourceAnalysisSummary {
368
380
  routePath: string;
369
381
  sourceHash: string;
370
382
  streamRoute: boolean;
383
+ usesRequestInput?: boolean | undefined;
371
384
  usesRuntimeCacheControl: boolean;
372
385
  }
373
386
 
@@ -393,6 +406,7 @@ interface BuildRouteSourceAnalysis extends BuildSourceAnalysis {
393
406
  route: AppRoute & { kind: "page" };
394
407
  routeCode: string;
395
408
  streamRoute: boolean;
409
+ usesRequestInput: boolean;
396
410
  }
397
411
 
398
412
  interface BuildSourceAnalysisScope {
@@ -412,7 +426,11 @@ export interface BuiltServerModuleOutput {
412
426
  export interface BuiltPrerenderedRoute {
413
427
  headers: Record<string, string>;
414
428
  html: string;
429
+ navigationHtml?: string | undefined;
430
+ /** Identifies entries that satisfy the complete current prerender contract. */
431
+ schemaVersion?: 4 | undefined;
415
432
  status: number;
433
+ strictTransportSecurity?: string | undefined;
416
434
  }
417
435
 
418
436
  type StaticParams = Record<string, string | number | boolean | readonly string[]>;
@@ -1384,6 +1402,14 @@ async function analyzeBuildRouteSources(options: {
1384
1402
  projectRoot: options.projectRoot,
1385
1403
  source,
1386
1404
  }),
1405
+ usesRequestInput: await buildRouteUsesRequestInput({
1406
+ file,
1407
+ files: options.files,
1408
+ project: options.project,
1409
+ projectRoot: options.projectRoot,
1410
+ routeFile: route.file,
1411
+ source,
1412
+ }),
1387
1413
  },
1388
1414
  ] as const;
1389
1415
  },
@@ -1411,6 +1437,39 @@ async function analyzeBuildRouteSources(options: {
1411
1437
  };
1412
1438
  }
1413
1439
 
1440
+ async function buildRouteUsesRequestInput(options: {
1441
+ file: string;
1442
+ files: Record<string, string>;
1443
+ project: ResolvedAppRouterProject;
1444
+ projectRoot: string;
1445
+ routeFile: string;
1446
+ source: string;
1447
+ }): Promise<boolean> {
1448
+ const shells = await existingRouteShellCandidates(
1449
+ options.project.routesDir,
1450
+ options.routeFile,
1451
+ async (shellFile) =>
1452
+ options.files[relative(options.projectRoot, shellFile).split(sep).join("/")] !== undefined,
1453
+ );
1454
+ const sources = [
1455
+ { file: options.file, source: options.source },
1456
+ ...shells.flatMap((shell) => {
1457
+ const file = relative(options.projectRoot, shell.file).split(sep).join("/");
1458
+ const source = options.files[file];
1459
+ return source === undefined ? [] : [{ file, source }];
1460
+ }),
1461
+ ];
1462
+
1463
+ return sources.some((entry) =>
1464
+ routeClosureMayUseRequestInput({
1465
+ filename: entry.file,
1466
+ files: options.files,
1467
+ projectRoot: options.projectRoot,
1468
+ source: entry.source,
1469
+ })
1470
+ );
1471
+ }
1472
+
1414
1473
  function analyzeBuildSource(source: string, filename: string): BuildSourceAnalysis {
1415
1474
  const cachePolicy = routeCachePolicyFromSource(source);
1416
1475
  const sourceHash = hashText(source);
@@ -2564,7 +2623,11 @@ async function prerenderStaticRoutes(options: {
2564
2623
 
2565
2624
  const entries: Array<[string, BuiltPrerenderedRoute]> = [];
2566
2625
  for (const pathname of await prerenderPathsForRoute(route, analysis, options.vitePlugins)) {
2567
- const response = await renderAppRequest({
2626
+ const renderSignals = {
2627
+ headerDependent: () => true,
2628
+ strictTransportSecurity: () => undefined as string | undefined,
2629
+ };
2630
+ const renderOptions = {
2568
2631
  appDir: options.appDir,
2569
2632
  assetBaseUrl: options.assetBaseUrl,
2570
2633
  clientScripts,
@@ -2572,22 +2635,52 @@ async function prerenderStaticRoutes(options: {
2572
2635
  define: options.define,
2573
2636
  importPolicy,
2574
2637
  navigationScripts,
2575
- request: new Request(`http://mreact.local${pathname}`),
2638
+ request: new Request(
2639
+ `http://mreact.local${pathname}`,
2640
+ analysis.clientRoute
2641
+ ? undefined
2642
+ : { headers: { "x-mreact-prerender-variant-capture": "1" } },
2643
+ ),
2644
+ renderSignals,
2576
2645
  serverModuleCacheVersion,
2577
2646
  serverModules: serverModuleMap,
2578
2647
  vitePlugins: options.vitePlugins,
2579
- });
2580
- const headers: Record<string, string> = {};
2648
+ } satisfies RenderAppRequestRuntimeOptions;
2649
+ const response = await renderAppRequest(renderOptions);
2650
+ const renderedHtml = await response.text();
2581
2651
 
2582
- response.headers.forEach((value, key) => {
2583
- headers[key] = value;
2584
- });
2652
+ if (renderSignals.headerDependent() || isVisitorDependentResponse(response)) {
2653
+ continue;
2654
+ }
2655
+
2656
+ let html = renderedHtml;
2657
+ let navigationHtml = renderedHtml;
2658
+ if (!analysis.clientRoute) {
2659
+ const capture = prerenderVariantMarkerParts(route.path);
2660
+ const start = renderedHtml.indexOf(capture.prefix);
2661
+ const end = renderedHtml.lastIndexOf(capture.suffix);
2662
+ if (start === -1 || end < start + capture.prefix.length) {
2663
+ throw new Error(`Failed to capture prerender response variants for ${pathname}.`);
2664
+ }
2665
+ const before = renderedHtml.slice(0, start);
2666
+ const content = renderedHtml.slice(start + capture.prefix.length, end);
2667
+ const after = renderedHtml.slice(end + capture.suffix.length);
2668
+ const navigationMarker = routeMarkerParts(route.path);
2669
+ html = `${before}${content}${after}`;
2670
+ navigationHtml = `${before}${navigationMarker.prefix}${content}${navigationMarker.suffix}${after}`;
2671
+ }
2672
+
2673
+ const headers = storedPrerenderedRouteHeaders(response.headers);
2674
+ const strictTransportSecurity = renderSignals.strictTransportSecurity();
2585
2675
  entries.push([
2586
2676
  pathname,
2587
2677
  {
2588
2678
  headers,
2589
- html: await response.text(),
2679
+ html,
2680
+ navigationHtml,
2681
+ schemaVersion: PRERENDERED_ROUTE_SCHEMA_VERSION,
2590
2682
  status: response.status,
2683
+ ...(strictTransportSecurity === undefined ? {} : { strictTransportSecurity }),
2591
2684
  },
2592
2685
  ]);
2593
2686
  }
@@ -3141,6 +3234,7 @@ function builtRouteSourceAnalysisSummary(options: {
3141
3234
  routePath: options.analysis.route.path,
3142
3235
  sourceHash: options.analysis.sourceHash,
3143
3236
  streamRoute: options.analysis.streamRoute,
3237
+ usesRequestInput: options.analysis.usesRequestInput,
3144
3238
  usesRuntimeCacheControl: options.analysis.usesRuntimeCacheControl,
3145
3239
  };
3146
3240
  }
@@ -3887,6 +3981,7 @@ function cloudflarePageRouteFacadeModuleSource(componentImport: string): string
3887
3981
  const componentSlots = readComponentModuleExport(componentModule, "slots");
3888
3982
  const componentGenerateMetadata = readComponentModuleExport(componentModule, "generateMetadata");
3889
3983
  const componentMetadata = readComponentModuleExport(componentModule, "metadata");
3984
+ const componentSecurityHeadersApplied = readComponentModuleExport(componentModule, "__mreactSecurityHeadersApplied");
3890
3985
 
3891
3986
  export function App(props) {
3892
3987
  return renderCloudflareRouteComponent(props);
@@ -3901,6 +3996,7 @@ export const slots = componentSlots === undefined ? undefined : { ...componentSl
3901
3996
  export const generateMetadata =
3902
3997
  typeof componentGenerateMetadata === "function" ? componentGenerateMetadata : undefined;
3903
3998
  export const metadata = componentMetadata;
3999
+ export const __mreactSecurityHeadersApplied = componentSecurityHeadersApplied === true;
3904
4000
 
3905
4001
  function renderCloudflareRouteComponent(props) {
3906
4002
  const routeComponent = resolveCloudflareRouteComponent();
@@ -4647,7 +4743,9 @@ ${cloudflareShellRuntimeSource()}`;
4647
4743
  }
4648
4744
 
4649
4745
  function cloudflareShellRuntimeSource(): string {
4650
- return `async function renderLayoutShells(shells, props, namedSlots) {
4746
+ return `export const __mreactSecurityHeadersApplied = true;
4747
+
4748
+ async function renderLayoutShells(shells, props, namedSlots) {
4651
4749
  const slotContext = { consumedSlots: new Set(), namedSlots };
4652
4750
  const rendered = [];
4653
4751
  for (const shell of shells) {
@@ -4726,7 +4824,7 @@ function cloudflareHydrationMarkerParts(props) {
4726
4824
  const escapedRouteId = escapeHtmlAttribute(routeId);
4727
4825
  const propsJson = escapeScriptJson(JSON.stringify({
4728
4826
  params: props.params,
4729
- request: { url: props.request.url },
4827
+ request: { url: new URL(props.request.url).pathname },
4730
4828
  data: props.data,
4731
4829
  }));
4732
4830
  const clientReferencesJson = route.clientReferenceManifest === undefined || route.clientReferenceManifest.length === 0
@@ -5149,17 +5247,63 @@ function routeSecurityHeaders(security, request) {
5149
5247
  } else {
5150
5248
  headers["referrer-policy"] = validateHeaderValue(security?.referrerPolicy ?? "strict-origin-when-cross-origin");
5151
5249
  }
5250
+ if (security?.permissionsPolicy === null) {
5251
+ delete headers["permissions-policy"];
5252
+ } else {
5253
+ const permissionsPolicy = serializePermissionsPolicy(security?.permissionsPolicy);
5254
+ if (permissionsPolicy === undefined) {
5255
+ delete headers["permissions-policy"];
5256
+ } else {
5257
+ headers["permissions-policy"] = permissionsPolicy;
5258
+ }
5259
+ }
5152
5260
  if (security?.frameOptions === null) {
5153
5261
  delete headers["x-frame-options"];
5154
5262
  } else if (security?.frameOptions !== undefined) {
5155
5263
  headers["x-frame-options"] = validateHeaderValue(security.frameOptions);
5156
5264
  }
5157
5265
  if (request.url.startsWith("https://") && security?.hsts !== undefined && security.hsts !== false && security.hsts !== null) {
5158
- headers["strict-transport-security"] = \`max-age=\${Math.trunc(security.hsts.maxAge)}\${security.hsts.includeSubDomains === true ? "; includeSubDomains" : ""}\${security.hsts.preload === true ? "; preload" : ""}\`;
5266
+ headers["strict-transport-security"] = serializeHsts(security.hsts);
5159
5267
  }
5160
5268
  return headers;
5161
5269
  }
5162
5270
 
5271
+ function serializeHsts(hsts) {
5272
+ if (hsts === false) {
5273
+ throw new TypeError("Invalid security header value for hsts.");
5274
+ }
5275
+ const maxAge = Math.trunc(hsts.maxAge);
5276
+ if (!Number.isFinite(maxAge) || maxAge < 0) {
5277
+ throw new TypeError("Invalid security header value for hsts.maxAge.");
5278
+ }
5279
+ const parts = [\`max-age=\${maxAge}\`];
5280
+ if (hsts.includeSubDomains === true) {
5281
+ parts.push("includeSubDomains");
5282
+ }
5283
+ if (hsts.preload === true) {
5284
+ parts.push("preload");
5285
+ }
5286
+ return parts.join("; ");
5287
+ }
5288
+
5289
+ function serializePermissionsPolicy(policy) {
5290
+ if (policy === undefined) {
5291
+ return "camera=(), microphone=(), geolocation=()";
5292
+ }
5293
+ const directives = [];
5294
+ for (const [directive, allowlist] of Object.entries(policy)) {
5295
+ if (allowlist === null || allowlist === undefined) {
5296
+ continue;
5297
+ }
5298
+ validateToken(directive, "permissionsPolicy directive");
5299
+ for (const value of allowlist) {
5300
+ validatePermissionAllowlistValue(value);
5301
+ }
5302
+ directives.push(\`\${directive}=(\${allowlist.join(" ")})\`);
5303
+ }
5304
+ return directives.length === 0 ? undefined : directives.join(", ");
5305
+ }
5306
+
5163
5307
  function validateHeaderValue(value) {
5164
5308
  for (let index = 0; index < value.length; index += 1) {
5165
5309
  const code = value.charCodeAt(index);
@@ -5170,6 +5314,22 @@ function validateHeaderValue(value) {
5170
5314
  return value;
5171
5315
  }
5172
5316
 
5317
+ function validateToken(value, label) {
5318
+ if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(value)) {
5319
+ throw new TypeError(\`Invalid security header value for \${label}: \${JSON.stringify(value)}\`);
5320
+ }
5321
+ }
5322
+
5323
+ function validatePermissionAllowlistValue(value) {
5324
+ if (value === "self" || value === "*" || /^[A-Za-z][A-Za-z0-9+.-]*:$/.test(value)) {
5325
+ return;
5326
+ }
5327
+ if (/^https:\\/\\/[A-Za-z0-9.-]+(?::[0-9]+)?$/.test(value)) {
5328
+ return;
5329
+ }
5330
+ throw new TypeError(\`Invalid security header value for permissionsPolicy allowlist: \${JSON.stringify(value)}\`);
5331
+ }
5332
+
5173
5333
  function mergeRouteMetadata(metadata) {
5174
5334
  if (metadata.length === 0) {
5175
5335
  return undefined;
@@ -6149,6 +6309,10 @@ async function writeClientRouteBundles(options: {
6149
6309
  options.sourceAnalysis.byRouteFile.get(
6150
6310
  relative(options.projectRoot, route.file).split(sep).join("/"),
6151
6311
  )?.streamRoute === true,
6312
+ restoreRequestUrl:
6313
+ options.sourceAnalysis.byRouteFile.get(
6314
+ relative(options.projectRoot, route.file).split(sep).join("/"),
6315
+ )?.usesRequestInput === true,
6152
6316
  cacheDir: options.cacheDir,
6153
6317
  dropConsoleFunctions: options.clientConsolePureFunctions,
6154
6318
  filename: route.file,
@@ -25,7 +25,7 @@ export interface BuiltRuntime extends BuiltServerModuleArtifactRuntime {
25
25
  projectRoot: string;
26
26
  publicAssetBaseUrl?: string | undefined;
27
27
  prerenderableRoutes: ReadonlySet<string>;
28
- prerenderLocks: Map<string, Promise<Response>>;
28
+ prerenderLocks: Map<string, Promise<{ response: Response; shareable: boolean }>>;
29
29
  prerenderedRoutes: Map<string, BuiltPrerenderedRoute>;
30
30
  routeMatcher: RouteMatcher;
31
31
  routes: readonly AppRoute[];
@@ -78,7 +78,7 @@ export async function materializeBuiltRuntime(options: {
78
78
  }));
79
79
  const prerenderedRoutes = new Map(Object.entries(serverManifest.prerenderedRoutes ?? {}));
80
80
  const prerenderableRoutes = new Set(prerenderedRoutes.keys());
81
- const prerenderLocks = new Map<string, Promise<Response>>();
81
+ const prerenderLocks = new Map<string, Promise<{ response: Response; shareable: boolean }>>();
82
82
  const serverModules = new Map<string, BuiltServerModuleArtifact>(
83
83
  Object.entries(serverManifest.serverModules ?? {}).map(([file, artifact]) => [
84
84
  join(appDir, file),
package/src/cache.ts CHANGED
@@ -24,8 +24,31 @@ export interface AppRouterCacheEntry {
24
24
  body: string;
25
25
  cacheControl: string;
26
26
  expiresAt: number;
27
+ /**
28
+ * Response headers to replay on a cache hit.
29
+ *
30
+ * Metadata-derived security headers such as `content-security-policy`,
31
+ * `x-frame-options` and `x-content-type-options` live here, so that turning a
32
+ * route into a cached one does not quietly drop them.
33
+ */
34
+ headers?: Record<string, string> | undefined;
27
35
  path: string;
36
+ /**
37
+ * Identifies entries that preserve the complete current cache contract.
38
+ *
39
+ * Missing markers are treated as cache misses so HTML written by older
40
+ * runtimes, or custom serializers that drop new security fields, is never
41
+ * replayed under the current security rules.
42
+ */
43
+ schemaVersion?: 2 | undefined;
28
44
  status: number;
45
+ /**
46
+ * Strict-Transport-Security value to replay to secure requests only.
47
+ *
48
+ * It is kept out of `headers` because it depends on the scheme of the
49
+ * request that produced the entry, which the cache key does not cover.
50
+ */
51
+ strictTransportSecurity?: string | undefined;
29
52
  }
30
53
 
31
54
  /**
@@ -277,16 +300,29 @@ export function cachedRouteResponse(options: {
277
300
  const now = options.now ?? Date.now();
278
301
  const cached = await cache.get(options.key, now);
279
302
 
280
- if (cached === undefined || cached.expiresAt <= now) {
303
+ if (
304
+ cached === undefined ||
305
+ cached.expiresAt <= now ||
306
+ cached.schemaVersion !== ROUTE_CACHE_ENTRY_SCHEMA_VERSION ||
307
+ !isStringRecord(cached.headers)
308
+ ) {
281
309
  return undefined;
282
310
  }
283
311
 
312
+ const headers = new Headers(cached.headers ?? {});
313
+ headers.set("cache-control", cached.cacheControl);
314
+ if (!headers.has("content-type")) {
315
+ headers.set("content-type", "text/html; charset=utf-8");
316
+ }
317
+ headers.set("x-mreact-cache", "HIT");
318
+ // Replayed against the scheme of the request being served, not the one
319
+ // that produced the entry.
320
+ if (cached.strictTransportSecurity !== undefined && isSecureRequest(options.request)) {
321
+ headers.set(HSTS_HEADER, cached.strictTransportSecurity);
322
+ }
323
+
284
324
  return new Response(cached.body, {
285
- headers: {
286
- "cache-control": cached.cacheControl,
287
- "content-type": "text/html; charset=utf-8",
288
- "x-mreact-cache": "HIT",
289
- },
325
+ headers,
290
326
  status: cached.status,
291
327
  });
292
328
  });
@@ -294,18 +330,35 @@ export function cachedRouteResponse(options: {
294
330
 
295
331
  export async function cacheRouteResponse(options: {
296
332
  cache?: AppRouterCache | undefined;
333
+ headerDependent?: boolean | undefined;
297
334
  key: string;
298
335
  now?: number;
299
336
  path: string;
300
337
  policy: RouteCachePolicy | undefined;
301
338
  request?: Request | undefined;
302
339
  response: Response;
340
+ /** Configured Strict-Transport-Security value, independent of this request's scheme. */
341
+ strictTransportSecurity?: string | undefined;
303
342
  }): Promise<Response> {
304
343
  if (options.policy === undefined) {
305
344
  return options.response;
306
345
  }
307
346
 
308
347
  if (options.policy.revalidateSeconds === 0) {
348
+ // No entry is stored here, but cacheControl({ maxAge }) still emits a bare
349
+ // max-age that a shared cache may store, so a header dependent render has
350
+ // to opt out of that too. Directives that already keep the response out of
351
+ // shared caches are left exactly as the application asked for them.
352
+ const sharedCacheMayStore = !/(^|,)\s*(private|no-store)\s*(,|$)/.test(
353
+ options.policy.cacheControl,
354
+ );
355
+
356
+ if (options.headerDependent === true && sharedCacheMayStore) {
357
+ options.response.headers.set("cache-control", "private, no-store");
358
+ options.response.headers.set("x-mreact-cache", "DYNAMIC");
359
+ return options.response;
360
+ }
361
+
309
362
  options.response.headers.set("cache-control", options.policy.cacheControl);
310
363
  return options.response;
311
364
  }
@@ -315,7 +368,12 @@ export async function cacheRouteResponse(options: {
315
368
  const status = options.response.status;
316
369
  const contentType = options.response.headers.get("content-type") ?? "text/html; charset=utf-8";
317
370
 
371
+ // A render that read a request header only produced HTML that is correct for
372
+ // that header value. The cache key covers the path and query alone, so the
373
+ // entry cannot be shared; the response is marked uncacheable for downstream
374
+ // shared caches too, which are keyed the same way.
318
375
  if (
376
+ options.headerDependent === true ||
319
377
  requestCarriesCredentials(options.request) ||
320
378
  options.response.headers.has("set-cookie")
321
379
  ) {
@@ -324,6 +382,9 @@ export async function cacheRouteResponse(options: {
324
382
  if (!headers.has("content-type")) {
325
383
  headers.set("content-type", contentType);
326
384
  }
385
+ if (options.headerDependent === true) {
386
+ headers.set("x-mreact-cache", "DYNAMIC");
387
+ }
327
388
 
328
389
  return new Response(body, {
329
390
  headers,
@@ -331,24 +392,69 @@ export async function cacheRouteResponse(options: {
331
392
  });
332
393
  }
333
394
 
395
+ // Metadata-derived security headers are part of the rendered response, so
396
+ // they are stored with it and replayed on a hit rather than dropped by the
397
+ // rebuilt cache response. Strict-Transport-Security is kept apart because it
398
+ // is derived from the scheme of whichever request missed and the key does not
399
+ // cover the scheme: it is replayed only to requests that are themselves
400
+ // secure, instead of pinning one visitor's scheme onto the entry.
401
+ const storedHeaders: Record<string, string> = {};
402
+ options.response.headers.forEach((value, key) => {
403
+ if (key !== "cache-control" && key !== "x-mreact-cache" && key !== HSTS_HEADER) {
404
+ storedHeaders[key] = value;
405
+ }
406
+ });
407
+ // Taken from the route's configuration rather than from this response, whose
408
+ // HSTS header is present only when this particular request was secure. Using
409
+ // the response would let one plain request pin an entry that withholds HSTS
410
+ // from every later secure visitor.
411
+ const hsts = options.strictTransportSecurity;
412
+
334
413
  await (options.cache ?? cacheState.memoryCache).set(options.key, {
335
414
  body,
336
415
  cacheControl,
337
416
  expiresAt: (options.now ?? Date.now()) + options.policy.revalidateSeconds * 1000,
417
+ headers: storedHeaders,
418
+ ...(hsts === undefined ? {} : { strictTransportSecurity: hsts }),
338
419
  path: normalizeRevalidationPath(options.path),
420
+ schemaVersion: ROUTE_CACHE_ENTRY_SCHEMA_VERSION,
339
421
  status,
340
422
  });
341
423
 
424
+ const headers = new Headers(storedHeaders);
425
+ headers.set("cache-control", cacheControl);
426
+ if (!headers.has("content-type")) {
427
+ headers.set("content-type", contentType);
428
+ }
429
+ headers.set("x-mreact-cache", "MISS");
430
+ if (hsts !== undefined && isSecureRequest(options.request)) {
431
+ headers.set(HSTS_HEADER, hsts);
432
+ }
433
+
342
434
  return new Response(body, {
343
- headers: {
344
- "cache-control": cacheControl,
345
- "content-type": contentType,
346
- "x-mreact-cache": "MISS",
347
- },
435
+ headers,
348
436
  status,
349
437
  });
350
438
  }
351
439
 
440
+ const HSTS_HEADER = "strict-transport-security";
441
+ const ROUTE_CACHE_ENTRY_SCHEMA_VERSION = 2;
442
+
443
+ function isStringRecord(value: unknown): value is Record<string, string> {
444
+ return (
445
+ typeof value === "object" &&
446
+ value !== null &&
447
+ !Array.isArray(value) &&
448
+ Object.values(value).every((entry) => typeof entry === "string")
449
+ );
450
+ }
451
+
452
+ function isSecureRequest(request: Request | undefined): boolean {
453
+ // `Request.url` is always absolute, so this avoids parsing a URL on the
454
+ // cache-hit hot path.
455
+ return request?.url.startsWith("https://") === true;
456
+ }
457
+
352
458
  function requestCarriesCredentials(request: Request | undefined): boolean {
353
459
  if (request === undefined) {
354
460
  return false;
@@ -544,10 +650,16 @@ export function activeRouteCacheContext(): RouteCacheContext | undefined {
544
650
 
545
651
  // Host is excluded from the cache key to prevent attacker-supplied Host
546
652
  // headers from fragmenting / poisoning the cache (Issue 068). The Vary
547
- // dimension is the request path + query; same-origin reverse proxies are
548
- // expected to handle vhost separation at their layer.
549
- export function routeCacheKey(appDir: string, routePath: string, url: URL): string {
550
- return `${appDir}\0${normalizeRevalidationPath(routePath)}\0${url.pathname}${url.search}`;
653
+ // dimensions are the request path + query and the document/navigation HTML
654
+ // shape; same-origin reverse proxies are expected to handle vhost separation
655
+ // at their layer.
656
+ export function routeCacheKey(
657
+ appDir: string,
658
+ routePath: string,
659
+ url: URL,
660
+ responseVariant: "document" | "navigation" = "document",
661
+ ): string {
662
+ return `${appDir}\0${normalizeRevalidationPath(routePath)}\0${url.pathname}${url.search}\0${responseVariant}`;
551
663
  }
552
664
 
553
665
  export function stripRevalidateExport(code: string): string {
@@ -28,6 +28,7 @@ export interface ParsedCliArguments {
28
28
  routeArg?: string | undefined;
29
29
  skipRuntimeDependencyCheck?: boolean | undefined;
30
30
  target?: CliBuildTarget | undefined;
31
+ trustForwardedProto?: boolean | undefined;
31
32
  worker?: string | undefined;
32
33
  }
33
34
 
@@ -103,6 +104,14 @@ export function parseCliArguments(argv: readonly string[]): ParsedCliArguments {
103
104
  continue;
104
105
  }
105
106
 
107
+ if (value === "--trust-forwarded-proto") {
108
+ if (command !== "start") {
109
+ throw new Error("--trust-forwarded-proto is only supported by the start command");
110
+ }
111
+ parsed.trustForwardedProto = true;
112
+ continue;
113
+ }
114
+
106
115
  if (value.startsWith("--allowed-hosts=")) {
107
116
  parsed.allowedHosts = parseCliAllowedHosts(value.slice("--allowed-hosts=".length));
108
117
  continue;
@@ -326,10 +335,13 @@ export function formatCliHelp(command?: string | undefined): string {
326
335
  "",
327
336
  "Options:",
328
337
  " --host <host> Bind address. Default: 127.0.0.1. Use 0.0.0.0 inside containers behind explicit port publishing or a reverse proxy.",
338
+ " --port <port> TCP port. Overrides PORT. Default: 3001.",
329
339
  " --host-policy=strict|trusted-proxy",
330
340
  " Control Host header trust for request origin reconstruction.",
331
341
  " --allowed-hosts <host[,host...]>",
332
342
  " Exact Host header allow-list for public deployments.",
343
+ " --trust-forwarded-proto",
344
+ " Trust the first X-Forwarded-Proto value. Enable only when a trusted proxy overwrites the header and the Node port is not publicly reachable.",
333
345
  " --log=requests Print request summaries.",
334
346
  " -h, --help Show this help message.",
335
347
  "",
@@ -339,6 +351,8 @@ export function formatCliHelp(command?: string | undefined): string {
339
351
  " Host header trust policy when --host-policy is not set.",
340
352
  " MREACT_ROUTER_ALLOWED_HOSTS",
341
353
  " Comma-separated Host header allow-list when --allowed-hosts is not set.",
354
+ " MREACT_ROUTER_TRUST_FORWARDED_PROTO",
355
+ " Set to 1 to trust X-Forwarded-Proto when the flag is not set.",
342
356
  " PORT TCP port. Default: 3001.",
343
357
  ].join("\n");
344
358
  }
@@ -456,6 +470,18 @@ export function resolveCliDevPort(
456
470
  return envValue === undefined || envValue === "" ? viteConfigPort : parseCliPort(envValue);
457
471
  }
458
472
 
473
+ export function resolveCliStartPort(
474
+ flagValue: number | undefined,
475
+ env: { PORT?: string | undefined },
476
+ ): number {
477
+ if (flagValue !== undefined) {
478
+ return flagValue;
479
+ }
480
+
481
+ const envValue = env.PORT;
482
+ return envValue === undefined || envValue === "" ? 3001 : parseCliPort(envValue, "PORT");
483
+ }
484
+
459
485
  export function resolveCliHostPolicy(
460
486
  flagValue: RequestHostPolicy | undefined,
461
487
  env: { MREACT_ROUTER_HOST_POLICY?: string | undefined },
@@ -480,6 +506,17 @@ export function resolveCliAllowedHosts(
480
506
  return envValue === undefined || envValue === "" ? undefined : parseCliAllowedHosts(envValue);
481
507
  }
482
508
 
509
+ export function resolveCliTrustForwardedProto(
510
+ flagValue: boolean | undefined,
511
+ env: { MREACT_ROUTER_TRUST_FORWARDED_PROTO?: string | undefined },
512
+ ): boolean {
513
+ if (flagValue !== undefined) {
514
+ return flagValue;
515
+ }
516
+
517
+ return env.MREACT_ROUTER_TRUST_FORWARDED_PROTO === "1";
518
+ }
519
+
483
520
  export function createCliRequestLogger(): AppRouterLogger {
484
521
  return {
485
522
  error(event) {
@@ -503,7 +540,7 @@ function parseCliRequestLogMode(value: string): CliRequestLogMode {
503
540
  throw new Error(`Unsupported log mode ${JSON.stringify(value)}. Expected "requests".`);
504
541
  }
505
542
 
506
- function parseCliPort(value: string): number {
543
+ function parseCliPort(value: string, source = "port"): number {
507
544
  const port = Number(value);
508
545
 
509
546
  if (Number.isInteger(port) && port >= 0 && port <= 65535) {
@@ -511,7 +548,7 @@ function parseCliPort(value: string): number {
511
548
  }
512
549
 
513
550
  throw new Error(
514
- `Unsupported port ${JSON.stringify(value)}. Expected an integer from 0 to 65535.`,
551
+ `Unsupported ${source} ${JSON.stringify(value)}. Expected an integer from 0 to 65535.`,
515
552
  );
516
553
  }
517
554