@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.
- package/README.md +1 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +51 -4
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/node.d.ts +7 -0
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +7 -1
- package/dist/adapters/node.js.map +1 -1
- package/dist/adapters/static.d.ts.map +1 -1
- package/dist/adapters/static.js +14 -3
- package/dist/adapters/static.js.map +1 -1
- package/dist/build.d.ts +3 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +42 -5
- package/dist/build.js.map +1 -1
- package/dist/built-runtime.d.ts +4 -1
- package/dist/built-runtime.d.ts.map +1 -1
- package/dist/built-runtime.js.map +1 -1
- package/dist/cache.d.ts +26 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +78 -12
- package/dist/cache.js.map +1 -1
- package/dist/cli-options.d.ts +4 -0
- package/dist/cli-options.d.ts.map +1 -1
- package/dist/cli-options.js +17 -0
- package/dist/cli-options.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/node-server.d.ts +6 -0
- package/dist/node-server.d.ts.map +1 -1
- package/dist/node-server.js +19 -1
- package/dist/node-server.js.map +1 -1
- package/dist/prerender-entry.d.ts +5 -0
- package/dist/prerender-entry.d.ts.map +1 -0
- package/dist/prerender-entry.js +41 -0
- package/dist/prerender-entry.js.map +1 -0
- package/dist/render.d.ts +13 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +139 -87
- package/dist/render.js.map +1 -1
- package/dist/request-header-tracking.d.ts +44 -0
- package/dist/request-header-tracking.d.ts.map +1 -0
- package/dist/request-header-tracking.js +116 -0
- package/dist/request-header-tracking.js.map +1 -0
- package/dist/route-source.d.ts +16 -0
- package/dist/route-source.d.ts.map +1 -1
- package/dist/route-source.js +55 -1
- package/dist/route-source.js.map +1 -1
- package/dist/security-headers.d.ts +16 -0
- package/dist/security-headers.d.ts.map +1 -1
- package/dist/security-headers.js +37 -0
- package/dist/security-headers.js.map +1 -1
- package/dist/serve.d.ts +7 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +94 -28
- package/dist/serve.js.map +1 -1
- package/package.json +11 -11
- package/src/adapters/cloudflare.ts +81 -9
- package/src/adapters/node.ts +14 -1
- package/src/adapters/static.ts +18 -5
- package/src/build.ts +71 -4
- package/src/built-runtime.ts +2 -2
- package/src/cache.ts +117 -11
- package/src/cli-options.ts +24 -0
- package/src/cli.ts +5 -0
- package/src/node-server.ts +27 -1
- package/src/prerender-entry.ts +56 -0
- package/src/render.ts +188 -98
- package/src/request-header-tracking.ts +161 -0
- package/src/route-source.ts +71 -0
- package/src/security-headers.ts +41 -0
- package/src/serve.ts +133 -30
package/src/node-server.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|