@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.
- package/index.d.ts +26 -2
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +364 -64
- package/src/actions.js +14 -4
- package/src/asset-hash.js +66 -4
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/conditional-get.js +32 -9
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +111 -29
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +500 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +172 -33
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
package/src/page-action.js
DELETED
|
@@ -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
|
-
}
|