@reckona/mreact-router 0.0.202 → 0.0.204
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 +58 -11
- package/dist/actions.d.ts +8 -3
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +125 -45
- package/dist/actions.js.map +1 -1
- package/dist/adapters/aws-lambda.d.ts +15 -4
- package/dist/adapters/aws-lambda.d.ts.map +1 -1
- package/dist/adapters/aws-lambda.js +301 -63
- package/dist/adapters/aws-lambda.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +5 -2
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +35 -11
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/edge.d.ts +1 -1
- package/dist/adapters/edge.d.ts.map +1 -1
- package/dist/adapters/edge.js.map +1 -1
- package/dist/adapters/node.d.ts +5 -3
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +2 -1
- package/dist/adapters/node.js.map +1 -1
- package/dist/adapters/static.d.ts.map +1 -1
- package/dist/adapters/static.js +24 -2
- package/dist/adapters/static.js.map +1 -1
- package/dist/build.d.ts +4 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +185 -13
- package/dist/build.js.map +1 -1
- package/dist/built-runtime.d.ts +2 -0
- package/dist/built-runtime.d.ts.map +1 -1
- package/dist/built-runtime.js +16 -0
- package/dist/built-runtime.js.map +1 -1
- package/dist/cache.d.ts +4 -2
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +216 -17
- 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 +14 -3
- 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 +43 -3
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -2
- package/dist/config.js.map +1 -1
- package/dist/csp.d.ts +1 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +9 -0
- package/dist/csp.js.map +1 -1
- package/dist/dehydrate-policy.d.ts +3 -0
- package/dist/dehydrate-policy.d.ts.map +1 -0
- package/dist/dehydrate-policy.js +16 -0
- package/dist/dehydrate-policy.js.map +1 -0
- package/dist/dev-server.d.ts +7 -2
- package/dist/dev-server.d.ts.map +1 -1
- package/dist/dev-server.js +120 -11
- package/dist/dev-server.js.map +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +147 -7
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +22 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +22 -8
- package/dist/logger.js.map +1 -1
- package/dist/metadata.d.ts +8 -0
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +60 -22
- package/dist/metadata.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 +357 -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/navigation.d.ts +1 -0
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +1 -1
- package/dist/navigation.js.map +1 -1
- package/dist/node-server.d.ts +5 -2
- package/dist/node-server.d.ts.map +1 -1
- package/dist/node-server.js +75 -6
- package/dist/node-server.js.map +1 -1
- package/dist/prerender-entry.d.ts +17 -1
- package/dist/prerender-entry.d.ts.map +1 -1
- package/dist/prerender-entry.js +98 -3
- package/dist/prerender-entry.js.map +1 -1
- package/dist/render.d.ts +16 -2
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +142 -135
- package/dist/render.js.map +1 -1
- package/dist/serve.d.ts +11 -2
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +67 -41
- package/dist/serve.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +52 -19
- package/dist/session.js.map +1 -1
- package/dist/upgrade.d.ts +59 -2
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +282 -1
- package/dist/upgrade.js.map +1 -1
- package/dist/vite-config.d.ts +2 -0
- package/dist/vite-config.d.ts.map +1 -1
- package/dist/vite-config.js +2 -0
- package/dist/vite-config.js.map +1 -1
- package/dist/vite.d.ts +4 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +117 -6
- package/dist/vite.js.map +1 -1
- package/package.json +11 -11
- package/src/actions.ts +146 -60
- package/src/adapters/aws-lambda.ts +426 -115
- package/src/adapters/cloudflare.ts +58 -12
- package/src/adapters/edge.ts +2 -0
- package/src/adapters/node.ts +8 -10
- package/src/adapters/static.ts +31 -2
- package/src/build.ts +217 -13
- package/src/built-runtime.ts +28 -0
- package/src/cache.ts +272 -19
- package/src/cli-options.ts +15 -2
- package/src/cli.ts +16 -2
- package/src/client.ts +47 -3
- package/src/config.ts +43 -6
- package/src/csp.ts +11 -0
- package/src/dehydrate-policy.ts +23 -0
- package/src/dev-server.ts +188 -15
- package/src/http.ts +172 -6
- package/src/index.ts +14 -1
- package/src/logger.ts +47 -10
- package/src/metadata.ts +125 -60
- package/src/navigation-marker.ts +423 -0
- package/src/navigation-runtime.ts +1 -0
- package/src/navigation.ts +1 -1
- package/src/node-server.ts +128 -22
- package/src/prerender-entry.ts +127 -5
- package/src/render.ts +859 -749
- package/src/serve.ts +112 -82
- package/src/session.ts +56 -25
- package/src/upgrade.ts +402 -3
- package/src/vite-config.ts +6 -0
- package/src/vite.ts +157 -5
package/src/actions.ts
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
} from "@reckona/mreact-compiler";
|
|
9
9
|
import {
|
|
10
10
|
createServerActionHandler,
|
|
11
|
+
ensureServerActionReplayStoreContract,
|
|
11
12
|
type ServerActionHandlerOptions,
|
|
13
|
+
type ServerActionReplayClaim,
|
|
12
14
|
type ServerActionRegistry,
|
|
13
15
|
type ServerActionReplayStore,
|
|
14
16
|
type ServerActionRequestReference,
|
|
@@ -18,7 +20,7 @@ import { bundleRouterModule, type RouterCompatBuildApi } from "./bundle-pipeline
|
|
|
18
20
|
import { type AppRouterCache, withRouteCacheContext } from "./cache.js";
|
|
19
21
|
import { fileImportMetaUrlPlugin, importAppRouterSourceModule } from "./module-runner.js";
|
|
20
22
|
import { createAppRouterImportPolicyPlugin, type AppRouterImportPolicy } from "./import-policy.js";
|
|
21
|
-
import { cookies, type RequestCookies } from "./navigation.js";
|
|
23
|
+
import { cookies, isSafeInternalRedirect, type RequestCookies } from "./navigation.js";
|
|
22
24
|
export {
|
|
23
25
|
createFormCsrfToken,
|
|
24
26
|
formCsrfCookie,
|
|
@@ -71,6 +73,7 @@ const actionTokenSecret = configuredActionTokenSecret(process.env.MREACT_SERVER_
|
|
|
71
73
|
// traffic does not trip false-positive 409s.
|
|
72
74
|
const DEFAULT_REPLAY_TTL_MS = 10 * 60 * 1000;
|
|
73
75
|
const DEFAULT_REPLAY_MAX_ENTRIES = 50_000;
|
|
76
|
+
const DEFAULT_REPLAY_MINIMUM_RETENTION_MS = 60_000;
|
|
74
77
|
const DEFAULT_ACTION_BODY_MAX_BYTES = 10 * 1024 * 1024;
|
|
75
78
|
const DEFAULT_ACTION_FORM_MAX_FIELDS = 1_000;
|
|
76
79
|
let warnedUnrestrictedServerActions = false;
|
|
@@ -112,48 +115,114 @@ function configuredActionTokenSecret(value: string | undefined): string {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
class BoundedReplayStore {
|
|
115
|
-
private readonly entries = new Map<
|
|
118
|
+
private readonly entries = new Map<
|
|
119
|
+
string,
|
|
120
|
+
| { state: "in-flight"; leaseExpiresAt: number }
|
|
121
|
+
| { state: "completed"; expiresAt: number; protectedUntil: number }
|
|
122
|
+
>();
|
|
123
|
+
private capacityWarningActive = false;
|
|
124
|
+
private warnedLeaseReclamation = false;
|
|
125
|
+
private readonly minimumRetentionMs: number;
|
|
116
126
|
|
|
117
127
|
constructor(
|
|
118
128
|
private readonly ttlMs: number,
|
|
119
129
|
private readonly maxEntries: number,
|
|
120
|
-
) {
|
|
130
|
+
) {
|
|
131
|
+
this.minimumRetentionMs = Math.min(ttlMs, DEFAULT_REPLAY_MINIMUM_RETENTION_MS);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
claim(value: string): ServerActionReplayClaim {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
if (this.entries.size < this.maxEntries) {
|
|
137
|
+
this.capacityWarningActive = false;
|
|
138
|
+
}
|
|
121
139
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (expiresAt === undefined) return false;
|
|
125
|
-
if (expiresAt < Date.now()) {
|
|
140
|
+
const existing = this.entries.get(value);
|
|
141
|
+
if (existing?.state === "completed" && existing.expiresAt <= now) {
|
|
126
142
|
this.entries.delete(value);
|
|
127
|
-
|
|
143
|
+
} else if (existing?.state === "in-flight" && existing.leaseExpiresAt <= now) {
|
|
144
|
+
this.entries.delete(value);
|
|
145
|
+
this.warnLeaseReclamation();
|
|
128
146
|
}
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
147
|
|
|
132
|
-
|
|
133
|
-
const now = Date.now();
|
|
148
|
+
if (this.entries.has(value)) return { status: "replay" };
|
|
134
149
|
if (this.entries.size >= this.maxEntries) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
this.warnCapacity();
|
|
151
|
+
let reclaimableCompletedKey: string | undefined;
|
|
152
|
+
for (const [key, entry] of this.entries) {
|
|
153
|
+
if (
|
|
154
|
+
(entry.state === "completed" && entry.expiresAt <= now) ||
|
|
155
|
+
(entry.state === "in-flight" && entry.leaseExpiresAt <= now)
|
|
156
|
+
) {
|
|
157
|
+
this.entries.delete(key);
|
|
158
|
+
} else if (
|
|
159
|
+
reclaimableCompletedKey === undefined &&
|
|
160
|
+
entry.state === "completed" &&
|
|
161
|
+
entry.protectedUntil <= now
|
|
162
|
+
) {
|
|
163
|
+
reclaimableCompletedKey = key;
|
|
147
164
|
}
|
|
148
165
|
}
|
|
166
|
+
if (this.entries.size >= this.maxEntries && reclaimableCompletedKey !== undefined) {
|
|
167
|
+
this.entries.delete(reclaimableCompletedKey);
|
|
168
|
+
}
|
|
169
|
+
if (this.entries.size >= this.maxEntries) return { status: "capacity-exceeded" };
|
|
149
170
|
}
|
|
150
|
-
|
|
171
|
+
|
|
172
|
+
const entry = { state: "in-flight" as const, leaseExpiresAt: now + this.ttlMs };
|
|
173
|
+
this.entries.set(value, entry);
|
|
174
|
+
let finalized = false;
|
|
175
|
+
return {
|
|
176
|
+
status: "claimed",
|
|
177
|
+
finalize: () => {
|
|
178
|
+
if (finalized) return;
|
|
179
|
+
finalized = true;
|
|
180
|
+
if (this.entries.get(value) === entry) {
|
|
181
|
+
const completedAt = Date.now();
|
|
182
|
+
this.entries.delete(value);
|
|
183
|
+
this.entries.set(value, {
|
|
184
|
+
state: "completed",
|
|
185
|
+
expiresAt: completedAt + this.ttlMs,
|
|
186
|
+
protectedUntil: completedAt + this.minimumRetentionMs,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Exposed for tests; not part of the ServerActionReplayStore interface.
|
|
194
|
+
clear(): void {
|
|
195
|
+
this.entries.clear();
|
|
196
|
+
this.capacityWarningActive = false;
|
|
197
|
+
this.warnedLeaseReclamation = false;
|
|
151
198
|
}
|
|
152
199
|
|
|
153
200
|
// Exposed for tests; not part of the ServerActionReplayStore interface.
|
|
154
201
|
size(): number {
|
|
155
202
|
return this.entries.size;
|
|
156
203
|
}
|
|
204
|
+
|
|
205
|
+
private warnCapacity(): void {
|
|
206
|
+
if (this.capacityWarningActive) return;
|
|
207
|
+
this.capacityWarningActive = true;
|
|
208
|
+
let completed = 0;
|
|
209
|
+
let inFlight = 0;
|
|
210
|
+
for (const entry of this.entries.values()) {
|
|
211
|
+
if (entry.state === "completed") completed += 1;
|
|
212
|
+
else inFlight += 1;
|
|
213
|
+
}
|
|
214
|
+
console.error(
|
|
215
|
+
`mreact-router: default server action replay store reached capacity (size=${this.entries.size}, maxEntries=${this.maxEntries}, completed=${completed}, inFlight=${inFlight}, minimumRetentionMs=${this.minimumRetentionMs}). Completed claims remain exact through minimumRetentionMs and may be reclaimed under pressure afterward; expired in-flight leases may also be reclaimed. Applications requiring exact protection for the full action lifetime and retention window must use a shared replay store.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private warnLeaseReclamation(): void {
|
|
220
|
+
if (this.warnedLeaseReclamation) return;
|
|
221
|
+
this.warnedLeaseReclamation = true;
|
|
222
|
+
console.error(
|
|
223
|
+
`mreact-router: default server action replay store reclaimed an expired in-flight claim after leaseMs=${this.ttlMs}; exact duplicate-execution protection no longer applies to that abandoned claim.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
157
226
|
}
|
|
158
227
|
|
|
159
228
|
const usedFormActionNonces = new BoundedReplayStore(
|
|
@@ -165,7 +234,7 @@ const usedFormActionNonces = new BoundedReplayStore(
|
|
|
165
234
|
// so tests can drive its eviction semantics directly. Not part of the
|
|
166
235
|
// public surface (prefixed with `__`).
|
|
167
236
|
export function __clearDefaultReplayStore(): void {
|
|
168
|
-
|
|
237
|
+
usedFormActionNonces.clear();
|
|
169
238
|
}
|
|
170
239
|
|
|
171
240
|
export function __readDefaultReplayStore(): BoundedReplayStore {
|
|
@@ -456,7 +525,7 @@ async function dispatchServerActionRequestWithoutCacheContext(options: {
|
|
|
456
525
|
: { allowedActions: jsonAllowedServerActions(options.serverActions.allowedActions) }),
|
|
457
526
|
csrf: { cookieName: serverActionCookieName() },
|
|
458
527
|
maxBodyBytes: options.serverActions?.maxBodyBytes ?? DEFAULT_ACTION_BODY_MAX_BYTES,
|
|
459
|
-
replayProtection: {
|
|
528
|
+
replayProtection: { store: replayStore },
|
|
460
529
|
});
|
|
461
530
|
|
|
462
531
|
return handle(options.request);
|
|
@@ -528,15 +597,6 @@ async function dispatchServerActionRequestWithoutCacheContext(options: {
|
|
|
528
597
|
return jsonResponse({ ok: false, error: "Unknown server action." }, 404);
|
|
529
598
|
}
|
|
530
599
|
|
|
531
|
-
const nonceResponse = validateFormNonce(
|
|
532
|
-
formData,
|
|
533
|
-
options.serverActions?.replayStore ?? usedFormActionNonces,
|
|
534
|
-
);
|
|
535
|
-
|
|
536
|
-
if (nonceResponse !== undefined) {
|
|
537
|
-
return nonceResponse;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
600
|
let registry: ServerActionRegistry;
|
|
541
601
|
try {
|
|
542
602
|
registry = await loadServerActionRegistry({
|
|
@@ -570,24 +630,40 @@ async function dispatchServerActionRequestWithoutCacheContext(options: {
|
|
|
570
630
|
return authorizationResponse;
|
|
571
631
|
}
|
|
572
632
|
|
|
633
|
+
const replayClaim = await claimFormNonce(
|
|
634
|
+
nonce,
|
|
635
|
+
options.serverActions?.replayStore ?? usedFormActionNonces,
|
|
636
|
+
);
|
|
637
|
+
if (replayClaim instanceof Response) return replayClaim;
|
|
638
|
+
|
|
639
|
+
let actionResponse: Response | undefined;
|
|
640
|
+
let actionError: unknown;
|
|
573
641
|
try {
|
|
574
642
|
const value = await action(actionFormData, createServerActionContext(options.request));
|
|
575
643
|
|
|
576
644
|
if (value instanceof Response) {
|
|
577
|
-
|
|
645
|
+
actionResponse = value;
|
|
646
|
+
} else if (value === undefined || value === null) {
|
|
647
|
+
actionResponse = redirectToFormReferer(options.request);
|
|
648
|
+
} else {
|
|
649
|
+
actionResponse = jsonResponse({ ok: true, value }, 200);
|
|
578
650
|
}
|
|
579
|
-
|
|
580
|
-
if (value === undefined || value === null) {
|
|
581
|
-
return redirectToFormReferer(options.request);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
return jsonResponse({ ok: true, value }, 200);
|
|
585
651
|
} catch (error) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
652
|
+
actionError = error;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
await replayClaim.finalize();
|
|
657
|
+
} catch {
|
|
658
|
+
return replayStoreUnavailableResponse();
|
|
590
659
|
}
|
|
660
|
+
|
|
661
|
+
return actionError === undefined
|
|
662
|
+
? actionResponse!
|
|
663
|
+
: jsonResponse(
|
|
664
|
+
{ ok: false, error: actionError instanceof Error ? actionError.message : String(actionError) },
|
|
665
|
+
500,
|
|
666
|
+
);
|
|
591
667
|
}
|
|
592
668
|
|
|
593
669
|
function createServerActionContext(request: Request): ServerActionContext {
|
|
@@ -785,8 +861,9 @@ function sameOriginRefererPath(request: Request): string | undefined {
|
|
|
785
861
|
const requestUrl = new URL(request.url);
|
|
786
862
|
const refererUrl = new URL(referer, requestUrl);
|
|
787
863
|
|
|
788
|
-
|
|
789
|
-
|
|
864
|
+
const location = `${refererUrl.pathname}${refererUrl.search}`;
|
|
865
|
+
return refererUrl.origin === requestUrl.origin && isSafeInternalRedirect(location)
|
|
866
|
+
? location
|
|
790
867
|
: undefined;
|
|
791
868
|
} catch {
|
|
792
869
|
return undefined;
|
|
@@ -1482,22 +1559,31 @@ function validateServerActionRequestOrigin(request: Request): Response | undefin
|
|
|
1482
1559
|
}
|
|
1483
1560
|
}
|
|
1484
1561
|
|
|
1485
|
-
function
|
|
1486
|
-
|
|
1562
|
+
async function claimFormNonce(
|
|
1563
|
+
nonce: string,
|
|
1487
1564
|
replayStore: ServerActionReplayStore,
|
|
1488
|
-
):
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
return jsonResponse({ ok: false, error: "Missing server action nonce." }, 400);
|
|
1565
|
+
): Promise<Extract<Awaited<ReturnType<ServerActionReplayStore["claim"]>>, { status: "claimed" }> | Response> {
|
|
1566
|
+
let claim: Awaited<ReturnType<ServerActionReplayStore["claim"]>>;
|
|
1567
|
+
if (!ensureServerActionReplayStoreContract(replayStore)) {
|
|
1568
|
+
return replayStoreUnavailableResponse();
|
|
1493
1569
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1570
|
+
try {
|
|
1571
|
+
claim = await replayStore.claim(nonce);
|
|
1572
|
+
} catch {
|
|
1573
|
+
return replayStoreUnavailableResponse();
|
|
1574
|
+
}
|
|
1575
|
+
if (claim.status === "replay") {
|
|
1496
1576
|
return jsonResponse({ ok: false, error: "Server action nonce was already used." }, 409);
|
|
1497
1577
|
}
|
|
1578
|
+
if (claim.status === "capacity-exceeded") return replayStoreUnavailableResponse();
|
|
1579
|
+
return claim;
|
|
1580
|
+
}
|
|
1498
1581
|
|
|
1499
|
-
|
|
1500
|
-
return
|
|
1582
|
+
function replayStoreUnavailableResponse(): Response {
|
|
1583
|
+
return new Response(
|
|
1584
|
+
JSON.stringify({ ok: false, error: "Server action replay protection is unavailable." }),
|
|
1585
|
+
{ status: 503, headers: { "content-type": "application/json", "retry-after": "1" } },
|
|
1586
|
+
);
|
|
1501
1587
|
}
|
|
1502
1588
|
|
|
1503
1589
|
function cleanActionFormData(formData: FormData): FormData {
|