@reckona/mreact-router 0.0.201 → 0.0.202

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 (72) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/cloudflare.d.ts.map +1 -1
  3. package/dist/adapters/cloudflare.js +51 -4
  4. package/dist/adapters/cloudflare.js.map +1 -1
  5. package/dist/adapters/node.d.ts +7 -0
  6. package/dist/adapters/node.d.ts.map +1 -1
  7. package/dist/adapters/node.js +7 -1
  8. package/dist/adapters/node.js.map +1 -1
  9. package/dist/adapters/static.d.ts.map +1 -1
  10. package/dist/adapters/static.js +14 -3
  11. package/dist/adapters/static.js.map +1 -1
  12. package/dist/build.d.ts +3 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +42 -5
  15. package/dist/build.js.map +1 -1
  16. package/dist/built-runtime.d.ts +4 -1
  17. package/dist/built-runtime.d.ts.map +1 -1
  18. package/dist/built-runtime.js.map +1 -1
  19. package/dist/cache.d.ts +26 -0
  20. package/dist/cache.d.ts.map +1 -1
  21. package/dist/cache.js +78 -12
  22. package/dist/cache.js.map +1 -1
  23. package/dist/cli-options.d.ts +4 -0
  24. package/dist/cli-options.d.ts.map +1 -1
  25. package/dist/cli-options.js +17 -0
  26. package/dist/cli-options.js.map +1 -1
  27. package/dist/cli.js +2 -1
  28. package/dist/cli.js.map +1 -1
  29. package/dist/node-server.d.ts +6 -0
  30. package/dist/node-server.d.ts.map +1 -1
  31. package/dist/node-server.js +19 -1
  32. package/dist/node-server.js.map +1 -1
  33. package/dist/prerender-entry.d.ts +5 -0
  34. package/dist/prerender-entry.d.ts.map +1 -0
  35. package/dist/prerender-entry.js +41 -0
  36. package/dist/prerender-entry.js.map +1 -0
  37. package/dist/render.d.ts +13 -0
  38. package/dist/render.d.ts.map +1 -1
  39. package/dist/render.js +139 -87
  40. package/dist/render.js.map +1 -1
  41. package/dist/request-header-tracking.d.ts +44 -0
  42. package/dist/request-header-tracking.d.ts.map +1 -0
  43. package/dist/request-header-tracking.js +116 -0
  44. package/dist/request-header-tracking.js.map +1 -0
  45. package/dist/route-source.d.ts +16 -0
  46. package/dist/route-source.d.ts.map +1 -1
  47. package/dist/route-source.js +55 -1
  48. package/dist/route-source.js.map +1 -1
  49. package/dist/security-headers.d.ts +16 -0
  50. package/dist/security-headers.d.ts.map +1 -1
  51. package/dist/security-headers.js +37 -0
  52. package/dist/security-headers.js.map +1 -1
  53. package/dist/serve.d.ts +7 -0
  54. package/dist/serve.d.ts.map +1 -1
  55. package/dist/serve.js +94 -28
  56. package/dist/serve.js.map +1 -1
  57. package/package.json +11 -11
  58. package/src/adapters/cloudflare.ts +81 -9
  59. package/src/adapters/node.ts +14 -1
  60. package/src/adapters/static.ts +18 -5
  61. package/src/build.ts +71 -4
  62. package/src/built-runtime.ts +2 -2
  63. package/src/cache.ts +117 -11
  64. package/src/cli-options.ts +24 -0
  65. package/src/cli.ts +5 -0
  66. package/src/node-server.ts +27 -1
  67. package/src/prerender-entry.ts +56 -0
  68. package/src/render.ts +188 -98
  69. package/src/request-header-tracking.ts +161 -0
  70. package/src/route-source.ts +71 -0
  71. package/src/security-headers.ts +41 -0
  72. package/src/serve.ts +133 -30
@@ -30,6 +30,27 @@ export interface StartNodeRequestServerOptions {
30
30
  hostPolicy?: "strict" | "trusted-proxy" | undefined;
31
31
  rawHost: string | undefined;
32
32
  }) => string) | undefined;
