@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/render.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { readFileSync } from "node:fs";
3
4
  import { access, readFile, stat } from "node:fs/promises";
4
5
  import { dirname, join, relative, resolve, sep } from "node:path";
5
6
  import { formatDiagnostic, transform } from "@reckona/mreact-compiler";
@@ -15,6 +16,7 @@ import {
15
16
  installQueryAsyncStorage,
16
17
  runWithQueryClient,
17
18
  type DehydratedQueryClient,
19
+ type DehydrateOptions,
18
20
  type QueryClient,
19
21
  } from "@reckona/mreact-query";
20
22
  import {
@@ -34,9 +36,9 @@ import {
34
36
  } from "./client-route-inference.js";
35
37
  import {
36
38
  hydrationMarkerParts,
39
+ routeMarkerParts,
37
40
  routeIdForPath,
38
41
  withHydrationMarkers,
39
- withRouteMarkers,
40
42
  } from "./navigation-runtime.js";
41
43
  import { assetPath } from "./assets.js";
42
44
  import { escapeHtmlAttribute } from "@reckona/mreact-shared/html-escape";
@@ -60,6 +62,15 @@ import {
60
62
  routeCacheKey,
61
63
  routeCachePolicyFromSource,
62
64
  } from "./cache.js";
65
+ import {
66
+ trackRequestHeaderReads,
67
+ type TrackedHeaderRequest,
68
+ withTrackedRequest,
69
+ } from "./request-header-tracking.js";
70
+ import {
71
+ configuredHstsHeader,
72
+ hasInvalidConfiguredHsts,
73
+ } from "./security-headers.js";
63
74
  import { resolveRouterCacheLimit } from "./cache-config.js";
64
75
  import {
65
76
  importAppRouterBuiltFileModule,
@@ -78,6 +89,7 @@ import {
78
89
  hasLoaderExport,
79
90
  isStreamRouteSource,
80
91
  routeClosureMayUseAwaitBoundary,
92
+ routeClosureMayUseRequestInput,
81
93
  stripRouteBuildExports,
82
94
  stripRouteModuleExports,
83
95
  stripRouteClientOnlyExports,
@@ -191,6 +203,7 @@ export interface RenderAppRequestOptions {
191
203
  clientStylesByFile?: ReadonlyMap<string, readonly string[]>;
192
204
  clientStyles?: ReadonlyMap<string, readonly string[]>;
193
205
  define?: UserConfig["define"] | undefined;
206
+ dehydrateOptions?: DehydrateOptions | undefined;
194
207
  env?: unknown;
195
208
  importPolicy?: AppRouterImportPolicy | undefined;
196
209
  instrumentation?: RouterInstrumentation | undefined;
@@ -222,6 +235,22 @@ export interface RenderAppRequestOptions {
222
235
  }
223
236
 
224
237
  export interface RenderAppRequestRuntimeOptions extends RenderAppRequestOptions {
238
+ /**
239
+ * Receives whether the render depended on the incoming request headers.
240
+ *
241
+ * Callers that store the rendered HTML under a key which ignores headers,
242
+ * such as prerender regeneration, pass this to learn whether the result is
243
+ * only correct for the current visitor. Initialize it to a function returning
244
+ * `true`: a render that returns without reporting back stays header
245
+ * dependent. Call it only after draining the response body, because a
246
+ * streamed render can read headers while its deferred parts resolve.
247
+ */
248
+ renderSignals?:
249
+ | {
250
+ headerDependent: () => boolean;
251
+ strictTransportSecurity: () => string | undefined;
252
+ }
253
+ | undefined;
225
254
  serverRenderArtifactLoader?: AppRouterServerRenderArtifactLoader | undefined;
226
255
  }
227
256
 
@@ -617,30 +646,18 @@ export function routerRenderRuntimeCacheStats(): RouterRuntimeCacheStat[] {
617
646
  maxComposedRouteMetadataCacheEntries,
618
647
  composedRouteMetadataCacheCounters,
619
648
  ),
620
- routerRuntimeCacheStat(
621
- "rendered-shell",
622
- renderedShellCache,
623
- MAX_RENDERED_SHELL_CACHE_ENTRIES,
624
- renderedShellCacheCounters,
625
- ),
649
+ // Preserve the public diagnostics entry while making explicit that rendered HTML reuse is disabled.
650
+ {
651
+ evictions: 0,
652
+ hits: 0,
653
+ maxEntries: 0,
654
+ misses: 0,
655
+ name: "rendered-shell",
656
+ size: 0,
657
+ },
626
658
  ];
627
659
  }
628
660
 
629
- // Issue 086: per-shell prefix/suffix cache. Pure layouts (whose
630
- // exported component takes zero arguments and therefore cannot
631
- // depend on the request props) produce the same HTML for every
632
- // request, so we cache the already-split { prefix, suffix } strings
633
- // keyed by appDir + shellFile + serverModuleCacheVersion. Impure
634
- // layouts (function.length > 0) are tagged "impure" so we skip the
635
- // detection on subsequent requests but still render per-request.
636
- //
637
- // The cache is only active when a version is present (production
638
- // builds); dev mode keeps the previous behaviour so reloads pick up
639
- // edits without server restart.
640
- const renderedShellCache = new Map<string, RenderedShell | "impure">();
641
- const MAX_RENDERED_SHELL_CACHE_ENTRIES = 1024;
642
- const renderedShellCacheCounters = createRouterRuntimeCacheCounters();
643
-
644
661
  interface RenderedShell {
645
662
  hasOutOfOrderBoundary: boolean;
646
663
  prefix: string;
@@ -654,6 +671,7 @@ interface RouteSourceAnalysis {
654
671
  hasLoader: boolean;
655
672
  routeCode: string;
656
673
  streamRoute: boolean;
674
+ usesRequestInput: boolean;
657
675
  usesRuntimeCacheControl: boolean;
658
676
  }
659
677
 
@@ -932,6 +950,9 @@ async function renderAppRequestInternal(
932
950
  renderAppRequestInternal({
933
951
  ...options,
934
952
  matchedRoute: undefined,
953
+ // This renders a different route than the caller asked about, so its
954
+ // header dependence says nothing about the caller's request.
955
+ renderSignals: undefined,
935
956
  request: singleFlightNavigationRequest({
936
957
  path: singleFlight.path,
937
958
  request: singleFlight.request,
@@ -983,6 +1004,10 @@ async function renderAppRequestInternal(
983
1004
  script: string | undefined;
984
1005
  }
985
1006
  | undefined;
1007
+ let trackedRequest: TrackedHeaderRequest | undefined;
1008
+ let sourceUsesRequestInput = true;
1009
+ let invalidConfiguredHsts = false;
1010
+ let renderedStrictTransportSecurity: string | undefined;
986
1011
  return (await withRouteCacheContext(options.routeCache, async () => {
987
1012
  try {
988
1013
  if (matched.route.kind === "asset") {
@@ -1055,14 +1080,22 @@ async function renderAppRequestInternal(
1055
1080
  timing,
1056
1081
  vitePlugins: options.vitePlugins,
1057
1082
  });
1083
+ sourceUsesRequestInput = originalAnalysis.usesRequestInput;
1058
1084
  finishRenderTimingPhase(timing, phaseStartedAt, "sourceAnalysisMs");
1059
1085
  const cachePolicy = originalAnalysis.cachePolicy;
1060
1086
  const navigationScript = options.navigationScripts?.get(matched.route.path);
1061
- const cacheKey = routeCacheKey(options.appDir, matched.route.path, url);
1087
+ const navigationRequest = isNavigationRequest(options.request);
1088
+ const cacheKey = routeCacheKey(
1089
+ options.appDir,
1090
+ matched.route.path,
1091
+ url,
1092
+ navigationRequest ? "navigation" : "document",
1093
+ );
1062
1094
  const mayUseRouteCache =
1063
- cachePolicy === undefined
1095
+ !sourceUsesRequestInput &&
1096
+ (cachePolicy === undefined
1064
1097
  ? originalAnalysis.usesRuntimeCacheControl
1065
- : cachePolicy.revalidateSeconds !== 0;
1098
+ : cachePolicy.revalidateSeconds !== 0);
1066
1099
  const reloadRouteCache = isNavigationRouteCacheReloadRequest(options.request);
1067
1100
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
1068
1101
  const cachedResponse =
@@ -1079,6 +1112,18 @@ async function renderAppRequestInternal(
1079
1112
  return cachedResponse;
1080
1113
  }
1081
1114
 
1115
+ // Only a route whose result may be stored pays for header-read tracking;
1116
+ // cache hits returned above never construct it. Application code reads
1117
+ // headers through this request so that a render depending on one is never
1118
+ // stored under a key that ignores it. Callers that store by path alone ask
1119
+ // for tracking through `renderSignals` even when the route itself declares
1120
+ // no cache policy.
1121
+ trackedRequest =
1122
+ mayUseRouteCache || options.renderSignals !== undefined
1123
+ ? trackRequestHeaderReads(options.request)
1124
+ : undefined;
1125
+ const appRequest = trackedRequest?.request ?? options.request;
1126
+
1082
1127
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
1083
1128
  const preparedActions = await prepareRouteServerActions({
1084
1129
  appDir: options.appDir,
@@ -1120,12 +1165,11 @@ async function renderAppRequestInternal(
1120
1165
  ? loadRouteDataWithInstrumentation({
1121
1166
  appDir: options.appDir,
1122
1167
  code: originalCode,
1123
- context: {
1168
+ context: withTrackedRequest({
1124
1169
  env: options.env,
1125
1170
  params: matched.params,
1126
1171
  queryClient,
1127
- request: options.request,
1128
- },
1172
+ }, appRequest, trackedRequest),
1129
1173
  filename: matched.route.file,
1130
1174
  importPolicy: options.importPolicy,
1131
1175
  instrumentation: options.instrumentation,
@@ -1146,7 +1190,7 @@ async function renderAppRequestInternal(
1146
1190
  clientRoute,
1147
1191
  props: {
1148
1192
  params: matched.params,
1149
- request: { url: options.request.url },
1193
+ request: { url: url.pathname },
1150
1194
  },
1151
1195
  routePath: matched.route.path,
1152
1196
  script: clientScript,
@@ -1210,12 +1254,11 @@ async function renderAppRequestInternal(
1210
1254
  const renderedPage = await runWithQueryClient(queryClient, () =>
1211
1255
  runServerModuleWithSlots(
1212
1256
  stringOutput.code,
1213
- {
1257
+ withTrackedRequest({
1214
1258
  data,
1215
1259
  params: matched.params,
1216
1260
  queryClient,
1217
- request: options.request,
1218
- },
1261
+ }, appRequest, trackedRequest),
1219
1262
  matched.route.file,
1220
1263
  options.serverModules,
1221
1264
  options.serverModuleCacheVersion,
@@ -1236,27 +1279,21 @@ async function renderAppRequestInternal(
1236
1279
  script: clientScript,
1237
1280
  props: {
1238
1281
  params: matched.params,
1239
- request: { url: options.request.url },
1282
+ request: { url: url.pathname },
1240
1283
  data,
1241
1284
  },
1242
1285
  })
1243
- : isNavigationRequest(options.request)
1244
- ? withRouteMarkers({
1245
- html: pageHtml,
1246
- routePath: matched.route.path,
1247
- })
1248
- : pageHtml;
1286
+ : withServerOnlyRouteMarkers(pageHtml, matched.route.path, options.request);
1249
1287
  let html = await runWithQueryClient(queryClient, () =>
1250
1288
  applyLayouts({
1251
1289
  appDir: options.appDir,
1252
1290
  pageFile: matched.route.file,
1253
1291
  html: pageHtmlForLayout,
1254
- props: {
1292
+ props: withTrackedRequest({
1255
1293
  data,
1256
1294
  params: matched.params,
1257
1295
  queryClient,
1258
- request: options.request,
1259
- },
1296
+ }, appRequest, trackedRequest),
1260
1297
  slots: renderedPage.slots,
1261
1298
  serverModules: options.serverModules,
1262
1299
  serverModuleCacheVersion: options.serverModuleCacheVersion,
@@ -1270,11 +1307,10 @@ async function renderAppRequestInternal(
1270
1307
  const metadata = await loadComposedRouteMetadata({
1271
1308
  appDir: options.appDir,
1272
1309
  code: originalCode,
1273
- context: {
1310
+ context: withTrackedRequest({
1274
1311
  data,
1275
1312
  params: matched.params,
1276
- request: options.request,
1277
- },
1313
+ }, appRequest, trackedRequest),
1278
1314
  filename: matched.route.file,
1279
1315
  importPolicy: options.importPolicy,
1280
1316
  routes,
@@ -1297,7 +1333,7 @@ async function renderAppRequestInternal(
1297
1333
  html,
1298
1334
  originalAnalysis.authIncludesClaims ? currentAuthClaims() : undefined,
1299
1335
  );
1300
- html = injectQueryState(html, dehydrate(queryClient));
1336
+ html = injectQueryState(html, dehydrate(queryClient, options.dehydrateOptions));
1301
1337
  const response = withOptionalActionCookie(
1302
1338
  htmlResponse(
1303
1339
  applyActionHtmlReplacements(
@@ -1319,6 +1355,7 @@ async function renderAppRequestInternal(
1319
1355
  preparedActions.csrfToken,
1320
1356
  preparedActions.csrfTokenIsNew === true,
1321
1357
  );
1358
+ appendNavigationVary(response.headers);
1322
1359
  emitRenderTiming(options, timing, response.status);
1323
1360
  return response;
1324
1361
  }
@@ -1385,10 +1422,13 @@ async function renderAppRequestInternal(
1385
1422
  data: streamDataPromise,
1386
1423
  define: options.define,
1387
1424
  loadingFile,
1425
+ markerRequest: options.request,
1388
1426
  pageFile: matched.route.file,
1389
1427
  params: matched.params,
1390
1428
  queryClient,
1391
- request: options.request,
1429
+ request: appRequest,
1430
+ requestUrl: url.pathname,
1431
+ trackedRequest,
1392
1432
  routePath: matched.route.path,
1393
1433
  routeScripts: options.clientScripts,
1394
1434
  serverModules: options.serverModules,
@@ -1409,23 +1449,25 @@ async function renderAppRequestInternal(
1409
1449
  preparedActions.csrfToken,
1410
1450
  preparedActions.csrfTokenIsNew === true,
1411
1451
  );
1452
+ appendNavigationVary(response.headers);
1412
1453
  emitRenderTiming(options, timing, response.status);
1413
1454
  return response;
1414
1455
  }
1415
1456
 
1416
1457
  const data = streamData;
1417
- const props = {
1458
+ const props = withTrackedRequest({
1418
1459
  data,
1419
1460
  params: matched.params,
1420
1461
  queryClient,
1421
- request: options.request,
1422
- };
1462
+ }, appRequest, trackedRequest);
1423
1463
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
1424
1464
  const stream = runServerStreamModule(output.code, {
1425
1465
  appDir: options.appDir,
1426
1466
  assetBaseUrl: options.assetBaseUrl,
1427
1467
  pageFile: matched.route.file,
1468
+ markerRequest: options.request,
1428
1469
  props,
1470
+ requestUrl: url.pathname,
1429
1471
  routePath: matched.route.path,
1430
1472
  routeScripts: options.clientScripts,
1431
1473
  serverModules: options.serverModules,
@@ -1448,6 +1490,7 @@ async function renderAppRequestInternal(
1448
1490
  preparedActions.csrfToken,
1449
1491
  preparedActions.csrfTokenIsNew === true,
1450
1492
  );
1493
+ appendNavigationVary(response.headers);
1451
1494
  emitRenderTiming(options, timing, response.status);
1452
1495
  return response;
1453
1496
  }
@@ -1463,6 +1506,12 @@ async function renderAppRequestInternal(
1463
1506
  emitRenderTiming(options, timing, data.status);
1464
1507
  return data;
1465
1508
  }
1509
+ sourceUsesRequestInput ||= await routeShellsMayUseRequestInput({
1510
+ appDir: options.appDir,
1511
+ pageFile: matched.route.file,
1512
+ serverModuleCacheVersion: options.serverModuleCacheVersion,
1513
+ serverSourceFiles: options.serverSourceFiles,
1514
+ });
1466
1515
  await waitForRenderPreload(options, timing);
1467
1516
  await loadServerRenderArtifacts(options, matched.route.file, timing);
1468
1517
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
@@ -1488,12 +1537,11 @@ async function renderAppRequestInternal(
1488
1537
  const renderedPage = await runWithQueryClient(queryClient, () =>
1489
1538
  runServerModuleWithSlots(
1490
1539
  output.code,
1491
- {
1540
+ withTrackedRequest({
1492
1541
  data,
1493
1542
  params: matched.params,
1494
1543
  queryClient,
1495
- request: options.request,
1496
- },
1544
+ }, appRequest, trackedRequest),
1497
1545
  matched.route.file,
1498
1546
  options.serverModules,
1499
1547
  options.serverModuleCacheVersion,
@@ -1520,28 +1568,22 @@ async function renderAppRequestInternal(
1520
1568
  script: clientScript,
1521
1569
  props: {
1522
1570
  params: matched.params,
1523
- request: { url: options.request.url },
1571
+ request: { url: url.pathname },
1524
1572
  data,
1525
1573
  },
1526
1574
  })
1527
- : isNavigationRequest(options.request)
1528
- ? withRouteMarkers({
1529
- html: pageHtml,
1530
- routePath: matched.route.path,
1531
- })
1532
- : pageHtml;
1575
+ : withServerOnlyRouteMarkers(pageHtml, matched.route.path, options.request);
1533
1576
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
1534
1577
  let html = await runWithQueryClient(queryClient, () =>
1535
1578
  applyLayouts({
1536
1579
  appDir: options.appDir,
1537
1580
  pageFile: matched.route.file,
1538
1581
  html: pageHtmlForLayout,
1539
- props: {
1582
+ props: withTrackedRequest({
1540
1583
  data,
1541
1584
  params: matched.params,
1542
1585
  queryClient,
1543
- request: options.request,
1544
- },
1586
+ }, appRequest, trackedRequest),
1545
1587
  slots: renderedPage.slots,
1546
1588
  serverModules: options.serverModules,
1547
1589
  serverModuleCacheVersion: options.serverModuleCacheVersion,
@@ -1556,11 +1598,10 @@ async function renderAppRequestInternal(
1556
1598
  const metadata = await loadComposedRouteMetadata({
1557
1599
  appDir: options.appDir,
1558
1600
  code: originalCode,
1559
- context: {
1601
+ context: withTrackedRequest({
1560
1602
  data,
1561
1603
  params: matched.params,
1562
- request: options.request,
1563
- },
1604
+ }, appRequest, trackedRequest),
1564
1605
  filename: matched.route.file,
1565
1606
  importPolicy: options.importPolicy,
1566
1607
  routes,
@@ -1585,7 +1626,7 @@ async function renderAppRequestInternal(
1585
1626
  html,
1586
1627
  originalAnalysis.authIncludesClaims ? currentAuthClaims() : undefined,
1587
1628
  );
1588
- html = injectQueryState(html, dehydrate(queryClient));
1629
+ html = injectQueryState(html, dehydrate(queryClient, options.dehydrateOptions));
1589
1630
 
1590
1631
  const response = withOptionalActionCookie(
1591
1632
  htmlResponse(
@@ -1606,14 +1647,27 @@ async function renderAppRequestInternal(
1606
1647
  preparedActions.csrfToken,
1607
1648
  preparedActions.csrfTokenIsNew === true,
1608
1649
  );
1650
+ appendNavigationVary(response.headers);
1609
1651
 
1610
1652
  const effectiveCachePolicy = cachePolicy ?? activeRouteCacheContext()?.cachePolicy;
1653
+ const configuredHsts = configuredHstsHeader(metadata?.security);
1654
+ renderedStrictTransportSecurity = configuredHsts;
1655
+ invalidConfiguredHsts = hasInvalidConfiguredHsts(metadata?.security);
1611
1656
 
1612
1657
  const finalResponse = preparedActions.hasFormActions
1613
1658
  ? withRouteCacheHeader(response, effectiveCachePolicy)
1614
1659
  : await cacheRouteResponse({
1615
1660
  key: cacheKey,
1616
1661
  cache: options.routeCache,
1662
+ // A policy can still arrive from a runtime cacheControl() call made
1663
+ // outside the page source, which is what `mayUseRouteCache` scans.
1664
+ // Without a tracker there is no evidence the render ignored request
1665
+ // headers, so the entry is treated as header dependent.
1666
+ headerDependent:
1667
+ sourceUsesRequestInput ||
1668
+ invalidConfiguredHsts ||
1669
+ (trackedRequest?.requestDependent() ?? true),
1670
+ ...(configuredHsts === undefined ? {} : { strictTransportSecurity: configuredHsts }),
1617
1671
  path: matched.route.path,
1618
1672
  policy: effectiveCachePolicy,
1619
1673
  request: options.request,
@@ -1686,6 +1740,18 @@ async function renderAppRequestInternal(
1686
1740
  });
1687
1741
  emitRenderTiming(options, timing, response.status);
1688
1742
  return response;
1743
+ } finally {
1744
+ // Reported from every return path, including the streaming ones, so that a
1745
+ // caller storing by path alone never mistakes an unreported render for a
1746
+ // shareable one. The value stays lazy because a streamed render can still
1747
+ // read request headers while its deferred parts resolve; callers read it
1748
+ // after draining the body.
1749
+ if (options.renderSignals !== undefined) {
1750
+ const tracked = trackedRequest;
1751
+ options.renderSignals.headerDependent = () =>
1752
+ sourceUsesRequestInput || invalidConfiguredHsts || (tracked?.requestDependent() ?? true);
1753
+ options.renderSignals.strictTransportSecurity = () => renderedStrictTransportSecurity;
1754
+ }
1689
1755
  }
1690
1756
  })).value;
1691
1757
  }
@@ -1695,9 +1761,24 @@ async function applyAppRouterResponseHook(
1695
1761
  options: RenderAppRequestOptions,
1696
1762
  ): Promise<Response> {
1697
1763
  const onResponse = await resolveAppRouterResponseHook(options);
1698
- const hooked = await onResponse?.(response, {
1699
- request: options.request,
1700
- });
1764
+
1765
+ if (onResponse === undefined) {
1766
+ return response;
1767
+ }
1768
+
1769
+ const renderSignals = (options as RenderAppRequestRuntimeOptions).renderSignals;
1770
+ const trackedRequest =
1771
+ renderSignals === undefined ? undefined : trackRequestHeaderReads(options.request);
1772
+ const hooked = await onResponse(
1773
+ response,
1774
+ withTrackedRequest({}, trackedRequest?.request ?? options.request, trackedRequest),
1775
+ );
1776
+
1777
+ if (renderSignals !== undefined && trackedRequest !== undefined) {
1778
+ const renderHeaderDependent = renderSignals.headerDependent;
1779
+ renderSignals.headerDependent = () =>
1780
+ renderHeaderDependent() || trackedRequest.requestDependent();
1781
+ }
1701
1782
 
1702
1783
  return hooked instanceof Response ? hooked : response;
1703
1784
  }
@@ -1899,6 +1980,44 @@ function isNavigationRequest(request: Request): boolean {
1899
1980
  return request.headers.get("x-mreact-navigation") === "1";
1900
1981
  }
1901
1982
 
1983
+ function withServerOnlyRouteMarkers(html: string, routePath: string, request: Request): string {
1984
+ const marker = serverOnlyRouteMarkerParts(routePath, request);
1985
+ return marker === undefined ? html : `${marker.prefix}${html}${marker.suffix}`;
1986
+ }
1987
+
1988
+ function serverOnlyRouteMarkerParts(
1989
+ routePath: string,
1990
+ request: Request,
1991
+ ): { prefix: string; suffix: string } | undefined {
1992
+ if (request.headers.get("x-mreact-prerender-variant-capture") === "1") {
1993
+ return prerenderVariantMarkerParts(routePath);
1994
+ }
1995
+ return isNavigationRequest(request) ? routeMarkerParts(routePath) : undefined;
1996
+ }
1997
+
1998
+ export function prerenderVariantMarkerParts(routePath: string): {
1999
+ prefix: string;
2000
+ suffix: string;
2001
+ } {
2002
+ const routeId = routeIdForPath(routePath);
2003
+ return {
2004
+ prefix: `<!--mreact-route-variant-start:${routeId}-->`,
2005
+ suffix: `<!--mreact-route-variant-end:${routeId}-->`,
2006
+ };
2007
+ }
2008
+
2009
+ function appendNavigationVary(headers: Headers): void {
2010
+ const vary = headers.get("vary");
2011
+ if (
2012
+ vary
2013
+ ?.split(",")
2014
+ .some((value) => value.trim().toLowerCase() === "x-mreact-navigation") === true
2015
+ ) {
2016
+ return;
2017
+ }
2018
+ headers.append("vary", "x-mreact-navigation");
2019
+ }
2020
+
1902
2021
  function isNavigationRouteCacheReloadRequest(request: Request): boolean {
1903
2022
  return (
1904
2023
  isNavigationRequest(request) && request.headers.get("x-mreact-navigation-cache") === "reload"
@@ -3010,6 +3129,9 @@ function routeSourceAnalysisFromArtifact(
3010
3129
  hasLoader: artifact.hasLoader,
3011
3130
  routeCode: artifact.routeCode,
3012
3131
  streamRoute: artifact.streamRoute,
3132
+ // Older built artifacts predate request-input safety analysis. Treat them
3133
+ // as dependent until rebuilt rather than trusting an absent safety signal.
3134
+ usesRequestInput: artifact.usesRequestInput ?? true,
3013
3135
  usesRuntimeCacheControl: artifact.usesRuntimeCacheControl,
3014
3136
  };
3015
3137
  }
@@ -3052,17 +3174,77 @@ async function analyzeRouteSourceUncached(options: {
3052
3174
  projectRoot: options.appDir,
3053
3175
  source: options.code,
3054
3176
  }),
3177
+ usesRequestInput: routeClosureMayUseRequestInput({
3178
+ filename: options.filename,
3179
+ files: routeSourceFilesForAnalysis(options),
3180
+ projectRoot: options.appDir,
3181
+ source: options.code,
3182
+ }),
3055
3183
  usesRuntimeCacheControl: usesRuntimeCacheControl(options.code),
3056
3184
  };
3057
3185
  }
3058
3186
 
3059
3187
  function routeSourceFilesForAnalysis(options: {
3188
+ appDir: string;
3060
3189
  code: string;
3061
3190
  filename: string;
3062
3191
  serverSourceFiles?: ReadonlyMap<string, string> | undefined;
3063
3192
  }): (file: string) => string | undefined {
3064
- return (file) =>
3065
- file === options.filename ? options.code : options.serverSourceFiles?.get(file);
3193
+ return (file) => {
3194
+ const known = file === options.filename ? options.code : options.serverSourceFiles?.get(file);
3195
+ if (known !== undefined) {
3196
+ return known;
3197
+ }
3198
+
3199
+ const relativeFile = relative(resolve(options.appDir), resolve(file));
3200
+ if (relativeFile === "" || relativeFile === ".." || relativeFile.startsWith(`..${sep}`)) {
3201
+ return undefined;
3202
+ }
3203
+
3204
+ try {
3205
+ return readFileSync(file, "utf8");
3206
+ } catch {
3207
+ return undefined;
3208
+ }
3209
+ };
3210
+ }
3211
+
3212
+ async function routeShellsMayUseRequestInput(options: {
3213
+ appDir: string;
3214
+ pageFile: string;
3215
+ serverModuleCacheVersion: string | undefined;
3216
+ serverSourceFiles: ReadonlyMap<string, string> | undefined;
3217
+ }): Promise<boolean> {
3218
+ const shells = await shellFilesForPage(
3219
+ options.appDir,
3220
+ options.pageFile,
3221
+ options.serverModuleCacheVersion,
3222
+ );
3223
+
3224
+ for (const shell of shells) {
3225
+ const source = await readServerSourceFile(
3226
+ shell.file,
3227
+ options.serverModuleCacheVersion,
3228
+ options.serverSourceFiles,
3229
+ );
3230
+ if (
3231
+ routeClosureMayUseRequestInput({
3232
+ filename: shell.file,
3233
+ files: routeSourceFilesForAnalysis({
3234
+ appDir: options.appDir,
3235
+ code: source,
3236
+ filename: shell.file,
3237
+ serverSourceFiles: options.serverSourceFiles,
3238
+ }),
3239
+ projectRoot: options.appDir,
3240
+ source,
3241
+ })
3242
+ ) {
3243
+ return true;
3244
+ }
3245
+ }
3246
+
3247
+ return false;
3066
3248
  }
3067
3249
 
3068
3250
  async function runServerModule(
@@ -3269,8 +3451,10 @@ function runServerStreamModule(
3269
3451
  appDir: string;
3270
3452
  assetBaseUrl?: string | undefined;
3271
3453
  clientRouteInferenceCache: ClientRouteInferenceCache;
3454
+ markerRequest: Request;
3272
3455
  pageFile: string;
3273
3456
  props: ServerComponentProps;
3457
+ requestUrl: string;
3274
3458
  routePath: string;
3275
3459
  routeScripts?: ReadonlyMap<string, string> | undefined;
3276
3460
  clientRoute: boolean;
@@ -3315,11 +3499,11 @@ function runServerStreamModule(
3315
3499
  script: options.script,
3316
3500
  props: {
3317
3501
  params: options.props.params,
3318
- request: { url: options.props.request.url },
3502
+ request: { url: options.requestUrl },
3319
3503
  data: options.props.data,
3320
3504
  },
3321
3505
  })
3322
- : undefined;
3506
+ : serverOnlyRouteMarkerParts(options.routePath, options.markerRequest);
3323
3507
 
3324
3508
  sink.append("<!DOCTYPE html>");
3325
3509
  sink.append(
@@ -3537,10 +3721,13 @@ async function runServerStreamModuleWithLoading(
3537
3721
  data: Promise<unknown>;
3538
3722
  define?: UserConfig["define"] | undefined;
3539
3723
  loadingFile: string;
3724
+ markerRequest: Request;
3540
3725
  pageFile: string;
3541
3726
  params: RouteParams;
3542
3727
  queryClient: QueryClient;
3543
3728
  request: Request;
3729
+ requestUrl: string;
3730
+ trackedRequest?: TrackedHeaderRequest | undefined;
3544
3731
  routePath: string;
3545
3732
  routeScripts?: ReadonlyMap<string, string> | undefined;
3546
3733
  serverModules?: ReadonlyMap<string, BuiltServerModuleArtifact> | undefined;
@@ -3551,12 +3738,11 @@ async function runServerStreamModuleWithLoading(
3551
3738
  importPolicy?: AppRouterImportPolicy | undefined;
3552
3739
  },
3553
3740
  ): Promise<ReadableStream<Uint8Array>> {
3554
- const loadingProps = {
3741
+ const loadingProps = withTrackedRequest({
3555
3742
  data: undefined,
3556
3743
  params: options.params,
3557
3744
  queryClient: options.queryClient,
3558
- request: options.request,
3559
- };
3745
+ }, options.request, options.trackedRequest);
3560
3746
  const layoutShells = await layoutShellsForPage(
3561
3747
  options.appDir,
3562
3748
  options.pageFile,
@@ -3587,10 +3773,10 @@ async function runServerStreamModuleWithLoading(
3587
3773
  script: options.script,
3588
3774
  props: {
3589
3775
  params: options.params,
3590
- request: { url: options.request.url },
3776
+ request: { url: options.requestUrl },
3591
3777
  },
3592
3778
  })
3593
- : undefined;
3779
+ : serverOnlyRouteMarkerParts(options.routePath, options.markerRequest);
3594
3780
 
3595
3781
  return renderToReadableStream((sink) => {
3596
3782
  sink.append("<!DOCTYPE html>");
@@ -3616,12 +3802,11 @@ async function runServerStreamModuleWithLoading(
3616
3802
  await appendServerStreamModule(
3617
3803
  code,
3618
3804
  boundarySink,
3619
- {
3805
+ withTrackedRequest({
3620
3806
  data,
3621
3807
  params: options.params,
3622
3808
  queryClient: options.queryClient,
3623
- request: options.request,
3624
- },
3809
+ }, options.request, options.trackedRequest),
3625
3810
  options.pageFile,
3626
3811
  options.serverModules,
3627
3812
  options.serverModuleCacheVersion,
@@ -4014,17 +4199,6 @@ async function renderShellPrefixSuffix(
4014
4199
  serverModuleCacheVersion === undefined || hasNamedSlots || shell.kind === "template"
4015
4200
  ? undefined
4016
4201
  : `${appDir}\0${shell.file}\0${serverModuleCacheVersion}\0${importPolicyCacheKey(importPolicy)}\0${viteDefineCacheKey(define)}\0${vitePluginsCacheKey(vitePlugins)}`;
4017
- if (cacheKey !== undefined) {
4018
- const cached = readRouterRuntimeCacheEntry(
4019
- renderedShellCache,
4020
- cacheKey,
4021
- renderedShellCacheCounters,
4022
- );
4023
- if (cached !== undefined && cached !== "impure") {
4024
- return cached;
4025
- }
4026
- }
4027
-
4028
4202
  const staticEntry = cacheKey === undefined ? undefined : shellStaticRenderCache.get(cacheKey);
4029
4203
  const loadedStaticEntry =
4030
4204
  staticEntry ??
@@ -4052,34 +4226,6 @@ async function renderShellPrefixSuffix(
4052
4226
  hasOutOfOrderBoundary: loadedStaticEntry.hasOutOfOrderBoundary,
4053
4227
  };
4054
4228
  addRenderTimingPhaseDuration(timing, phaseStartedAt, "layoutSlotSplitMs");
4055
- const shellCacheKey = loadedStaticEntry.shellUsesAwait ? undefined : cacheKey;
4056
- const cached =
4057
- shellCacheKey !== undefined
4058
- ? readRouterRuntimeCacheEntry(renderedShellCache, shellCacheKey, renderedShellCacheCounters)
4059
- : undefined;
4060
-
4061
- // Detect purity: a zero-arg component cannot depend on props. The
4062
- // markShellBoundary + splitLayoutSlot output is then constant for
4063
- // the (appDir, shellFile, version) tuple. We only set the cache
4064
- // entry on the first request that observes the function arity; on
4065
- // an "impure" tag we never overwrite it.
4066
- if (shellCacheKey !== undefined && cached !== "impure") {
4067
- if (loadedStaticEntry.component.length === 0) {
4068
- if (renderedShellCache.size >= MAX_RENDERED_SHELL_CACHE_ENTRIES) {
4069
- const oldestKey = renderedShellCache.keys().next().value;
4070
- if (oldestKey !== undefined) {
4071
- renderedShellCache.delete(oldestKey);
4072
- renderedShellCacheCounters.evictions += 1;
4073
- }
4074
- }
4075
- renderedShellCache.set(shellCacheKey, rendered);
4076
- } else {
4077
- // Impure — stamp the cache so subsequent lookups short-circuit
4078
- // without re-checking arity. We still run the per-request
4079
- // render path above so the props are honoured.
4080
- renderedShellCache.set(shellCacheKey, "impure");
4081
- }
4082
- }
4083
4229
 
4084
4230
  return rendered;
4085
4231
  }