@webjsdev/server 0.8.58 → 0.8.60

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.
@@ -0,0 +1,666 @@
1
+ import { isNotFound, isRedirect, isForbidden, isUnauthorized, FORM_ACTION_FIELD } from '@webjsdev/core';
2
+ import { ssrPage, ssrNotFound, ssrForbidden, ssrUnauthorized } from './ssr.js';
3
+ import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
4
+ import { getBodyLimits } from './context.js';
5
+ import { propagateTrustedRemoteIp } from './rate-limit.js';
6
+ import { makeThenable } from './thenable-params.js';
7
+ import { lookupActionIdentity } from './form-action-identity.js';
8
+ import { verifyOrigin } from './csrf.js';
9
+ import { actionMethod, actionConfigFn, actionMiddleware, resolveTags, RESERVED_CONFIG } from './action-config.js';
10
+ import { runValidate } from './actions.js';
11
+ import { runActionChain } from './action-middleware.js';
12
+ import { runWithActionSignal } from './action-signal.js';
13
+ import { isStreamable } from './action-stream.js';
14
+ import { revalidateTags } from './cache-tags.js';
15
+ import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
16
+
17
+ /**
18
+ * The form-submission dispatcher (#1155): a non-GET request to a PAGE's own url
19
+ * carrying the `__webjs_action` hidden field runs the server action that field
20
+ * names, and the response drives the page.
21
+ *
22
+ * This is the ONE write path. A `<form action=${importedAction}>` renders to a
23
+ * plain HTML form that posts here with no JS, and the client router posts the
24
+ * SAME body to the SAME url when JS is on, so the two paths are identical by
25
+ * construction rather than by two implementations agreeing. It replaces the
26
+ * page `action` export (#244), which forced every module action through a
27
+ * hand-written per-page adapter.
28
+ *
29
+ * Behaviour:
30
+ * - Action throws `redirect(url)` or `notFound()` => honored exactly as a page
31
+ * render does (3xx / 404). A thrown `redirect()` may target an external URL
32
+ * (it is the explicit nav sentinel, author-controlled).
33
+ * - Action returns a SUCCESS result => 303 See Other to `result.redirect` if
34
+ * present, else to the page's own path (Post/Redirect/Get, so a reload does
35
+ * not resubmit). `result.redirect` MUST be a same-site local path (see
36
+ * `sameSiteRedirect`), a non-local value is ignored to avoid an
37
+ * open-redirect through a user-controlled action result.
38
+ * - Action returns a FAILURE result => re-SSR the SAME page (status 422) with
39
+ * the result on `ctx.actionData`, so the page template can read
40
+ * `actionData.fieldErrors` / `actionData.values` and repopulate inputs.
41
+ *
42
+ * The action's declared config exports run here too (`validate`, `middleware`,
43
+ * `invalidates`), on the same seam `invokeAction` uses. Skipping them would
44
+ * mean an action was protected over RPC and unprotected over a form, which is
45
+ * a privilege gap and not a missing feature.
46
+ *
47
+ * @typedef {{
48
+ * success?: boolean,
49
+ * data?: unknown,
50
+ * error?: string,
51
+ * fieldErrors?: Record<string,string>,
52
+ * values?: Record<string,string>,
53
+ * status?: number,
54
+ * redirect?: string,
55
+ * }} ActionResult
56
+ */
57
+
58
+ /**
59
+ * What a form submission whose identity no longer resolves is told.
60
+ *
61
+ * A form held open across a deploy submits a hash the new build has never
62
+ * seen. The response re-renders the page at 422 with this on `actionData`,
63
+ * because the alternatives are worse: a 404 loses everything the user typed,
64
+ * and treating it as a no-op shows a success page for a write that did not
65
+ * happen. Next surfaces the same case as an "older or newer deployment" error.
66
+ */
67
+ export const ACTION_SKEW_MESSAGE =
68
+ 'This page was updated while the form was open. Please submit again.';
69
+
70
+ /**
71
+ * Whether an action result is a FAILURE (re-render the page) rather than a
72
+ * success (PRG redirect). A result is a failure when ANY of these hold:
73
+ * - `result.success === false` (explicit), OR
74
+ * - `result.fieldErrors` is present (per-field validation messages), OR
75
+ * - `result.error` is present AND `result.success !== true`.
76
+ *
77
+ * Success is the explicit `success: true`, or a bare value (or
78
+ * undefined/null) carrying no error markers. This means an action that returns
79
+ * `{ error, status }` or `{ fieldErrors }` WITHOUT a literal `success: false`
80
+ * is still treated as a failure and its error is surfaced, not swallowed.
81
+ *
82
+ * @param {ActionResult | null | undefined} result
83
+ * @returns {boolean}
84
+ */
85
+ function isFailureResult(result) {
86
+ if (!result || typeof result !== 'object') return false;
87
+ if (result.success === false) return true;
88
+ if (result.fieldErrors != null) return true;
89
+ if (result.error != null && result.success !== true) return true;
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Restrict a form action's `result.redirect` to a SAME-SITE local target.
95
+ * Allowed: a path beginning with a single `/` (e.g. `/login`, `/a?b=1#c`).
96
+ * Rejected: a protocol-relative `//host/...` and any absolute `scheme://...`
97
+ * URL. A user-controlled redirect target is an open-redirect vector, so a
98
+ * non-local value is dropped and the caller falls back to the page's own path.
99
+ *
100
+ * The check runs on the string the BROWSER will parse, not the one the action
101
+ * returned, and those differ. The WHATWG URL parser REMOVES every ASCII tab,
102
+ * LF, and CR from a URL before parsing, so `/<TAB>/evil.com` reaches the parser
103
+ * as `//evil.com` and resolves cross-origin. `Headers` rejects a LF or CR in a
104
+ * field value, but a tab is a legal HTTP field-value character and rides
105
+ * through to the wire intact (measured: `new URL('/\t/evil.com', origin).href`
106
+ * is `https://evil.com/`, and Chromium follows it to the attacker host). So
107
+ * strip those three FIRST and validate what is left, which also makes the
108
+ * returned value exactly what the browser sees. A raw tab in a Location is
109
+ * malformed regardless; a legitimate path percent-encodes it.
110
+ *
111
+ * A thrown `redirect(absoluteUrl)` (the nav sentinel) is intentionally NOT
112
+ * routed through here: that is the author-controlled escape hatch for a
113
+ * legitimate external redirect.
114
+ *
115
+ * @param {unknown} target
116
+ * @returns {string | null} the safe local path, or null when not same-site
117
+ */
118
+ function sameSiteRedirect(target) {
119
+ if (typeof target !== 'string') return null;
120
+ // What the URL parser will actually see (tab / LF / CR are removed anywhere
121
+ // in the string, not only at the front).
122
+ const url = target.replace(/[\t\n\r]/g, '');
123
+ // Must start with a single slash (a leading `//` is protocol-relative and
124
+ // would navigate cross-origin).
125
+ if (!url.startsWith('/') || url.startsWith('//')) return null;
126
+ // A backslash after the leading slash (`/\evil.com`) is normalized by some
127
+ // browsers into a protocol-relative URL, so reject it too.
128
+ if (url.startsWith('/\\')) return null;
129
+ return url;
130
+ }
131
+
132
+ /**
133
+ * Read the submitted body ONCE, bounded by the form/multipart limit (issue
134
+ * #237), and return both a `FormData` (handed to the action) and a rebuilt
135
+ * `Request` carrying the already-read bytes (so a middleware or the action can
136
+ * still call `request.json()` / `request.formData()`). The body is consumed off
137
+ * the ORIGINAL request directly, NOT via `req.clone()`: a tee'd clone whose
138
+ * reader is cancelled mid-stream (the over-limit case) deadlocks the untaken
139
+ * branch, hanging the response.
140
+ *
141
+ * An over-limit body is reported as `tooLarge` (the caller returns 413) and is
142
+ * never buffered whole. A form posts more than a JSON RPC call (textarea, small
143
+ * upload), so it uses the higher `multipart` cap. A non-form content type yields
144
+ * an empty FormData so the action signature stays stable; the rebuilt request
145
+ * still carries the raw bytes for the action to parse however it likes.
146
+ *
147
+ * @param {Request} req
148
+ * @returns {Promise<{ tooLarge: boolean, formData: FormData, request: Request }>}
149
+ */
150
+ async function parseFormBody(req) {
151
+ const ct = req.headers.get('content-type') || '';
152
+ const limits = getBodyLimits();
153
+ const limit = limits ? limits.multipart : DEFAULT_MAX_MULTIPART_BYTES;
154
+ const { tooLarge, bytes } = await readBytesBounded(req, limit);
155
+ if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
156
+
157
+ // Rebuild a fresh Request from the bytes so the action can re-read the body.
158
+ // SECURITY (#756): strip any inbound `x-webjs-remote-ip` the copy carried so a
159
+ // client cannot spoof it through the rebuild, and carry the FRAMEWORK-trusted
160
+ // remote IP forward out of band (the rebuild is a new object, so the listener's
161
+ // WeakMap stamp on `req` does not follow it). Without this, `clientIp` inside a
162
+ // form action (the no-JS write path, e.g. login throttling) would read the
163
+ // spoofable header on Bun.
164
+ const headers = new Headers(req.headers);
165
+ headers.delete('x-webjs-remote-ip');
166
+ const rebuilt = new Request(req.url, {
167
+ method: req.method,
168
+ headers,
169
+ body: bytes && bytes.byteLength ? bytes : undefined,
170
+ // Carry the ORIGINAL abort signal. Without it the rebuild gets a fresh,
171
+ // never-aborted one, and since this request is what drives
172
+ // `runWithActionSignal`, `actionSignal()` (#492) inside a form-bound action
173
+ // could never fire, no matter what the transport underneath did. The RPC
174
+ // path gets this for free: it consumes the original request rather than
175
+ // rebuilding it (`readTextBounded` in actions.js).
176
+ //
177
+ // How far it actually reaches is a property of the listener, not of this
178
+ // line. On Bun, `Bun.serve` hands over a request whose signal fires on
179
+ // disconnect (`listener-bun.js` forwards it), so this makes cancellation
180
+ // work. On Node, `toWebRequest` (dev.js) builds its Request with no signal
181
+ // at all, so nothing aborts on EITHER transport there; that is a listener
182
+ // gap ahead of this one, and closing it here would be a fiction.
183
+ signal: req.signal,
184
+ });
185
+ propagateTrustedRemoteIp(req, rebuilt);
186
+
187
+ const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
188
+ let formData = new FormData();
189
+ if (isForm) {
190
+ // Parse a SECOND fresh Request (the rebuilt one is reserved for the action).
191
+ const forParse = new Request(req.url, {
192
+ method: 'POST',
193
+ headers: ct ? { 'content-type': ct } : undefined,
194
+ body: bytes && bytes.byteLength ? bytes : undefined,
195
+ });
196
+ formData = await forParse.formData();
197
+ }
198
+ return { tooLarge: false, formData, request: rebuilt };
199
+ }
200
+
201
+ /**
202
+ * Does this request look like a form submission at all?
203
+ *
204
+ * Content-type only, and deliberately cheap: the authoritative test is the
205
+ * hidden field, which needs the body read, and buffering the body of every
206
+ * stray POST to a page path just to decide it is a 405 would be a free way to
207
+ * make the server do work an attacker chose for it.
208
+ *
209
+ * @param {Request} req
210
+ * @returns {boolean}
211
+ */
212
+ function looksLikeFormSubmission(req) {
213
+ const ct = req.headers.get('content-type') || '';
214
+ return /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
215
+ }
216
+
217
+ /**
218
+ * Fingerprints already reported this process, keyed `CODE METHOD <route>`.
219
+ * Capped, and never cleared.
220
+ *
221
+ * Both signals below are reachable by anyone: an empty urlencoded POST to any
222
+ * page path, or `?__webjs_action=x` appended to any url. Reporting every hit
223
+ * would turn a public endpoint into a free amplifier into a paid APM sink, and
224
+ * an app needs to learn the SHAPE exists rather than count it (a real bug
225
+ * reproduces on the next boot).
226
+ *
227
+ * The key is the matched ROUTE PATTERN, not the request pathname, and that is
228
+ * load-bearing rather than cosmetic. Keyed on the pathname, a dynamic route
229
+ * yields unbounded distinct keys, so a few hundred crafted requests to
230
+ * `/blog/1`, `/blog/2`, … would fill the cap and permanently silence the
231
+ * diagnostics for the process, which is a worse failure than the amplification
232
+ * the cap exists to stop. Keyed on the pattern, all of those collapse to one
233
+ * entry and the key space is bounded by the app's own route count, which leaves
234
+ * the cap as a backstop no real app reaches. The code is part of the key too, so
235
+ * a flood of one signal can never silence the other.
236
+ *
237
+ * @type {Set<string>}
238
+ */
239
+ const reportedFormFingerprints = new Set();
240
+ const FINGERPRINT_CAP = 256;
241
+
242
+ /**
243
+ * @param {string} key
244
+ * @returns {boolean} true the first time only, and false once the cap is hit
245
+ */
246
+ function firstSighting(key) {
247
+ if (reportedFormFingerprints.has(key)) return false;
248
+ if (reportedFormFingerprints.size >= FINGERPRINT_CAP) return false;
249
+ reportedFormFingerprints.add(key);
250
+ return true;
251
+ }
252
+
253
+ /**
254
+ * The matched route's identity for the dedupe key: its page FILE, which is one
255
+ * per route and reads better in a report than a serialized RegExp (`pattern` is
256
+ * a RegExp, not a printable path). Falls back to the request pathname only when
257
+ * a caller has no route in hand.
258
+ *
259
+ * @param {{ file?: string } | null | undefined} route
260
+ * @param {URL} url
261
+ * @returns {string}
262
+ */
263
+ function routeKeyOf(route, url) {
264
+ return (route && route.file) || url.pathname;
265
+ }
266
+
267
+ /**
268
+ * Reset the per-process report dedupe. Test seam only: the cap and the
269
+ * never-cleared set are the point in production.
270
+ */
271
+ export function resetFormReportDedupe() {
272
+ reportedFormFingerprints.clear();
273
+ }
274
+
275
+ /**
276
+ * A form posted to a page and carried no action identity, so nothing ran and
277
+ * the answer is a 405. Route it to the APM sink with a code an app can group
278
+ * on, instead of leaving an anonymous 405 in an access log.
279
+ *
280
+ * Carries the field NAMES, which are template constants and are what identify
281
+ * WHICH form posted nowhere. Never the values, which are user data. The form's
282
+ * own `action` attribute is not carried because a bound form has none (the
283
+ * renderer strips it so the form posts to its own url), so the request url
284
+ * already is that information.
285
+ *
286
+ * @param {URL} url
287
+ * @param {Request} req
288
+ * @param {FormData} formData
289
+ * @param {((error: unknown) => void) | undefined} onError
290
+ * @param {{ warn?: (msg: string, meta?: Record<string, unknown>) => void }} [logger]
291
+ * @param {boolean} [dev]
292
+ * @param {{ file?: string } | null} [route] the matched page route, for the dedupe key
293
+ */
294
+ export function reportFormActionMissing(url, req, formData, onError, logger, dev, route) {
295
+ const willLog = !!(dev && logger && logger.warn);
296
+ const willReport = typeof onError === 'function';
297
+ // Nothing to say: do not spend a dedupe slot, which would let a run with no
298
+ // sink configured quietly consume the cap for one that does.
299
+ if (!willLog && !willReport) return;
300
+ if (!firstSighting(`WEBJS_FORM_ACTION_MISSING ${req.method} ${routeKeyOf(route, url)}`)) return;
301
+ const fields = [...new Set([...formData.keys()])];
302
+ if (willLog) {
303
+ logger.warn(
304
+ `[webjs] a form posted to ${url.pathname} carrying no action identity, so nothing ran (405). Bind the action: <form action=\${yourAction}>.`,
305
+ { fields },
306
+ );
307
+ }
308
+ if (!willReport) return;
309
+ const err = new Error(
310
+ `A form submission to ${url.pathname} carried no \`${FORM_ACTION_FIELD}\` identity, so no server action ran and the request was answered with a 405.`,
311
+ );
312
+ /** @type {any} */ (err).code = 'WEBJS_FORM_ACTION_MISSING';
313
+ /** @type {any} */ (err).method = req.method;
314
+ /** @type {any} */ (err).pathname = url.pathname;
315
+ /** @type {any} */ (err).fields = fields;
316
+ onError(err);
317
+ }
318
+
319
+ /**
320
+ * A page GET carrying `__webjs_action` in the QUERY STRING (#1307). Nothing in
321
+ * this framework ever puts the reserved field in a url, so a submission
322
+ * carrying a bound action's identity went out as a GET: the identity rode the
323
+ * query string (a browser does this natively, and `performSubmission` promotes
324
+ * a safe-method body the same way), no action ran, and the page is about to
325
+ * render as if nothing was submitted.
326
+ *
327
+ * A bound submitter no longer produces this by accident, because it carries its
328
+ * own `formmethod="post"`. What reaches here now is an explicit override the
329
+ * author wrote and the renderer deliberately honours: a `formmethod="get"` on
330
+ * the pressed button, or on the form, wins by native precedence. This is the
331
+ * production counterpart to the dev-time client guard, which reports the same
332
+ * shape at submit time.
333
+ *
334
+ * DETECTS ONLY. The GET keeps rendering its 200 page, because answering
335
+ * differently on a query parameter would hand any visitor a way to turn any
336
+ * page into an error.
337
+ *
338
+ * @param {URL} url
339
+ * @param {Request} req
340
+ * @param {((error: unknown) => void) | undefined} onError
341
+ * @param {{ warn?: (msg: string, meta?: Record<string, unknown>) => void }} [logger]
342
+ * @param {boolean} [dev]
343
+ * @param {{ file?: string } | null} [route] the matched page route, for the dedupe key
344
+ */
345
+ export function reportFormSubmittedAsGet(url, req, onError, logger, dev, route) {
346
+ // Cheapest possible guard first, and it runs on EVERY page GET: a substring
347
+ // test on the already-parsed `search` string, so the common case never builds
348
+ // a URLSearchParams at all.
349
+ if (!url.search || !url.search.includes(FORM_ACTION_FIELD)) return;
350
+ if (!url.searchParams.has(FORM_ACTION_FIELD)) return;
351
+ const willLog = !!(dev && logger && logger.warn);
352
+ const willReport = typeof onError === 'function';
353
+ if (!willLog && !willReport) return;
354
+ if (!firstSighting(`WEBJS_FORM_SUBMITTED_AS_GET ${req.method} ${routeKeyOf(route, url)}`)) return;
355
+ if (willLog) {
356
+ logger.warn(
357
+ `[webjs] ${url.pathname} was requested with \`${FORM_ACTION_FIELD}\` in the query string, which means a submission carrying a bound action's identity went out as a GET. A GET sends no body, so the identity rode the url, the action never ran, and this page is simply re-rendering. Look for a formmethod="get" on the button that was pressed, or a method="get" on its form: a submitter's own formmethod wins by native precedence and WebJs honours it rather than refusing it.`,
358
+ );
359
+ }
360
+ if (!willReport) return;
361
+ const err = new Error(
362
+ `A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran. The submitter's enclosing <form> binds no action.`,
363
+ );
364
+ /** @type {any} */ (err).code = 'WEBJS_FORM_SUBMITTED_AS_GET';
365
+ /** @type {any} */ (err).method = req.method;
366
+ /** @type {any} */ (err).pathname = url.pathname;
367
+ onError(err);
368
+ }
369
+
370
+ /**
371
+ * The submitted TEXT fields as a plain record, for repopulating a form the
372
+ * dispatcher could not run.
373
+ *
374
+ * Files are skipped: a `File` is not a string, and no browser lets a page
375
+ * refill a file input anyway, so carrying one here would only put an object
376
+ * where the page expects text.
377
+ *
378
+ * @param {FormData} formData
379
+ * @returns {Record<string, string>}
380
+ */
381
+ function textValuesOf(formData) {
382
+ /** @type {Record<string, string>} */
383
+ const out = {};
384
+ for (const [k, v] of formData) if (typeof v === 'string') out[k] = v;
385
+ return out;
386
+ }
387
+
388
+ /**
389
+ * A page path exists but only renders: the method is the problem, not the url.
390
+ * @returns {Response}
391
+ */
392
+ function methodNotAllowed() {
393
+ return new Response('Method Not Allowed', {
394
+ status: 405,
395
+ headers: { allow: 'GET, HEAD' },
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Run a bound form action for a non-GET/HEAD request to a page's own url and
401
+ * produce the HTTP response. The caller has already matched the path to a page
402
+ * route and confirmed the request carries a form body; this runs inside the
403
+ * page's segment middleware (the caller wraps it).
404
+ *
405
+ * @param {import('./router.js').PageRoute} route
406
+ * @param {Record<string,string>} params
407
+ * @param {URL} url
408
+ * @param {Request} req
409
+ * @param {object} ssrOpts the same opts object `ssrPage` receives in dev.js
410
+ * @param {{
411
+ * actionIndex: import('./actions.js').ActionIndex,
412
+ * allowedOrigins?: string[],
413
+ * onError?: (error: unknown) => void,
414
+ * logger?: { warn?: (msg: string, meta?: Record<string, unknown>) => void },
415
+ * }} deps
416
+ * @returns {Promise<Response>}
417
+ */
418
+ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
419
+ const { actionIndex, allowedOrigins = [], onError, logger } = deps;
420
+
421
+ // Not a form body at all (a stray JSON POST, a probe): the page path exists
422
+ // and only renders. Answered before the body is touched.
423
+ if (!looksLikeFormSubmission(req)) return methodNotAllowed();
424
+
425
+ // CSRF, before the body is read and before anything runs. Same Origin /
426
+ // Sec-Fetch-Site check the RPC endpoint applies (`csrf.js`). The page `action`
427
+ // export this replaces had none, and was shielded only by SameSite=Lax cookies.
428
+ const origin = verifyOrigin(req, allowedOrigins);
429
+ if (!origin.ok) return new Response('CSRF validation failed', { status: 403 });
430
+
431
+ let formData = new FormData();
432
+ let actionReq = req;
433
+ try {
434
+ const parsed = await parseFormBody(req);
435
+ // Over the form/multipart limit (issue #237): 413 before anything runs.
436
+ if (parsed.tooLarge) return payloadTooLarge();
437
+ formData = parsed.formData;
438
+ actionReq = parsed.request;
439
+ } catch {
440
+ formData = new FormData();
441
+ }
442
+
443
+ const actions = formData.getAll(FORM_ACTION_FIELD);
444
+ const id = actions.length ? actions[actions.length - 1] : null;
445
+ // A form body carrying no identity: a hand-written `<form method="post">`
446
+ // that binds no action. Nothing to run, and the page only renders. Reported
447
+ // before the 405 so the shape is not just an anonymous status in an access
448
+ // log.
449
+ //
450
+ // NOT the unparseable-enctype variant of #1307. An `enctype="text/plain"`
451
+ // submission arrives with that content type, so `looksLikeFormSubmission`
452
+ // answers it above, before the body is read and before this point. That is
453
+ // deliberate (buffering an attacker-chosen body to classify it is exactly what
454
+ // the early return avoids), and it means the text/plain variant is visible
455
+ // only as a bare 405. The dev-time client guard covers it instead.
456
+ if (typeof id !== 'string' || !id) {
457
+ reportFormActionMissing(url, req, formData, onError, logger, !!ssrOpts.dev, route);
458
+ return methodNotAllowed();
459
+ }
460
+ // The field is framework wire, not app data. Removing it keeps an action
461
+ // that iterates the FormData (building a record, echoing values back into a
462
+ // 422 re-render) from seeing a key it did not put there.
463
+ formData.delete(FORM_ACTION_FIELD);
464
+
465
+ const found = await lookupActionIdentity(actionIndex, id);
466
+ if (!found.ok) {
467
+ // The action's module threw at import (a bad env var read at module scope,
468
+ // a syntax error in dev). That is a server fault, not skew: telling the
469
+ // user to resubmit would loop them forever against a module that cannot
470
+ // load, and swallowing the error would leave nothing in the log or the APM
471
+ // sink. Surface it the way any other action throw is surfaced.
472
+ if (found.reason === 'load-failed') {
473
+ if (typeof onError === 'function') onError(found.error);
474
+ return await formActionErrorResponse(found.error, ssrOpts.dev);
475
+ }
476
+ if (found.reason === 'skew') {
477
+ return ssrPage(route, params, url, {
478
+ ...ssrOpts, req,
479
+ // The submitted values ride along on the standard `actionData.values`
480
+ // key, so the page's ordinary repopulation idiom refills the form and
481
+ // "please submit again" is something a person can actually act on
482
+ // rather than an instruction to retype everything.
483
+ actionData: { success: false, error: ACTION_SKEW_MESSAGE, values: textValuesOf(formData) },
484
+ status: 422,
485
+ });
486
+ }
487
+ return new Response('Unknown action', { status: 404 });
488
+ }
489
+
490
+ const { fnName, module: mod } = found;
491
+ const fn = fnName === 'default' ? mod.default : mod[fnName];
492
+ // A reserved config export is never a callable action even though some are
493
+ // functions (#488), so a forged `__webjs_action` naming one is a 404 rather
494
+ // than a way to invoke `validate` directly.
495
+ if (typeof fn !== 'function' || RESERVED_CONFIG.has(fnName)) {
496
+ return new Response('Unknown action', { status: 404 });
497
+ }
498
+ // A GET-declared action cannot be a form target: it is CSRF-exempt and rides
499
+ // its args in the url, so binding one to a POST form is a contradiction. A
500
+ // `webjs check` rule catches it at edit time; this is the runtime backstop.
501
+ if (actionMethod(mod) === 'GET') {
502
+ return new Response(
503
+ `The action ${fnName} declares method = 'GET', so it cannot be bound to a form.`,
504
+ { status: 405, headers: { allow: 'GET' } },
505
+ );
506
+ }
507
+
508
+ // A form-bound action always receives the `FormData` (#1155). `validate` is
509
+ // the typing seam: it takes the FormData and its transform-return becomes the
510
+ // action's typed input, the same `runValidate` contract the RPC path uses.
511
+ let args = [formData];
512
+ const validate = actionConfigFn(mod, 'validate');
513
+ if (typeof validate === 'function') {
514
+ const v = runValidate(validate, args[0]);
515
+ if (!v.ok) {
516
+ if (v.thrown !== undefined) {
517
+ // A THROWN validator is answered here rather than rethrown. Letting it
518
+ // escape reaches the handler's last-resort catch, which reports the
519
+ // SAME error to `onError` a second time and answers a bare 500 with no
520
+ // digest, so the one thing that makes a prod error diagnosable is lost.
521
+ // The RPC path does exactly this (`actionErrorResponse` in actions.js).
522
+ if (typeof onError === 'function') onError(v.thrown);
523
+ return await formActionErrorResponse(v.thrown, ssrOpts.dev);
524
+ }
525
+ // A structured failure envelope is a NORMAL result the page renders, so
526
+ // it takes the 422 re-render path exactly as a failing action would.
527
+ return ssrPage(route, params, url, { ...ssrOpts, req, actionData: v.result, status: 422 });
528
+ }
529
+ args = [v.value];
530
+ }
531
+
532
+ const searchParams = Object.fromEntries(url.searchParams.entries());
533
+ /** @type {ActionResult | undefined} */
534
+ let result;
535
+ let ranAction = false;
536
+ try {
537
+ const middleware = actionMiddleware(mod);
538
+ result = await runWithActionSignal(actionReq.signal, () =>
539
+ runActionChain(
540
+ middleware,
541
+ {
542
+ request: actionReq,
543
+ args,
544
+ signal: actionReq.signal,
545
+ // Route context a form middleware may want, absent on the RPC path
546
+ // because an RPC call has no page. Params are awaitable AND
547
+ // sync-readable, matching a page render (#848).
548
+ params: makeThenable(params),
549
+ searchParams: makeThenable(searchParams),
550
+ url,
551
+ },
552
+ () => { ranAction = true; return fn(...args); },
553
+ ));
554
+ } catch (err) {
555
+ if (isRedirect(err)) {
556
+ const e = /** @type any */ (err);
557
+ // A thrown redirect from an action (a POST) defaults to 307 Temporary
558
+ // Redirect, which is method-preserving so the action's intent survives
559
+ // the bounce; an explicit `redirect(url, status)` overrides it. This is
560
+ // deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
561
+ // the SUCCESS-result path below.
562
+ return new Response(null, { status: e.status || 307, headers: { location: e.url } });
563
+ }
564
+ if (isNotFound(err)) {
565
+ return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
566
+ }
567
+ // forbidden()/unauthorized() from a form action render the same 403/401
568
+ // boundary as the page-render path (#848), not a generic 500.
569
+ if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url });
570
+ if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url });
571
+ if (typeof onError === 'function') onError(err);
572
+ return await formActionErrorResponse(err, ssrOpts.dev);
573
+ }
574
+
575
+ // A streamed return (#489) has no consumer here. The RPC stub decodes frames;
576
+ // a form submission is answered with a redirect or a page, and with JS off
577
+ // there is nothing on the other end at all. Refusing loudly beats emitting a
578
+ // frame stream a browser renders as garbage.
579
+ if (ranAction && isStreamable(result)) {
580
+ const err = new Error(
581
+ `The action ${fnName} returned a stream, which a form submission cannot consume. `
582
+ + 'Return an ActionResult from a form-bound action; stream from a programmatic call instead.',
583
+ );
584
+ if (typeof onError === 'function') onError(err);
585
+ return await formActionErrorResponse(err, ssrOpts.dev);
586
+ }
587
+
588
+ // Only a COMPLETED action evicts its `invalidates` tags; a middleware
589
+ // short-circuit (the action never ran) does not.
590
+ //
591
+ // The tags are also REPORTED, the way `invokeAction` reports them, so the
592
+ // browser-side tag coordinator can bypass a stale cached GET. One reach
593
+ // limit, stated because it looks like an oversight: `fetch` follows the 303
594
+ // transparently, so JS cannot read the headers of a redirect response. The
595
+ // header is on the wire and the 422 re-render path carries it to the router;
596
+ // after a successful PRG the client learns nothing from it. The redirect's
597
+ // own re-render is server-side and seeds fresh data, so what remains exposed
598
+ // is only a later client refetch of a separately cached GET key.
599
+ let invalidated = [];
600
+ if (ranAction) {
601
+ invalidated = resolveTags(actionConfigFn(mod, 'invalidates'), args);
602
+ if (invalidated.length) await revalidateTags(invalidated);
603
+ }
604
+ const invalidateHeader = invalidated.length
605
+ ? { 'x-webjs-invalidate': invalidated.join(',') }
606
+ : null;
607
+
608
+ // An action MAY return a `Response` directly (e.g. a content-negotiated
609
+ // `streamResponse`, #248). Honor it verbatim, so the action owns the status +
610
+ // content type and the router applies it (a stream body surgically). With JS
611
+ // off the same action returns a normal ActionResult instead, so the PRG /
612
+ // re-render paths below still drive the no-JS form.
613
+ if (result instanceof Response) return result;
614
+
615
+ if (!isFailureResult(result)) {
616
+ // SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
617
+ // honored when it is a same-site local path; otherwise fall back to the
618
+ // page's own path so a poisoned value cannot become an open redirect.
619
+ const ownPath = (url.pathname + url.search) || '/';
620
+ const safe = result ? sameSiteRedirect(result.redirect) : null;
621
+ return new Response(null, {
622
+ status: 303,
623
+ headers: { location: safe || ownPath, ...invalidateHeader },
624
+ });
625
+ }
626
+
627
+ // FAILURE: re-render the SAME page with the action result available on
628
+ // ctx.actionData, status 422. Repopulation is the page author's job (native
629
+ // `value=${actionData.values?.field}`).
630
+ const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
631
+ const page = await ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status });
632
+ // A failure result can still have mutated (a partial write, then a rejected
633
+ // second step), so the eviction above applies here too and this response, not
634
+ // being a redirect, is one the router can actually read the header off.
635
+ if (invalidateHeader && page instanceof Response) {
636
+ for (const [k, v] of Object.entries(invalidateHeader)) page.headers.set(k, v);
637
+ }
638
+ return page;
639
+ }
640
+
641
+ /**
642
+ * A thrown form action becomes a 500 whose body is sanitized the same way the
643
+ * RPC path's is (#749): dev shows the real message, prod shows a generic one
644
+ * plus a correlation digest and logs the full error. A raw driver message or an
645
+ * fs path is not author-controlled, so it does not reach the browser, and here
646
+ * the browser is showing it to a person rather than parsing it.
647
+ *
648
+ * @param {unknown} err
649
+ * @param {boolean} [dev]
650
+ * @returns {Promise<Response>}
651
+ */
652
+ async function formActionErrorResponse(err, dev) {
653
+ if (dev) {
654
+ console.error('[webjs] form action threw:', err);
655
+ const msg = err instanceof Error ? err.message : String(err);
656
+ return new Response(msg, { status: 500 });
657
+ }
658
+ if (isControlFlowThrow(err)) {
659
+ console.error('[webjs] form action threw:', err);
660
+ const msg = err instanceof Error ? err.message : String(err);
661
+ return new Response(msg, { status: 500 });
662
+ }
663
+ const digest = await errorDigest(err);
664
+ console.error(`[webjs] form action threw [digest=${digest}]:`, err);
665
+ return new Response(`${GENERIC_ERROR_MESSAGE} (digest ${digest})`, { status: 500 });
666
+ }