@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.
Files changed (152) hide show
  1. package/README.md +58 -11
  2. package/dist/actions.d.ts +8 -3
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +125 -45
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/aws-lambda.d.ts +15 -4
  7. package/dist/adapters/aws-lambda.d.ts.map +1 -1
  8. package/dist/adapters/aws-lambda.js +301 -63
  9. package/dist/adapters/aws-lambda.js.map +1 -1
  10. package/dist/adapters/cloudflare.d.ts +5 -2
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +35 -11
  13. package/dist/adapters/cloudflare.js.map +1 -1
  14. package/dist/adapters/edge.d.ts +1 -1
  15. package/dist/adapters/edge.d.ts.map +1 -1
  16. package/dist/adapters/edge.js.map +1 -1
  17. package/dist/adapters/node.d.ts +5 -3
  18. package/dist/adapters/node.d.ts.map +1 -1
  19. package/dist/adapters/node.js +2 -1
  20. package/dist/adapters/node.js.map +1 -1
  21. package/dist/adapters/static.d.ts.map +1 -1
  22. package/dist/adapters/static.js +24 -2
  23. package/dist/adapters/static.js.map +1 -1
  24. package/dist/build.d.ts +4 -1
  25. package/dist/build.d.ts.map +1 -1
  26. package/dist/build.js +185 -13
  27. package/dist/build.js.map +1 -1
  28. package/dist/built-runtime.d.ts +2 -0
  29. package/dist/built-runtime.d.ts.map +1 -1
  30. package/dist/built-runtime.js +16 -0
  31. package/dist/built-runtime.js.map +1 -1
  32. package/dist/cache.d.ts +4 -2
  33. package/dist/cache.d.ts.map +1 -1
  34. package/dist/cache.js +216 -17
  35. package/dist/cache.js.map +1 -1
  36. package/dist/cli-options.d.ts +3 -0
  37. package/dist/cli-options.d.ts.map +1 -1
  38. package/dist/cli-options.js +10 -2
  39. package/dist/cli-options.js.map +1 -1
  40. package/dist/cli.js +14 -3
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client.d.ts +5 -0
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +43 -3
  45. package/dist/client.js.map +1 -1
  46. package/dist/config.d.ts +3 -0
  47. package/dist/config.d.ts.map +1 -1
  48. package/dist/config.js +21 -2
  49. package/dist/config.js.map +1 -1
  50. package/dist/csp.d.ts +1 -0
  51. package/dist/csp.d.ts.map +1 -1
  52. package/dist/csp.js +9 -0
  53. package/dist/csp.js.map +1 -1
  54. package/dist/dehydrate-policy.d.ts +3 -0
  55. package/dist/dehydrate-policy.d.ts.map +1 -0
  56. package/dist/dehydrate-policy.js +16 -0
  57. package/dist/dehydrate-policy.js.map +1 -0
  58. package/dist/dev-server.d.ts +7 -2
  59. package/dist/dev-server.d.ts.map +1 -1
  60. package/dist/dev-server.js +120 -11
  61. package/dist/dev-server.js.map +1 -1
  62. package/dist/http.d.ts +1 -1
  63. package/dist/http.d.ts.map +1 -1
  64. package/dist/http.js +147 -7
  65. package/dist/http.js.map +1 -1
  66. package/dist/index.d.ts +4 -3
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/logger.d.ts +22 -1
  71. package/dist/logger.d.ts.map +1 -1
  72. package/dist/logger.js +22 -8
  73. package/dist/logger.js.map +1 -1
  74. package/dist/metadata.d.ts +8 -0
  75. package/dist/metadata.d.ts.map +1 -1
  76. package/dist/metadata.js +60 -22
  77. package/dist/metadata.js.map +1 -1
  78. package/dist/navigation-marker.d.ts +2 -0
  79. package/dist/navigation-marker.d.ts.map +1 -0
  80. package/dist/navigation-marker.js +357 -0
  81. package/dist/navigation-marker.js.map +1 -0
  82. package/dist/navigation-runtime.d.ts +1 -1
  83. package/dist/navigation-runtime.d.ts.map +1 -1
  84. package/dist/navigation-runtime.js +1 -1
  85. package/dist/navigation-runtime.js.map +1 -1
  86. package/dist/navigation.d.ts +1 -0
  87. package/dist/navigation.d.ts.map +1 -1
  88. package/dist/navigation.js +1 -1
  89. package/dist/navigation.js.map +1 -1
  90. package/dist/node-server.d.ts +5 -2
  91. package/dist/node-server.d.ts.map +1 -1
  92. package/dist/node-server.js +75 -6
  93. package/dist/node-server.js.map +1 -1
  94. package/dist/prerender-entry.d.ts +17 -1
  95. package/dist/prerender-entry.d.ts.map +1 -1
  96. package/dist/prerender-entry.js +98 -3
  97. package/dist/prerender-entry.js.map +1 -1
  98. package/dist/render.d.ts +16 -2
  99. package/dist/render.d.ts.map +1 -1
  100. package/dist/render.js +142 -135
  101. package/dist/render.js.map +1 -1
  102. package/dist/serve.d.ts +11 -2
  103. package/dist/serve.d.ts.map +1 -1
  104. package/dist/serve.js +67 -41
  105. package/dist/serve.js.map +1 -1
  106. package/dist/session.d.ts.map +1 -1
  107. package/dist/session.js +52 -19
  108. package/dist/session.js.map +1 -1
  109. package/dist/upgrade.d.ts +59 -2
  110. package/dist/upgrade.d.ts.map +1 -1
  111. package/dist/upgrade.js +282 -1
  112. package/dist/upgrade.js.map +1 -1
  113. package/dist/vite-config.d.ts +2 -0
  114. package/dist/vite-config.d.ts.map +1 -1
  115. package/dist/vite-config.js +2 -0
  116. package/dist/vite-config.js.map +1 -1
  117. package/dist/vite.d.ts +4 -1
  118. package/dist/vite.d.ts.map +1 -1
  119. package/dist/vite.js +117 -6
  120. package/dist/vite.js.map +1 -1
  121. package/package.json +11 -11
  122. package/src/actions.ts +146 -60
  123. package/src/adapters/aws-lambda.ts +426 -115
  124. package/src/adapters/cloudflare.ts +58 -12
  125. package/src/adapters/edge.ts +2 -0
  126. package/src/adapters/node.ts +8 -10
  127. package/src/adapters/static.ts +31 -2
  128. package/src/build.ts +217 -13
  129. package/src/built-runtime.ts +28 -0
  130. package/src/cache.ts +272 -19
  131. package/src/cli-options.ts +15 -2
  132. package/src/cli.ts +16 -2
  133. package/src/client.ts +47 -3
  134. package/src/config.ts +43 -6
  135. package/src/csp.ts +11 -0
  136. package/src/dehydrate-policy.ts +23 -0
  137. package/src/dev-server.ts +188 -15
  138. package/src/http.ts +172 -6
  139. package/src/index.ts +14 -1
  140. package/src/logger.ts +47 -10
  141. package/src/metadata.ts +125 -60
  142. package/src/navigation-marker.ts +423 -0
  143. package/src/navigation-runtime.ts +1 -0
  144. package/src/navigation.ts +1 -1
  145. package/src/node-server.ts +128 -22
  146. package/src/prerender-entry.ts +127 -5
  147. package/src/render.ts +859 -749
  148. package/src/serve.ts +112 -82
  149. package/src/session.ts +56 -25
  150. package/src/upgrade.ts +402 -3
  151. package/src/vite-config.ts +6 -0
  152. 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<string, number>();
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
- has(value: string): boolean {
123
- const expiresAt = this.entries.get(value);
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
- return false;
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
- add(value: string): void {
133
- const now = Date.now();
148
+ if (this.entries.has(value)) return { status: "replay" };
134
149
  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
- }
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
- this.entries.set(value, now + this.ttlMs);
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
- (usedFormActionNonces as unknown as { entries: Map<string, number> }).entries.clear();
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: { seen: replayStore },
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
- return value;
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
- return jsonResponse(
587
- { ok: false, error: error instanceof Error ? error.message : String(error) },
588
- 500,
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
- return refererUrl.origin === requestUrl.origin
789
- ? `${refererUrl.pathname}${refererUrl.search}`
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 validateFormNonce(
1486
- formData: FormData,
1562
+ async function claimFormNonce(
1563
+ nonce: string,
1487
1564
  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);
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
- if (replayStore.has(nonce)) {
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
- replayStore.add(nonce);
1500
- return undefined;
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 {