@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.
Files changed (93) hide show
  1. package/README.md +6 -3
  2. package/dist/actions.d.ts +2 -3
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +61 -41
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/cloudflare.d.ts +3 -1
  7. package/dist/adapters/cloudflare.d.ts.map +1 -1
  8. package/dist/adapters/cloudflare.js +74 -11
  9. package/dist/adapters/cloudflare.js.map +1 -1
  10. package/dist/adapters/node.d.ts +7 -0
  11. package/dist/adapters/node.d.ts.map +1 -1
  12. package/dist/adapters/node.js +7 -1
  13. package/dist/adapters/node.js.map +1 -1
  14. package/dist/adapters/static.d.ts.map +1 -1
  15. package/dist/adapters/static.js +14 -3
  16. package/dist/adapters/static.js.map +1 -1
  17. package/dist/build.d.ts +5 -0
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +137 -14
  20. package/dist/build.js.map +1 -1
  21. package/dist/built-runtime.d.ts +4 -1
  22. package/dist/built-runtime.d.ts.map +1 -1
  23. package/dist/built-runtime.js.map +1 -1
  24. package/dist/cache.d.ts +27 -1
  25. package/dist/cache.d.ts.map +1 -1
  26. package/dist/cache.js +83 -16
  27. package/dist/cache.js.map +1 -1
  28. package/dist/cli-options.d.ts +7 -0
  29. package/dist/cli-options.d.ts.map +1 -1
  30. package/dist/cli-options.js +27 -2
  31. package/dist/cli-options.js.map +1 -1
  32. package/dist/cli.js +3 -2
  33. package/dist/cli.js.map +1 -1
  34. package/dist/client.d.ts +5 -0
  35. package/dist/client.d.ts.map +1 -1
  36. package/dist/client.js +11 -2
  37. package/dist/client.js.map +1 -1
  38. package/dist/navigation-marker.d.ts +2 -0
  39. package/dist/navigation-marker.d.ts.map +1 -0
  40. package/dist/navigation-marker.js +294 -0
  41. package/dist/navigation-marker.js.map +1 -0
  42. package/dist/navigation-runtime.d.ts +1 -1
  43. package/dist/navigation-runtime.d.ts.map +1 -1
  44. package/dist/navigation-runtime.js +1 -1
  45. package/dist/navigation-runtime.js.map +1 -1
  46. package/dist/node-server.d.ts +6 -0
  47. package/dist/node-server.d.ts.map +1 -1
  48. package/dist/node-server.js +19 -1
  49. package/dist/node-server.js.map +1 -1
  50. package/dist/prerender-entry.d.ts +7 -0
  51. package/dist/prerender-entry.d.ts.map +1 -0
  52. package/dist/prerender-entry.js +99 -0
  53. package/dist/prerender-entry.js.map +1 -0
  54. package/dist/render.d.ts +20 -1
  55. package/dist/render.d.ts.map +1 -1
  56. package/dist/render.js +190 -110
  57. package/dist/render.js.map +1 -1
  58. package/dist/request-header-tracking.d.ts +44 -0
  59. package/dist/request-header-tracking.d.ts.map +1 -0
  60. package/dist/request-header-tracking.js +116 -0
  61. package/dist/request-header-tracking.js.map +1 -0
  62. package/dist/route-source.d.ts +16 -0
  63. package/dist/route-source.d.ts.map +1 -1
  64. package/dist/route-source.js +55 -1
  65. package/dist/route-source.js.map +1 -1
  66. package/dist/security-headers.d.ts +16 -0
  67. package/dist/security-headers.d.ts.map +1 -1
  68. package/dist/security-headers.js +37 -0
  69. package/dist/security-headers.js.map +1 -1
  70. package/dist/serve.d.ts +10 -0
  71. package/dist/serve.d.ts.map +1 -1
  72. package/dist/serve.js +132 -42
  73. package/dist/serve.js.map +1 -1
  74. package/package.json +11 -11
  75. package/src/actions.ts +72 -56
  76. package/src/adapters/cloudflare.ts +118 -16
  77. package/src/adapters/node.ts +14 -1
  78. package/src/adapters/static.ts +18 -5
  79. package/src/build.ts +176 -12
  80. package/src/built-runtime.ts +2 -2
  81. package/src/cache.ts +127 -15
  82. package/src/cli-options.ts +39 -2
  83. package/src/cli.ts +7 -1
  84. package/src/client.ts +15 -2
  85. package/src/navigation-marker.ts +353 -0
  86. package/src/navigation-runtime.ts +1 -0
  87. package/src/node-server.ts +27 -1
  88. package/src/prerender-entry.ts +129 -0
  89. package/src/render.ts +267 -121
  90. package/src/request-header-tracking.ts +161 -0
  91. package/src/route-source.ts +71 -0
  92. package/src/security-headers.ts +41 -0
  93. package/src/serve.ts +188 -48
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Observes request inputs read by application code during a render.
3
+ *
4
+ * The shared route cache is keyed on the app directory, route path, pathname
5
+ * and query only. A render that reads a request header therefore produces HTML
6
+ * that is only correct for that header value, and storing it would serve one
7
+ * visitor's variant to the next: `accept-language` mixes locales, `user-agent`
8
+ * mixes mobile and desktop shells, and `cf-connecting-ip` or `referer` disclose
9
+ * one visitor's data to another. Reads are observed rather than inferred from
10
+ * the source so that headers read through helper modules are covered too.
11
+ *
12
+ * Only the request handed to application code is tracked. Router internals keep
13
+ * using the original request, so their own header reads never mark a route as
14
+ * header dependent.
15
+ *
16
+ * Reconstructing the request with `new Request(request)` copies headers from
17
+ * internal slots that no interception can reach. Application contexts therefore
18
+ * mark access when their lazy `request` property is retrieved, before opaque
19
+ * helpers can reconstruct it. `render.ts` also uses conservative source closure
20
+ * analysis so a previously stored entry cannot bypass the runtime observation.
21
+ */
22
+ export interface TrackedHeaderRequest {
23
+ /** Marks application access to the Request before it can escape tracking. */
24
+ markRequestAccess(): void;
25
+ /** Returns true once application code has observed request-specific input. */
26
+ requestDependent(): boolean;
27
+ /** The request to hand to loaders, metadata functions and components. */
28
+ request: Request;
29
+ }
30
+
31
+ const HEADER_READ_METHODS = new Set<string | symbol>([
32
+ "entries",
33
+ "forEach",
34
+ "get",
35
+ "getSetCookie",
36
+ "has",
37
+ "keys",
38
+ "values",
39
+ Symbol.iterator,
40
+ ]);
41
+
42
+ /**
43
+ * Wraps a request so that header reads performed through it are recorded.
44
+ *
45
+ * Falls back to reporting every request as header dependent when the request
46
+ * cannot be cloned, so an unexpected runtime never silently enables sharing.
47
+ */
48
+ export function trackRequestHeaderReads(request: Request): TrackedHeaderRequest {
49
+ let readAny = false;
50
+
51
+ let tracked: Request;
52
+ try {
53
+ // Cloning a request that carries a body would tee its stream and buffer
54
+ // whichever half goes unread. Only bodyless requests are worth tracking:
55
+ // a request with a body is reported as header dependent so that its
56
+ // response is never shared.
57
+ if (request.body !== null) {
58
+ return {
59
+ markRequestAccess() {},
60
+ requestDependent: () => true,
61
+ request,
62
+ };
63
+ }
64
+
65
+ tracked = request.clone();
66
+ } catch {
67
+ return {
68
+ markRequestAccess() {},
69
+ requestDependent: () => true,
70
+ request,
71
+ };
72
+ }
73
+
74
+ const originalHeaders = tracked.headers;
75
+ const originalUrl = tracked.url;
76
+ const headers = new Proxy(originalHeaders, {
77
+ get(target, property) {
78
+ const value = Reflect.get(target, property, target);
79
+
80
+ if (typeof value !== "function" || !HEADER_READ_METHODS.has(property)) {
81
+ return value;
82
+ }
83
+
84
+ return (...args: unknown[]): unknown => {
85
+ readAny = true;
86
+ return Reflect.apply(value as (...callArgs: unknown[]) => unknown, target, args);
87
+ };
88
+ },
89
+ });
90
+
91
+ // Origin/host are not part of the route cache key. Treat any URL observation
92
+ // as request dependence so a multi-host process cannot replay one tenant's
93
+ // absolute URLs to another. Path and query reads are conservatively included.
94
+ Object.defineProperty(tracked, "url", {
95
+ configurable: true,
96
+ get: () => {
97
+ readAny = true;
98
+ return originalUrl;
99
+ },
100
+ });
101
+
102
+ // Shadow the prototype getter on a real Request so that `fetch(request)`,
103
+ // `request.json()` and instanceof checks keep working in every runtime.
104
+ // Taking a reference counts as a read on its own: a caller that reached for
105
+ // the headers is assumed to depend on them, which keeps the decision closed
106
+ // when the reference escapes into code this module cannot observe.
107
+ Object.defineProperty(tracked, "headers", {
108
+ configurable: true,
109
+ get: () => {
110
+ readAny = true;
111
+ return headers;
112
+ },
113
+ });
114
+
115
+ // `clone()` copies headers from internal slots rather than through the
116
+ // property above, so an unshadowed clone would hand back untracked headers.
117
+ Object.defineProperty(tracked, "clone", {
118
+ configurable: true,
119
+ value: (): Request => {
120
+ readAny = true;
121
+ return Request.prototype.clone.call(tracked);
122
+ },
123
+ writable: true,
124
+ });
125
+
126
+ return {
127
+ markRequestAccess: () => {
128
+ readAny = true;
129
+ },
130
+ requestDependent: () => readAny,
131
+ request: tracked,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Adds an enumerable Request property that records application access before
137
+ * opaque code can copy native Request internal slots.
138
+ */
139
+ export function withTrackedRequest<T extends object>(
140
+ values: T,
141
+ request: Request,
142
+ tracked: TrackedHeaderRequest | undefined,
143
+ ): T & { request: Request } {
144
+ const context = values as T & { request: Request };
145
+ let currentRequest = request;
146
+
147
+ Object.defineProperty(context, "request", {
148
+ configurable: true,
149
+ enumerable: true,
150
+ get() {
151
+ tracked?.markRequestAccess();
152
+ return currentRequest;
153
+ },
154
+ set(value: Request) {
155
+ tracked?.markRequestAccess();
156
+ currentRequest = value;
157
+ },
158
+ });
159
+
160
+ return context;
161
+ }
@@ -1,5 +1,6 @@
1
1
  import { dirname, isAbsolute, join } from "node:path";
2
2
  import {
3
+ collectIdentifierReferenceNames,
3
4
  collectFormActionExpressionReferences,
4
5
  collectTopLevelValueExportNames,
5
6
  collectJsxComponentRootNames,
@@ -203,6 +204,76 @@ export function routeClosureMayUseAwaitBoundary(options: {
203
204
  }
204
205
  }
205
206
 
207
+ const REQUEST_INPUT_IDENTIFIERS = new Set(["fetch", "request", "Request"]);
208
+
209
+ /**
210
+ * Conservatively detects route closures that may consume Request inputs which
211
+ * are not represented by the shared route cache key.
212
+ *
213
+ * This deliberately over-approximates: a false positive disables shared HTML
214
+ * reuse, while a false negative can disclose one request's rendered values to
215
+ * another. Local server imports are traversed so helpers that reconstruct a
216
+ * Request from its internal slots are covered before cache lookup.
217
+ */
218
+ export function routeClosureMayUseRequestInput(options: {
219
+ filename: string;
220
+ files: RouteSourceLookup;
221
+ projectRoot: string;
222
+ seen?: Set<string> | undefined;
223
+ source: string;
224
+ }): boolean {
225
+ const seen = options.seen ?? new Set<string>();
226
+ if (seen.has(options.filename)) {
227
+ return false;
228
+ }
229
+
230
+ seen.add(options.filename);
231
+
232
+ try {
233
+ if (hasModuleDirective({ code: options.source, directive: "use client" })) {
234
+ return false;
235
+ }
236
+
237
+ const sourceFilename = sourceFilenameForCompiler(options.projectRoot, options.filename);
238
+ if (
239
+ collectIdentifierReferenceNames({
240
+ code: options.source,
241
+ filename: sourceFilename,
242
+ }).some((name) => REQUEST_INPUT_IDENTIFIERS.has(name))
243
+ ) {
244
+ return true;
245
+ }
246
+
247
+ for (const reference of collectStaticImportReferences({
248
+ code: options.source,
249
+ filename: sourceFilename,
250
+ })) {
251
+ const resolved = resolveLocalSourceImport(options.files, options.filename, reference.source);
252
+
253
+ if (
254
+ resolved !== undefined &&
255
+ routeClosureMayUseRequestInput({
256
+ filename: resolved.filename,
257
+ files: options.files,
258
+ projectRoot: options.projectRoot,
259
+ seen,
260
+ source: resolved.source,
261
+ })
262
+ ) {
263
+ return true;
264
+ }
265
+ }
266
+
267
+ return false;
268
+ } catch {
269
+ // An analysis parser mismatch must not turn an unknown route into a shared
270
+ // cache candidate. The normal compiler still reports actual source errors.
271
+ return true;
272
+ } finally {
273
+ seen.delete(options.filename);
274
+ }
275
+ }
276
+
206
277
  export function hasPrerenderExport(code: string, filename?: string | undefined): boolean {
207
278
  return hasTopLevelExportDeclaration({ code, filename, names: ["prerender"] });
208
279
  }
@@ -58,6 +58,47 @@ export function routeSecurityHeaders(options: {
58
58
  return headers;
59
59
  }
60
60
 
61
+ /**
62
+ * Returns the configured Strict-Transport-Security value regardless of scheme.
63
+ *
64
+ * `routeSecurityHeaders` only emits the header for secure requests, so callers
65
+ * that persist a response across requests need the configured value on its own:
66
+ * the scheme of the request that happened to produce the response must not
67
+ * decide what later requests receive.
68
+ *
69
+ * An invalid value yields `undefined` rather than throwing. This runs on every
70
+ * render that may be cached, including plain ones that would never emit the
71
+ * header, and reporting the configuration error is `routeSecurityHeaders`'s
72
+ * job on the request that actually emits it.
73
+ */
74
+ export function configuredHstsHeader(
75
+ security: RouteSecurityHeaders | undefined,
76
+ ): string | undefined {
77
+ if (security?.hsts === undefined || security.hsts === false || security.hsts === null) {
78
+ return undefined;
79
+ }
80
+
81
+ try {
82
+ return serializeHsts(security.hsts);
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ /** Returns whether HSTS is configured but cannot be serialized safely. */
89
+ export function hasInvalidConfiguredHsts(security: RouteSecurityHeaders | undefined): boolean {
90
+ if (security?.hsts === undefined || security.hsts === false || security.hsts === null) {
91
+ return false;
92
+ }
93
+
94
+ try {
95
+ serializeHsts(security.hsts);
96
+ return false;
97
+ } catch {
98
+ return true;
99
+ }
100
+ }
101
+
61
102
  function serializeHsts(hsts: NonNullable<RouteSecurityHeaders["hsts"]>): string {
62
103
  if (hsts === false) {
63
104
  throw new TypeError("Invalid security header value for hsts.");
package/src/serve.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Server } from "node:http";
2
+ import type { DehydrateOptions } from "@reckona/mreact-query";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import type { BuiltPrerenderedRoute } from "./build.js";
@@ -57,6 +58,13 @@ import { startNodeRequestServer } from "./node-server.js";
57
58
  import { builtAppRuntimePreloadPlan } from "./preload-policy.js";
58
59
  import { normalizeRoutePath } from "./route-path.js";
59
60
  import type { HttpUpgradeHandler } from "./upgrade.js";
61
+ import {
62
+ isCurrentPrerenderedRoute,
63
+ isVisitorDependentResponse,
64
+ PRERENDERED_ROUTE_SCHEMA_VERSION,
65
+ replayedPrerenderedRouteHeaders,
66
+ storedPrerenderedRouteHeaders,
67
+ } from "./prerender-entry.js";
60
68
 
61
69
  interface BuiltRuntimeCacheEntry {
62
70
  clientManifestText: string;
@@ -94,6 +102,7 @@ let warnedImplicitHostTrust = false;
94
102
  * Configures rendering a request against a built app-router output directory.
95
103
  */
96
104
  export interface RenderBuiltAppRequestOptions {
105
+ dehydrateOptions?: DehydrateOptions | undefined;
97
106
  outDir: string;
98
107
  importPolicy?: AppRouterImportPolicy | undefined;
99
108
  instrumentation?: RouterInstrumentation | undefined;
@@ -151,6 +160,7 @@ export interface BuiltAppRuntimePreloadStrategy {
151
160
  * Configures the Node HTTP server used to serve a built app-router output.
152
161
  */
153
162
  export interface StartServerOptions {
163
+ dehydrateOptions?: DehydrateOptions | undefined;
154
164
  outDir: string;
155
165
  port: number;
156
166
  hostname?: string;
@@ -176,6 +186,13 @@ export interface StartServerOptions {
176
186
  serverActions?: AppRouterServerActionOptions | undefined;
177
187
  sinkStrategy?: ResponseSinkStrategy;
178
188
  onUpgrade?: HttpUpgradeHandler | undefined;
189
+ /**
190
+ * Trusts the first `X-Forwarded-Proto` value when the socket is not TLS.
191
+ *
192
+ * Defaults to `false`. Enable only behind a proxy that overwrites the header
193
+ * and prevents untrusted clients from reaching the Node listener directly.
194
+ */
195
+ trustForwardedProto?: boolean | undefined;
179
196
  }
180
197
 
181
198
  export function resolveRequestHost(options: {
@@ -449,7 +466,14 @@ async function renderBuiltAppRequestWithRuntime(
449
466
  let normalizedPath = normalizeRoutePath(url.pathname);
450
467
  let matched = options.runtime.routeMatcher.match(normalizedPath);
451
468
 
452
- if (options.request.method === "GET" || options.request.method === "HEAD") {
469
+ // Prerendered HTML may only be served ahead of middleware when the app has
470
+ // no middleware at all. Otherwise a middleware that gates access (auth,
471
+ // geo blocking, maintenance mode) would be bypassed for exactly the routes
472
+ // it is most often used to protect.
473
+ if (
474
+ !options.runtime.hasMiddleware &&
475
+ (request.method === "GET" || request.method === "HEAD")
476
+ ) {
453
477
  // Sync fast path when no external prerender store is configured (the
454
478
  // common case): skip the Promise wrap that `readPrerenderedRoute`
455
479
  // would otherwise introduce just to satisfy the async signature.
@@ -461,18 +485,10 @@ async function renderBuiltAppRequestWithRuntime(
461
485
  normalizedPath,
462
486
  options.prerenderStore,
463
487
  );
488
+ const prerenderedResponse = prerenderedRouteResponse(prerendered, request);
464
489
 
465
- if (prerendered !== undefined) {
466
- if (options.request.method === "HEAD") {
467
- return new Response(null, {
468
- headers: prerendered.headers,
469
- status: prerendered.status,
470
- });
471
- }
472
- return htmlResponse(prerendered.html, {
473
- headers: prerendered.headers,
474
- status: prerendered.status,
475
- });
490
+ if (prerenderedResponse !== undefined) {
491
+ return prerenderedResponse;
476
492
  }
477
493
  }
478
494
 
@@ -489,6 +505,29 @@ async function renderBuiltAppRequestWithRuntime(
489
505
  matched = options.runtime.routeMatcher.match(normalizedPath);
490
506
  }
491
507
 
508
+ // Middleware apps resolve prerendered HTML here instead, so that a
509
+ // middleware response or rewrite is honoured before the stored HTML is
510
+ // served. The lookup uses the post-middleware path.
511
+ if (
512
+ options.runtime.hasMiddleware &&
513
+ (request.method === "GET" || request.method === "HEAD")
514
+ ) {
515
+ const prerendered =
516
+ options.prerenderStore === undefined
517
+ ? options.runtime.prerenderedRoutes.get(normalizedPath)
518
+ : await readPrerenderedRoute(
519
+ options.runtime,
520
+ normalizedPath,
521
+ options.prerenderStore,
522
+ );
523
+ const prerenderedResponse = prerenderedRouteResponse(prerendered, request);
524
+
525
+ if (prerenderedResponse !== undefined) {
526
+ emitBuiltRenderTiming(options, request, timing, prerenderedResponse.status);
527
+ return prerenderedResponse;
528
+ }
529
+ }
530
+
492
531
  if (request.method === "GET" && options.runtime.prerenderableRoutes.has(normalizedPath)) {
493
532
  await loadBuiltServerModuleArtifactsForRequest(options.runtime, matched?.route.file, {
494
533
  includeRender: false,
@@ -517,6 +556,33 @@ async function renderBuiltAppRequestWithRuntime(
517
556
  : response;
518
557
  }
519
558
 
559
+ function prerenderedRouteResponse(
560
+ prerendered: BuiltPrerenderedRoute | undefined,
561
+ request: Request,
562
+ ): Response | undefined {
563
+ if (!isCurrentPrerenderedRoute(prerendered)) {
564
+ return undefined;
565
+ }
566
+
567
+ const navigation = request.headers.get("x-mreact-navigation") === "1";
568
+ const html = navigation ? prerendered.navigationHtml : prerendered.html;
569
+ if (html === undefined) {
570
+ return undefined;
571
+ }
572
+
573
+ if (request.method === "HEAD") {
574
+ return new Response(null, {
575
+ headers: replayedPrerenderedRouteHeaders(prerendered, request),
576
+ status: prerendered.status,
577
+ });
578
+ }
579
+
580
+ return htmlResponse(html, {
581
+ headers: replayedPrerenderedRouteHeaders(prerendered, request),
582
+ status: prerendered.status,
583
+ });
584
+ }
585
+
520
586
  async function resolveBuiltMiddleware(
521
587
  options: RenderBuiltAppRequestOptions & {
522
588
  runtime: BuiltRuntime;
@@ -651,8 +717,10 @@ export async function startServer(
651
717
  onUpgrade: options.onUpgrade,
652
718
  port: options.port,
653
719
  resolveHost: resolveRequestHost,
720
+ trustForwardedProto: options.trustForwardedProto,
654
721
  render: (request) =>
655
722
  runtime.render(request, {
723
+ dehydrateOptions: options.dehydrateOptions,
656
724
  instrumentation: options.instrumentation,
657
725
  logger: options.logger,
658
726
  onResponse: options.onResponse,
@@ -815,13 +883,22 @@ async function readPrerenderedRoute(
815
883
  const stored = await store?.get(path);
816
884
 
817
885
  if (stored !== undefined) {
818
- runtime.prerenderedRoutes.set(path, stored);
819
- return stored;
886
+ if (isCurrentPrerenderedRoute(stored)) {
887
+ runtime.prerenderedRoutes.set(path, stored);
888
+ return stored;
889
+ }
890
+
891
+ await store?.delete(path);
820
892
  }
821
893
 
822
894
  const manifestEntry = runtime.prerenderedRoutes.get(path);
823
895
 
824
- if (manifestEntry !== undefined && store !== undefined) {
896
+ if (!isCurrentPrerenderedRoute(manifestEntry)) {
897
+ runtime.prerenderedRoutes.delete(path);
898
+ return undefined;
899
+ }
900
+
901
+ if (store !== undefined) {
825
902
  await store.set(path, manifestEntry);
826
903
  }
827
904
 
@@ -831,11 +908,15 @@ async function readPrerenderedRoute(
831
908
  function renderBuiltDynamicResponse(
832
909
  options: RenderBuiltAppRequestOptions & {
833
910
  matchedRoute?: MatchedRoute | undefined;
911
+ renderSignals?: { headerDependent: () => boolean } | undefined;
834
912
  requestUrl?: URL | undefined;
835
913
  runtime: BuiltRuntime;
836
914
  },
837
915
  ): Promise<Response> {
838
- return renderAppRequest(builtRenderAppRequestOptions(options));
916
+ return renderAppRequest({
917
+ ...builtRenderAppRequestOptions(options),
918
+ ...(options.renderSignals === undefined ? {} : { renderSignals: options.renderSignals }),
919
+ });
839
920
  }
840
921
 
841
922
  function builtRenderAppRequestOptions(
@@ -858,6 +939,7 @@ function builtRenderAppRequestOptions(
858
939
  clientScripts: options.runtime.clientScripts,
859
940
  clientStylesByFile: options.runtime.clientStylesByFile,
860
941
  clientStyles: options.runtime.clientStyles,
942
+ dehydrateOptions: options.dehydrateOptions,
861
943
  importPolicy: {
862
944
  ...options.importPolicy,
863
945
  allowedSourceDirs: options.runtime.allowedSourceDirs,
@@ -907,51 +989,106 @@ async function renderAndCachePrerenderWithLock(
907
989
  options: RenderBuiltAppRequestOptions & { runtime: BuiltRuntime },
908
990
  path: string,
909
991
  ): Promise<Response> {
910
- const existing = options.runtime.prerenderLocks.get(path);
992
+ const lockKey =
993
+ options.request.headers.get("x-mreact-navigation") === "1"
994
+ ? `${path}\0navigation`
995
+ : path;
996
+ const existing = options.runtime.prerenderLocks.get(lockKey);
911
997
 
912
998
  if (existing !== undefined) {
913
- return cloneResponse(await existing);
999
+ const result = await existing;
1000
+ if (result.shareable) {
1001
+ return cloneResponse(result.response);
1002
+ }
1003
+
1004
+ return (await runPrerenderRegeneration(options, path)).response;
914
1005
  }
915
1006
 
916
1007
  const task = runPrerenderRegeneration(options, path);
917
- options.runtime.prerenderLocks.set(path, task);
1008
+ options.runtime.prerenderLocks.set(lockKey, task);
918
1009
 
919
1010
  try {
920
- return cloneResponse(await task);
1011
+ return cloneResponse((await task).response);
921
1012
  } finally {
922
- options.runtime.prerenderLocks.delete(path);
1013
+ options.runtime.prerenderLocks.delete(lockKey);
923
1014
  }
924
1015
  }
925
1016
 
926
1017
  async function runPrerenderRegeneration(
927
1018
  options: RenderBuiltAppRequestOptions & { runtime: BuiltRuntime },
928
1019
  path: string,
929
- ): Promise<Response> {
1020
+ ): Promise<{ response: Response; shareable: boolean }> {
1021
+ const lockKey =
1022
+ options.request.headers.get("x-mreact-navigation") === "1"
1023
+ ? `${path}\0navigation`
1024
+ : path;
930
1025
  const regenerate = async () => {
931
1026
  const stored = await readPrerenderedRoute(options.runtime, path, options.prerenderStore);
1027
+ const storedResponse = prerenderedRouteResponse(stored, options.request);
932
1028
 
933
- if (stored !== undefined) {
934
- return htmlResponse(stored.html, {
935
- headers: stored.headers,
936
- status: stored.status,
937
- });
1029
+ if (storedResponse !== undefined) {
1030
+ return {
1031
+ response: storedResponse,
1032
+ shareable: true,
1033
+ };
1034
+ }
1035
+
1036
+ // A prerendered entry is replayed to every visitor by path alone, so a
1037
+ // render that depended on this visitor's request headers must not become
1038
+ // one; it is returned to the caller without being stored. The signal starts
1039
+ // closed so that a render which never reports back is not stored either.
1040
+ const renderSignals = {
1041
+ headerDependent: () => true,
1042
+ strictTransportSecurity: () => undefined as string | undefined,
1043
+ };
1044
+ const response = await renderBuiltDynamicResponse({ ...options, renderSignals });
1045
+
1046
+ if (!response.ok) {
1047
+ return { response, shareable: false };
938
1048
  }
939
1049
 
940
- const response = await renderBuiltDynamicResponse(options);
1050
+ // Draining the body finishes a streamed render, so the signal is only
1051
+ // final once the whole response has been read.
1052
+ const body = await response.text();
1053
+
1054
+ if (renderSignals.headerDependent() || isVisitorDependentResponse(response)) {
1055
+ return {
1056
+ response: htmlResponse(body, {
1057
+ headers: response.headers,
1058
+ status: response.status,
1059
+ }),
1060
+ shareable: false,
1061
+ };
1062
+ }
941
1063
 
942
- return response.ok
943
- ? await cacheRegeneratedPrerenderedRoute(
944
- options.runtime,
945
- path,
946
- response,
947
- options.prerenderStore,
948
- )
949
- : response;
1064
+ const navigation = options.request.headers.get("x-mreact-navigation") === "1";
1065
+ if (navigation && stored === undefined) {
1066
+ return {
1067
+ response: htmlResponse(body, {
1068
+ headers: response.headers,
1069
+ status: response.status,
1070
+ }),
1071
+ shareable: false,
1072
+ };
1073
+ }
1074
+
1075
+ return {
1076
+ response: await cacheRegeneratedPrerenderedRoute(
1077
+ options.runtime,
1078
+ path,
1079
+ body,
1080
+ response,
1081
+ renderSignals.strictTransportSecurity(),
1082
+ options.prerenderStore,
1083
+ navigation ? stored : undefined,
1084
+ ),
1085
+ shareable: true,
1086
+ };
950
1087
  };
951
1088
 
952
1089
  return options.prerenderStore?.withLock === undefined
953
1090
  ? await regenerate()
954
- : await options.prerenderStore.withLock(path, regenerate);
1091
+ : await options.prerenderStore.withLock(lockKey, regenerate);
955
1092
  }
956
1093
 
957
1094
  async function applyBuiltPrerenderInvalidations(
@@ -975,20 +1112,23 @@ async function applyBuiltPrerenderInvalidations(
975
1112
  async function cacheRegeneratedPrerenderedRoute(
976
1113
  runtime: BuiltRuntime,
977
1114
  path: string,
1115
+ body: string,
978
1116
  response: Response,
1117
+ strictTransportSecurity: string | undefined,
979
1118
  store: AppRouterPrerenderStore | undefined,
1119
+ existing?: BuiltPrerenderedRoute | undefined,
980
1120
  ): Promise<Response> {
981
- const body = await response.text();
982
- const headers: Record<string, string> = {};
983
-
984
- response.headers.forEach((value, key) => {
985
- headers[key] = value;
986
- });
987
- const entry = {
988
- headers,
989
- html: body,
990
- status: response.status,
991
- };
1121
+ const headers = storedPrerenderedRouteHeaders(response.headers);
1122
+ const entry: BuiltPrerenderedRoute =
1123
+ existing === undefined
1124
+ ? {
1125
+ headers,
1126
+ html: body,
1127
+ schemaVersion: PRERENDERED_ROUTE_SCHEMA_VERSION,
1128
+ status: response.status,
1129
+ ...(strictTransportSecurity === undefined ? {} : { strictTransportSecurity }),
1130
+ }
1131
+ : { ...existing, navigationHtml: body };
992
1132
  runtime.prerenderedRoutes.set(path, entry);
993
1133
  await store?.set(path, entry);
994
1134