@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.
- package/README.md +5 -2
- 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 +26 -10
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/build.d.ts +3 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +98 -12
- package/dist/build.js.map +1 -1
- package/dist/cache.d.ts +1 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +5 -4
- package/dist/cache.js.map +1 -1
- package/dist/cli-options.d.ts +3 -0
- package/dist/cli-options.d.ts.map +1 -1
- package/dist/cli-options.js +10 -2
- package/dist/cli-options.js.map +1 -1
- package/dist/cli.js +2 -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/prerender-entry.d.ts +3 -1
- package/dist/prerender-entry.d.ts.map +1 -1
- package/dist/prerender-entry.js +61 -3
- package/dist/prerender-entry.js.map +1 -1
- package/dist/render.d.ts +7 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +51 -23
- package/dist/render.js.map +1 -1
- package/dist/serve.d.ts +3 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +54 -30
- package/dist/serve.js.map +1 -1
- package/package.json +11 -11
- package/src/actions.ts +72 -56
- package/src/adapters/cloudflare.ts +40 -10
- package/src/build.ts +107 -10
- package/src/cache.ts +10 -4
- package/src/cli-options.ts +15 -2
- package/src/cli.ts +2 -1
- package/src/client.ts +15 -2
- package/src/navigation-marker.ts +353 -0
- package/src/navigation-runtime.ts +1 -0
- package/src/prerender-entry.ts +78 -5
- package/src/render.ts +80 -24
- 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<
|
|
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,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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
1398
|
-
headers: prerendered
|
|
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?:
|
|
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(
|
|
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
|
|
2650
|
+
const renderedHtml = await response.text();
|
|
2640
2651
|
|
|
2641
2652
|
if (renderSignals.headerDependent() || isVisitorDependentResponse(response)) {
|
|
2642
2653
|
continue;
|
|
2643
2654
|
}
|
|
2644
2655
|
|
|
2645
|
-
|
|
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
|
|
2648
|
-
|
|
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 `
|
|
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"] =
|
|
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
|
-
//
|
|
654
|
-
// expected to handle vhost separation
|
|
655
|
-
|
|
656
|
-
|
|
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 {
|
package/src/cli-options.ts
CHANGED
|
@@ -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
|
|
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:
|
|
173
|
+
port: resolveCliStartPort(parsed.port, process.env),
|
|
173
174
|
trustForwardedProto: resolveCliTrustForwardedProto(
|
|
174
175
|
parsed.trustForwardedProto,
|
|
175
176
|
process.env,
|