@reckona/mreact-router 0.0.202 → 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 (60) hide show
  1. package/README.md +5 -2
  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 +26 -10
  9. package/dist/adapters/cloudflare.js.map +1 -1
  10. package/dist/build.d.ts +3 -1
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +98 -12
  13. package/dist/build.js.map +1 -1
  14. package/dist/cache.d.ts +1 -1
  15. package/dist/cache.d.ts.map +1 -1
  16. package/dist/cache.js +5 -4
  17. package/dist/cache.js.map +1 -1
  18. package/dist/cli-options.d.ts +3 -0
  19. package/dist/cli-options.d.ts.map +1 -1
  20. package/dist/cli-options.js +10 -2
  21. package/dist/cli-options.js.map +1 -1
  22. package/dist/cli.js +2 -2
  23. package/dist/cli.js.map +1 -1
  24. package/dist/client.d.ts +5 -0
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +11 -2
  27. package/dist/client.js.map +1 -1
  28. package/dist/navigation-marker.d.ts +2 -0
  29. package/dist/navigation-marker.d.ts.map +1 -0
  30. package/dist/navigation-marker.js +294 -0
  31. package/dist/navigation-marker.js.map +1 -0
  32. package/dist/navigation-runtime.d.ts +1 -1
  33. package/dist/navigation-runtime.d.ts.map +1 -1
  34. package/dist/navigation-runtime.js +1 -1
  35. package/dist/navigation-runtime.js.map +1 -1
  36. package/dist/prerender-entry.d.ts +3 -1
  37. package/dist/prerender-entry.d.ts.map +1 -1
  38. package/dist/prerender-entry.js +61 -3
  39. package/dist/prerender-entry.js.map +1 -1
  40. package/dist/render.d.ts +7 -1
  41. package/dist/render.d.ts.map +1 -1
  42. package/dist/render.js +51 -23
  43. package/dist/render.js.map +1 -1
  44. package/dist/serve.d.ts +3 -0
  45. package/dist/serve.d.ts.map +1 -1
  46. package/dist/serve.js +54 -30
  47. package/dist/serve.js.map +1 -1
  48. package/package.json +11 -11
  49. package/src/actions.ts +72 -56
  50. package/src/adapters/cloudflare.ts +40 -10
  51. package/src/build.ts +107 -10
  52. package/src/cache.ts +10 -4
  53. package/src/cli-options.ts +15 -2
  54. package/src/cli.ts +2 -1
  55. package/src/client.ts +15 -2
  56. package/src/navigation-marker.ts +353 -0
  57. package/src/navigation-runtime.ts +1 -0
  58. package/src/prerender-entry.ts +78 -5
  59. package/src/render.ts +80 -24
  60. package/src/serve.ts +71 -34
package/src/actions.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  createServerActionHandler,
11
11
  type ServerActionHandlerOptions,
12
+ type ServerActionReplayClaim,
12
13
  type ServerActionRegistry,
13
14
  type ServerActionReplayStore,
14
15
  type ServerActionRequestReference,
@@ -112,42 +113,44 @@ function configuredActionTokenSecret(value: string | undefined): string {
112
113
  }
113
114
 
