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