@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.
- package/package.json +13 -12
- package/src/actions.ts +1130 -0
- package/src/adapters/aws-lambda.ts +993 -0
- package/src/adapters/cloudflare.ts +1286 -0
- package/src/adapters/devtools.ts +5 -0
- package/src/adapters/edge.ts +70 -0
- package/src/adapters/node.ts +126 -0
- package/src/adapters/static.ts +61 -0
- package/src/app-router-globals.ts +19 -0
- package/src/assets.ts +113 -0
- package/src/build.ts +2948 -0
- package/src/bundle-pipeline.ts +496 -0
- package/src/cache-config.ts +35 -0
- package/src/cache-stats.ts +54 -0
- package/src/cache.ts +418 -0
- package/src/cli-options.ts +296 -0
- package/src/cli.ts +94 -0
- package/src/client.ts +3398 -0
- package/src/config.ts +146 -0
- package/src/cookies.ts +113 -0
- package/src/csp.ts +103 -0
- package/src/csrf.ts +132 -0
- package/src/deferred.ts +52 -0
- package/src/dev-server.ts +262 -0
- package/src/file-conventions.ts +88 -0
- package/src/http.ts +128 -0
- package/src/i18n.ts +98 -0
- package/src/import-policy.ts +261 -0
- package/src/index.ts +221 -0
- package/src/link.ts +47 -0
- package/src/logger.ts +149 -0
- package/src/module-runner.ts +554 -0
- package/src/multipart.ts +577 -0
- package/src/native-escape.ts +53 -0
- package/src/native-route-matcher.ts +173 -0
- package/src/navigation-state.ts +98 -0
- package/src/navigation.ts +215 -0
- package/src/prerender-store.ts +233 -0
- package/src/render.ts +5187 -0
- package/src/route-path.ts +5 -0
- package/src/route-shells.ts +47 -0
- package/src/route-source.ts +224 -0
- package/src/route-styles.ts +91 -0
- package/src/routes.ts +362 -0
- package/src/runtime-cache.ts +8 -0
- package/src/runtime-state.ts +37 -0
- package/src/security-headers.ts +134 -0
- package/src/serve.ts +1265 -0
- package/src/session.ts +238 -0
- package/src/source-jsx.ts +30 -0
- package/src/source-modules.ts +69 -0
- package/src/stream-list.ts +45 -0
- package/src/trace.ts +114 -0
- package/src/types.ts +155 -0
- package/src/upgrade.ts +8 -0
- package/src/vite-config.ts +63 -0
- package/src/vite-plugin-cache-key.ts +53 -0
- package/src/vite.ts +690 -0
- 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("&", "&")
|
|
1127
|
+
.replaceAll('"', """)
|
|
1128
|
+
.replaceAll("<", "<")
|
|
1129
|
+
.replaceAll(">", ">");
|
|
1130
|
+
}
|