@webjsdev/server 0.8.8 → 0.8.10

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/src/headers.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Secure-by-default response headers plus a small declarative per-path
3
+ * header config (issue #232).
4
+ *
5
+ * webjs sets a baseline of standard security headers on every document
6
+ * and asset response, so a scaffolded app is not clickjackable or
7
+ * MIME-sniffable out of the box (no reverse proxy required for the
8
+ * baseline). The defaults are LITERAL HTTP headers, no abstraction.
9
+ *
10
+ * An app can ADD, OVERRIDE, or DISABLE any header per path via a
11
+ * declarative config (`package.json` -> `webjs.headers`), shaped like
12
+ * Next's: an array of `{ source, headers: [{ key, value }] }` where
13
+ * `source` is a path pattern matched with the native URLPattern API.
14
+ *
15
+ * Precedence, lowest to highest:
16
+ * 1. secure defaults
17
+ * 2. path config (webjs.headers) overrides/adds/removes a default
18
+ * 3. app middleware (already on the Response when we merge)
19
+ *
20
+ * In other words middleware wins over the path config, which wins over
21
+ * the defaults. We only ever ADD a header the response does not already
22
+ * carry, so a header an app set (in middleware, a route handler, or via
23
+ * `expose`) is never clobbered. A path-config entry with a null/empty
24
+ * value REMOVES a header (e.g. drop a default on a public embed route).
25
+ *
26
+ * This is the connective tissue #233 (CSP) and #234 (CORS) plug into
27
+ * later: the merge seam (applySecurityHeaders) is the single place a
28
+ * future per-path policy layers onto a Response, so neither needs to
29
+ * touch the response pipeline again.
30
+ */
31
+
32
+ /**
33
+ * Baseline security headers, as literal name -> value pairs. Set only
34
+ * when absent, so an app override always wins. HSTS is NOT here: it is
35
+ * conditional (production + HTTPS) and added separately.
36
+ *
37
+ * @type {ReadonlyArray<[string, string]>}
38
+ */
39
+ const SECURE_DEFAULTS = [
40
+ ['X-Content-Type-Options', 'nosniff'],
41
+ ['X-Frame-Options', 'SAMEORIGIN'],
42
+ ['Referrer-Policy', 'strict-origin-when-cross-origin'],
43
+ ['Permissions-Policy', 'camera=(), microphone=(), geolocation=()'],
44
+ ];
45
+
46
+ /** The standard HSTS posture: two years, include subdomains. */
47
+ const HSTS_VALUE = 'max-age=63072000; includeSubDomains';
48
+
49
+ /**
50
+ * Read the per-path header config from the app's package.json
51
+ * (`webjs.headers`). Returns a normalized array of compiled rules, each
52
+ * pairing a URLPattern against the configured header directives. A
53
+ * malformed or absent config yields an empty array (no per-path rules),
54
+ * never a throw: a broken config must not take the app down.
55
+ *
56
+ * Shape consumed:
57
+ * "webjs": { "headers": [ { "source": "/embed/:path*",
58
+ * "headers": [ { "key": "X-Frame-Options", "value": null } ] } ] }
59
+ *
60
+ * A `value` of null, undefined, or false REMOVES the header on a match
61
+ * (the disable-a-default escape hatch). Any other value is stringified
62
+ * and SET (override or add).
63
+ *
64
+ * @param {unknown} pkg parsed package.json (or any object)
65
+ * @returns {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>}
66
+ */
67
+ export function compileHeaderRules(pkg) {
68
+ const raw =
69
+ pkg &&
70
+ typeof pkg === 'object' &&
71
+ /** @type {any} */ (pkg).webjs &&
72
+ /** @type {any} */ (pkg).webjs.headers;
73
+ if (!Array.isArray(raw)) return [];
74
+ /** @type {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>} */
75
+ const rules = [];
76
+ for (const entry of raw) {
77
+ if (!entry || typeof entry !== 'object') continue;
78
+ const source = /** @type {any} */ (entry).source;
79
+ const list = /** @type {any} */ (entry).headers;
80
+ if (typeof source !== 'string' || !Array.isArray(list)) continue;
81
+ let pattern;
82
+ try {
83
+ // Match on the pathname only. A bare path string is the common
84
+ // Next-style usage; URLPattern treats it as the pathname component.
85
+ pattern = new URLPattern({ pathname: source });
86
+ } catch {
87
+ continue; // skip an invalid pattern rather than crash the request
88
+ }
89
+ /** @type {Array<{ key: string, value: string | null }>} */
90
+ const directives = [];
91
+ for (const d of list) {
92
+ if (!d || typeof d !== 'object') continue;
93
+ const key = /** @type {any} */ (d).key;
94
+ if (typeof key !== 'string' || !key) continue;
95
+ const v = /** @type {any} */ (d).value;
96
+ // null / undefined / false means REMOVE the header on a match.
97
+ const value = v === null || v === undefined || v === false ? null : String(v);
98
+ // Validate the key/value against the real Headers parser at COMPILE
99
+ // time (consistent with dropping a bad `source`). A name or value
100
+ // that Headers rejects (an invalid header name, or a value carrying
101
+ // CR/LF) would otherwise make `applySecurityHeaders` THROW on every
102
+ // matching request, a self-inflicted 500, which breaks this file's
103
+ // "a broken config must not take the app down" guarantee. Probe on a
104
+ // throwaway Headers and DROP the directive if it throws. For a delete
105
+ // (value null) only the key needs to be a valid header name, so probe
106
+ // it with a placeholder value.
107
+ try {
108
+ new Headers().set(key, value === null ? 'x' : value);
109
+ } catch {
110
+ // eslint-disable-next-line no-console
111
+ console.warn(`[webjs] dropping invalid webjs.headers directive for key "${key}"`);
112
+ continue;
113
+ }
114
+ directives.push({ key, value });
115
+ }
116
+ if (directives.length) rules.push({ pattern, directives });
117
+ }
118
+ return rules;
119
+ }
120
+
121
+ /**
122
+ * Apply the security defaults and the per-path config to a Response,
123
+ * returning a Response carrying the merged headers. The input Response
124
+ * is not mutated; a new Headers is derived from it so the body/status
125
+ * are preserved by reference (no body copy).
126
+ *
127
+ * Headers already on the response (set by app middleware, a route
128
+ * handler, or `expose`) are treated as authoritative and never
129
+ * overwritten by a default. The per-path config runs AFTER the defaults
130
+ * but BEFORE that "already present" rule is consulted for middleware:
131
+ * the config can override a default freely (it is the app's own
132
+ * declarative intent), while middleware still wins because its headers
133
+ * are on the response before we are even called.
134
+ *
135
+ * @param {Response} res the response produced by the app pipeline
136
+ * @param {{
137
+ * pathname: string,
138
+ * https: boolean,
139
+ * prod: boolean,
140
+ * rules?: Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>,
141
+ * }} ctx
142
+ * @returns {Response}
143
+ */
144
+ export function applySecurityHeaders(res, ctx) {
145
+ const headers = new Headers(res.headers);
146
+ // Snapshot the keys the app already set, so a default never clobbers
147
+ // them. Captured BEFORE we add anything. Lowercased for compare;
148
+ // Headers itself is case-insensitive.
149
+ const appSet = new Set();
150
+ headers.forEach((_v, k) => appSet.add(k.toLowerCase()));
151
+
152
+ // 1. Secure defaults: set only if the app did not already set them.
153
+ for (const [k, v] of SECURE_DEFAULTS) {
154
+ if (!appSet.has(k.toLowerCase())) headers.set(k, v);
155
+ }
156
+ // HSTS only in production AND only over HTTPS (never on a plain-HTTP
157
+ // hop). Same "do not clobber" rule.
158
+ if (ctx.prod && ctx.https && !appSet.has('strict-transport-security')) {
159
+ headers.set('Strict-Transport-Security', HSTS_VALUE);
160
+ }
161
+
162
+ // 2. Per-path config: override / add / remove. Runs over the merged
163
+ // set so it can replace a default this same call just added. It does
164
+ // NOT override a header the app set in middleware (appSet), preserving
165
+ // the middleware-wins precedence.
166
+ const rules = ctx.rules || [];
167
+ for (const rule of rules) {
168
+ if (!rule.pattern.test({ pathname: ctx.pathname })) continue;
169
+ for (const { key, value } of rule.directives) {
170
+ if (appSet.has(key.toLowerCase())) continue; // middleware wins
171
+ // Belt and suspenders: compileHeaderRules already validated the
172
+ // key/value, so this never throws in practice, but a surprise from a
173
+ // directive constructed elsewhere must never throw the response
174
+ // pipeline. Skip the bad directive instead.
175
+ try {
176
+ if (value === null) headers.delete(key);
177
+ else headers.set(key, value);
178
+ } catch {
179
+ /* skip a directive the Headers parser rejects */
180
+ }
181
+ }
182
+ }
183
+
184
+ return new Response(res.body, {
185
+ status: res.status,
186
+ statusText: res.statusText,
187
+ headers,
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Detect whether the original client request was HTTPS, from a web
193
+ * `Request` (the shape `handle()` works with). Honors the same
194
+ * reverse-proxy trust posture as `forwarded.js`: read
195
+ * `X-Forwarded-Proto` only when proxy trust is on
196
+ * (`WEBJS_NO_TRUST_PROXY !== '1'`); otherwise fall back to the request
197
+ * URL's scheme. Never trusts the header blindly.
198
+ *
199
+ * @param {Request} req
200
+ * @returns {boolean}
201
+ */
202
+ export function webRequestIsHttps(req) {
203
+ const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1';
204
+ if (trust) {
205
+ const proto = req.headers.get('x-forwarded-proto');
206
+ if (proto) return proto.split(',')[0].trim().toLowerCase() === 'https';
207
+ }
208
+ try {
209
+ return new URL(req.url).protocol === 'https:';
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
package/src/json.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { stringify as wjStringify, parse as wjParse } from '@webjsdev/core';
2
- import { getRequest } from './context.js';
2
+ import { getRequest, getBodyLimits } from './context.js';
3
3
  import { RPC_CONTENT_TYPE } from './actions.js';
4
+ import { readTextBounded, BodyLimitError, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
4
5
 
5
6
  /**
6
7
  * Content-negotiated JSON helper for API routes (`route.js` handlers).
@@ -51,11 +52,19 @@ export async function json(data, init = {}) {
51
52
  * that accept rich bodies from the `richFetch` helper but plain JSON
52
53
  * from everyone else.
53
54
  *
55
+ * Enforces the request body-size limit (issue #237): an over-limit body throws
56
+ * a `BodyLimitError`, which the API dispatcher (`handleApi`) maps to a 413, so a
57
+ * `route.{js,ts}` handler doing `await readBody(req)` is protected with no extra
58
+ * code. The over-limit body is never buffered whole (see `readTextBounded`).
59
+ *
54
60
  * @param {Request} req
55
61
  */
56
62
  export async function readBody(req) {
57
63
  const ct = req.headers.get('content-type') || '';
58
- const text = await req.text();
64
+ const limits = getBodyLimits();
65
+ const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
66
+ const { tooLarge, text } = await readTextBounded(req, limit);
67
+ if (tooLarge) throw new BodyLimitError();
59
68
  if (!text) return null;
60
69
  if (ct.includes(RPC_CONTENT_TYPE)) return wjParse(text);
61
70
  return JSON.parse(text);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Node-version preflight guard (issue #238).
3
+ *
4
+ * webjs depends on Node 24+ built-ins: `module.stripTypeScriptTypes` (the
5
+ * no-build TypeScript strip) and recursive `fs.watch` (dev live-reload). On an
6
+ * older Node the failure surfaces late and cryptically (a strip error or a
7
+ * missing API deep inside a request), not as a clear "you need Node 24+". This
8
+ * module is the single early preflight that fails fast with an actionable
9
+ * message naming the exact version found and the version required.
10
+ *
11
+ * The check is a PURE function (`checkNodeVersion`) so it unit-tests with
12
+ * injected version strings, no spawning an old Node. `assertNodeVersion` is the
13
+ * thin side-effecting wrapper the CLI and the server entry both call: it prints
14
+ * to stderr + exits non-zero (CLI), or throws a clear Error (embedded server),
15
+ * depending on the `onFail` mode.
16
+ *
17
+ * The minimum is sourced from ONE place, this package's own `engines.node`
18
+ * field, so it never drifts from what npm enforces on install.
19
+ */
20
+ import { createRequire } from 'node:module';
21
+
22
+ /**
23
+ * Parse the leading major-version integer out of a Node version string.
24
+ * Handles `'24.1.0'`, `'v24.1.0'`, and prerelease tags like
25
+ * `'24.0.0-nightly20240101'`. Returns `NaN` when no leading integer is found.
26
+ * @param {string} version
27
+ * @returns {number}
28
+ */
29
+ export function parseMajor(version) {
30
+ const m = String(version).trim().match(/^v?(\d+)/);
31
+ return m ? Number(m[1]) : NaN;
32
+ }
33
+
34
+ /**
35
+ * Parse the minimum major version out of an `engines.node` range like
36
+ * `'>=24.0.0'`, `'>= 24'`, or `'24.x'`. Returns `NaN` when no integer is found.
37
+ * @param {string} engines
38
+ * @returns {number}
39
+ */
40
+ export function parseRequiredMajor(engines) {
41
+ const m = String(engines).match(/(\d+)/);
42
+ return m ? Number(m[1]) : NaN;
43
+ }
44
+
45
+ /**
46
+ * Pure version check. Compares the running Node major against the required
47
+ * minimum and returns a structured result (no side effects).
48
+ * @param {string} current the running Node version (e.g. `process.versions.node`)
49
+ * @param {number} requiredMajor the minimum acceptable major version
50
+ * @returns {{ ok: boolean, current: string, currentMajor: number, requiredMajor: number, message: string }}
51
+ */
52
+ export function checkNodeVersion(current, requiredMajor) {
53
+ const currentMajor = parseMajor(current);
54
+ // If we cannot parse the running version, fail open: do not block a runtime
55
+ // that reports an unusual version string. The deep APIs guard themselves.
56
+ const ok = Number.isNaN(currentMajor) || currentMajor >= requiredMajor;
57
+ const message = ok
58
+ ? ''
59
+ : `webjs requires Node ${requiredMajor}+ but found Node ${current}. ` +
60
+ `webjs is buildless and relies on Node ${requiredMajor}'s built-in ` +
61
+ `TypeScript strip (module.stripTypeScriptTypes) and recursive fs.watch, ` +
62
+ `neither of which exists on Node ${currentMajor}. ` +
63
+ `Upgrade to Node ${requiredMajor} or newer (see https://nodejs.org).`;
64
+ return { ok, current, currentMajor, requiredMajor, message };
65
+ }
66
+
67
+ /**
68
+ * Read the minimum required Node major from this package's own
69
+ * `engines.node` field, so the minimum lives in exactly one place. Falls back
70
+ * to 24 if the field is missing or unparseable (defensive only).
71
+ * @returns {number}
72
+ */
73
+ export function requiredNodeMajor() {
74
+ try {
75
+ const require = createRequire(import.meta.url);
76
+ const pkg = require('../package.json');
77
+ const major = parseRequiredMajor(pkg?.engines?.node || '');
78
+ if (!Number.isNaN(major)) return major;
79
+ } catch {}
80
+ return 24;
81
+ }
82
+
83
+ /**
84
+ * Side-effecting preflight: assert the running Node satisfies the minimum.
85
+ * On an unsupported Node, either exits the process non-zero (CLI, `onFail:
86
+ * 'exit'`) or throws a clear Error (embedded server, `onFail: 'throw'`).
87
+ * A no-op on a supported Node.
88
+ * @param {{ current?: string, requiredMajor?: number, onFail?: 'exit'|'throw' }} [opts]
89
+ * @returns {void}
90
+ */
91
+ export function assertNodeVersion(opts = {}) {
92
+ const current = opts.current ?? process.versions.node;
93
+ const requiredMajor = opts.requiredMajor ?? requiredNodeMajor();
94
+ const onFail = opts.onFail ?? 'throw';
95
+ const result = checkNodeVersion(current, requiredMajor);
96
+ if (result.ok) return;
97
+ if (onFail === 'exit') {
98
+ console.error(result.message);
99
+ process.exit(1);
100
+ }
101
+ throw new Error(result.message);
102
+ }
@@ -0,0 +1,225 @@
1
+ import { isNotFound, isRedirect } from '@webjsdev/core';
2
+ import { ssrPage, ssrNotFound, loadModule } from './ssr.js';
3
+ import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
4
+ import { getBodyLimits } from './context.js';
5
+
6
+ /**
7
+ * Page server actions: a `page.{js,ts}` may export an `action` function that
8
+ * handles a non-GET/HEAD submission to the page's own URL. This is webjs's
9
+ * Remix-style page-action path, adapted to the no-build progressive-enhancement
10
+ * model: a `<form method="POST">` submits with JS disabled, the action runs on
11
+ * the server, and a validation failure re-renders the SAME page with field
12
+ * errors and the user's typed values preserved.
13
+ *
14
+ * Behavior (see #244):
15
+ * - Action throws `redirect(url)` or `notFound()` => honored exactly as a page
16
+ * render does (3xx / 404). A thrown `redirect()` may target an external URL
17
+ * (it is the explicit nav sentinel, author-controlled).
18
+ * - Action returns a SUCCESS result => 303 See Other to `result.redirect` if
19
+ * present, else to the page's own path (Post/Redirect/Get, so a reload does
20
+ * not resubmit). `result.redirect` MUST be a same-site local path (see
21
+ * `sameSiteRedirect`), a non-local value is ignored to avoid an
22
+ * open-redirect through a user-controlled action result.
23
+ * - Action returns a FAILURE result => re-SSR the SAME page (status 422) with
24
+ * the result on `ctx.actionData`, so the page template can read
25
+ * `actionData.fieldErrors` / `actionData.values` and repopulate inputs.
26
+ *
27
+ * Failure detection is robust (it does not require a literal `success: false`):
28
+ * see `isFailureResult`.
29
+ *
30
+ * @typedef {{
31
+ * success?: boolean,
32
+ * data?: unknown,
33
+ * error?: string,
34
+ * fieldErrors?: Record<string,string>,
35
+ * values?: Record<string,string>,
36
+ * status?: number,
37
+ * redirect?: string,
38
+ * }} ActionResult
39
+ */
40
+
41
+ /**
42
+ * Whether an action result is a FAILURE (re-render the page) rather than a
43
+ * success (PRG redirect). A result is a failure when ANY of these hold:
44
+ * - `result.success === false` (explicit), OR
45
+ * - `result.fieldErrors` is present (per-field validation messages), OR
46
+ * - `result.error` is present AND `result.success !== true`.
47
+ *
48
+ * Success is the explicit `success: true`, or a bare value (or
49
+ * undefined/null) carrying no error markers. This means an action that returns
50
+ * `{ error, status }` or `{ fieldErrors }` WITHOUT a literal `success: false`
51
+ * is still treated as a failure and its error is surfaced, not swallowed.
52
+ *
53
+ * @param {ActionResult | null | undefined} result
54
+ * @returns {boolean}
55
+ */
56
+ function isFailureResult(result) {
57
+ if (!result || typeof result !== 'object') return false;
58
+ if (result.success === false) return true;
59
+ if (result.fieldErrors != null) return true;
60
+ if (result.error != null && result.success !== true) return true;
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Restrict a page action's `result.redirect` to a SAME-SITE local target.
66
+ * Allowed: a path beginning with a single `/` (e.g. `/login`, `/a?b=1#c`).
67
+ * Rejected: a protocol-relative `//host/...` and any absolute `scheme://...`
68
+ * URL. A user-controlled redirect target is an open-redirect vector, so a
69
+ * non-local value is dropped and the caller falls back to the page's own path.
70
+ *
71
+ * A thrown `redirect(absoluteUrl)` (the nav sentinel) is intentionally NOT
72
+ * routed through here: that is the author-controlled escape hatch for a
73
+ * legitimate external redirect.
74
+ *
75
+ * @param {unknown} target
76
+ * @returns {string | null} the safe local path, or null when not same-site
77
+ */
78
+ function sameSiteRedirect(target) {
79
+ if (typeof target !== 'string') return null;
80
+ // Must start with a single slash (a leading `//` is protocol-relative and
81
+ // would navigate cross-origin).
82
+ if (!target.startsWith('/') || target.startsWith('//')) return null;
83
+ // A backslash after the leading slash (`/\evil.com`) is normalized by some
84
+ // browsers into a protocol-relative URL, so reject it too.
85
+ if (target.startsWith('/\\')) return null;
86
+ return target;
87
+ }
88
+
89
+ /**
90
+ * Load a page module and return its `action` export, or null if it has none.
91
+ * Uses ssr.js's shared `loadModule`, so page-action loading is consistent with
92
+ * the SSR re-render. In prod the URL is stable and Node's module cache serves
93
+ * one evaluation; in dev a cache-bust forces a fresh evaluation.
94
+ *
95
+ * @param {string} file absolute path to the page module
96
+ * @param {boolean} dev
97
+ * @returns {Promise<{ action: Function, module: Record<string, unknown> } | null>}
98
+ */
99
+ export async function loadPageAction(file, dev) {
100
+ try {
101
+ const mod = await loadModule(file, dev);
102
+ return typeof mod.action === 'function'
103
+ ? { action: /** @type {Function} */ (mod.action), module: mod }
104
+ : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Read the submitted body ONCE, bounded by the form/multipart limit (issue
112
+ * #237), and return both a `FormData` (handed to the action as `formData`) and a
113
+ * rebuilt `Request` carrying the already-read bytes (handed to the action as
114
+ * `request`, so it can still call `request.json()` / `request.formData()`). The
115
+ * body is consumed off the ORIGINAL request directly, NOT via `req.clone()`: a
116
+ * tee'd clone whose reader is cancelled mid-stream (the over-limit case)
117
+ * deadlocks the untaken branch, hanging the response.
118
+ *
119
+ * An over-limit body is reported as `tooLarge` (the caller returns 413) and is
120
+ * never buffered whole. A form posts more than a JSON RPC call (textarea, small
121
+ * upload), so it uses the higher `multipart` cap. A non-form content type yields
122
+ * an empty FormData so the action signature stays stable; the rebuilt request
123
+ * still carries the raw bytes for the action to parse however it likes.
124
+ *
125
+ * @param {Request} req
126
+ * @returns {Promise<{ tooLarge: boolean, formData: FormData, request: Request }>}
127
+ */
128
+ async function parseFormBody(req) {
129
+ const ct = req.headers.get('content-type') || '';
130
+ const limits = getBodyLimits();
131
+ const limit = limits ? limits.multipart : DEFAULT_MAX_MULTIPART_BYTES;
132
+ const { tooLarge, bytes } = await readBytesBounded(req, limit);
133
+ if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
134
+
135
+ // Rebuild a fresh Request from the bytes so the action can re-read the body.
136
+ const headers = new Headers(req.headers);
137
+ const rebuilt = new Request(req.url, {
138
+ method: req.method,
139
+ headers,
140
+ body: bytes && bytes.byteLength ? bytes : undefined,
141
+ });
142
+
143
+ const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
144
+ let formData = new FormData();
145
+ if (isForm) {
146
+ // Parse a SECOND fresh Request (the rebuilt one is reserved for the action).
147
+ const forParse = new Request(req.url, {
148
+ method: 'POST',
149
+ headers: ct ? { 'content-type': ct } : undefined,
150
+ body: bytes && bytes.byteLength ? bytes : undefined,
151
+ });
152
+ formData = await forParse.formData();
153
+ }
154
+ return { tooLarge: false, formData, request: rebuilt };
155
+ }
156
+
157
+ /**
158
+ * Run a page `action` for a non-GET/HEAD request and produce the HTTP response.
159
+ * The caller has already confirmed the path matches a page route AND that the
160
+ * page module exports an `action` (via `loadPageAction`). The action runs inside
161
+ * the same segment middleware as the page (the caller wraps this).
162
+ *
163
+ * The failure re-render reuses the SAME page module instance whose `action` just
164
+ * ran (passed through as `pageModule`), so the page module is loaded once per
165
+ * POST rather than evaluated a second time.
166
+ *
167
+ * @param {import('./router.js').PageRoute} route
168
+ * @param {Record<string,string>} params
169
+ * @param {URL} url
170
+ * @param {{ action: Function, module: Record<string, unknown> }} loaded the page module's `action` plus the loaded module
171
+ * @param {Request} req
172
+ * @param {object} ssrOpts the same opts object `ssrPage` receives in dev.js
173
+ * @returns {Promise<Response>}
174
+ */
175
+ export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
176
+ const { action, module: pageModule } = loaded;
177
+ const searchParams = Object.fromEntries(url.searchParams.entries());
178
+ let formData = new FormData();
179
+ // The body is read ONCE here (bounded). `actionReq` is a rebuilt request the
180
+ // action can re-read; on a parse failure it falls back to the original `req`.
181
+ let actionReq = req;
182
+ try {
183
+ const parsed = await parseFormBody(req);
184
+ // Over the form/multipart limit (issue #237): 413 before the action runs.
185
+ if (parsed.tooLarge) return payloadTooLarge();
186
+ formData = parsed.formData;
187
+ actionReq = parsed.request;
188
+ } catch {
189
+ formData = new FormData();
190
+ }
191
+
192
+ /** @type {ActionResult | undefined} */
193
+ let result;
194
+ try {
195
+ result = await action({ request: actionReq, params, searchParams, url, formData });
196
+ } catch (err) {
197
+ if (isRedirect(err)) {
198
+ const e = /** @type any */ (err);
199
+ // A thrown redirect from an action is honored as the page render does.
200
+ // Use the action's chosen status (307/308) so an explicit redirect()
201
+ // keeps its semantics; PRG (303) is the SUCCESS-result path below.
202
+ return new Response(null, { status: e.status || 307, headers: { location: e.url } });
203
+ }
204
+ if (isNotFound(err)) {
205
+ return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
206
+ }
207
+ throw err;
208
+ }
209
+
210
+ if (!isFailureResult(result)) {
211
+ // SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
212
+ // honored when it is a same-site local path; otherwise fall back to the
213
+ // page's own path so a poisoned value cannot become an open redirect.
214
+ const ownPath = (url.pathname + url.search) || '/';
215
+ const safe = result ? sameSiteRedirect(result.redirect) : null;
216
+ return new Response(null, { status: 303, headers: { location: safe || ownPath } });
217
+ }
218
+
219
+ // FAILURE: re-render the SAME page with the action result available on
220
+ // ctx.actionData, status 422. Repopulation is the page author's job (native
221
+ // `value=${actionData.values?.field}`). Pass the already-loaded page module so
222
+ // the re-render shares this POST's single evaluation.
223
+ const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
224
+ return ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status, pageModule });
225
+ }