@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
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,6 +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";
42
+ import { isCurrentPrerenderedRoute, replayedPrerenderedRouteHeaders } from "../prerender-entry.js";
43
+ import { hasNavigationRouteMarker } from "../navigation-marker.js";
41
44
 
42
45
  /** Re-exports build manifest contracts used by Cloudflare handlers. */
43
46
  export type {
@@ -290,6 +293,7 @@ export type CloudflareRouteModuleRegistry<Env = unknown> = Record<
290
293
  * Configures the Cloudflare route module renderer.
291
294
  */
292
295
  export interface CloudflareRouteModuleRendererOptions<Env = unknown> {
296
+ dehydrateOptions?: DehydrateOptions | undefined;
293
297
  document?:
294
298
  | ((
295
299
  context: CloudflareRouteModuleComponentProps<unknown, Env> & {
@@ -470,7 +474,38 @@ export function createCloudflareRouteModuleRenderer<Env = unknown>(
470
474
  if (middlewareResult.type === "response") {
471
475
  return middlewareResult.response;
472
476
  }
473
- request = middlewareResult.request;
477
+
478
+ if (middlewareResult.request !== request) {
479
+ // The route was matched against the pre-middleware URL, so a rewrite has
480
+ // to re-match: otherwise the rewritten path would still render the module
481
+ // the visitor asked for, which is the module a rewrite is often used to
482
+ // keep them away from. The built runtime re-matches the same way.
483
+ request = middlewareResult.request;
484
+ const rematched = matchCloudflareRoute(
485
+ sortedCloudflareRoutes(context.serverManifest),
486
+ new URL(request.url).pathname,
487
+ );
488
+
489
+ if (rematched === undefined) {
490
+ return new Response("Not Found", { status: 404 });
491
+ }
492
+
493
+ context = { ...context, params: rematched.params, route: rematched.route };
494
+ }
495
+
496
+ // Middleware apps skip the prerendered fast path in `handleCloudflareRequest`
497
+ // so that access gates run first, so the stored HTML is resolved here
498
+ // instead, against the post-middleware path.
499
+ const prerendered = prerenderedResponse(
500
+ context.serverManifest.prerenderedRoutes,
501
+ normalizeRoutePath(new URL(request.url).pathname),
502
+ request,
503
+ isCloudflareNavigationRequest(request),
504
+ );
505
+
506
+ if (prerendered !== undefined) {
507
+ return prerendered;
508
+ }
474
509
 
475
510
  if (context.route.kind !== "server" && isCloudflareNavigationRequest(request)) {
476
511
  return cloudflareDocumentReloadNavigationResponse();
@@ -567,6 +602,16 @@ export function createCloudflareRouteModuleRenderer<Env = unknown>(
567
602
  );
568
603
 
569
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
+
570
615
  return withDefaultSecurityHeaders(rendered, request, metadata);
571
616
  }
572
617
 
@@ -591,7 +636,7 @@ export function createCloudflareRouteModuleRenderer<Env = unknown>(
591
636
  );
592
637
  const documentedWithQueryState = await injectCloudflareQueryState(
593
638
  documented,
594
- dehydrate(queryClient),
639
+ dehydrate(queryClient, options.dehydrateOptions),
595
640
  );
596
641
 
597
642
  return withDefaultSecurityHeaders(
@@ -690,6 +735,9 @@ async function runWithCloudflareQueryClient<T>(
690
735
  if (isQueryClientScopeUnavailableError(error)) {
691
736
  installQueryAsyncStorage(cloudflareQueryClientStorage);
692
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
+ );
693
741
  return await runWithSerializedCloudflareQueryClient(queryClient, fn);
694
742
  }
695
743
 
@@ -701,6 +749,11 @@ const cloudflareQueryClientStorage = createCloudflareQueryClientStorage();
701
749
  let cloudflareQueryClientFallbackQueue: Promise<void> = Promise.resolve();
702
750
  let cloudflareQueryClientFallbackInstalled = false;
703
751
 
752
+ export function __resetCloudflareQueryClientFallbackForTesting(): void {
753
+ cloudflareQueryClientFallbackQueue = Promise.resolve();
754
+ cloudflareQueryClientFallbackInstalled = false;
755
+ }
756
+
704
757
  async function runWithSerializedCloudflareQueryClient<T>(
705
758
  queryClient: QueryClient,
706
759
  fn: () => T,
@@ -1193,12 +1246,18 @@ async function handleCloudflareRequest<Env>(
1193
1246
  return assetResponse;
1194
1247
  }
1195
1248
 
1196
- const staticResponse = prerenderedResponse(
1197
- options.serverManifest.prerenderedRoutes,
1198
- normalizeRoutePath(url.pathname),
1199
- request.method,
1200
- isCloudflareNavigationRequest(request),
1201
- );
1249
+ // Prerendered HTML may only short-circuit `render` when the app has no
1250
+ // middleware. Middleware runs inside `render`, so serving stored HTML here
1251
+ // would bypass auth, geo blocking and maintenance gates for exactly the
1252
+ // routes they are most often used to protect.
1253
+ const staticResponse = builtServerManifestHasMiddleware(options.serverManifest)
1254
+ ? undefined
1255
+ : prerenderedResponse(
1256
+ options.serverManifest.prerenderedRoutes,
1257
+ normalizeRoutePath(url.pathname),
1258
+ request,
1259
+ isCloudflareNavigationRequest(request),
1260
+ );
1202
1261
 
1203
1262
  if (staticResponse !== undefined) {
1204
1263
  emitRouterDevtoolsEvent({
@@ -1319,28 +1378,54 @@ function cacheControlHasDirective(cacheControl: string | null, directive: string
1319
1378
  return cacheControl.split(",").some((part) => part.trim().toLowerCase() === normalizedDirective);
1320
1379
  }
1321
1380
 
1381
+ // `serverManifest.files` is keyed on paths relative to the project root, so a
1382
+ // default `src/app` project stores the middleware as "src/app/middleware.ts".
1383
+ // This mirrors `hasMiddleware` in built-runtime.ts, which resolves the same two
1384
+ // names against the routes directory.
1385
+ function builtServerManifestHasMiddleware(manifest: {
1386
+ files: Record<string, string>;
1387
+ routesDir?: string | undefined;
1388
+ }): boolean {
1389
+ const routesDir = (manifest.routesDir ?? "").replaceAll("\\", "/").replace(/\/+$/, "");
1390
+ const prefix = routesDir === "" ? "" : `${routesDir}/`;
1391
+
1392
+ return (
1393
+ manifest.files[`${prefix}middleware.ts`] !== undefined ||
1394
+ manifest.files[`${prefix}middleware.mreact.ts`] !== undefined
1395
+ );
1396
+ }
1397
+
1322
1398
  function prerenderedResponse(
1323
1399
  prerenderedRoutes: Record<string, BuiltPrerenderedRoute> | undefined,
1324
1400
  path: string,
1325
- method: string,
1401
+ request: Request,
1326
1402
  isNavigation: boolean,
1327
1403
  ): Response | undefined {
1328
- if (method !== "GET" && method !== "HEAD") {
1404
+ if (request.method !== "GET" && request.method !== "HEAD") {
1329
1405
  return undefined;
1330
1406
  }
1331
1407
 
1332
1408
  const prerendered = prerenderedRoutes?.[path];
1409
+ const candidateNavigationHtml = prerendered?.navigationHtml;
1333
1410
 
1334
- if (prerendered === undefined) {
1411
+ if (!isCurrentPrerenderedRoute(prerendered)) {
1412
+ if (
1413
+ isNavigation &&
1414
+ typeof candidateNavigationHtml === "string" &&
1415
+ !hasNavigationRouteMarker(candidateNavigationHtml)
1416
+ ) {
1417
+ return cloudflareDocumentReloadNavigationResponse();
1418
+ }
1335
1419
  return undefined;
1336
1420
  }
1337
1421
 
1338
- if (isNavigation && !prerendered.html.includes("data-mreact-route-id")) {
1422
+ const html = isNavigation ? prerendered.navigationHtml : prerendered.html;
1423
+ if (html === undefined) {
1339
1424
  return cloudflareDocumentReloadNavigationResponse();
1340
1425
  }
1341
1426
 
1342
- return new Response(method === "HEAD" ? null : prerendered.html, {
1343
- headers: prerendered.headers,
1427
+ return new Response(request.method === "HEAD" ? null : html, {
1428
+ headers: replayedPrerenderedRouteHeaders(prerendered, request),
1344
1429
  status: prerendered.status,
1345
1430
  });
1346
1431
  }
@@ -1351,7 +1436,7 @@ function cloudflareRouteRequiresModule(route: AppRoute, manifest: BuiltServerMan
1351
1436
  route.kind === "server" ||
1352
1437
  (route.kind === "page" &&
1353
1438
  (route.segments.some((segment) => segment.kind !== "static") ||
1354
- manifest.prerenderedRoutes?.[route.path] === undefined))
1439
+ !isCurrentPrerenderedRoute(manifest.prerenderedRoutes?.[route.path])))
1355
1440
  );
1356
1441
  }
1357
1442
 
@@ -1368,6 +1453,23 @@ function normalizeCloudflareRouteModulePath(path: string): string {
1368
1453
  return withoutPrefix.replace(/\.(?:mjs|js|ts|tsx)$/, "");
1369
1454
  }
1370
1455
 
1456
+ // The manifest is the same object across requests, so a rewrite-heavy app pays
1457
+ // for the specificity sort once rather than on every rewritten request.
1458
+ const sortedRoutesByManifest = new WeakMap<BuiltServerManifest, readonly AppRoute[]>();
1459
+
1460
+ function sortedCloudflareRoutes(manifest: BuiltServerManifest): readonly AppRoute[] {
1461
+ const cached = sortedRoutesByManifest.get(manifest);
1462
+
1463
+ if (cached !== undefined) {
1464
+ return cached;
1465
+ }
1466
+
1467
+ const sorted = [...manifest.routes].sort(compareCloudflareRoutes);
1468
+ sortedRoutesByManifest.set(manifest, sorted);
1469
+
1470
+ return sorted;
1471
+ }
1472
+
1371
1473
  function matchCloudflareRoute(
1372
1474
  routes: readonly AppRoute[],
1373
1475
  pathname: string,
@@ -1550,7 +1652,7 @@ function cloudflareHydrationMarkerParts(options: {
1550
1652
  const propsJson = escapeScriptJson(
1551
1653
  JSON.stringify({
1552
1654
  params: options.params,
1553
- request: { url: options.request.url },
1655
+ request: { url: new URL(options.request.url).pathname },
1554
1656
  data: options.data,
1555
1657
  }),
1556
1658
  );
@@ -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