@webjsdev/server 0.8.57 → 0.8.59

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.
@@ -1,259 +0,0 @@
1
- import { isNotFound, isRedirect, isForbidden, isUnauthorized } from '@webjsdev/core';
2
- import { ssrPage, ssrNotFound, ssrForbidden, ssrUnauthorized, loadModule } 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
-
8
- /**
9
- * Page server actions: a `page.{js,ts}` may export an `action` function that
10
- * handles a non-GET/HEAD submission to the page's own URL. This is webjs's
11
- * Remix-style page-action path, adapted to the no-build progressive-enhancement
12
- * model: a `<form method="POST">` submits with JS disabled, the action runs on
13
- * the server, and a validation failure re-renders the SAME page with field
14
- * errors and the user's typed values preserved.
15
- *
16
- * Behavior (see #244):
17
- * - Action throws `redirect(url)` or `notFound()` => honored exactly as a page
18
- * render does (3xx / 404). A thrown `redirect()` may target an external URL
19
- * (it is the explicit nav sentinel, author-controlled).
20
- * - Action returns a SUCCESS result => 303 See Other to `result.redirect` if
21
- * present, else to the page's own path (Post/Redirect/Get, so a reload does
22
- * not resubmit). `result.redirect` MUST be a same-site local path (see
23
- * `sameSiteRedirect`), a non-local value is ignored to avoid an
24
- * open-redirect through a user-controlled action result.
25
- * - Action returns a FAILURE result => re-SSR the SAME page (status 422) with
26
- * the result on `ctx.actionData`, so the page template can read
27
- * `actionData.fieldErrors` / `actionData.values` and repopulate inputs.
28
- *
29
- * Failure detection is robust (it does not require a literal `success: false`):
30
- * see `isFailureResult`.
31
- *
32
- * @typedef {{
33
- * success?: boolean,
34
- * data?: unknown,
35
- * error?: string,
36
- * fieldErrors?: Record<string,string>,
37
- * values?: Record<string,string>,
38
- * status?: number,
39
- * redirect?: string,
40
- * }} ActionResult
41
- */
42
-
43
- /**
44
- * Whether an action result is a FAILURE (re-render the page) rather than a
45
- * success (PRG redirect). A result is a failure when ANY of these hold:
46
- * - `result.success === false` (explicit), OR
47
- * - `result.fieldErrors` is present (per-field validation messages), OR
48
- * - `result.error` is present AND `result.success !== true`.
49
- *
50
- * Success is the explicit `success: true`, or a bare value (or
51
- * undefined/null) carrying no error markers. This means an action that returns
52
- * `{ error, status }` or `{ fieldErrors }` WITHOUT a literal `success: false`
53
- * is still treated as a failure and its error is surfaced, not swallowed.
54
- *
55
- * @param {ActionResult | null | undefined} result
56
- * @returns {boolean}
57
- */
58
- function isFailureResult(result) {
59
- if (!result || typeof result !== 'object') return false;
60
- if (result.success === false) return true;
61
- if (result.fieldErrors != null) return true;
62
- if (result.error != null && result.success !== true) return true;
63
- return false;
64
- }
65
-
66
- /**
67
- * Restrict a page action's `result.redirect` to a SAME-SITE local target.
68
- * Allowed: a path beginning with a single `/` (e.g. `/login`, `/a?b=1#c`).
69
- * Rejected: a protocol-relative `//host/...` and any absolute `scheme://...`
70
- * URL. A user-controlled redirect target is an open-redirect vector, so a
71
- * non-local value is dropped and the caller falls back to the page's own path.
72
- *
73
- * A thrown `redirect(absoluteUrl)` (the nav sentinel) is intentionally NOT
74
- * routed through here: that is the author-controlled escape hatch for a
75
- * legitimate external redirect.
76
- *
77
- * @param {unknown} target
78
- * @returns {string | null} the safe local path, or null when not same-site
79
- */
80
- function sameSiteRedirect(target) {
81
- if (typeof target !== 'string') return null;
82
- // Must start with a single slash (a leading `//` is protocol-relative and
83
- // would navigate cross-origin).
84
- if (!target.startsWith('/') || target.startsWith('//')) return null;
85
- // A backslash after the leading slash (`/\evil.com`) is normalized by some
86
- // browsers into a protocol-relative URL, so reject it too.
87
- if (target.startsWith('/\\')) return null;
88
- return target;
89
- }
90
-
91
- /**
92
- * Load a page module and return its `action` export, or null if it has none.
93
- * Uses ssr.js's shared `loadModule`, so page-action loading is consistent with
94
- * the SSR re-render. In prod the URL is stable and Node's module cache serves
95
- * one evaluation; in dev a cache-bust forces a fresh evaluation.
96
- *
97
- * @param {string} file absolute path to the page module
98
- * @param {boolean} dev
99
- * @returns {Promise<{ action: Function, module: Record<string, unknown> } | null>}
100
- */
101
- export async function loadPageAction(file, dev) {
102
- try {
103
- const mod = await loadModule(file, dev);
104
- return typeof mod.action === 'function'
105
- ? { action: /** @type {Function} */ (mod.action), module: mod }
106
- : null;
107
- } catch {
108
- return null;
109
- }
110
- }
111
-
112
- /**
113
- * Read the submitted body ONCE, bounded by the form/multipart limit (issue
114
- * #237), and return both a `FormData` (handed to the action as `formData`) and a
115
- * rebuilt `Request` carrying the already-read bytes (handed to the action as
116
- * `request`, so it can still call `request.json()` / `request.formData()`). The
117
- * body is consumed off the ORIGINAL request directly, NOT via `req.clone()`: a
118
- * tee'd clone whose reader is cancelled mid-stream (the over-limit case)
119
- * deadlocks the untaken branch, hanging the response.
120
- *
121
- * An over-limit body is reported as `tooLarge` (the caller returns 413) and is
122
- * never buffered whole. A form posts more than a JSON RPC call (textarea, small
123
- * upload), so it uses the higher `multipart` cap. A non-form content type yields
124
- * an empty FormData so the action signature stays stable; the rebuilt request
125
- * still carries the raw bytes for the action to parse however it likes.
126
- *
127
- * @param {Request} req
128
- * @returns {Promise<{ tooLarge: boolean, formData: FormData, request: Request }>}
129
- */
130
- async function parseFormBody(req) {
131
- const ct = req.headers.get('content-type') || '';
132
- const limits = getBodyLimits();
133
- const limit = limits ? limits.multipart : DEFAULT_MAX_MULTIPART_BYTES;
134
- const { tooLarge, bytes } = await readBytesBounded(req, limit);
135
- if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
136
-
137
- // Rebuild a fresh Request from the bytes so the action can re-read the body.
138
- // SECURITY (#756): strip any inbound `x-webjs-remote-ip` the copy carried so a
139
- // client cannot spoof it through the rebuild, and carry the FRAMEWORK-trusted
140
- // remote IP forward out of band (the rebuild is a new object, so the listener's
141
- // WeakMap stamp on `req` does not follow it). Without this, `clientIp` inside a
142
- // page `action` (the no-JS form write path, e.g. login throttling) would read
143
- // the spoofable header on Bun.
144
- const headers = new Headers(req.headers);
145
- headers.delete('x-webjs-remote-ip');
146
- const rebuilt = new Request(req.url, {
147
- method: req.method,
148
- headers,
149
- body: bytes && bytes.byteLength ? bytes : undefined,
150
- });
151
- propagateTrustedRemoteIp(req, rebuilt);
152
-
153
- const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
154
- let formData = new FormData();
155
- if (isForm) {
156
- // Parse a SECOND fresh Request (the rebuilt one is reserved for the action).
157
- const forParse = new Request(req.url, {
158
- method: 'POST',
159
- headers: ct ? { 'content-type': ct } : undefined,
160
- body: bytes && bytes.byteLength ? bytes : undefined,
161
- });
162
- formData = await forParse.formData();
163
- }
164
- return { tooLarge: false, formData, request: rebuilt };
165
- }
166
-
167
- /**
168
- * Run a page `action` for a non-GET/HEAD request and produce the HTTP response.
169
- * The caller has already confirmed the path matches a page route AND that the
170
- * page module exports an `action` (via `loadPageAction`). The action runs inside
171
- * the same segment middleware as the page (the caller wraps this).
172
- *
173
- * The failure re-render reuses the SAME page module instance whose `action` just
174
- * ran (passed through as `pageModule`), so the page module is loaded once per
175
- * POST rather than evaluated a second time.
176
- *
177
- * @param {import('./router.js').PageRoute} route
178
- * @param {Record<string,string>} params
179
- * @param {URL} url
180
- * @param {{ action: Function, module: Record<string, unknown> }} loaded the page module's `action` plus the loaded module
181
- * @param {Request} req
182
- * @param {object} ssrOpts the same opts object `ssrPage` receives in dev.js
183
- * @returns {Promise<Response>}
184
- */
185
- export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
186
- const { action, module: pageModule } = loaded;
187
- const searchParams = Object.fromEntries(url.searchParams.entries());
188
- let formData = new FormData();
189
- // The body is read ONCE here (bounded). `actionReq` is a rebuilt request the
190
- // action can re-read; on a parse failure it falls back to the original `req`.
191
- let actionReq = req;
192
- try {
193
- const parsed = await parseFormBody(req);
194
- // Over the form/multipart limit (issue #237): 413 before the action runs.
195
- if (parsed.tooLarge) return payloadTooLarge();
196
- formData = parsed.formData;
197
- actionReq = parsed.request;
198
- } catch {
199
- formData = new FormData();
200
- }
201
-
202
- /** @type {ActionResult | undefined} */
203
- let result;
204
- try {
205
- // params / searchParams are awaitable AND sync-readable here too (#848).
206
- result = await action({
207
- request: actionReq,
208
- params: makeThenable(params),
209
- searchParams: makeThenable(searchParams),
210
- url,
211
- formData,
212
- });
213
- } catch (err) {
214
- if (isRedirect(err)) {
215
- const e = /** @type any */ (err);
216
- // A thrown redirect from an action (a POST) defaults to 307 Temporary
217
- // Redirect, which is method-preserving so the action's intent survives
218
- // the bounce; an explicit `redirect(url, status)` overrides it. This is
219
- // deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
220
- // the SUCCESS-result path below.
221
- return new Response(null, { status: e.status || 307, headers: { location: e.url } });
222
- }
223
- if (isNotFound(err)) {
224
- return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
225
- }
226
- // forbidden()/unauthorized() from a page action render the same 403/401
227
- // boundary as the page-render path (#848), not a generic 500.
228
- if (isForbidden(err)) {
229
- return ssrForbidden(route, { ...ssrOpts, req, url });
230
- }
231
- if (isUnauthorized(err)) {
232
- return ssrUnauthorized(route, { ...ssrOpts, req, url });
233
- }
234
- throw err;
235
- }
236
-
237
- // A page action MAY return a `Response` directly (e.g. a content-negotiated
238
- // `streamResponse`, #248). Honor it verbatim, so the action owns the status +
239
- // content type and the router applies it (a stream body surgically). With JS
240
- // off the same action returns a normal ActionResult instead, so the PRG /
241
- // re-render paths below still drive the no-JS form.
242
- if (result instanceof Response) return result;
243
-
244
- if (!isFailureResult(result)) {
245
- // SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
246
- // honored when it is a same-site local path; otherwise fall back to the
247
- // page's own path so a poisoned value cannot become an open redirect.
248
- const ownPath = (url.pathname + url.search) || '/';
249
- const safe = result ? sameSiteRedirect(result.redirect) : null;
250
- return new Response(null, { status: 303, headers: { location: safe || ownPath } });
251
- }
252
-
253
- // FAILURE: re-render the SAME page with the action result available on
254
- // ctx.actionData, status 422. Repopulation is the page author's job (native
255
- // `value=${actionData.values?.field}`). Pass the already-loaded page module so
256
- // the re-render shares this POST's single evaluation.
257
- const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
258
- return ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status, pageModule });
259
- }