@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
|
@@ -38,6 +38,7 @@ import { routeSecurityHeaders } from "../security-headers.js";
|
|
|
38
38
|
import type { AppRouterPrerenderStore } from "../serve.js";
|
|
39
39
|
import { emitRouterDevtoolsEvent } from "./devtools.js";
|
|
40
40
|
import { escapeHtmlAttribute, escapeHtmlText } from "@reckona/mreact-shared/html-escape";
|
|
41
|
+
import { isCurrentPrerenderedRoute } from "../prerender-entry.js";
|
|
41
42
|
|
|
42
43
|
/** Re-exports build manifest contracts used by Cloudflare handlers. */
|
|
43
44
|
export type {
|
|
@@ -470,7 +471,38 @@ export function createCloudflareRouteModuleRenderer<Env = unknown>(
|
|
|
470
471
|
if (middlewareResult.type === "response") {
|
|
471
472
|
return middlewareResult.response;
|
|
472
473
|
}
|
|
473
|
-
|
|
474
|
+
|
|
475
|
+
if (middlewareResult.request !== request) {
|
|
476
|
+
// The route was matched against the pre-middleware URL, so a rewrite has
|
|
477
|
+
// to re-match: otherwise the rewritten path would still render the module
|
|
478
|
+
// the visitor asked for, which is the module a rewrite is often used to
|
|
479
|
+
// keep them away from. The built runtime re-matches the same way.
|
|
480
|
+
request = middlewareResult.request;
|
|
481
|
+
const rematched = matchCloudflareRoute(
|
|
482
|
+
sortedCloudflareRoutes(context.serverManifest),
|
|
483
|
+
new URL(request.url).pathname,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
if (rematched === undefined) {
|
|
487
|
+
return new Response("Not Found", { status: 404 });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
context = { ...context, params: rematched.params, route: rematched.route };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Middleware apps skip the prerendered fast path in `handleCloudflareRequest`
|
|
494
|
+
// so that access gates run first, so the stored HTML is resolved here
|
|
495
|
+
// instead, against the post-middleware path.
|
|
496
|
+
const prerendered = prerenderedResponse(
|
|
497
|
+
context.serverManifest.prerenderedRoutes,
|
|
498
|
+
normalizeRoutePath(new URL(request.url).pathname),
|
|
499
|
+
request.method,
|
|
500
|
+
isCloudflareNavigationRequest(request),
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
if (prerendered !== undefined) {
|
|
504
|
+
return prerendered;
|
|
505
|
+
}
|
|
474
506
|
|
|
475
507
|
if (context.route.kind !== "server" && isCloudflareNavigationRequest(request)) {
|
|
476
508
|
return cloudflareDocumentReloadNavigationResponse();
|
|
@@ -1193,12 +1225,18 @@ async function handleCloudflareRequest<Env>(
|
|
|
1193
1225
|
return assetResponse;
|
|
1194
1226
|
}
|
|
1195
1227
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1228
|
+
// Prerendered HTML may only short-circuit `render` when the app has no
|
|
1229
|
+
// middleware. Middleware runs inside `render`, so serving stored HTML here
|
|
1230
|
+
// would bypass auth, geo blocking and maintenance gates for exactly the
|
|
1231
|
+
// routes they are most often used to protect.
|
|
1232
|
+
const staticResponse = builtServerManifestHasMiddleware(options.serverManifest)
|
|
1233
|
+
? undefined
|
|
1234
|
+
: prerenderedResponse(
|
|
1235
|
+
options.serverManifest.prerenderedRoutes,
|
|
1236
|
+
normalizeRoutePath(url.pathname),
|
|
1237
|
+
request.method,
|
|
1238
|
+
isCloudflareNavigationRequest(request),
|
|
1239
|
+
);
|
|
1202
1240
|
|
|
1203
1241
|
if (staticResponse !== undefined) {
|
|
1204
1242
|
emitRouterDevtoolsEvent({
|
|
@@ -1319,6 +1357,23 @@ function cacheControlHasDirective(cacheControl: string | null, directive: string
|
|
|
1319
1357
|
return cacheControl.split(",").some((part) => part.trim().toLowerCase() === normalizedDirective);
|
|
1320
1358
|
}
|
|
1321
1359
|
|
|
1360
|
+
// `serverManifest.files` is keyed on paths relative to the project root, so a
|
|
1361
|
+
// default `src/app` project stores the middleware as "src/app/middleware.ts".
|
|
1362
|
+
// This mirrors `hasMiddleware` in built-runtime.ts, which resolves the same two
|
|
1363
|
+
// names against the routes directory.
|
|
1364
|
+
function builtServerManifestHasMiddleware(manifest: {
|
|
1365
|
+
files: Record<string, string>;
|
|
1366
|
+
routesDir?: string | undefined;
|
|
1367
|
+
}): boolean {
|
|
1368
|
+
const routesDir = (manifest.routesDir ?? "").replaceAll("\\", "/").replace(/\/+$/, "");
|
|
1369
|
+
const prefix = routesDir === "" ? "" : `${routesDir}/`;
|
|
1370
|
+
|
|
1371
|
+
return (
|
|
1372
|
+
manifest.files[`${prefix}middleware.ts`] !== undefined ||
|
|
1373
|
+
manifest.files[`${prefix}middleware.mreact.ts`] !== undefined
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1322
1377
|
function prerenderedResponse(
|
|
1323
1378
|
prerenderedRoutes: Record<string, BuiltPrerenderedRoute> | undefined,
|
|
1324
1379
|
path: string,
|
|
@@ -1331,7 +1386,7 @@ function prerenderedResponse(
|
|
|
1331
1386
|
|
|
1332
1387
|
const prerendered = prerenderedRoutes?.[path];
|
|
1333
1388
|
|
|
1334
|
-
if (prerendered
|
|
1389
|
+
if (!isCurrentPrerenderedRoute(prerendered)) {
|
|
1335
1390
|
return undefined;
|
|
1336
1391
|
}
|
|
1337
1392
|
|
|
@@ -1351,7 +1406,7 @@ function cloudflareRouteRequiresModule(route: AppRoute, manifest: BuiltServerMan
|
|
|
1351
1406
|
route.kind === "server" ||
|
|
1352
1407
|
(route.kind === "page" &&
|
|
1353
1408
|
(route.segments.some((segment) => segment.kind !== "static") ||
|
|
1354
|
-
manifest.prerenderedRoutes?.[route.path]
|
|
1409
|
+
!isCurrentPrerenderedRoute(manifest.prerenderedRoutes?.[route.path])))
|
|
1355
1410
|
);
|
|
1356
1411
|
}
|
|
1357
1412
|
|
|
@@ -1368,6 +1423,23 @@ function normalizeCloudflareRouteModulePath(path: string): string {
|
|
|
1368
1423
|
return withoutPrefix.replace(/\.(?:mjs|js|ts|tsx)$/, "");
|
|
1369
1424
|
}
|
|
1370
1425
|
|
|
1426
|
+
// The manifest is the same object across requests, so a rewrite-heavy app pays
|
|
1427
|
+
// for the specificity sort once rather than on every rewritten request.
|
|
1428
|
+
const sortedRoutesByManifest = new WeakMap<BuiltServerManifest, readonly AppRoute[]>();
|
|
1429
|
+
|
|
1430
|
+
function sortedCloudflareRoutes(manifest: BuiltServerManifest): readonly AppRoute[] {
|
|
1431
|
+
const cached = sortedRoutesByManifest.get(manifest);
|
|
1432
|
+
|
|
1433
|
+
if (cached !== undefined) {
|
|
1434
|
+
return cached;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const sorted = [...manifest.routes].sort(compareCloudflareRoutes);
|
|
1438
|
+
sortedRoutesByManifest.set(manifest, sorted);
|
|
1439
|
+
|
|
1440
|
+
return sorted;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1371
1443
|
function matchCloudflareRoute(
|
|
1372
1444
|
routes: readonly AppRoute[],
|
|
1373
1445
|
pathname: string,
|
package/src/adapters/node.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../logger.js";
|
|
15
15
|
import type { AppRouterResponseHook } from "../render.js";
|
|
16
16
|
import type { RouterInstrumentation } from "../trace.js";
|
|
17
|
+
import { resolveNodeRequestProtocol } from "../node-server.js";
|
|
17
18
|
|
|
18
19
|
/** Re-exports cache contracts used by Node handlers. */
|
|
19
20
|
export type { AppRouterCache, AppRouterCacheEntry } from "../cache.js";
|
|
@@ -92,6 +93,13 @@ export interface NodeRequestHandlerOptions {
|
|
|
92
93
|
routeCache?: AppRouterCache | undefined;
|
|
93
94
|
serverActions?: AppRouterServerActionOptions | undefined;
|
|
94
95
|
sinkStrategy?: ResponseSinkStrategy | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* Trusts the first `X-Forwarded-Proto` value when the socket is not TLS.
|
|
98
|
+
*
|
|
99
|
+
* Defaults to `false`. Enable only behind a proxy that overwrites the header
|
|
100
|
+
* and prevents untrusted clients from reaching the Node listener directly.
|
|
101
|
+
*/
|
|
102
|
+
trustForwardedProto?: boolean | undefined;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
/**
|
|
@@ -126,7 +134,12 @@ export function createNodeRequestHandler(options: NodeRequestHandlerOptions): No
|
|
|
126
134
|
hostPolicy: options.hostPolicy,
|
|
127
135
|
rawHost: incoming.headers.host,
|
|
128
136
|
});
|
|
129
|
-
const
|
|
137
|
+
const protocol = resolveNodeRequestProtocol({
|
|
138
|
+
encrypted: (incoming.socket as { encrypted?: boolean }).encrypted === true,
|
|
139
|
+
forwardedProto: incoming.headers["x-forwarded-proto"],
|
|
140
|
+
trustForwardedProto: options.trustForwardedProto,
|
|
141
|
+
});
|
|
142
|
+
const request = nodeRequestToWebRequest(incoming, `${protocol}://${host}`);
|
|
130
143
|
const logFields = requestLogFields(request, "node");
|
|
131
144
|
emitRouterLog(options.logger, "info", {
|
|
132
145
|
...logFields,
|
package/src/adapters/static.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve, sep } from "node:path";
|
|
3
3
|
import type { BuiltPrerenderedRoute, BuiltServerManifest } from "../build.js";
|
|
4
|
+
import { isCurrentPrerenderedRoute } from "../prerender-entry.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Configures static export from a built app-router output directory.
|
|
@@ -32,17 +33,29 @@ export async function exportStaticApp(options: StaticExportOptions): Promise<Sta
|
|
|
32
33
|
) as { publicAssets?: readonly string[] };
|
|
33
34
|
const prerenderedRoutes = manifest.prerenderedRoutes ?? {};
|
|
34
35
|
const routes = [...(options.paths ?? Object.keys(prerenderedRoutes))].sort();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await mkdir(options.exportDir, { recursive: true });
|
|
38
|
-
|
|
39
|
-
for (const route of routes) {
|
|
36
|
+
const entries = routes.map((route): readonly [string, BuiltPrerenderedRoute] => {
|
|
37
|
+
routeToHtmlFile(options.exportDir, route);
|
|
40
38
|
const entry = prerenderedRoutes[route];
|
|
41
39
|
|
|
42
40
|
if (entry === undefined) {
|
|
43
41
|
throw new Error(`Cannot export non-prerendered route: ${route}`);
|
|
44
42
|
}
|
|
45
43
|
|
|
44
|
+
if (!isCurrentPrerenderedRoute(entry)) {
|
|
45
|
+
throw new Error(`Cannot export invalid prerendered route: ${route}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (entry.status < 200 || entry.status >= 300) {
|
|
49
|
+
throw new Error(`Cannot export route ${route} with status ${entry.status}.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [route, entry];
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await rm(options.exportDir, { force: true, recursive: true });
|
|
56
|
+
await mkdir(options.exportDir, { recursive: true });
|
|
57
|
+
|
|
58
|
+
for (const [route, entry] of entries) {
|
|
46
59
|
await writePrerenderedRoute(options.exportDir, route, entry);
|
|
47
60
|
}
|
|
48
61
|
|
package/src/build.ts
CHANGED
|
@@ -70,7 +70,11 @@ import {
|
|
|
70
70
|
import type { ModuleMetadata } from "@reckona/mreact-compiler";
|
|
71
71
|
import type { RouteCachePolicy } from "./cache.js";
|
|
72
72
|
import { routeCachePolicyFromSource } from "./cache.js";
|
|
73
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
bundleMiddlewareModuleCode,
|
|
75
|
+
renderAppRequest,
|
|
76
|
+
type RenderAppRequestRuntimeOptions,
|
|
77
|
+
} from "./render.js";
|
|
74
78
|
import { createAppRouterImportPolicyPlugin, type AppRouterImportPolicy } from "./import-policy.js";
|
|
75
79
|
import {
|
|
76
80
|
hasGenerateStaticParamsExport,
|
|
@@ -78,6 +82,7 @@ import {
|
|
|
78
82
|
hasPrerenderExport,
|
|
79
83
|
isStreamRouteSource,
|
|
80
84
|
routeClosureMayUseAwaitBoundary,
|
|
85
|
+
routeClosureMayUseRequestInput,
|
|
81
86
|
stripRouteBuildExports,
|
|
82
87
|
stripRouteClientOnlyExports,
|
|
83
88
|
stripRouteClientSource,
|
|
@@ -97,6 +102,10 @@ import { collectRouteCssFilesFromSources, collectSpecialBoundaryFiles } from "./
|
|
|
97
102
|
import { existingRouteShellCandidates } from "./route-shells.js";
|
|
98
103
|
import { sourceModuleCandidates } from "./source-modules.js";
|
|
99
104
|
import { collectBuildInferredServerActions } from "./server-action-inference.js";
|
|
105
|
+
import {
|
|
106
|
+
isVisitorDependentResponse,
|
|
107
|
+
PRERENDERED_ROUTE_SCHEMA_VERSION,
|
|
108
|
+
} from "./prerender-entry.js";
|
|
100
109
|
import { prepareRouteServerActionPlaceholders } from "./actions.js";
|
|
101
110
|
import { viteDefineCacheKey, vitePluginsCacheKey } from "./vite-plugin-cache-key.js";
|
|
102
111
|
import { workspacePackageFile } from "./workspace-packages.js";
|
|
@@ -368,6 +377,7 @@ export interface BuiltRouteSourceAnalysisSummary {
|
|
|
368
377
|
routePath: string;
|
|
369
378
|
sourceHash: string;
|
|
370
379
|
streamRoute: boolean;
|
|
380
|
+
usesRequestInput?: boolean | undefined;
|
|
371
381
|
usesRuntimeCacheControl: boolean;
|
|
372
382
|
}
|
|
373
383
|
|
|
@@ -393,6 +403,7 @@ interface BuildRouteSourceAnalysis extends BuildSourceAnalysis {
|
|
|
393
403
|
route: AppRoute & { kind: "page" };
|
|
394
404
|
routeCode: string;
|
|
395
405
|
streamRoute: boolean;
|
|
406
|
+
usesRequestInput: boolean;
|
|
396
407
|
}
|
|
397
408
|
|
|
398
409
|
interface BuildSourceAnalysisScope {
|
|
@@ -412,6 +423,8 @@ export interface BuiltServerModuleOutput {
|
|
|
412
423
|
export interface BuiltPrerenderedRoute {
|
|
413
424
|
headers: Record<string, string>;
|
|
414
425
|
html: string;
|
|
426
|
+
/** Identifies entries that satisfy the complete current prerender contract. */
|
|
427
|
+
schemaVersion?: 2 | undefined;
|
|
415
428
|
status: number;
|
|
416
429
|
}
|
|
417
430
|
|
|
@@ -1384,6 +1397,14 @@ async function analyzeBuildRouteSources(options: {
|
|
|
1384
1397
|
projectRoot: options.projectRoot,
|
|
1385
1398
|
source,
|
|
1386
1399
|
}),
|
|
1400
|
+
usesRequestInput: await buildRouteUsesRequestInput({
|
|
1401
|
+
file,
|
|
1402
|
+
files: options.files,
|
|
1403
|
+
project: options.project,
|
|
1404
|
+
projectRoot: options.projectRoot,
|
|
1405
|
+
routeFile: route.file,
|
|
1406
|
+
source,
|
|
1407
|
+
}),
|
|
1387
1408
|
},
|
|
1388
1409
|
] as const;
|
|
1389
1410
|
},
|
|
@@ -1411,6 +1432,39 @@ async function analyzeBuildRouteSources(options: {
|
|
|
1411
1432
|
};
|
|
1412
1433
|
}
|
|
1413
1434
|
|
|
1435
|
+
async function buildRouteUsesRequestInput(options: {
|
|
1436
|
+
file: string;
|
|
1437
|
+
files: Record<string, string>;
|
|
1438
|
+
project: ResolvedAppRouterProject;
|
|
1439
|
+
projectRoot: string;
|
|
1440
|
+
routeFile: string;
|
|
1441
|
+
source: string;
|
|
1442
|
+
}): Promise<boolean> {
|
|
1443
|
+
const shells = await existingRouteShellCandidates(
|
|
1444
|
+
options.project.routesDir,
|
|
1445
|
+
options.routeFile,
|
|
1446
|
+
async (shellFile) =>
|
|
1447
|
+
options.files[relative(options.projectRoot, shellFile).split(sep).join("/")] !== undefined,
|
|
1448
|
+
);
|
|
1449
|
+
const sources = [
|
|
1450
|
+
{ file: options.file, source: options.source },
|
|
1451
|
+
...shells.flatMap((shell) => {
|
|
1452
|
+
const file = relative(options.projectRoot, shell.file).split(sep).join("/");
|
|
1453
|
+
const source = options.files[file];
|
|
1454
|
+
return source === undefined ? [] : [{ file, source }];
|
|
1455
|
+
}),
|
|
1456
|
+
];
|
|
1457
|
+
|
|
1458
|
+
return sources.some((entry) =>
|
|
1459
|
+
routeClosureMayUseRequestInput({
|
|
1460
|
+
filename: entry.file,
|
|
1461
|
+
files: options.files,
|
|
1462
|
+
projectRoot: options.projectRoot,
|
|
1463
|
+
source: entry.source,
|
|
1464
|
+
})
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1414
1468
|
function analyzeBuildSource(source: string, filename: string): BuildSourceAnalysis {
|
|
1415
1469
|
const cachePolicy = routeCachePolicyFromSource(source);
|
|
1416
1470
|
const sourceHash = hashText(source);
|
|
@@ -2564,7 +2618,10 @@ async function prerenderStaticRoutes(options: {
|
|
|
2564
2618
|
|
|
2565
2619
|
const entries: Array<[string, BuiltPrerenderedRoute]> = [];
|
|
2566
2620
|
for (const pathname of await prerenderPathsForRoute(route, analysis, options.vitePlugins)) {
|
|
2567
|
-
const
|
|
2621
|
+
const renderSignals = {
|
|
2622
|
+
headerDependent: () => true,
|
|
2623
|
+
};
|
|
2624
|
+
const renderOptions = {
|
|
2568
2625
|
appDir: options.appDir,
|
|
2569
2626
|
assetBaseUrl: options.assetBaseUrl,
|
|
2570
2627
|
clientScripts,
|
|
@@ -2573,10 +2630,18 @@ async function prerenderStaticRoutes(options: {
|
|
|
2573
2630
|
importPolicy,
|
|
2574
2631
|
navigationScripts,
|
|
2575
2632
|
request: new Request(`http://mreact.local${pathname}`),
|
|
2633
|
+
renderSignals,
|
|
2576
2634
|
serverModuleCacheVersion,
|
|
2577
2635
|
serverModules: serverModuleMap,
|
|
2578
2636
|
vitePlugins: options.vitePlugins,
|
|
2579
|
-
}
|
|
2637
|
+
} satisfies RenderAppRequestRuntimeOptions;
|
|
2638
|
+
const response = await renderAppRequest(renderOptions);
|
|
2639
|
+
const html = await response.text();
|
|
2640
|
+
|
|
2641
|
+
if (renderSignals.headerDependent() || isVisitorDependentResponse(response)) {
|
|
2642
|
+
continue;
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2580
2645
|
const headers: Record<string, string> = {};
|
|
2581
2646
|
|
|
2582
2647
|
response.headers.forEach((value, key) => {
|
|
@@ -2586,7 +2651,8 @@ async function prerenderStaticRoutes(options: {
|
|
|
2586
2651
|
pathname,
|
|
2587
2652
|
{
|
|
2588
2653
|
headers,
|
|
2589
|
-
html
|
|
2654
|
+
html,
|
|
2655
|
+
schemaVersion: PRERENDERED_ROUTE_SCHEMA_VERSION,
|
|
2590
2656
|
status: response.status,
|
|
2591
2657
|
},
|
|
2592
2658
|
]);
|
|
@@ -3141,6 +3207,7 @@ function builtRouteSourceAnalysisSummary(options: {
|
|
|
3141
3207
|
routePath: options.analysis.route.path,
|
|
3142
3208
|
sourceHash: options.analysis.sourceHash,
|
|
3143
3209
|
streamRoute: options.analysis.streamRoute,
|
|
3210
|
+
usesRequestInput: options.analysis.usesRequestInput,
|
|
3144
3211
|
usesRuntimeCacheControl: options.analysis.usesRuntimeCacheControl,
|
|
3145
3212
|
};
|
|
3146
3213
|
}
|
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;
|
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;
|
|
@@ -330,6 +339,8 @@ export function formatCliHelp(command?: string | undefined): string {
|
|
|
330
339
|
" Control Host header trust for request origin reconstruction.",
|
|
331
340
|
" --allowed-hosts <host[,host...]>",
|
|
332
341
|
" Exact Host header allow-list for public deployments.",
|
|
342
|
+
" --trust-forwarded-proto",
|
|
343
|
+
" 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
344
|
" --log=requests Print request summaries.",
|
|
334
345
|
" -h, --help Show this help message.",
|
|
335
346
|
"",
|
|
@@ -339,6 +350,8 @@ export function formatCliHelp(command?: string | undefined): string {
|
|
|
339
350
|
" Host header trust policy when --host-policy is not set.",
|
|
340
351
|
" MREACT_ROUTER_ALLOWED_HOSTS",
|
|
341
352
|
" Comma-separated Host header allow-list when --allowed-hosts is not set.",
|
|
353
|
+
" MREACT_ROUTER_TRUST_FORWARDED_PROTO",
|
|
354
|
+
" Set to 1 to trust X-Forwarded-Proto when the flag is not set.",
|
|
342
355
|
" PORT TCP port. Default: 3001.",
|
|
343
356
|
].join("\n");
|
|
344
357
|
}
|
|
@@ -480,6 +493,17 @@ export function resolveCliAllowedHosts(
|
|
|
480
493
|
return envValue === undefined || envValue === "" ? undefined : parseCliAllowedHosts(envValue);
|
|
481
494
|
}
|
|
482
495
|
|
|
496
|
+
export function resolveCliTrustForwardedProto(
|
|
497
|
+
flagValue: boolean | undefined,
|
|
498
|
+
env: { MREACT_ROUTER_TRUST_FORWARDED_PROTO?: string | undefined },
|
|
499
|
+
): boolean {
|
|
500
|
+
if (flagValue !== undefined) {
|
|
501
|
+
return flagValue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return env.MREACT_ROUTER_TRUST_FORWARDED_PROTO === "1";
|
|
505
|
+
}
|
|
506
|
+
|
|
483
507
|
export function createCliRequestLogger(): AppRouterLogger {
|
|
484
508
|
return {
|
|
485
509
|
error(event) {
|
package/src/cli.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
resolveCliHost,
|
|
26
26
|
resolveCliHostPolicy,
|
|
27
27
|
resolveCliRequestLogMode,
|
|
28
|
+
resolveCliTrustForwardedProto,
|
|
28
29
|
} from "./cli-options.js";
|
|
29
30
|
import { startDevServer } from "./dev-server.js";
|
|
30
31
|
import { startServer } from "./serve.js";
|
|
@@ -169,6 +170,10 @@ if (parsed !== undefined) {
|
|
|
169
170
|
logger,
|
|
170
171
|
outDir: resolve(routeArg ?? ".mreact"),
|
|
171
172
|
port: Number(process.env.PORT ?? 3001),
|
|
173
|
+
trustForwardedProto: resolveCliTrustForwardedProto(
|
|
174
|
+
parsed.trustForwardedProto,
|
|
175
|
+
process.env,
|
|
176
|
+
),
|
|
172
177
|
});
|
|
173
178
|
console.log(`mreact app router serving built output at ${server.url}`);
|
|
174
179
|
} else {
|