@reckona/mreact-router 0.0.65 → 0.0.67

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 (59) hide show
  1. package/package.json +13 -12
  2. package/src/actions.ts +1130 -0
  3. package/src/adapters/aws-lambda.ts +993 -0
  4. package/src/adapters/cloudflare.ts +1286 -0
  5. package/src/adapters/devtools.ts +5 -0
  6. package/src/adapters/edge.ts +70 -0
  7. package/src/adapters/node.ts +126 -0
  8. package/src/adapters/static.ts +61 -0
  9. package/src/app-router-globals.ts +19 -0
  10. package/src/assets.ts +113 -0
  11. package/src/build.ts +2948 -0
  12. package/src/bundle-pipeline.ts +496 -0
  13. package/src/cache-config.ts +35 -0
  14. package/src/cache-stats.ts +54 -0
  15. package/src/cache.ts +418 -0
  16. package/src/cli-options.ts +296 -0
  17. package/src/cli.ts +94 -0
  18. package/src/client.ts +3398 -0
  19. package/src/config.ts +146 -0
  20. package/src/cookies.ts +113 -0
  21. package/src/csp.ts +103 -0
  22. package/src/csrf.ts +132 -0
  23. package/src/deferred.ts +52 -0
  24. package/src/dev-server.ts +262 -0
  25. package/src/file-conventions.ts +88 -0
  26. package/src/http.ts +128 -0
  27. package/src/i18n.ts +98 -0
  28. package/src/import-policy.ts +261 -0
  29. package/src/index.ts +221 -0
  30. package/src/link.ts +47 -0
  31. package/src/logger.ts +149 -0
  32. package/src/module-runner.ts +554 -0
  33. package/src/multipart.ts +577 -0
  34. package/src/native-escape.ts +53 -0
  35. package/src/native-route-matcher.ts +173 -0
  36. package/src/navigation-state.ts +98 -0
  37. package/src/navigation.ts +215 -0
  38. package/src/prerender-store.ts +233 -0
  39. package/src/render.ts +5187 -0
  40. package/src/route-path.ts +5 -0
  41. package/src/route-shells.ts +47 -0
  42. package/src/route-source.ts +224 -0
  43. package/src/route-styles.ts +91 -0
  44. package/src/routes.ts +362 -0
  45. package/src/runtime-cache.ts +8 -0
  46. package/src/runtime-state.ts +37 -0
  47. package/src/security-headers.ts +134 -0
  48. package/src/serve.ts +1265 -0
  49. package/src/session.ts +238 -0
  50. package/src/source-jsx.ts +30 -0
  51. package/src/source-modules.ts +69 -0
  52. package/src/stream-list.ts +45 -0
  53. package/src/trace.ts +114 -0
  54. package/src/types.ts +155 -0
  55. package/src/upgrade.ts +8 -0
  56. package/src/vite-config.ts +63 -0
  57. package/src/vite-plugin-cache-key.ts +53 -0
  58. package/src/vite.ts +690 -0
  59. package/src/workspace-packages.ts +67 -0