114
115
  class BoundedReplayStore {
115
- private readonly entries = new Map<string, number>();
116
+ private readonly entries = new Map<
117
+ string,
118
+ { state: "in-flight" } | { state: "completed"; expiresAt: number }
119
+ >();
116
120
 
117
121
  constructor(
118
122
  private readonly ttlMs: number,
119
123
  private readonly maxEntries: number,
120
124
  ) {}
121
125
 
122
- has(value: string): boolean {
123
- const expiresAt = this.entries.get(value);
124
- if (expiresAt === undefined) return false;
125
- if (expiresAt < Date.now()) {
126
+ claim(value: string): ServerActionReplayClaim {
127
+ const existing = this.entries.get(value);
128
+ if (existing?.state === "completed" && existing.expiresAt < Date.now()) {
126
129
  this.entries.delete(value);
127
- return false;
128
130
  }
129
- return true;
130
- }
131
131
 
132
- add(value: string): void {
133
- const now = Date.now();
132
+ if (this.entries.has(value)) return { status: "replay" };
134
133
  if (this.entries.size >= this.maxEntries) {
135
- const oldest = this.entries.entries().next().value;
136
- if (oldest !== undefined) {
137
- const [key, expiresAt] = oldest;
138
- this.entries.delete(key);
139
- if (expiresAt < now) {
140
- while (this.entries.size >= this.maxEntries) {
141
- const nextOldest = this.entries.entries().next().value;
142
- if (nextOldest === undefined || nextOldest[1] >= now) {
143
- break;
144
- }
145
- this.entries.delete(nextOldest[0]);
146
- }
147
- }
134
+ const now = Date.now();
135
+ for (const [key, entry] of this.entries) {
136
+ if (entry.state === "completed" && entry.expiresAt < now) this.entries.delete(key);
148
137
  }
138
+ if (this.entries.size >= this.maxEntries) return { status: "capacity-exceeded" };
149
139
  }
150
- this.entries.set(value, now + this.ttlMs);
140
+
141
+ const entry = { state: "in-flight" as const };
142
+ this.entries.set(value, entry);
143
+ let finalized = false;
144
+ return {
145
+ status: "claimed",
146
+ finalize: () => {
147
+ if (finalized) return;
148
+ finalized = true;
149
+ if (this.entries.get(value) === entry) {
150
+ this.entries.set(value, { state: "completed", expiresAt: Date.now() + this.ttlMs });
151
+ }
152
+ },
153
+ };
151
154
  }
152
155
 
153
156
  // Exposed for tests; not part of the ServerActionReplayStore interface.
@@ -456,7 +459,7 @@ async function dispatchServerActionRequestWithoutCacheContext(options: {
456
459
  : { allowedActions: jsonAllowedServerActions(options.serverActions.allowedActions) }),
457
460
  csrf: { cookieName: serverActionCookieName() },
458
461
  maxBodyBytes: options.serverActions?.maxBodyBytes ?? DEFAULT_ACTION_BODY_MAX_BYTES,
459
- replayProtection: { seen: replayStore },
462
+ replayProtection: { store: replayStore },
460
463
  });
461
464
 
462
465
  return handle(options.request);
@@ -528,15 +531,6 @@ async function dispatchServerActionRequestWithoutCacheContext(options: {
528
531
  return jsonResponse({ ok: false, error: "Unknown server action." }, 404);
529
532
  }
530
533
 
531
- const nonceResponse = validateFormNonce(
532
- formData,
533
- options.serverActions?.replayStore ?? usedFormActionNonces,
534
- );
535
-
536
- if (nonceResponse !== undefined) {
537
- return nonceResponse;
538
- }
539
-
540
534
  let registry: ServerActionRegistry;
541
535
  try {
542
536
  registry = await loadServerActionRegistry({
@@ -570,24 +564,40 @@ async function dispatchServerActionRequestWithoutCacheContext(options: {
570
564
  return authorizationResponse;
571
565
  }
572
566
 
567
+ const replayClaim = await claimFormNonce(
568
+ nonce,
569
+ options.serverActions?.replayStore ?? usedFormActionNonces,
570
+ );
571
+ if (replayClaim instanceof Response) return replayClaim;
572
+
573
+ let actionResponse: Response | undefined;
574
+ let actionError: unknown;
573
575
  try {
574
576
  const value = await action(actionFormData, createServerActionContext(options.request));
575
577
 
576
578
  if (value instanceof Response) {
577
- return value;
579
+ actionResponse = value;
580
+ } else if (value === undefined || value === null) {
581
+ actionResponse = redirectToFormReferer(options.request);
582
+ } else {
583
+ actionResponse = jsonResponse({ ok: true, value }, 200);
578
584
  }
579
-
580
- if (value === undefined || value === null) {
581
- return redirectToFormReferer(options.request);
582
- }
583
-
584
- return jsonResponse({ ok: true, value }, 200);
585
585
  } catch (error) {
586
- return jsonResponse(
587
- { ok: false, error: error instanceof Error ? error.message : String(error) },
588
- 500,
589
- );
586
+ actionError = error;
587
+ }
588
+
589
+ try {
590
+ await replayClaim.finalize();
591
+ } catch {
592
+ return replayStoreUnavailableResponse();
590
593
  }
594
+
595
+ return actionError === undefined
596
+ ? actionResponse!
597
+ : jsonResponse(
598
+ { ok: false, error: actionError instanceof Error ? actionError.message : String(actionError) },
599
+ 500,
600
+ );
591
601
  }
592
602
 
593
603
  function createServerActionContext(request: Request): ServerActionContext {
@@ -1482,22 +1492,28 @@ function validateServerActionRequestOrigin(request: Request): Response | undefin
1482
1492
  }
1483
1493
  }
1484
1494
 
1485
- function validateFormNonce(
1486
- formData: FormData,
1495
+ async function claimFormNonce(
1496
+ nonce: string,
1487
1497
  replayStore: ServerActionReplayStore,
1488
- ): Response | undefined {
1489
- const nonce = stringFormValue(formData.get(formFieldNonce));
1490
-
1491
- if (nonce === undefined || nonce.length === 0) {
1492
- return jsonResponse({ ok: false, error: "Missing server action nonce." }, 400);
1498
+ ): Promise<Extract<Awaited<ReturnType<ServerActionReplayStore["claim"]>>, { status: "claimed" }> | Response> {
1499
+ let claim: Awaited<ReturnType<ServerActionReplayStore["claim"]>>;
1500
+ try {
1501
+ claim = await replayStore.claim(nonce);
1502
+ } catch {
1503
+ return replayStoreUnavailableResponse();
1493
1504
  }
1494
-
1495
- if (replayStore.has(nonce)) {
1505
+ if (claim.status === "replay") {
1496
1506
  return jsonResponse({ ok: false, error: "Server action nonce was already used." }, 409);
1497
1507
  }
1508
+ if (claim.status === "capacity-exceeded") return replayStoreUnavailableResponse();
1509
+ return claim;
1510
+ }
1498
1511
 
1499
- replayStore.add(nonce);
1500
- return undefined;
1512
+ function replayStoreUnavailableResponse(): Response {
1513
+ return new Response(
1514
+ JSON.stringify({ ok: false, error: "Server action replay protection is unavailable." }),
1515
+ { status: 503, headers: { "content-type": "application/json", "retry-after": "1" } },
1516
+ );
1501
1517
  }
1502
1518
 
1503
1519
  function cleanActionFormData(formData: FormData): FormData {
@@ -10,6 +10,7 @@ import {
10
10
  isQueryClientScopeUnavailableError,
11
11
  runWithQueryClient,
12
12
  type DehydratedQueryClient,
13
+ type DehydrateOptions,
13
14
  type QueryAsyncStorage,
14
15
  type QueryClient,
15
16
  } from "@reckona/mreact-query";
@@ -38,7 +39,8 @@ import { routeSecurityHeaders } from "../security-headers.js";
38
39
  import type { AppRouterPrerenderStore } from "../serve.js";
39
40
  import { emitRouterDevtoolsEvent } from "./devtools.js";
40
41
  import { escapeHtmlAttribute, escapeHtmlText } from "@reckona/mreact-shared/html-escape";
41
- import { isCurrentPrerenderedRoute } from "../prerender-entry.js";
42
+ import { isCurrentPrerenderedRoute, replayedPrerenderedRouteHeaders } from "../prerender-entry.js";
43
+ import { hasNavigationRouteMarker } from "../navigation-marker.js";
42
44
 
43
45
  /** Re-exports build manifest contracts used by Cloudflare handlers. */
44
46
  export type {
@@ -291,6 +293,7 @@ export type CloudflareRouteModuleRegistry<Env = unknown> = Record<
291
293
  * Configures the Cloudflare route module renderer.
292
294
  */
293
295
  export interface CloudflareRouteModuleRendererOptions<Env = unknown> {
296
+ dehydrateOptions?: DehydrateOptions | undefined;
294
297
  document?:
295
298
  | ((
296
299
  context: CloudflareRouteModuleComponentProps<unknown, Env> & {
@@ -496,7 +499,7 @@ export function createCloudflareRouteModuleRenderer<Env = unknown>(
496
499
  const prerendered = prerenderedResponse(
497
500
  context.serverManifest.prerenderedRoutes,
498
501
  normalizeRoutePath(new URL(request.url).pathname),
499
- request.method,
502
+ request,
500
503
  isCloudflareNavigationRequest(request),
501
504
  );
502
505
 
@@ -599,6 +602,16 @@ export function createCloudflareRouteModuleRenderer<Env = unknown>(
599
602
  );
600
603
 
601
604
  if (rendered instanceof Response) {
605
+ if (
606
+ (
607
+ pageModule as CloudflareRouteModule<unknown, Env> & {
608
+ __mreactSecurityHeadersApplied?: boolean | undefined;
609
+ }
610
+ ).__mreactSecurityHeadersApplied === true
611
+ ) {
612
+ return rendered;
613
+ }
614
+
602
615
  return withDefaultSecurityHeaders(rendered, request, metadata);
603
616
  }
604
617
 
@@ -623,7 +636,7 @@ export function createCloudflareRouteModuleRenderer<Env = unknown>(
623
636
  );
624
637
  const documentedWithQueryState = await injectCloudflareQueryState(
625
638
  documented,
626
- dehydrate(queryClient),
639
+ dehydrate(queryClient, options.dehydrateOptions),
627
640
  );
628
641
 
629
642
  return withDefaultSecurityHeaders(
@@ -722,6 +735,9 @@ async function runWithCloudflareQueryClient<T>(
722
735
  if (isQueryClientScopeUnavailableError(error)) {
723
736
  installQueryAsyncStorage(cloudflareQueryClientStorage);
724
737
  cloudflareQueryClientFallbackInstalled = true;
738
+ console.warn(
739
+ '[mreact] Cloudflare AsyncLocalStorage is unavailable. Enable the "nodejs_compat" compatibility flag; rendering is serialized until native request-local storage is available.',
740
+ );
725
741
  return await runWithSerializedCloudflareQueryClient(queryClient, fn);
726
742
  }
727
743
 
@@ -733,6 +749,11 @@ const cloudflareQueryClientStorage = createCloudflareQueryClientStorage();
733
749
  let cloudflareQueryClientFallbackQueue: Promise<void> = Promise.resolve();
734
750
  let cloudflareQueryClientFallbackInstalled = false;
735
751
 
752
+ export function __resetCloudflareQueryClientFallbackForTesting(): void {
753
+ cloudflareQueryClientFallbackQueue = Promise.resolve();
754
+ cloudflareQueryClientFallbackInstalled = false;
755
+ }
756
+
736
757
  async function runWithSerializedCloudflareQueryClient<T>(
737
758
  queryClient: QueryClient,
738
759
  fn: () => T,
@@ -1234,7 +1255,7 @@ async function handleCloudflareRequest<Env>(
1234
1255
  : prerenderedResponse(
1235
1256
  options.serverManifest.prerenderedRoutes,
1236
1257
  normalizeRoutePath(url.pathname),
1237
- request.method,
1258
+ request,
1238
1259
  isCloudflareNavigationRequest(request),
1239
1260
  );
1240
1261
 
@@ -1377,25 +1398,34 @@ function builtServerManifestHasMiddleware(manifest: {
1377
1398
  function prerenderedResponse(
1378
1399
  prerenderedRoutes: Record<string, BuiltPrerenderedRoute> | undefined,
1379
1400
  path: string,
1380
- method: string,
1401
+ request: Request,
1381
1402
  isNavigation: boolean,
1382
1403
  ): Response | undefined {
1383
- if (method !== "GET" && method !== "HEAD") {
1404
+ if (request.method !== "GET" && request.method !== "HEAD") {
1384
1405
  return undefined;
1385
1406
  }
1386
1407
 
1387
1408
  const prerendered = prerenderedRoutes?.[path];
1409
+ const candidateNavigationHtml = prerendered?.navigationHtml;
1388
1410
 
1389
1411
  if (!isCurrentPrerenderedRoute(prerendered)) {
1412
+ if (
1413
+ isNavigation &&
1414
+ typeof candidateNavigationHtml === "string" &&
1415
+ !hasNavigationRouteMarker(candidateNavigationHtml)
1416
+ ) {
1417
+ return cloudflareDocumentReloadNavigationResponse();
1418
+ }
1390
1419
  return undefined;
1391
1420
  }
1392
1421
 
1393
- if (isNavigation && !prerendered.html.includes("data-mreact-route-id")) {
1422
+ const html = isNavigation ? prerendered.navigationHtml : prerendered.html;
1423
+ if (html === undefined) {
1394
1424
  return cloudflareDocumentReloadNavigationResponse();
1395
1425
  }
1396
1426
 
1397
- return new Response(method === "HEAD" ? null : prerendered.html, {
1398
- headers: prerendered.headers,
1427
+ return new Response(request.method === "HEAD" ? null : html, {
1428
+ headers: replayedPrerenderedRouteHeaders(prerendered, request),
1399
1429
  status: prerendered.status,
1400
1430
  });
1401
1431
  }
@@ -1622,7 +1652,7 @@ function cloudflareHydrationMarkerParts(options: {
1622
1652
  const propsJson = escapeScriptJson(
1623
1653
  JSON.stringify({
1624
1654
  params: options.params,
1625
- request: { url: options.request.url },
1655
+ request: { url: new URL(options.request.url).pathname },
1626
1656
  data: options.data,
1627
1657
  }),
1628
1658
  );
package/src/build.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  buildNavigationRuntimeBundle,
47
47
  clientScriptForPath,
48
48
  routeIdForPath,
49
+ routeMarkerParts,
49
50
  type BuildClientRouteOutputOptions,
50
51
  } from "./navigation-runtime.js";
51
52
  import {
@@ -72,6 +73,7 @@ import type { RouteCachePolicy } from "./cache.js";
72
73
  import { routeCachePolicyFromSource } from "./cache.js";
73
74
  import {
74
75
  bundleMiddlewareModuleCode,
76
+ prerenderVariantMarkerParts,
75
77
  renderAppRequest,
76
78
  type RenderAppRequestRuntimeOptions,
77
79
  } from "./render.js";
@@ -105,6 +107,7 @@ import { collectBuildInferredServerActions } from "./server-action-inference.js"
105
107
  import {
106
108
  isVisitorDependentResponse,
107
109
  PRERENDERED_ROUTE_SCHEMA_VERSION,
110
+ storedPrerenderedRouteHeaders,
108
111
  } from "./prerender-entry.js";
109
112
  import { prepareRouteServerActionPlaceholders } from "./actions.js";
110
113
  import { viteDefineCacheKey, vitePluginsCacheKey } from "./vite-plugin-cache-key.js";
@@ -423,9 +426,11 @@ export interface BuiltServerModuleOutput {
423
426
  export interface BuiltPrerenderedRoute {
424
427
  headers: Record<string, string>;
425
428
  html: string;
429
+ navigationHtml?: string | undefined;
426
430
  /** Identifies entries that satisfy the complete current prerender contract. */
427
- schemaVersion?: 2 | undefined;
431
+ schemaVersion?: 4 | undefined;
428
432
  status: number;
433
+ strictTransportSecurity?: string | undefined;
429
434
  }
430
435
 
431
436
  type StaticParams = Record<string, string | number | boolean | readonly string[]>;
@@ -2620,6 +2625,7 @@ async function prerenderStaticRoutes(options: {
2620
2625
  for (const pathname of await prerenderPathsForRoute(route, analysis, options.vitePlugins)) {
2621
2626
  const renderSignals = {
2622
2627
  headerDependent: () => true,
2628
+ strictTransportSecurity: () => undefined as string | undefined,
2623
2629
  };
2624
2630
  const renderOptions = {
2625
2631
  appDir: options.appDir,
@@ -2629,31 +2635,52 @@ async function prerenderStaticRoutes(options: {
2629
2635
  define: options.define,
2630
2636
  importPolicy,
2631
2637
  navigationScripts,
2632
- request: new Request(`http://mreact.local${pathname}`),
2638
+ request: new Request(
2639
+ `http://mreact.local${pathname}`,
2640
+ analysis.clientRoute
2641
+ ? undefined
2642
+ : { headers: { "x-mreact-prerender-variant-capture": "1" } },
2643
+ ),
2633
2644
  renderSignals,
2634
2645
  serverModuleCacheVersion,
2635
2646
  serverModules: serverModuleMap,
2636
2647
  vitePlugins: options.vitePlugins,
2637
2648
  } satisfies RenderAppRequestRuntimeOptions;
2638
2649
  const response = await renderAppRequest(renderOptions);
2639
- const html = await response.text();
2650
+ const renderedHtml = await response.text();
2640
2651
 
2641
2652
  if (renderSignals.headerDependent() || isVisitorDependentResponse(response)) {
2642
2653
  continue;
2643
2654
  }
2644
2655
 
2645
- const headers: Record<string, string> = {};
2656
+ let html = renderedHtml;
2657
+ let navigationHtml = renderedHtml;
2658
+ if (!analysis.clientRoute) {
2659
+ const capture = prerenderVariantMarkerParts(route.path);
2660
+ const start = renderedHtml.indexOf(capture.prefix);
2661
+ const end = renderedHtml.lastIndexOf(capture.suffix);
2662
+ if (start === -1 || end < start + capture.prefix.length) {
2663
+ throw new Error(`Failed to capture prerender response variants for ${pathname}.`);
2664
+ }
2665
+ const before = renderedHtml.slice(0, start);
2666
+ const content = renderedHtml.slice(start + capture.prefix.length, end);
2667
+ const after = renderedHtml.slice(end + capture.suffix.length);
2668
+ const navigationMarker = routeMarkerParts(route.path);
2669
+ html = `${before}${content}${after}`;
2670
+ navigationHtml = `${before}${navigationMarker.prefix}${content}${navigationMarker.suffix}${after}`;
2671
+ }
2646
2672
 
2647
- response.headers.forEach((value, key) => {
2648
- headers[key] = value;
2649
- });
2673
+ const headers = storedPrerenderedRouteHeaders(response.headers);
2674
+ const strictTransportSecurity = renderSignals.strictTransportSecurity();
2650
2675
  entries.push([
2651
2676
  pathname,
2652
2677
  {
2653
2678
  headers,
2654
2679
  html,
2680
+ navigationHtml,
2655
2681
  schemaVersion: PRERENDERED_ROUTE_SCHEMA_VERSION,
2656
2682
  status: response.status,
2683
+ ...(strictTransportSecurity === undefined ? {} : { strictTransportSecurity }),
2657
2684
  },
2658
2685
  ]);
2659
2686
  }
@@ -3954,6 +3981,7 @@ function cloudflarePageRouteFacadeModuleSource(componentImport: string): string
3954
3981
  const componentSlots = readComponentModuleExport(componentModule, "slots");
3955
3982
  const componentGenerateMetadata = readComponentModuleExport(componentModule, "generateMetadata");
3956
3983
  const componentMetadata = readComponentModuleExport(componentModule, "metadata");
3984
+ const componentSecurityHeadersApplied = readComponentModuleExport(componentModule, "__mreactSecurityHeadersApplied");
3957
3985
 
3958
3986
  export function App(props) {
3959
3987
  return renderCloudflareRouteComponent(props);
@@ -3968,6 +3996,7 @@ export const slots = componentSlots === undefined ? undefined : { ...componentSl
3968
3996
  export const generateMetadata =
3969
3997
  typeof componentGenerateMetadata === "function" ? componentGenerateMetadata : undefined;
3970
3998
  export const metadata = componentMetadata;
3999
+ export const __mreactSecurityHeadersApplied = componentSecurityHeadersApplied === true;
3971
4000
 
3972
4001
  function renderCloudflareRouteComponent(props) {
3973
4002
  const routeComponent = resolveCloudflareRouteComponent();
@@ -4714,7 +4743,9 @@ ${cloudflareShellRuntimeSource()}`;
4714
4743
  }
4715
4744
 
4716
4745
  function cloudflareShellRuntimeSource(): string {
4717
- return `async function renderLayoutShells(shells, props, namedSlots) {
4746
+ return `export const __mreactSecurityHeadersApplied = true;
4747
+
4748
+ async function renderLayoutShells(shells, props, namedSlots) {
4718
4749
  const slotContext = { consumedSlots: new Set(), namedSlots };
4719
4750
  const rendered = [];
4720
4751
  for (const shell of shells) {
@@ -4793,7 +4824,7 @@ function cloudflareHydrationMarkerParts(props) {
4793
4824
  const escapedRouteId = escapeHtmlAttribute(routeId);
4794
4825
  const propsJson = escapeScriptJson(JSON.stringify({
4795
4826
  params: props.params,
4796
- request: { url: props.request.url },
4827
+ request: { url: new URL(props.request.url).pathname },
4797
4828
  data: props.data,
4798
4829
  }));
4799
4830
  const clientReferencesJson = route.clientReferenceManifest === undefined || route.clientReferenceManifest.length === 0
@@ -5216,17 +5247,63 @@ function routeSecurityHeaders(security, request) {
5216
5247
  } else {
5217
5248
  headers["referrer-policy"] = validateHeaderValue(security?.referrerPolicy ?? "strict-origin-when-cross-origin");
5218
5249
  }
5250
+ if (security?.permissionsPolicy === null) {
5251
+ delete headers["permissions-policy"];
5252
+ } else {
5253
+ const permissionsPolicy = serializePermissionsPolicy(security?.permissionsPolicy);
5254
+ if (permissionsPolicy === undefined) {
5255
+ delete headers["permissions-policy"];
5256
+ } else {
5257
+ headers["permissions-policy"] = permissionsPolicy;
5258
+ }
5259
+ }
5219
5260
  if (security?.frameOptions === null) {
5220
5261
  delete headers["x-frame-options"];
5221
5262
  } else if (security?.frameOptions !== undefined) {
5222
5263
  headers["x-frame-options"] = validateHeaderValue(security.frameOptions);
5223
5264
  }
5224
5265
  if (request.url.startsWith("https://") && security?.hsts !== undefined && security.hsts !== false && security.hsts !== null) {
5225
- headers["strict-transport-security"] = \`max-age=\${Math.trunc(security.hsts.maxAge)}\${security.hsts.includeSubDomains === true ? "; includeSubDomains" : ""}\${security.hsts.preload === true ? "; preload" : ""}\`;
5266
+ headers["strict-transport-security"] = serializeHsts(security.hsts);
5226
5267
  }
5227
5268
  return headers;
5228
5269
  }
5229
5270
 
5271
+ function serializeHsts(hsts) {
5272
+ if (hsts === false) {
5273
+ throw new TypeError("Invalid security header value for hsts.");
5274
+ }
5275
+ const maxAge = Math.trunc(hsts.maxAge);
5276
+ if (!Number.isFinite(maxAge) || maxAge < 0) {
5277
+ throw new TypeError("Invalid security header value for hsts.maxAge.");
5278
+ }
5279
+ const parts = [\`max-age=\${maxAge}\`];
5280
+ if (hsts.includeSubDomains === true) {
5281
+ parts.push("includeSubDomains");
5282
+ }
5283
+ if (hsts.preload === true) {
5284
+ parts.push("preload");
5285
+ }
5286
+ return parts.join("; ");
5287
+ }
5288
+
5289
+ function serializePermissionsPolicy(policy) {
5290
+ if (policy === undefined) {
5291
+ return "camera=(), microphone=(), geolocation=()";
5292
+ }
5293
+ const directives = [];
5294
+ for (const [directive, allowlist] of Object.entries(policy)) {
5295
+ if (allowlist === null || allowlist === undefined) {
5296
+ continue;
5297
+ }
5298
+ validateToken(directive, "permissionsPolicy directive");
5299
+ for (const value of allowlist) {
5300
+ validatePermissionAllowlistValue(value);
5301
+ }
5302
+ directives.push(\`\${directive}=(\${allowlist.join(" ")})\`);
5303
+ }
5304
+ return directives.length === 0 ? undefined : directives.join(", ");
5305
+ }
5306
+
5230
5307
  function validateHeaderValue(value) {
5231
5308
  for (let index = 0; index < value.length; index += 1) {
5232
5309
  const code = value.charCodeAt(index);
@@ -5237,6 +5314,22 @@ function validateHeaderValue(value) {
5237
5314
  return value;
5238
5315
  }
5239
5316
 
5317
+ function validateToken(value, label) {
5318
+ if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(value)) {
5319
+ throw new TypeError(\`Invalid security header value for \${label}: \${JSON.stringify(value)}\`);
5320
+ }
5321
+ }
5322
+
5323
+ function validatePermissionAllowlistValue(value) {
5324
+ if (value === "self" || value === "*" || /^[A-Za-z][A-Za-z0-9+.-]*:$/.test(value)) {
5325
+ return;
5326
+ }
5327
+ if (/^https:\\/\\/[A-Za-z0-9.-]+(?::[0-9]+)?$/.test(value)) {
5328
+ return;
5329
+ }
5330
+ throw new TypeError(\`Invalid security header value for permissionsPolicy allowlist: \${JSON.stringify(value)}\`);
5331
+ }
5332
+
5240
5333
  function mergeRouteMetadata(metadata) {
5241
5334
  if (metadata.length === 0) {
5242
5335
  return undefined;
@@ -6216,6 +6309,10 @@ async function writeClientRouteBundles(options: {
6216
6309
  options.sourceAnalysis.byRouteFile.get(
6217
6310
  relative(options.projectRoot, route.file).split(sep).join("/"),
6218
6311
  )?.streamRoute === true,
6312
+ restoreRequestUrl:
6313
+ options.sourceAnalysis.byRouteFile.get(
6314
+ relative(options.projectRoot, route.file).split(sep).join("/"),
6315
+ )?.usesRequestInput === true,
6219
6316
  cacheDir: options.cacheDir,
6220
6317
  dropConsoleFunctions: options.clientConsolePureFunctions,
6221
6318
  filename: route.file,
package/src/cache.ts CHANGED
@@ -650,10 +650,16 @@ export function activeRouteCacheContext(): RouteCacheContext | undefined {
650
650
 
651
651
  // Host is excluded from the cache key to prevent attacker-supplied Host
652
652
  // headers from fragmenting / poisoning the cache (Issue 068). The Vary
653
- // dimension is the request path + query; same-origin reverse proxies are
654
- // expected to handle vhost separation at their layer.
655
- export function routeCacheKey(appDir: string, routePath: string, url: URL): string {
656
- return `${appDir}\0${normalizeRevalidationPath(routePath)}\0${url.pathname}${url.search}`;
653
+ // dimensions are the request path + query and the document/navigation HTML
654
+ // shape; same-origin reverse proxies are expected to handle vhost separation
655
+ // at their layer.
656
+ export function routeCacheKey(
657
+ appDir: string,
658
+ routePath: string,
659
+ url: URL,
660
+ responseVariant: "document" | "navigation" = "document",
661
+ ): string {
662
+ return `${appDir}\0${normalizeRevalidationPath(routePath)}\0${url.pathname}${url.search}\0${responseVariant}`;
657
663
  }
658
664
 
659
665
  export function stripRevalidateExport(code: string): string {
@@ -335,6 +335,7 @@ export function formatCliHelp(command?: string | undefined): string {
335
335
  "",
336
336
  "Options:",
337
337
  " --host <host> Bind address. Default: 127.0.0.1. Use 0.0.0.0 inside containers behind explicit port publishing or a reverse proxy.",
338
+ " --port <port> TCP port. Overrides PORT. Default: 3001.",
338
339
  " --host-policy=strict|trusted-proxy",
339
340
  " Control Host header trust for request origin reconstruction.",
340
341
  " --allowed-hosts <host[,host...]>",
@@ -469,6 +470,18 @@ export function resolveCliDevPort(
469
470
  return envValue === undefined || envValue === "" ? viteConfigPort : parseCliPort(envValue);
470
471
  }
471
472
 
473
+ export function resolveCliStartPort(
474
+ flagValue: number | undefined,
475
+ env: { PORT?: string | undefined },
476
+ ): number {
477
+ if (flagValue !== undefined) {
478
+ return flagValue;
479
+ }
480
+
481
+ const envValue = env.PORT;
482
+ return envValue === undefined || envValue === "" ? 3001 : parseCliPort(envValue, "PORT");
483
+ }
484
+
472
485
  export function resolveCliHostPolicy(
473
486
  flagValue: RequestHostPolicy | undefined,
474
487
  env: { MREACT_ROUTER_HOST_POLICY?: string | undefined },
@@ -527,7 +540,7 @@ function parseCliRequestLogMode(value: string): CliRequestLogMode {
527
540
  throw new Error(`Unsupported log mode ${JSON.stringify(value)}. Expected "requests".`);
528
541
  }
529
542
 
530
- function parseCliPort(value: string): number {
543
+ function parseCliPort(value: string, source = "port"): number {
531
544
  const port = Number(value);
532
545
 
533
546
  if (Number.isInteger(port) && port >= 0 && port <= 65535) {
@@ -535,7 +548,7 @@ function parseCliPort(value: string): number {
535
548
  }
536
549
 
537
550
  throw new Error(
538
- `Unsupported port ${JSON.stringify(value)}. Expected an integer from 0 to 65535.`,
551
+ `Unsupported ${source} ${JSON.stringify(value)}. Expected an integer from 0 to 65535.`,
539
552
  );
540
553
  }
541
554
 
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  resolveCliHost,
26
26
  resolveCliHostPolicy,
27
27
  resolveCliRequestLogMode,
28
+ resolveCliStartPort,
28
29
  resolveCliTrustForwardedProto,
29
30
  } from "./cli-options.js";
30
31
  import { startDevServer } from "./dev-server.js";
@@ -169,7 +170,7 @@ if (parsed !== undefined) {
169
170
  hostname: resolveCliHost(parsed.host, process.env),
170
171
  logger,
171
172
  outDir: resolve(routeArg ?? ".mreact"),
172
- port: Number(process.env.PORT ?? 3001),
173
+ port: resolveCliStartPort(parsed.port, process.env),
173
174
  trustForwardedProto: resolveCliTrustForwardedProto(
174
175
  parsed.trustForwardedProto,
175
176
  process.env,