33
+ trustForwardedProto?: boolean | undefined;
34
+ }
35
+
36
+ export function resolveNodeRequestProtocol(options: {
37
+ encrypted: boolean;
38
+ forwardedProto: string | readonly string[] | undefined;
39
+ trustForwardedProto?: boolean | undefined;
40
+ }): "http" | "https" {
41
+ if (options.encrypted) {
42
+ return "https";
43
+ }
44
+
45
+ if (options.trustForwardedProto !== true) {
46
+ return "http";
47
+ }
48
+
49
+ const forwarded = Array.isArray(options.forwardedProto)
50
+ ? options.forwardedProto[0]
51
+ : options.forwardedProto;
52
+ const first = forwarded?.split(",", 1)[0]?.trim().toLowerCase();
53
+ return first === "https" ? "https" : "http";
33
54
  }
34
55
 
35
56
  export async function startNodeRequestServer(
@@ -51,7 +72,12 @@ export async function startNodeRequestServer(
51
72
  hostPolicy: options.hostPolicy,
52
73
  rawHost: incoming.headers.host,
53
74
  });
54
- const request = nodeRequestToWebRequest(incoming, `http://${host}`);
75
+ const protocol = resolveNodeRequestProtocol({
76
+ encrypted: (incoming.socket as { encrypted?: boolean }).encrypted === true,
77
+ forwardedProto: incoming.headers["x-forwarded-proto"],
78
+ trustForwardedProto: options.trustForwardedProto,
79
+ });
80
+ const request = nodeRequestToWebRequest(incoming, `${protocol}://${host}`);
55
81
  const logFields = requestLogFields(request, "node");