package/src/actions.ts ADDED
@@ -0,0 +1,1130 @@
1
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { access, readdir, readFile } from "node:fs/promises";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ collectFormActionReferenceNames,
7
+ collectFormActionReferences,
8
+ hasModuleDirective,
9
+ } from "@reckona/mreact-compiler";
10
+ import {
11
+ createServerActionHandler,
12
+ type ServerActionHandlerOptions,
13
+ type ServerActionRegistry,
14
+ type ServerActionReplayStore,
15
+ type ServerActionRequestReference,
16
+ type ServerActionValidationResult,
17
+ } from "@reckona/mreact-server";
18
+ import { bundleRouterModule, type RouterCompatBuildApi } from "./bundle-pipeline.js";
19
+ import { type AppRouterCache, withRouteCacheContext } from "./cache.js";
20
+ import { fileImportMetaUrlPlugin, importAppRouterSourceModule } from "./module-runner.js";
21
+ import { createAppRouterImportPolicyPlugin, type AppRouterImportPolicy } from "./import-policy.js";
22
+ export {
23
+ createFormCsrfToken,
24
+ formCsrfCookie,
25
+ formCsrfFieldName,
26
+ serverActionCookie,
27
+ validateFormCsrf,
28
+ } from "./csrf.js";
29
+ import {
30
+ formCsrfFieldName,
31
+ readExistingFormCsrfToken,
32
+ validateFormCsrf,
33
+ } from "./csrf.js";
34
+
35
+ function isProductionEnvironment(): boolean {
36
+ return process.env.NODE_ENV === "production";
37
+ }
38
+ const formFieldModuleId = "__mreact_module_id";
39
+ const formFieldExportName = "__mreact_export_name";
40
+ const formFieldNonce = "__mreact_action_nonce";
41
+ const formFieldActionToken = "__mreact_action_token";
42
+ const actionTokenSecret = process.env.MREACT_SERVER_ACTION_SECRET ?? randomBytes(32).toString("base64url");
43
+ // Bounded default replay store for form-action nonces. The previous
44
+ // implementation was an unbounded Set that grew with every successful
45
+ // submission (Issue 069). Production callers should still pass a shared
46
+ // store (Redis / KV) via `serverActions.replayStore` for multi-instance
47
+ // deployments -- this default only guarantees replay protection within
48
+ // a single process and is the safe fallback for dev / single-node setups.
49
+ //
50
+ // Retention model:
51
+ // - TTL: 10 minutes. Form nonces are minted at SSR time and the user
52
+ // has to submit before the cookie's SameSite=Lax window anyway; this
53
+ // is generous for any realistic submit cadence.
54
+ // - Max size: 50_000 entries. Old entries are evicted FIFO once the cap
55
+ // is reached so a flood cannot trigger OOM.
56
+ //
57
+ // Both bounds are intentional defaults -- tight enough that a single
58
+ // process cannot leak unbounded RSS, loose enough that legitimate
59
+ // traffic does not trip false-positive 409s.
60
+ const DEFAULT_REPLAY_TTL_MS = 10 * 60 * 1000;
61
+ const DEFAULT_REPLAY_MAX_ENTRIES = 50_000;
62
+ const DEFAULT_ACTION_BODY_MAX_BYTES = 10 * 1024 * 1024;
63
+ let warnedUnrestrictedServerActions = false;
64
+
65
+ class BoundedReplayStore {
66
+ private readonly entries = new Map<string, number>();
67
+
68
+ constructor(
69
+ private readonly ttlMs: number,
70
+ private readonly maxEntries: number,
71
+ ) {}
72
+
73
+ has(value: string): boolean {
74
+ const expiresAt = this.entries.get(value);
75
+ if (expiresAt === undefined) return false;
76
+ if (expiresAt < Date.now()) {
77
+ this.entries.delete(value);
78
+ return false;
79
+ }
80
+ return true;
81
+ }
82
+
83
+ add(value: string): void {
84
+ const now = Date.now();
85
+ // Cheap opportunistic sweep: drop the oldest expired entries first,
86
+ // then enforce the hard cap with FIFO eviction.
87
+ if (this.entries.size >= this.maxEntries) {
88
+ for (const [key, expiresAt] of this.entries) {
89
+ if (expiresAt < now) {
90
+ this.entries.delete(key);
91
+ }
92
+ if (this.entries.size < this.maxEntries) break;
93
+ }
94
+ while (this.entries.size >= this.maxEntries) {
95
+ const oldest = this.entries.keys().next().value;
96
+ if (oldest === undefined) break;
97
+ this.entries.delete(oldest);
98
+ }
99
+ }
100
+ this.entries.set(value, now + this.ttlMs);
101
+ }
102
+
103
+ // Exposed for tests; not part of the ServerActionReplayStore interface.
104
+ size(): number {
105
+ return this.entries.size;
106
+ }
107
+ }
108
+
109
+ const usedFormActionNonces = new BoundedReplayStore(
110
+ DEFAULT_REPLAY_TTL_MS,
111
+ DEFAULT_REPLAY_MAX_ENTRIES,
112
+ );
113
+
114
+ // Test helpers: drop all entries between cases / expose the bounded store
115
+ // so tests can drive its eviction semantics directly. Not part of the
116
+ // public surface (prefixed with `__`).
117
+ export function __clearDefaultReplayStore(): void {
118
+ (usedFormActionNonces as unknown as { entries: Map<string, number> }).entries.clear();
119
+ }
120
+
121
+ export function __readDefaultReplayStore(): BoundedReplayStore {
122
+ return usedFormActionNonces;
123
+ }
124
+
125
+ export interface AppRouterServerActionOptions {
126
+ allowedActions?: readonly AppRouterAllowedServerAction[] | undefined;
127
+ authorize?: ServerActionHandlerOptions["authorize"] | undefined;
128
+ maxBodyBytes?: number | undefined;
129
+ replayStore?: ServerActionReplayStore | undefined;
130
+ }
131
+
132
+ export interface AppRouterAllowedServerAction extends ServerActionRequestReference {
133
+ inferred?: boolean | undefined;
134
+ }
135
+
136
+ export interface PreparedRouteActions {
137
+ actionNonce?: string;
138
+ code: string;
139
+ csrfToken?: string;
140
+ // True when the cookie should be (re)set on the response. False means
141
+ // the incoming request already carried a valid CSRF cookie that the
142
+ // render is reusing -- skipping Set-Cookie avoids cookie thrash across
143
+ // concurrent tabs (Issue 070).
144
+ csrfTokenIsNew?: boolean;
145
+ hasFormActions: boolean;
146
+ }
147
+
148
+ interface ActionReference {
149
+ exportName: string;
150
+ inferred: boolean;
151
+ moduleId: string;
152
+ }
153
+
154
+ const inferredServerActionReferences = new Map<string, Map<string, Map<string, ActionReference>>>();
155
+
156
+ export async function prepareRouteServerActions(options: {
157
+ appDir: string;
158
+ code: string;
159
+ pageFile: string;
160
+ request?: Request | undefined;
161
+ }): Promise<PreparedRouteActions> {
162
+ if (!hasFormActionCandidate(options.code, options.pageFile)) {
163
+ replaceInferredServerActionReferences(options.appDir, options.pageFile, new Map());
164
+ return { code: options.code, hasFormActions: false };
165
+ }
166
+
167
+ const references = await collectImportedServerActions(options);
168
+
169
+ if (references.size === 0) {
170
+ replaceInferredServerActionReferences(options.appDir, options.pageFile, new Map());
171
+ return { code: options.code, hasFormActions: false };
172
+ }
173
+
174
+ // Reuse the existing CSRF token when the browser already sent one.
175
+ // Rotating the cookie on every render (Issue 070) broke concurrent
176
+ // forms because the older tab's hidden input no longer matched the
177
+ // cookie value. The actionNonce stays per-render -- that is the field
178
+ // tied to a specific submission via replay protection.
179
+ const existingToken = readExistingFormCsrfToken(options.request);
180
+ const csrfToken = existingToken ?? randomUUID();
181
+ const csrfTokenIsNew = existingToken === undefined;
182
+ const actionNonce = randomUUID();
183
+ const lowered = lowerFormActions({
184
+ actionNonce,
185
+ code: options.code,
186
+ csrfToken,
187
+ references,
188
+ });
189
+
190
+ if (lowered === options.code) {
191
+ replaceInferredServerActionReferences(options.appDir, options.pageFile, new Map());
192
+ return { code: options.code, hasFormActions: false };
193
+ }
194
+
195
+ replaceInferredServerActionReferences(options.appDir, options.pageFile, references);
196
+
197
+ return {
198
+ actionNonce,
199
+ code: lowered,
200
+ csrfToken,
201
+ csrfTokenIsNew,
202
+ hasFormActions: true,
203
+ };
204
+ }
205
+
206
+ function hasFormActionCandidate(code: string, filename: string): boolean {
207
+ return collectFormActionReferenceNames({ code, filename }).length > 0;
208
+ }
209
+
210
+ export async function dispatchServerActionRequest(options: {
211
+ appDir: string;
212
+ importPolicy?: AppRouterImportPolicy | undefined;
213
+ request: Request;
214
+ routeCache?: AppRouterCache | undefined;
215
+ serverActionCacheVersion?: string | undefined;
216
+ serverActions?: AppRouterServerActionOptions | undefined;
217
+ }): Promise<Response> {
218
+ const { revalidatedPaths, value } = await withRouteCacheContext(options.routeCache, () =>
219
+ dispatchServerActionRequestWithoutCacheContext(options),
220
+ );
221
+
222
+ return withRevalidationHeader(value, revalidatedPaths);
223
+ }
224
+
225
+ async function dispatchServerActionRequestWithoutCacheContext(options: {
226
+ appDir: string;
227
+ importPolicy?: AppRouterImportPolicy | undefined;
228
+ request: Request;
229
+ serverActionCacheVersion?: string | undefined;
230
+ serverActions?: AppRouterServerActionOptions | undefined;
231
+ }): Promise<Response> {
232
+ // Validate everything we can statically before touching the filesystem
233
+ // / bundler. A flood of malformed POSTs must not pay the registry-load
234
+ // cost (Issue 067).
235
+ if (options.request.method !== "POST") {
236
+ return jsonResponse({ ok: false, error: "Method not allowed." }, 405);
237
+ }
238
+
239
+ const bodySizeResponse = validateServerActionBodySize(
240
+ options.request,
241
+ options.serverActions?.maxBodyBytes ?? DEFAULT_ACTION_BODY_MAX_BYTES,
242
+ );
243
+ if (bodySizeResponse !== undefined) {
244
+ return bodySizeResponse;
245
+ }
246
+
247
+ const originResponse = validateServerActionRequestOrigin(options.request);
248
+ if (originResponse !== undefined) {
249
+ return originResponse;
250
+ }
251
+
252
+ const contentType = options.request.headers.get("content-type") ?? "";
253
+
254
+ if (contentType.includes("application/json")) {
255
+ // JSON path delegates CSRF/replay to createServerActionHandler. The
256
+ // registry is still needed here, but the handler short-circuits on
257
+ // CSRF mismatch before invoking the action.
258
+ let registry: ServerActionRegistry;
259
+ try {
260
+ registry = await loadServerActionRegistry({
261
+ appDir: options.appDir,
262
+ cacheVersion: options.serverActionCacheVersion,
263
+ importPolicy: options.importPolicy,
264
+ });
265
+ } catch (error) {
266
+ return jsonResponse(
267
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
268
+ 500,
269
+ );
270
+ }
271
+
272
+ const replayStore = options.serverActions?.replayStore ?? usedFormActionNonces;
273
+ warnIfUnrestrictedServerActions(options.serverActions?.allowedActions);
274
+ const handle = createServerActionHandler(jsonServerActionRegistry({
275
+ allowedActions: options.serverActions?.allowedActions,
276
+ appDir: options.appDir,
277
+ registry,
278
+ }), {
279
+ ...(options.serverActions?.authorize === undefined
280
+ ? {}
281
+ : { authorize: options.serverActions.authorize }),
282
+ ...(options.serverActions?.allowedActions === undefined
283
+ ? {}
284
+ : { allowedActions: jsonAllowedServerActions(options.serverActions.allowedActions) }),
285
+ csrf: true,
286
+ maxBodyBytes: options.serverActions?.maxBodyBytes ?? DEFAULT_ACTION_BODY_MAX_BYTES,
287
+ replayProtection: { seen: replayStore },
288
+ });
289
+
290
+ return handle(options.request);
291
+ }
292
+
293
+ if (
294
+ !contentType.includes("application/x-www-form-urlencoded") &&
295
+ !contentType.includes("multipart/form-data")
296
+ ) {
297
+ return jsonResponse({ ok: false, error: "Unsupported server action content type." }, 415);
298
+ }
299
+
300
+ const formData = await options.request.formData();
301
+ const csrfResponse = validateFormCsrf(options.request, formData);
302
+
303
+ if (csrfResponse !== undefined) {
304
+ return csrfResponse;
305
+ }
306
+
307
+ const moduleId = stringFormValue(formData.get(formFieldModuleId));
308
+ const exportName = stringFormValue(formData.get(formFieldExportName));
309
+ const nonce = stringFormValue(formData.get(formFieldNonce));
310
+
311
+ if (moduleId === undefined || exportName === undefined || nonce === undefined) {
312
+ return jsonResponse({ ok: false, error: "Invalid server action reference." }, 400);
313
+ }
314
+
315
+ if (!isAllowedServerAction({ moduleId, exportName }, options.serverActions?.allowedActions)) {
316
+ return jsonResponse({ ok: false, error: "Unknown server action." }, 404);
317
+ }
318
+
319
+ if (
320
+ isInferredServerActionReference({
321
+ allowedActions: options.serverActions?.allowedActions,
322
+ appDir: options.appDir,
323
+ exportName,
324
+ moduleId,
325
+ }) &&
326
+ !isValidFormActionToken({
327
+ csrfToken: stringFormValue(formData.get(formCsrfFieldName)),
328
+ exportName,
329
+ moduleId,
330
+ nonce,
331
+ token: stringFormValue(formData.get(formFieldActionToken)),
332
+ })
333
+ ) {
334
+ return jsonResponse({ ok: false, error: "Unknown server action." }, 404);
335
+ }
336
+
337
+ const nonceResponse = validateFormNonce(
338
+ formData,
339
+ options.serverActions?.replayStore ?? usedFormActionNonces,
340
+ );
341
+
342
+ if (nonceResponse !== undefined) {
343
+ return nonceResponse;
344
+ }
345
+
346
+ let registry: ServerActionRegistry;
347
+ try {
348
+ registry = await loadServerActionRegistry({
349
+ appDir: options.appDir,
350
+ cacheVersion: options.serverActionCacheVersion,
351
+ importPolicy: options.importPolicy,
352
+ });
353
+ } catch (error) {
354
+ return jsonResponse(
355
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
356
+ 500,
357
+ );
358
+ }
359
+
360
+ const action = registry[`${moduleId}#${exportName}`];
361
+
362
+ if (typeof action !== "function") {
363
+ return jsonResponse({ ok: false, error: "Unknown server action." }, 404);
364
+ }
365
+
366
+ const actionFormData = cleanActionFormData(formData);
367
+ const authorizationResponse = await authorizeFormAction({
368
+ args: [actionFormData],
369
+ authorize: options.serverActions?.authorize,
370
+ exportName,
371
+ moduleId,
372
+ request: options.request,
373
+ });
374
+
375
+ if (authorizationResponse !== undefined) {
376
+ return authorizationResponse;
377
+ }
378
+
379
+ try {
380
+ const value = await action(actionFormData);
381
+
382
+ if (value instanceof Response) {
383
+ return value;
384
+ }
385
+
386
+ if (value === undefined || value === null) {
387
+ return redirectToFormReferer(options.request);
388
+ }
389
+
390
+ return jsonResponse({ ok: true, value }, 200);
391
+ } catch (error) {
392
+ return jsonResponse(
393
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
394
+ 500,
395
+ );
396
+ }
397
+ }
398
+
399
+ function validateServerActionBodySize(
400
+ request: Request,
401
+ maxBodyBytes: number,
402
+ ): Response | undefined {
403
+ if (!Number.isFinite(maxBodyBytes) || maxBodyBytes < 0) {
404
+ return undefined;
405
+ }
406
+
407
+ const contentLength = request.headers.get("content-length");
408
+ if (contentLength === null) {
409
+ return undefined;
410
+ }
411
+
412
+ const bytes = Number(contentLength);
413
+ if (!Number.isFinite(bytes) || bytes < 0) {
414
+ return jsonResponse({ ok: false, error: "Invalid Content-Length header." }, 400);
415
+ }
416
+
417
+ if (bytes > maxBodyBytes) {
418
+ return jsonResponse({ ok: false, error: "Server action request body is too large." }, 413);
419
+ }
420
+
421
+ return undefined;
422
+ }
423
+
424
+ function redirectToFormReferer(request: Request): Response {
425
+ return new Response(null, {
426
+ status: 303,
427
+ headers: {
428
+ location: sameOriginRefererPath(request) ?? "/",
429
+ },
430
+ });
431
+ }
432
+
433
+ function sameOriginRefererPath(request: Request): string | undefined {
434
+ const referer = request.headers.get("referer");
435
+
436
+ if (referer === null) {
437
+ return undefined;
438
+ }
439
+
440
+ try {
441
+ const requestUrl = new URL(request.url);
442
+ const refererUrl = new URL(referer, requestUrl);
443
+
444
+ return refererUrl.origin === requestUrl.origin
445
+ ? `${refererUrl.pathname}${refererUrl.search}`
446
+ : undefined;
447
+ } catch {
448
+ return undefined;
449
+ }
450
+ }
451
+
452
+ function withRevalidationHeader(response: Response, paths: string[]): Response {
453
+ if (paths.length > 0) {
454
+ response.headers.set("x-mreact-revalidate", paths.join(","));
455
+ }
456
+
457
+ return response;
458
+ }
459
+
460
+ async function authorizeFormAction(options: {
461
+ args: unknown[];
462
+ authorize?: ServerActionHandlerOptions["authorize"];
463
+ exportName: string;
464
+ moduleId: string;
465
+ request: Request;
466
+ }): Promise<Response | undefined> {
467
+ const reference: ServerActionRequestReference = {
468
+ exportName: options.exportName,
469
+ moduleId: options.moduleId,
470
+ };
471
+ const authorizationResult = await options.authorize?.(options.request, reference, options.args);
472
+
473
+ return authorizationResult !== undefined && authorizationResult !== true
474
+ ? jsonResponse(
475
+ {
476
+ ok: false,
477
+ error: authorizationError(authorizationResult),
478
+ },
479
+ 403,
480
+ )
481
+ : undefined;
482
+ }
483
+
484
+ function authorizationError(result: Exclude<ServerActionValidationResult, true>): string {
485
+ return typeof result === "string" ? result : "Server action not authorized.";
486
+ }
487
+
488
+ function jsonAllowedServerActions(
489
+ allowedActions: readonly AppRouterAllowedServerAction[],
490
+ ): ServerActionRequestReference[] {
491
+ return allowedActions
492
+ .filter((reference) => reference.inferred !== true)
493
+ .map((reference) => ({
494
+ exportName: reference.exportName,
495
+ moduleId: reference.moduleId,
496
+ }));
497
+ }
498
+
499
+ function jsonServerActionRegistry(options: {
500
+ allowedActions: readonly AppRouterAllowedServerAction[] | undefined;
501
+ appDir: string;
502
+ registry: ServerActionRegistry;
503
+ }): ServerActionRegistry {
504
+ const inferredKeys = new Set<string>();
505
+
506
+ for (const reference of inferredServerActionReferencesForApp(options.appDir).values()) {
507
+ inferredKeys.add(serverActionKey(reference));
508
+ }
509
+
510
+ for (const reference of options.allowedActions ?? []) {
511
+ if (reference.inferred === true) {
512
+ inferredKeys.add(serverActionKey(reference));
513
+ }
514
+ }
515
+
516
+ if (inferredKeys.size === 0) {
517
+ return options.registry;
518
+ }
519
+
520
+ const registry: ServerActionRegistry = {};
521
+
522
+ for (const [key, value] of Object.entries(options.registry)) {
523
+ if (!inferredKeys.has(key)) {
524
+ registry[key] = value;
525
+ }
526
+ }
527
+
528
+ return registry;
529
+ }
530
+
531
+ function serverActionKey(reference: ServerActionRequestReference): string {
532
+ return `${reference.moduleId}#${reference.exportName}`;
533
+ }
534
+
535
+ function isAllowedServerAction(
536
+ reference: ServerActionRequestReference,
537
+ allowedActions: readonly AppRouterAllowedServerAction[] | undefined,
538
+ ): boolean {
539
+ if (allowedActions === undefined) {
540
+ warnIfUnrestrictedServerActions(allowedActions);
541
+ return true;
542
+ }
543
+
544
+ return allowedActions.some(
545
+ (allowed) =>
546
+ allowed.moduleId === reference.moduleId && allowed.exportName === reference.exportName,
547
+ );
548
+ }
549
+
550
+ function warnIfUnrestrictedServerActions(
551
+ allowedActions: readonly AppRouterAllowedServerAction[] | undefined,
552
+ ): void {
553
+ if (
554
+ allowedActions !== undefined ||
555
+ !isProductionEnvironment() ||
556
+ warnedUnrestrictedServerActions
557
+ ) {
558
+ return;
559
+ }
560
+
561
+ warnedUnrestrictedServerActions = true;
562
+ console.warn(
563
+ "[mreact] Server actions are running without an allowedActions manifest. Built app-router deployments generate this manifest automatically; direct production integrations should pass serverActions.allowedActions.",
564
+ );
565
+ }
566
+
567
+ function lowerFormActions(options: {
568
+ actionNonce: string;
569
+ code: string;
570
+ csrfToken: string;
571
+ references: Map<string, ActionReference>;
572
+ }): string {
573
+ let code = options.code;
574
+ const formReferences = collectFormActionReferences({ code: options.code });
575
+
576
+ for (const formReference of [...formReferences].reverse()) {
577
+ const reference = options.references.get(formReference.name);
578
+
579
+ if (reference === undefined) {
580
+ continue;
581
+ }
582
+
583
+ const opening = code.slice(formReference.start, formReference.end);
584
+ const loweredOpening = lowerFormActionOpening({
585
+ actionNonce: options.actionNonce,
586
+ csrfToken: options.csrfToken,
587
+ opening,
588
+ reference,
589
+ referenceName: formReference.name,
590
+ });
591
+
592
+ if (loweredOpening === opening) {
593
+ continue;
594
+ }
595
+
596
+ code = `${code.slice(0, formReference.start)}${loweredOpening}${code.slice(formReference.end)}`;
597
+ }
598
+
599
+ return code;
600
+ }
601
+
602
+ function lowerFormActionOpening(options: {
603
+ actionNonce: string;
604
+ csrfToken: string;
605
+ opening: string;
606
+ reference: ActionReference;
607
+ referenceName: string;
608
+ }): string {
609
+ const actionPattern = new RegExp(
610
+ `^(?<prefix><form\\b[\\s\\S]*?)\\saction=\\{${escapeRegExp(options.referenceName)}\\}(?<suffix>[\\s\\S]*?)>$`,
611
+ "u",
612
+ );
613
+ const match = options.opening.match(actionPattern);
614
+ const prefix = match?.groups?.prefix;
615
+ const suffix = match?.groups?.suffix;
616
+
617
+ if (prefix === undefined || suffix === undefined) {
618
+ return options.opening;
619
+ }
620
+
621
+ const attrs = `${prefix.slice("<form".length)}${suffix}`.replace(
622
+ /\s+method=(?:"[^"]*"|'[^']*'|\{[^}]*\})/g,
623
+ "",
624
+ );
625
+ const hidden = [
626
+ hiddenInput(formFieldModuleId, options.reference.moduleId),
627
+ hiddenInput(formFieldExportName, options.reference.exportName),
628
+ hiddenInput(formCsrfFieldName, options.csrfToken),
629
+ hiddenInput(formFieldNonce, options.actionNonce),
630
+ hiddenInput(
631
+ formFieldActionToken,
632
+ formActionToken({
633
+ csrfToken: options.csrfToken,
634
+ exportName: options.reference.exportName,
635
+ moduleId: options.reference.moduleId,
636
+ nonce: options.actionNonce,
637
+ }),
638
+ ),
639
+ ].join("");
640
+
641
+ return `<form${attrs} method="post" action="/_mreact/actions">${hidden}`;
642
+ }
643
+
644
+ function escapeRegExp(value: string): string {
645
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
646
+ }
647
+
648
+ function hiddenInput(name: string, value: string): string {
649
+ return `<input type="hidden" name="${escapeAttribute(name)}" value="${escapeAttribute(value)}" />`;
650
+ }
651
+
652
+ async function collectImportedServerActions(options: {
653
+ appDir: string;
654
+ code: string;
655
+ pageFile: string;
656
+ }): Promise<Map<string, ActionReference>> {
657
+ const references = new Map<string, ActionReference>();
658
+ const actionNames = new Set(
659
+ collectFormActionReferenceNames({ code: options.code, filename: options.pageFile }),
660
+ );
661
+
662
+ if (actionNames.size === 0) {
663
+ return references;
664
+ }
665
+
666
+ const imports = options.code.matchAll(
667
+ /^import\s+\{\s*(?<specifiers>[^}]+)\s*\}\s+from\s+["'](?<source>[^"']+)["'];?/gm,
668
+ );
669
+
670
+ for (const match of imports) {
671
+ const source = match.groups?.source;
672
+ const specifiers = match.groups?.specifiers;
673
+
674
+ if (source === undefined || specifiers === undefined || !source.startsWith(".")) {
675
+ continue;
676
+ }
677
+
678
+ const file = await resolveSourceFile(dirname(options.pageFile), source);
679
+
680
+ if (file === undefined) {
681
+ continue;
682
+ }
683
+
684
+ const moduleId = moduleIdForFile(options.appDir, file);
685
+ const inferred = !(await isUseServerFile(file));
686
+
687
+ for (const specifier of specifiers.split(",")) {
688
+ const [exportName, localName] = specifier.trim().split(/\s+as\s+/);
689
+ const imported = exportName?.trim();
690
+ const local = localName?.trim() ?? imported;
691
+
692
+ if (
693
+ imported !== undefined &&
694
+ imported.length > 0 &&
695
+ local !== undefined &&
696
+ actionNames.has(local)
697
+ ) {
698
+ references.set(local, {
699
+ exportName: imported,
700
+ inferred,
701
+ moduleId,
702
+ });
703
+ }
704
+ }
705
+ }
706
+
707
+ return references;
708
+ }
709
+
710
+ function isInferredServerActionReference(options: {
711
+ allowedActions: readonly AppRouterAllowedServerAction[] | undefined;
712
+ appDir: string;
713
+ exportName: string;
714
+ moduleId: string;
715
+ }): boolean {
716
+ if (
717
+ options.allowedActions?.some(
718
+ (allowed) =>
719
+ allowed.inferred === true &&
720
+ allowed.moduleId === options.moduleId &&
721
+ allowed.exportName === options.exportName,
722
+ ) === true
723
+ ) {
724
+ return true;
725
+ }
726
+
727
+ return inferredServerActionReferencesForApp(options.appDir).has(
728
+ `${options.moduleId}#${options.exportName}`,
729
+ );
730
+ }
731
+
732
+ function formActionToken(options: {
733
+ csrfToken: string;
734
+ exportName: string;
735
+ moduleId: string;
736
+ nonce: string;
737
+ }): string {
738
+ return createHmac("sha256", actionTokenSecret)
739
+ .update(options.moduleId)
740
+ .update("\0")
741
+ .update(options.exportName)
742
+ .update("\0")
743
+ .update(options.csrfToken)
744
+ .update("\0")
745
+ .update(options.nonce)
746
+ .digest("base64url");
747
+ }
748
+
749
+ function isValidFormActionToken(options: {
750
+ csrfToken: string | undefined;
751
+ exportName: string;
752
+ moduleId: string;
753
+ nonce: string;
754
+ token: string | undefined;
755
+ }): boolean {
756
+ if (options.csrfToken === undefined || options.token === undefined) {
757
+ return false;
758
+ }
759
+
760
+ const expected = Buffer.from(
761
+ formActionToken({
762
+ csrfToken: options.csrfToken,
763
+ exportName: options.exportName,
764
+ moduleId: options.moduleId,
765
+ nonce: options.nonce,
766
+ }),
767
+ );
768
+ const actual = Buffer.from(options.token);
769
+
770
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
771
+ }
772
+
773
+ function replaceInferredServerActionReferences(
774
+ appDir: string,
775
+ pageFile: string,
776
+ references: ReadonlyMap<string, ActionReference>,
777
+ ): void {
778
+ const nextReferences = new Map(
779
+ Array.from(references.values())
780
+ .filter((reference) => reference.inferred)
781
+ .map((reference) => [`${reference.moduleId}#${reference.exportName}`, reference]),
782
+ );
783
+ let appReferencesByPage = inferredServerActionReferences.get(appDir);
784
+ const previousReferences = appReferencesByPage?.get(pageFile) ?? new Map();
785
+ const changed = !sameActionReferenceKeys(previousReferences, nextReferences);
786
+
787
+ if (!changed) {
788
+ return;
789
+ }
790
+
791
+ if (nextReferences.size === 0) {
792
+ appReferencesByPage?.delete(pageFile);
793
+ if (appReferencesByPage?.size === 0) {
794
+ inferredServerActionReferences.delete(appDir);
795
+ }
796
+ } else {
797
+ if (appReferencesByPage === undefined) {
798
+ appReferencesByPage = new Map();
799
+ inferredServerActionReferences.set(appDir, appReferencesByPage);
800
+ }
801
+ appReferencesByPage.set(pageFile, nextReferences);
802
+ }
803
+
804
+ clearServerActionRegistryCacheForApp(appDir);
805
+ }
806
+
807
+ // Cache the (expensive) collect+bundle+evaluate work keyed by appDir +
808
+ // caller-supplied version. Production callers pass the build-time hash
809
+ // (serverModuleCacheVersion) so the registry is reused for the lifetime
810
+ // of one deployment. Dev callers omit the version; the entry is then
811
+ // keyed on "dev" so the work happens once per process — restarts handle
812
+ // invalidation. Concurrent callers share a single in-flight promise.
813
+ const serverActionRegistryCache = new Map<string, Promise<ServerActionRegistry>>();
814
+
815
+ async function loadServerActionRegistry(options: {
816
+ appDir: string;
817
+ cacheVersion?: string | undefined;
818
+ importPolicy?: AppRouterImportPolicy | undefined;
819
+ }): Promise<ServerActionRegistry> {
820
+ const cacheKey = `${options.appDir}::${options.cacheVersion ?? "dev"}`;
821
+ const cached = serverActionRegistryCache.get(cacheKey);
822
+
823
+ if (cached !== undefined) {
824
+ return cached;
825
+ }
826
+
827
+ const pending = buildServerActionRegistry(options).catch((error) => {
828
+ // Drop the failed promise so a retry can re-run the load.
829
+ serverActionRegistryCache.delete(cacheKey);
830
+ throw error;
831
+ });
832
+ serverActionRegistryCache.set(cacheKey, pending);
833
+ return pending;
834
+ }
835
+
836
+ async function buildServerActionRegistry(options: {
837
+ appDir: string;
838
+ importPolicy?: AppRouterImportPolicy | undefined;
839
+ }): Promise<ServerActionRegistry> {
840
+ const files = await collectFiles(options.appDir);
841
+ const registry: ServerActionRegistry = {};
842
+ const inferredReferences = inferredServerActionReferencesForApp(options.appDir);
843
+
844
+ for (const file of files) {
845
+ const moduleId = moduleIdForFile(options.appDir, file);
846
+ const inferredExportNames = inferredExportNamesForModule(inferredReferences, moduleId);
847
+ const useServerFile = await isUseServerFile(file);
848
+
849
+ if (!useServerFile && inferredExportNames.size === 0) {
850
+ continue;
851
+ }
852
+
853
+ const module = await importServerActionModule({
854
+ appDir: options.appDir,
855
+ file,
856
+ importPolicy: options.importPolicy,
857
+ });
858
+
859
+ for (const [exportName, value] of Object.entries(module)) {
860
+ if (typeof value === "function" && (useServerFile || inferredExportNames.has(exportName))) {
861
+ registry[`${moduleId}#${exportName}`] = value as (...args: unknown[]) => unknown;
862
+ }
863
+ }
864
+ }
865
+
866
+ return registry;
867
+ }
868
+
869
+ // Exposed for tests that need a clean slate between cases (in-process state).
870
+ export function __clearServerActionRegistryCache(): void {
871
+ serverActionRegistryCache.clear();
872
+ inferredServerActionReferences.clear();
873
+ }
874
+
875
+ function clearServerActionRegistryCacheForApp(appDir: string): void {
876
+ const prefix = `${appDir}::`;
877
+
878
+ for (const key of serverActionRegistryCache.keys()) {
879
+ if (key.startsWith(prefix)) {
880
+ serverActionRegistryCache.delete(key);
881
+ }
882
+ }
883
+ }
884
+
885
+ function sameActionReferenceKeys(
886
+ left: ReadonlyMap<string, ActionReference>,
887
+ right: ReadonlyMap<string, ActionReference>,
888
+ ): boolean {
889
+ if (left.size !== right.size) {
890
+ return false;
891
+ }
892
+
893
+ for (const key of left.keys()) {
894
+ if (!right.has(key)) {
895
+ return false;
896
+ }
897
+ }
898
+
899
+ return true;
900
+ }
901
+
902
+ function inferredServerActionReferencesForApp(appDir: string): Map<string, ActionReference> {
903
+ const references = new Map<string, ActionReference>();
904
+ const appReferencesByPage = inferredServerActionReferences.get(appDir);
905
+
906
+ if (appReferencesByPage === undefined) {
907
+ return references;
908
+ }
909
+
910
+ for (const pageReferences of appReferencesByPage.values()) {
911
+ for (const [key, reference] of pageReferences) {
912
+ references.set(key, reference);
913
+ }
914
+ }
915
+
916
+ return references;
917
+ }
918
+
919
+ function inferredExportNamesForModule(
920
+ references: ReadonlyMap<string, ActionReference>,
921
+ moduleId: string,
922
+ ): Set<string> {
923
+ const names = new Set<string>();
924
+
925
+ for (const reference of references.values()) {
926
+ if (reference.moduleId === moduleId) {
927
+ names.add(reference.exportName);
928
+ }
929
+ }
930
+
931
+ return names;
932
+ }
933
+
934
+ async function importServerActionModule(options: {
935
+ appDir: string;
936
+ file: string;
937
+ importPolicy?: AppRouterImportPolicy | undefined;
938
+ }): Promise<Record<string, unknown>> {
939
+ const bundled = await bundleRouterModule({
940
+ code: `export * from ${JSON.stringify(options.file)};`,
941
+ filename: options.file,
942
+ platform: "node",
943
+ plugins: [
944
+ fileImportMetaUrlPlugin(),
945
+ serverActionRuntimePlugin(),
946
+ createAppRouterImportPolicyPlugin({
947
+ appDir: options.appDir,
948
+ importPolicy: options.importPolicy,
949
+ label: "Server action",
950
+ }),
951
+ ],
952
+ });
953
+ const code = bundled.code;
954
+
955
+ if (code === undefined) {
956
+ throw new Error(`Failed to compile server action module ${options.file}.`);
957
+ }
958
+
959
+ return importAppRouterSourceModule<Record<string, unknown>>({
960
+ code,
961
+ label: `server-action:${options.file}`,
962
+ });
963
+ }
964
+
965
+ function serverActionRuntimePlugin() {
966
+ const currentDir = dirname(fileURLToPath(import.meta.url));
967
+ const cachePath = join(currentDir, currentDir.endsWith(`${sep}dist`) ? "cache.js" : "cache.ts");
968
+
969
+ return {
970
+ name: "mreact-router-server-action-runtime",
971
+ setup(buildApi: RouterCompatBuildApi) {
972
+ buildApi.onResolve({ filter: /^@reckona\/mreact-router$/ }, () => ({
973
+ namespace: "mreact-router-server-api",
974
+ path: "index",
975
+ }));
976
+ buildApi.onLoad({ filter: /^index$/, namespace: "mreact-router-server-api" }, () => ({
977
+ contents: `export { revalidatePath } from ${JSON.stringify(cachePath)};`,
978
+ loader: "ts",
979
+ resolveDir: dirname(cachePath),
980
+ }));
981
+ },
982
+ };
983
+ }
984
+
985
+ async function collectFiles(directory: string): Promise<string[]> {
986
+ const entries = await readdir(directory, { withFileTypes: true });
987
+ const files: string[] = [];
988
+
989
+ for (const entry of entries) {
990
+ const path = join(directory, entry.name);
991
+
992
+ if (entry.isDirectory()) {
993
+ files.push(...(await collectFiles(path)));
994
+ continue;
995
+ }
996
+
997
+ if (entry.isFile() && /\.(?:mreact\.tsx|tsx|ts)$/.test(entry.name)) {
998
+ files.push(path);
999
+ }
1000
+ }
1001
+
1002
+ return files;
1003
+ }
1004
+
1005
+ async function resolveSourceFile(directory: string, source: string): Promise<string | undefined> {
1006
+ const base = join(directory, source);
1007
+ const tsEsmBase = /\.[cm]?js$/.test(base) ? base.replace(/\.[cm]?js$/, "") : undefined;
1008
+ const candidates = [
1009
+ base,
1010
+ ...(tsEsmBase === undefined
1011
+ ? []
1012
+ : [
1013
+ `${tsEsmBase}.ts`,
1014
+ `${tsEsmBase}.tsx`,
1015
+ `${tsEsmBase}.mreact.tsx`,
1016
+ join(tsEsmBase, "index.ts"),
1017
+ join(tsEsmBase, "index.tsx"),
1018
+ ]),
1019
+ `${base}.ts`,
1020
+ `${base}.tsx`,
1021
+ `${base}.mreact.tsx`,
1022
+ join(base, "index.ts"),
1023
+ join(base, "index.tsx"),
1024
+ ];
1025
+
1026
+ for (const candidate of candidates) {
1027
+ try {
1028
+ await access(candidate);
1029
+ return candidate;
1030
+ } catch {
1031
+ // Try the next candidate.
1032
+ }
1033
+ }
1034
+
1035
+ return undefined;
1036
+ }
1037
+
1038
+ async function isUseServerFile(file: string): Promise<boolean> {
1039
+ const code = await readFile(file, "utf8");
1040
+
1041
+ return hasModuleDirective({ code, directive: "use server", filename: file });
1042
+ }
1043
+
1044
+ function moduleIdForFile(appDir: string, file: string): string {
1045
+ return relative(appDir, file).split(sep).join("/");
1046
+ }
1047
+
1048
+ function validateServerActionRequestOrigin(request: Request): Response | undefined {
1049
+ if (!isProductionEnvironment()) {
1050
+ return undefined;
1051
+ }
1052
+
1053
+ const expectedOrigin = new URL(request.url).origin;
1054
+ const origin = request.headers.get("origin");
1055
+
1056
+ if (origin !== null) {
1057
+ return origin === expectedOrigin
1058
+ ? undefined
1059
+ : jsonResponse({ ok: false, error: "Origin not allowed." }, 403);
1060
+ }
1061
+
1062
+ const referer = request.headers.get("referer");
1063
+
1064
+ if (referer === null) {
1065
+ return jsonResponse({ ok: false, error: "Origin not allowed." }, 403);
1066
+ }
1067
+
1068
+ try {
1069
+ return new URL(referer).origin === expectedOrigin
1070
+ ? undefined
1071
+ : jsonResponse({ ok: false, error: "Origin not allowed." }, 403);
1072
+ } catch {
1073
+ return jsonResponse({ ok: false, error: "Origin not allowed." }, 403);
1074
+ }
1075
+ }
1076
+
1077
+ function validateFormNonce(
1078
+ formData: FormData,
1079
+ replayStore: ServerActionReplayStore,
1080
+ ): Response | undefined {
1081
+ const nonce = stringFormValue(formData.get(formFieldNonce));
1082
+
1083
+ if (nonce === undefined || nonce.length === 0) {
1084
+ return jsonResponse({ ok: false, error: "Missing server action nonce." }, 400);
1085
+ }
1086
+
1087
+ if (replayStore.has(nonce)) {
1088
+ return jsonResponse({ ok: false, error: "Server action nonce was already used." }, 409);
1089
+ }
1090
+
1091
+ replayStore.add(nonce);
1092
+ return undefined;
1093
+ }
1094
+
1095
+ function cleanActionFormData(formData: FormData): FormData {
1096
+ const cleaned = new FormData();
1097
+
1098
+ for (const [name, value] of formData.entries()) {
1099
+ if (
1100
+ name !== formFieldModuleId &&
1101
+ name !== formFieldExportName &&
1102
+ name !== formCsrfFieldName &&
1103
+ name !== formFieldNonce &&
1104
+ name !== formFieldActionToken
1105
+ ) {
1106
+ cleaned.append(name, value);
1107
+ }
1108
+ }
1109
+
1110
+ return cleaned;
1111
+ }
1112
+
1113
+ function stringFormValue(value: FormDataEntryValue | null): string | undefined {
1114
+ return typeof value === "string" ? value : undefined;
1115
+ }
1116
+
1117
+ function jsonResponse(payload: unknown, status: number): Response {
1118
+ return new Response(JSON.stringify(payload), {
1119
+ headers: { "content-type": "application/json; charset=utf-8" },
1120
+ status,
1121
+ });
1122
+ }
1123
+
1124
+ function escapeAttribute(value: string): string {
1125
+ return value
1126
+ .replaceAll("&", "&amp;")
1127
+ .replaceAll('"', "&quot;")
1128
+ .replaceAll("<", "&lt;")
1129
+ .replaceAll(">", "&gt;");
1130
+ }