@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.
- package/README.md +6 -3
- package/dist/actions.d.ts +2 -3
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +61 -41
- package/dist/actions.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +3 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +74 -11
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/node.d.ts +7 -0
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +7 -1
- package/dist/adapters/node.js.map +1 -1
- package/dist/adapters/static.d.ts.map +1 -1
- package/dist/adapters/static.js +14 -3
- package/dist/adapters/static.js.map +1 -1
- package/dist/build.d.ts +5 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +137 -14
- package/dist/build.js.map +1 -1
- package/dist/built-runtime.d.ts +4 -1
- package/dist/built-runtime.d.ts.map +1 -1
- package/dist/built-runtime.js.map +1 -1
- package/dist/cache.d.ts +27 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +83 -16
- package/dist/cache.js.map +1 -1
- package/dist/cli-options.d.ts +7 -0
- package/dist/cli-options.d.ts.map +1 -1
- package/dist/cli-options.js +27 -2
- package/dist/cli-options.js.map +1 -1
- package/dist/cli.js +3 -2
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +5 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -2
- package/dist/client.js.map +1 -1
- package/dist/navigation-marker.d.ts +2 -0
- package/dist/navigation-marker.d.ts.map +1 -0
- package/dist/navigation-marker.js +294 -0
- package/dist/navigation-marker.js.map +1 -0
- package/dist/navigation-runtime.d.ts +1 -1
- package/dist/navigation-runtime.d.ts.map +1 -1
- package/dist/navigation-runtime.js +1 -1
- package/dist/navigation-runtime.js.map +1 -1
- package/dist/node-server.d.ts +6 -0
- package/dist/node-server.d.ts.map +1 -1
- package/dist/node-server.js +19 -1
- package/dist/node-server.js.map +1 -1
- package/dist/prerender-entry.d.ts +7 -0
- package/dist/prerender-entry.d.ts.map +1 -0
- package/dist/prerender-entry.js +99 -0
- package/dist/prerender-entry.js.map +1 -0
- package/dist/render.d.ts +20 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +190 -110
- package/dist/render.js.map +1 -1
- package/dist/request-header-tracking.d.ts +44 -0
- package/dist/request-header-tracking.d.ts.map +1 -0
- package/dist/request-header-tracking.js +116 -0
- package/dist/request-header-tracking.js.map +1 -0
- package/dist/route-source.d.ts +16 -0
- package/dist/route-source.d.ts.map +1 -1
- package/dist/route-source.js +55 -1
- package/dist/route-source.js.map +1 -1
- package/dist/security-headers.d.ts +16 -0
- package/dist/security-headers.d.ts.map +1 -1
- package/dist/security-headers.js +37 -0
- package/dist/security-headers.js.map +1 -1
- package/dist/serve.d.ts +10 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +132 -42
- package/dist/serve.js.map +1 -1
- package/package.json +11 -11
- package/src/actions.ts +72 -56
- package/src/adapters/cloudflare.ts +118 -16
- package/src/adapters/node.ts +14 -1
- package/src/adapters/static.ts +18 -5
- package/src/build.ts +176 -12
- package/src/built-runtime.ts +2 -2
- package/src/cache.ts +127 -15
- package/src/cli-options.ts +39 -2
- package/src/cli.ts +7 -1
- package/src/client.ts +15 -2
- package/src/navigation-marker.ts +353 -0
- package/src/navigation-runtime.ts +1 -0
- package/src/node-server.ts +27 -1
- package/src/prerender-entry.ts +129 -0
- package/src/render.ts +267 -121
- package/src/request-header-tracking.ts +161 -0
- package/src/route-source.ts +71 -0
- package/src/security-headers.ts +41 -0
- package/src/serve.ts +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<
|
|
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
|
-
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
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
|
-
|
|
133
|
-
const now = Date.now();
|
|
132
|
+
if (this.entries.has(value)) return { status: "replay" };
|
|
134
133
|
if (this.entries.size >= this.maxEntries) {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
1486
|
-
|
|
1495
|
+
async function claimFormNonce(
|
|
1496
|
+
nonce: string,
|
|
1487
1497
|
replayStore: ServerActionReplayStore,
|
|
1488
|
-
):
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1500
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 :
|
|
1343
|
-
headers: prerendered
|
|
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]
|
|
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
|
);
|
package/src/adapters/node.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../logger.js";
|
|
15
15
|
import type { AppRouterResponseHook } from "../render.js";
|
|
16
16
|
import type { RouterInstrumentation } from "../trace.js";
|
|
17
|
+
import { resolveNodeRequestProtocol } from "../node-server.js";
|
|
17
18
|
|
|
18
19
|
/** Re-exports cache contracts used by Node handlers. */
|
|
19
20
|
export type { AppRouterCache, AppRouterCacheEntry } from "../cache.js";
|
|
@@ -92,6 +93,13 @@ export interface NodeRequestHandlerOptions {
|
|
|
92
93
|
routeCache?: AppRouterCache | undefined;
|
|
93
94
|
serverActions?: AppRouterServerActionOptions | undefined;
|
|
94
95
|
sinkStrategy?: ResponseSinkStrategy | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* Trusts the first `X-Forwarded-Proto` value when the socket is not TLS.
|
|
98
|
+
*
|
|
99
|
+
* Defaults to `false`. Enable only behind a proxy that overwrites the header
|
|
100
|
+
* and prevents untrusted clients from reaching the Node listener directly.
|
|
101
|
+
*/
|
|
102
|
+
trustForwardedProto?: boolean | undefined;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
/**
|
|
@@ -126,7 +134,12 @@ export function createNodeRequestHandler(options: NodeRequestHandlerOptions): No
|
|
|
126
134
|
hostPolicy: options.hostPolicy,
|
|
127
135
|
rawHost: incoming.headers.host,
|
|
128
136
|
});
|
|
129
|
-
const
|
|
137
|
+
const protocol = resolveNodeRequestProtocol({
|
|
138
|
+
encrypted: (incoming.socket as { encrypted?: boolean }).encrypted === true,
|
|
139
|
+
forwardedProto: incoming.headers["x-forwarded-proto"],
|
|
140
|
+
trustForwardedProto: options.trustForwardedProto,
|
|
141
|
+
});
|
|
142
|
+
const request = nodeRequestToWebRequest(incoming, `${protocol}://${host}`);
|
|
130
143
|
const logFields = requestLogFields(request, "node");
|
|
131
144
|
emitRouterLog(options.logger, "info", {
|
|
132
145
|
...logFields,
|
package/src/adapters/static.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve, sep } from "node:path";
|
|
3
3
|
import type { BuiltPrerenderedRoute, BuiltServerManifest } from "../build.js";
|
|
4
|
+
import { isCurrentPrerenderedRoute } from "../prerender-entry.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Configures static export from a built app-router output directory.
|
|
@@ -32,17 +33,29 @@ export async function exportStaticApp(options: StaticExportOptions): Promise<Sta
|
|
|
32
33
|
) as { publicAssets?: readonly string[] };
|
|
33
34
|
const prerenderedRoutes = manifest.prerenderedRoutes ?? {};
|
|
34
35
|
const routes = [...(options.paths ?? Object.keys(prerenderedRoutes))].sort();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await mkdir(options.exportDir, { recursive: true });
|
|
38
|
-
|
|
39
|
-
for (const route of routes) {
|
|
36
|
+
const entries = routes.map((route): readonly [string, BuiltPrerenderedRoute] => {
|
|
37
|
+
routeToHtmlFile(options.exportDir, route);
|
|
40
38
|
const entry = prerenderedRoutes[route];
|
|
41
39
|
|
|
42
40
|
if (entry === undefined) {
|
|
43
41
|
throw new Error(`Cannot export non-prerendered route: ${route}`);
|
|
44
42
|
}
|
|
45
43
|
|
|
44
|
+
if (!isCurrentPrerenderedRoute(entry)) {
|
|
45
|
+
throw new Error(`Cannot export invalid prerendered route: ${route}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (entry.status < 200 || entry.status >= 300) {
|
|
49
|
+
throw new Error(`Cannot export route ${route} with status ${entry.status}.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [route, entry];
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await rm(options.exportDir, { force: true, recursive: true });
|
|
56
|
+
await mkdir(options.exportDir, { recursive: true });
|
|
57
|
+
|
|
58
|
+
for (const [route, entry] of entries) {
|
|
46
59
|
await writePrerenderedRoute(options.exportDir, route, entry);
|
|
47
60
|
}
|
|
48
61
|
|