@reckona/mreact-router 0.0.201 → 0.0.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/cloudflare.d.ts.map +1 -1
  3. package/dist/adapters/cloudflare.js +51 -4
  4. package/dist/adapters/cloudflare.js.map +1 -1
  5. package/dist/adapters/node.d.ts +7 -0
  6. package/dist/adapters/node.d.ts.map +1 -1
  7. package/dist/adapters/node.js +7 -1
  8. package/dist/adapters/node.js.map +1 -1
  9. package/dist/adapters/static.d.ts.map +1 -1
  10. package/dist/adapters/static.js +14 -3
  11. package/dist/adapters/static.js.map +1 -1
  12. package/dist/build.d.ts +3 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +42 -5
  15. package/dist/build.js.map +1 -1
  16. package/dist/built-runtime.d.ts +4 -1
  17. package/dist/built-runtime.d.ts.map +1 -1
  18. package/dist/built-runtime.js.map +1 -1
  19. package/dist/cache.d.ts +26 -0
  20. package/dist/cache.d.ts.map +1 -1
  21. package/dist/cache.js +78 -12
  22. package/dist/cache.js.map +1 -1
  23. package/dist/cli-options.d.ts +4 -0
  24. package/dist/cli-options.d.ts.map +1 -1
  25. package/dist/cli-options.js +17 -0
  26. package/dist/cli-options.js.map +1 -1
  27. package/dist/cli.js +2 -1
  28. package/dist/cli.js.map +1 -1
  29. package/dist/node-server.d.ts +6 -0
  30. package/dist/node-server.d.ts.map +1 -1
  31. package/dist/node-server.js +19 -1
  32. package/dist/node-server.js.map +1 -1
  33. package/dist/prerender-entry.d.ts +5 -0
  34. package/dist/prerender-entry.d.ts.map +1 -0
  35. package/dist/prerender-entry.js +41 -0
  36. package/dist/prerender-entry.js.map +1 -0
  37. package/dist/render.d.ts +13 -0
  38. package/dist/render.d.ts.map +1 -1
  39. package/dist/render.js +139 -87
  40. package/dist/render.js.map +1 -1
  41. package/dist/request-header-tracking.d.ts +44 -0
  42. package/dist/request-header-tracking.d.ts.map +1 -0
  43. package/dist/request-header-tracking.js +116 -0
  44. package/dist/request-header-tracking.js.map +1 -0
  45. package/dist/route-source.d.ts +16 -0
  46. package/dist/route-source.d.ts.map +1 -1
  47. package/dist/route-source.js +55 -1
  48. package/dist/route-source.js.map +1 -1
  49. package/dist/security-headers.d.ts +16 -0
  50. package/dist/security-headers.d.ts.map +1 -1
  51. package/dist/security-headers.js +37 -0
  52. package/dist/security-headers.js.map +1 -1
  53. package/dist/serve.d.ts +7 -0
  54. package/dist/serve.d.ts.map +1 -1
  55. package/dist/serve.js +94 -28
  56. package/dist/serve.js.map +1 -1
  57. package/package.json +11 -11
  58. package/src/adapters/cloudflare.ts +81 -9
  59. package/src/adapters/node.ts +14 -1
  60. package/src/adapters/static.ts +18 -5
  61. package/src/build.ts +71 -4
  62. package/src/built-runtime.ts +2 -2
  63. package/src/cache.ts +117 -11
  64. package/src/cli-options.ts +24 -0
  65. package/src/cli.ts +5 -0
  66. package/src/node-server.ts +27 -1
  67. package/src/prerender-entry.ts +56 -0
  68. package/src/render.ts +188 -98
  69. package/src/request-header-tracking.ts +161 -0
  70. package/src/route-source.ts +71 -0
  71. package/src/security-headers.ts +41 -0
  72. package/src/serve.ts +133 -30
@@ -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
- request = middlewareResult.request;
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
- const staticResponse = prerenderedResponse(
1197
- options.serverManifest.prerenderedRoutes,
1198
- normalizeRoutePath(url.pathname),
1199
- request.method,
1200
- isCloudflareNavigationRequest(request),
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 === undefined) {
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] === undefined))
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,
@@ -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 request = nodeRequestToWebRequest(incoming, `http://${host}`);
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,
@@ -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
- await rm(options.exportDir, { force: true, recursive: true });
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 { bundleMiddlewareModuleCode, renderAppRequest } from "./render.js";
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 response = await renderAppRequest({
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: await response.text(),
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
  }
@@ -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 (cached === undefined || cached.expiresAt <= now) {
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;
@@ -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 {