56
82
  emitRouterLog(options.logger, "info", {
57
83
  ...logFields,
@@ -0,0 +1,56 @@
1
+ import type { BuiltPrerenderedRoute } from "./build.js";
2
+
3
+ export const PRERENDERED_ROUTE_SCHEMA_VERSION = 2;
4
+
5
+ export function isCurrentPrerenderedRoute(value: unknown): value is BuiltPrerenderedRoute {
6
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
7
+ return false;
8
+ }
9
+
10
+ const entry = value as Partial<BuiltPrerenderedRoute>;
11
+ return (
12
+ entry.schemaVersion === PRERENDERED_ROUTE_SCHEMA_VERSION &&
13
+ isStringRecord(entry.headers) &&
14
+ isShareableHeaderRecord(entry.headers) &&
15
+ typeof entry.html === "string" &&
16
+ typeof entry.status === "number" &&
17
+ Number.isInteger(entry.status) &&
18
+ entry.status >= 100 &&
19
+ entry.status <= 599
20
+ );
21
+ }
22
+
23
+ export function isVisitorDependentResponse(response: Response): boolean {
24
+ return isVisitorDependentHeaders(response.headers);
25
+ }
26
+
27
+ function isShareableHeaderRecord(headers: Record<string, string>): boolean {
28
+ try {
29
+ return !isVisitorDependentHeaders(new Headers(headers));
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function isVisitorDependentHeaders(headers: Headers): boolean {
36
+ const cacheControl = headers.get("cache-control") ?? "";
37
+ const forbidsSharedStorage = cacheControl.split(",").some((directive) =>
38
+ /^(?:private|no-cache|no-store)(?:=|$)/i.test(directive.trim()),
39
+ );
40
+
41
+ return (
42
+ headers.get("x-mreact-cache")?.toUpperCase() === "DYNAMIC" ||
43
+ headers.has("set-cookie") ||
44
+ headers.has("vary") ||
45
+ forbidsSharedStorage
46
+ );
47
+ }
48
+
49
+ function isStringRecord(value: unknown): value is Record<string, string> {
50
+ return (
51
+ typeof value === "object" &&
52
+ value !== null &&
53
+ !Array.isArray(value) &&
54
+ Object.values(value).every((entry) => typeof entry === "string")
55
+ );
56
+ }
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";
@@ -60,6 +61,15 @@ import {
60
61
  routeCacheKey,
61
62
  routeCachePolicyFromSource,
62
63
  } from "./cache.js";
64
+ import {
65
+ trackRequestHeaderReads,
66
+ type TrackedHeaderRequest,
67
+ withTrackedRequest,
68
+ } from "./request-header-tracking.js";
69
+ import {
70
+ configuredHstsHeader,
71
+ hasInvalidConfiguredHsts,
72
+ } from "./security-headers.js";
63
73
  import { resolveRouterCacheLimit } from "./cache-config.js";
64
74
  import {
65
75
  importAppRouterBuiltFileModule,
@@ -78,6 +88,7 @@ import {
78
88
  hasLoaderExport,
79
89
  isStreamRouteSource,
80
90
  routeClosureMayUseAwaitBoundary,
91
+ routeClosureMayUseRequestInput,
81
92
  stripRouteBuildExports,
82
93
  stripRouteModuleExports,
83
94
  stripRouteClientOnlyExports,
@@ -222,6 +233,17 @@ export interface RenderAppRequestOptions {
222
233
  }
223
234
 
224
235
  export interface RenderAppRequestRuntimeOptions extends RenderAppRequestOptions {
236
+ /**
237
+ * Receives whether the render depended on the incoming request headers.
238
+ *
239
+ * Callers that store the rendered HTML under a key which ignores headers,
240
+ * such as prerender regeneration, pass this to learn whether the result is
241
+ * only correct for the current visitor. Initialize it to a function returning
242
+ * `true`: a render that returns without reporting back stays header
243
+ * dependent. Call it only after draining the response body, because a
244
+ * streamed render can read headers while its deferred parts resolve.
245
+ */
246
+ renderSignals?: { headerDependent: () => boolean } | undefined;
225
247
  serverRenderArtifactLoader?: AppRouterServerRenderArtifactLoader | undefined;
226
248
  }
227
249
 
@@ -617,30 +639,18 @@ export function routerRenderRuntimeCacheStats(): RouterRuntimeCacheStat[] {
617
639
  maxComposedRouteMetadataCacheEntries,
618
640
  composedRouteMetadataCacheCounters,
619
641
  ),
620
- routerRuntimeCacheStat(
621
- "rendered-shell",
622
- renderedShellCache,
623
- MAX_RENDERED_SHELL_CACHE_ENTRIES,
624
- renderedShellCacheCounters,
625
- ),
642
+ // Preserve the public diagnostics entry while making explicit that rendered HTML reuse is disabled.
643
+ {
644
+ evictions: 0,
645
+ hits: 0,
646
+ maxEntries: 0,
647
+ misses: 0,
648
+ name: "rendered-shell",
649
+ size: 0,
650
+ },
626
651
  ];
627
652
  }
628
653
 
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
654
  interface RenderedShell {
645
655
  hasOutOfOrderBoundary: boolean;
646
656
  prefix: string;
@@ -654,6 +664,7 @@ interface RouteSourceAnalysis {
654
664
  hasLoader: boolean;
655
665
  routeCode: string;
656
666
  streamRoute: boolean;
667
+ usesRequestInput: boolean;
657
668
  usesRuntimeCacheControl: boolean;
658
669
  }
659
670
 
@@ -932,6 +943,9 @@ async function renderAppRequestInternal(
932
943
  renderAppRequestInternal({
933
944
  ...options,
934
945
  matchedRoute: undefined,
946
+ // This renders a different route than the caller asked about, so its
947
+ // header dependence says nothing about the caller's request.
948
+ renderSignals: undefined,
935
949
  request: singleFlightNavigationRequest({
936
950
  path: singleFlight.path,
937
951
  request: singleFlight.request,
@@ -983,6 +997,9 @@ async function renderAppRequestInternal(
983
997
  script: string | undefined;
984
998
  }
985
999
  | undefined;
1000
+ let trackedRequest: TrackedHeaderRequest | undefined;
1001
+ let sourceUsesRequestInput = true;
1002
+ let invalidConfiguredHsts = false;
986
1003
  return (await withRouteCacheContext(options.routeCache, async () => {
987
1004
  try {
988
1005
  if (matched.route.kind === "asset") {
@@ -1055,14 +1072,16 @@ async function renderAppRequestInternal(
1055
1072
  timing,
1056
1073
  vitePlugins: options.vitePlugins,
1057
1074
  });
1075
+ sourceUsesRequestInput = originalAnalysis.usesRequestInput;
1058
1076
  finishRenderTimingPhase(timing, phaseStartedAt, "sourceAnalysisMs");
1059
1077
  const cachePolicy = originalAnalysis.cachePolicy;
1060
1078
  const navigationScript = options.navigationScripts?.get(matched.route.path);
1061
1079
  const cacheKey = routeCacheKey(options.appDir, matched.route.path, url);
1062
1080
  const mayUseRouteCache =
1063
- cachePolicy === undefined
1081
+ !sourceUsesRequestInput &&
1082
+ (cachePolicy === undefined
1064
1083
  ? originalAnalysis.usesRuntimeCacheControl
1065
- : cachePolicy.revalidateSeconds !== 0;
1084
+ : cachePolicy.revalidateSeconds !== 0);
1066
1085
  const reloadRouteCache = isNavigationRouteCacheReloadRequest(options.request);
1067
1086
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
1068
1087
  const cachedResponse =
@@ -1079,6 +1098,18 @@ async function renderAppRequestInternal(
1079
1098
  return cachedResponse;
1080
1099
  }
1081
1100
 
1101
+ // Only a route whose result may be stored pays for header-read tracking;
1102
+ // cache hits returned above never construct it. Application code reads
1103
+ // headers through this request so that a render depending on one is never
1104
+ // stored under a key that ignores it. Callers that store by path alone ask
1105
+ // for tracking through `renderSignals` even when the route itself declares
1106
+ // no cache policy.
1107
+ trackedRequest =
1108
+ mayUseRouteCache || options.renderSignals !== undefined
1109
+ ? trackRequestHeaderReads(options.request)
1110
+ : undefined;
1111
+ const appRequest = trackedRequest?.request ?? options.request;
1112
+
1082
1113
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
1083
1114
  const preparedActions = await prepareRouteServerActions({
1084
1115
  appDir: options.appDir,
@@ -1120,12 +1151,11 @@ async function renderAppRequestInternal(
1120
1151
  ? loadRouteDataWithInstrumentation({
1121
1152
  appDir: options.appDir,
1122
1153
  code: originalCode,
1123
- context: {
1154
+ context: withTrackedRequest({
1124
1155
  env: options.env,
1125
1156
  params: matched.params,
1126
1157
  queryClient,
1127
- request: options.request,
1128
- },
1158
+ }, appRequest, trackedRequest),
1129
1159
  filename: matched.route.file,
1130
1160
  importPolicy: options.importPolicy,
1131
1161
  instrumentation: options.instrumentation,
@@ -1210,12 +1240,11 @@ async function renderAppRequestInternal(
1210
1240
  const renderedPage = await runWithQueryClient(queryClient, () =>
1211
1241
  runServerModuleWithSlots(
1212
1242
  stringOutput.code,
1213
- {
1243
+ withTrackedRequest({
1214
1244
  data,
1215
1245
  params: matched.params,
1216
1246
  queryClient,
1217
- request: options.request,
1218
- },
1247
+ }, appRequest, trackedRequest),
1219
1248
  matched.route.file,
1220
1249
  options.serverModules,
1221
1250
  options.serverModuleCacheVersion,
@@ -1251,12 +1280,11 @@ async function renderAppRequestInternal(
1251
1280
  appDir: options.appDir,
1252
1281
  pageFile: matched.route.file,
1253
1282
  html: pageHtmlForLayout,
1254
- props: {
1283
+ props: withTrackedRequest({
1255
1284
  data,
1256
1285
  params: matched.params,
1257
1286
  queryClient,
1258
- request: options.request,
1259
- },
1287
+ }, appRequest, trackedRequest),
1260
1288
  slots: renderedPage.slots,
1261
1289
  serverModules: options.serverModules,
1262
1290
  serverModuleCacheVersion: options.serverModuleCacheVersion,
@@ -1270,11 +1298,10 @@ async function renderAppRequestInternal(
1270
1298
  const metadata = await loadComposedRouteMetadata({
1271
1299
  appDir: options.appDir,
1272
1300
  code: originalCode,
1273
- context: {
1301
+ context: withTrackedRequest({
1274
1302
  data,
1275
1303
  params: matched.params,
1276
- request: options.request,
1277
- },
1304
+ }, appRequest, trackedRequest),
1278
1305
  filename: matched.route.file,
1279
1306
  importPolicy: options.importPolicy,
1280
1307
  routes,
@@ -1388,7 +1415,8 @@ async function renderAppRequestInternal(
1388
1415
  pageFile: matched.route.file,
1389
1416
  params: matched.params,
1390
1417
  queryClient,
1391
- request: options.request,
1418
+ request: appRequest,
1419
+ trackedRequest,
1392
1420
  routePath: matched.route.path,
1393
1421
  routeScripts: options.clientScripts,
1394
1422
  serverModules: options.serverModules,
@@ -1414,12 +1442,11 @@ async function renderAppRequestInternal(
1414
1442
  }
1415
1443
 
1416
1444
  const data = streamData;
1417
- const props = {
1445
+ const props = withTrackedRequest({
1418
1446
  data,
1419
1447
  params: matched.params,
1420
1448
  queryClient,
1421
- request: options.request,
1422
- };
1449
+ }, appRequest, trackedRequest);
1423
1450
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
1424
1451
  const stream = runServerStreamModule(output.code, {
1425
1452
  appDir: options.appDir,
@@ -1463,6 +1490,12 @@ async function renderAppRequestInternal(
1463
1490
  emitRenderTiming(options, timing, data.status);
1464
1491
  return data;
1465
1492
  }
1493
+ sourceUsesRequestInput ||= await routeShellsMayUseRequestInput({
1494
+ appDir: options.appDir,
1495
+ pageFile: matched.route.file,
1496
+ serverModuleCacheVersion: options.serverModuleCacheVersion,
1497
+ serverSourceFiles: options.serverSourceFiles,
1498
+ });
1466
1499
  await waitForRenderPreload(options, timing);
1467
1500
  await loadServerRenderArtifacts(options, matched.route.file, timing);
1468
1501
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
@@ -1488,12 +1521,11 @@ async function renderAppRequestInternal(
1488
1521
  const renderedPage = await runWithQueryClient(queryClient, () =>
1489
1522
  runServerModuleWithSlots(
1490
1523
  output.code,
1491
- {
1524
+ withTrackedRequest({
1492
1525
  data,
1493
1526
  params: matched.params,
1494
1527
  queryClient,
1495
- request: options.request,
1496
- },
1528
+ }, appRequest, trackedRequest),
1497
1529
  matched.route.file,
1498
1530
  options.serverModules,
1499
1531
  options.serverModuleCacheVersion,
@@ -1536,12 +1568,11 @@ async function renderAppRequestInternal(
1536
1568
  appDir: options.appDir,
1537
1569
  pageFile: matched.route.file,
1538
1570
  html: pageHtmlForLayout,
1539
- props: {
1571
+ props: withTrackedRequest({
1540
1572
  data,
1541
1573
  params: matched.params,
1542
1574
  queryClient,
1543
- request: options.request,
1544
- },
1575
+ }, appRequest, trackedRequest),
1545
1576
  slots: renderedPage.slots,
1546
1577
  serverModules: options.serverModules,
1547
1578
  serverModuleCacheVersion: options.serverModuleCacheVersion,
@@ -1556,11 +1587,10 @@ async function renderAppRequestInternal(
1556
1587
  const metadata = await loadComposedRouteMetadata({
1557
1588
  appDir: options.appDir,
1558
1589
  code: originalCode,
1559
- context: {
1590
+ context: withTrackedRequest({
1560
1591
  data,
1561
1592
  params: matched.params,
1562
- request: options.request,
1563
- },
1593
+ }, appRequest, trackedRequest),
1564
1594
  filename: matched.route.file,
1565
1595
  importPolicy: options.importPolicy,
1566
1596
  routes,
@@ -1608,12 +1638,23 @@ async function renderAppRequestInternal(
1608
1638
  );
1609
1639
 
1610
1640
  const effectiveCachePolicy = cachePolicy ?? activeRouteCacheContext()?.cachePolicy;
1641
+ const configuredHsts = configuredHstsHeader(metadata?.security);
1642
+ invalidConfiguredHsts = hasInvalidConfiguredHsts(metadata?.security);
1611
1643
 
1612
1644
  const finalResponse = preparedActions.hasFormActions
1613
1645
  ? withRouteCacheHeader(response, effectiveCachePolicy)
1614
1646
  : await cacheRouteResponse({
1615
1647
  key: cacheKey,
1616
1648
  cache: options.routeCache,
1649
+ // A policy can still arrive from a runtime cacheControl() call made
1650
+ // outside the page source, which is what `mayUseRouteCache` scans.
1651
+ // Without a tracker there is no evidence the render ignored request
1652
+ // headers, so the entry is treated as header dependent.
1653
+ headerDependent:
1654
+ sourceUsesRequestInput ||
1655
+ invalidConfiguredHsts ||
1656
+ (trackedRequest?.requestDependent() ?? true),
1657
+ ...(configuredHsts === undefined ? {} : { strictTransportSecurity: configuredHsts }),
1617
1658
  path: matched.route.path,
1618
1659
  policy: effectiveCachePolicy,
1619
1660
  request: options.request,
@@ -1686,6 +1727,17 @@ async function renderAppRequestInternal(
1686
1727
  });
1687
1728
  emitRenderTiming(options, timing, response.status);
1688
1729
  return response;
1730
+ } finally {
1731
+ // Reported from every return path, including the streaming ones, so that a
1732
+ // caller storing by path alone never mistakes an unreported render for a
1733
+ // shareable one. The value stays lazy because a streamed render can still
1734
+ // read request headers while its deferred parts resolve; callers read it
1735
+ // after draining the body.
1736
+ if (options.renderSignals !== undefined) {
1737
+ const tracked = trackedRequest;
1738
+ options.renderSignals.headerDependent = () =>
1739
+ sourceUsesRequestInput || invalidConfiguredHsts || (tracked?.requestDependent() ?? true);
1740
+ }
1689
1741
  }
1690
1742
  })).value;
1691
1743
  }
@@ -1695,9 +1747,24 @@ async function applyAppRouterResponseHook(
1695
1747
  options: RenderAppRequestOptions,
1696
1748
  ): Promise<Response> {
1697
1749
  const onResponse = await resolveAppRouterResponseHook(options);
1698
- const hooked = await onResponse?.(response, {
1699
- request: options.request,
1700
- });
1750
+
1751
+ if (onResponse === undefined) {
1752
+ return response;
1753
+ }
1754
+
1755
+ const renderSignals = (options as RenderAppRequestRuntimeOptions).renderSignals;
1756
+ const trackedRequest =
1757
+ renderSignals === undefined ? undefined : trackRequestHeaderReads(options.request);
1758
+ const hooked = await onResponse(
1759
+ response,
1760
+ withTrackedRequest({}, trackedRequest?.request ?? options.request, trackedRequest),
1761
+ );
1762
+
1763
+ if (renderSignals !== undefined && trackedRequest !== undefined) {
1764
+ const renderHeaderDependent = renderSignals.headerDependent;
1765
+ renderSignals.headerDependent = () =>
1766
+ renderHeaderDependent() || trackedRequest.requestDependent();
1767
+ }
1701
1768
 
1702
1769
  return hooked instanceof Response ? hooked : response;
1703
1770
  }
@@ -3010,6 +3077,9 @@ function routeSourceAnalysisFromArtifact(
3010
3077
  hasLoader: artifact.hasLoader,
3011
3078
  routeCode: artifact.routeCode,
3012
3079
  streamRoute: artifact.streamRoute,
3080
+ // Older built artifacts predate request-input safety analysis. Treat them
3081
+ // as dependent until rebuilt rather than trusting an absent safety signal.
3082
+ usesRequestInput: artifact.usesRequestInput ?? true,
3013
3083
  usesRuntimeCacheControl: artifact.usesRuntimeCacheControl,
3014
3084
  };
3015
3085
  }
@@ -3052,17 +3122,77 @@ async function analyzeRouteSourceUncached(options: {
3052
3122
  projectRoot: options.appDir,
3053
3123
  source: options.code,
3054
3124
  }),
3125
+ usesRequestInput: routeClosureMayUseRequestInput({
3126
+ filename: options.filename,
3127
+ files: routeSourceFilesForAnalysis(options),
3128
+ projectRoot: options.appDir,
3129
+ source: options.code,
3130
+ }),
3055
3131
  usesRuntimeCacheControl: usesRuntimeCacheControl(options.code),
3056
3132
  };
3057
3133
  }
3058
3134
 
3059
3135
  function routeSourceFilesForAnalysis(options: {
3136
+ appDir: string;
3060
3137
  code: string;
3061
3138
  filename: string;
3062
3139
  serverSourceFiles?: ReadonlyMap<string, string> | undefined;
3063
3140
  }): (file: string) => string | undefined {
3064
- return (file) =>
3065
- file === options.filename ? options.code : options.serverSourceFiles?.get(file);
3141
+ return (file) => {
3142
+ const known = file === options.filename ? options.code : options.serverSourceFiles?.get(file);
3143
+ if (known !== undefined) {
3144
+ return known;
3145
+ }
3146
+
3147
+ const relativeFile = relative(resolve(options.appDir), resolve(file));
3148
+ if (relativeFile === "" || relativeFile === ".." || relativeFile.startsWith(`..${sep}`)) {
3149
+ return undefined;
3150
+ }
3151
+
3152
+ try {
3153
+ return readFileSync(file, "utf8");
3154
+ } catch {
3155
+ return undefined;
3156
+ }
3157
+ };
3158
+ }
3159
+
3160
+ async function routeShellsMayUseRequestInput(options: {
3161
+ appDir: string;
3162
+ pageFile: string;
3163
+ serverModuleCacheVersion: string | undefined;
3164
+ serverSourceFiles: ReadonlyMap<string, string> | undefined;
3165
+ }): Promise<boolean> {
3166
+ const shells = await shellFilesForPage(
3167
+ options.appDir,
3168
+ options.pageFile,
3169
+ options.serverModuleCacheVersion,
3170
+ );
3171
+
3172
+ for (const shell of shells) {
3173
+ const source = await readServerSourceFile(
3174
+ shell.file,
3175
+ options.serverModuleCacheVersion,
3176
+ options.serverSourceFiles,
3177
+ );
3178
+ if (
3179
+ routeClosureMayUseRequestInput({
3180
+ filename: shell.file,
3181
+ files: routeSourceFilesForAnalysis({
3182
+ appDir: options.appDir,
3183
+ code: source,
3184
+ filename: shell.file,
3185
+ serverSourceFiles: options.serverSourceFiles,
3186
+ }),
3187
+ projectRoot: options.appDir,
3188
+ source,
3189
+ })
3190
+ ) {
3191
+ return true;
3192
+ }
3193
+ }
3194
+
3195
+ return false;
3066
3196
  }
3067
3197
 
3068
3198
  async function runServerModule(
@@ -3541,6 +3671,7 @@ async function runServerStreamModuleWithLoading(
3541
3671
  params: RouteParams;
3542
3672
  queryClient: QueryClient;
3543
3673
  request: Request;
3674
+ trackedRequest?: TrackedHeaderRequest | undefined;
3544
3675
  routePath: string;
3545
3676
  routeScripts?: ReadonlyMap<string, string> | undefined;
3546
3677
  serverModules?: ReadonlyMap<string, BuiltServerModuleArtifact> | undefined;
@@ -3551,12 +3682,11 @@ async function runServerStreamModuleWithLoading(
3551
3682
  importPolicy?: AppRouterImportPolicy | undefined;
3552
3683
  },
3553
3684
  ): Promise<ReadableStream<Uint8Array>> {
3554
- const loadingProps = {
3685
+ const loadingProps = withTrackedRequest({
3555
3686
  data: undefined,
3556
3687
  params: options.params,
3557
3688
  queryClient: options.queryClient,
3558
- request: options.request,
3559
- };
3689
+ }, options.request, options.trackedRequest);
3560
3690
  const layoutShells = await layoutShellsForPage(
3561
3691
  options.appDir,
3562
3692
  options.pageFile,
@@ -3616,12 +3746,11 @@ async function runServerStreamModuleWithLoading(
3616
3746
  await appendServerStreamModule(
3617
3747
  code,
3618
3748
  boundarySink,
3619
- {
3749
+ withTrackedRequest({
3620
3750
  data,
3621
3751
  params: options.params,
3622
3752
  queryClient: options.queryClient,
3623
- request: options.request,
3624
- },
3753
+ }, options.request, options.trackedRequest),
3625
3754
  options.pageFile,
3626
3755
  options.serverModules,
3627
3756
  options.serverModuleCacheVersion,
@@ -4014,17 +4143,6 @@ async function renderShellPrefixSuffix(
4014
4143
  serverModuleCacheVersion === undefined || hasNamedSlots || shell.kind === "template"
4015
4144
  ? undefined
4016
4145
  : `${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
4146
  const staticEntry = cacheKey === undefined ? undefined : shellStaticRenderCache.get(cacheKey);
4029
4147
  const loadedStaticEntry =
4030
4148
  staticEntry ??
@@ -4052,34 +4170,6 @@ async function renderShellPrefixSuffix(
4052
4170
  hasOutOfOrderBoundary: loadedStaticEntry.hasOutOfOrderBoundary,
4053
4171
  };
4054
4172
  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
4173
 
4084
4174
  return rendered;
4085
4175
  }