@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.
- package/index.d.ts +125 -12
- package/index.js +2 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +438 -70
- package/src/actions.js +10 -2
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/component-elision.js +75 -16
- package/src/component-scanner.js +82 -26
- 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 +151 -33
- package/src/elision-differential.js +101 -0
- package/src/elision-report.js +133 -30
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +666 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/js-scan.js +41 -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 +93 -17
- 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/webjs-config.schema.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "https://webjs.dev/schemas/webjs-config.schema.json",
|
|
4
4
|
"title": "webjs config block",
|
|
5
|
-
"description": "Schema for the `webjs` object in a
|
|
6
|
-
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the reader functions
|
|
5
|
+
"description": "Schema for the `webjs` object in a WebJs app's package.json. Each key maps to a documented reader, mostly a server reader and for a few keys a CLI one. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
|
|
6
|
+
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the reader functions. Adding a `webjs.*` key means updating all three (plus the KNOWN_KEYS drift test). The reader inventory is deliberately NOT repeated here: packages/server/AGENTS.md holds the one canonical list and the procedure, because every duplicate of that list drifted.",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
9
9
|
"properties": {
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"default": 5000
|
|
161
161
|
},
|
|
162
162
|
"dev": {
|
|
163
|
-
"description": "Dev task orchestration (#550). `before`/`parallel` are read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js)
|
|
163
|
+
"description": "Dev task orchestration (#550). Split per sub-key: `before`/`parallel` are read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js), while `regenerate` (readRegenerateRules in dev-regenerate.js) and `watch` (readDevWatchPathsFromApp in dev.js) are read by the server. webjs dev runs `before` (one-shot, e.g. webjs db migrate) to completion, then `parallel` (long-lived watchers) alongside the server. Replaces the old predev hook + concurrently watchers.",
|
|
164
164
|
"type": "object",
|
|
165
165
|
"additionalProperties": false,
|
|
166
166
|
"properties": {
|
|
@@ -216,6 +216,22 @@
|
|
|
216
216
|
"items": { "type": "string" }
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
|
+
},
|
|
220
|
+
"doctor": {
|
|
221
|
+
"description": "`webjs doctor` policy (#1257). Read by the CLI (readDoctorPolicy in packages/cli/lib/doctor.js), NOT the server.",
|
|
222
|
+
"type": "object",
|
|
223
|
+
"additionalProperties": false,
|
|
224
|
+
"properties": {
|
|
225
|
+
"gate": {
|
|
226
|
+
"description": "Per-check severity, keyed by the stable DOCTOR_CODES code (NODE_VERSION, UNMARKED_ASSET_LINKS, ELISION_CARRIERS, ...). `error` fails the exit, `warn` reports without failing, `off` silences the check: its finding is not printed and it cannot fail the exit, including under --strict (it still appears on the checklist as [off], in the summary's silenced count, and in full under --json). A code with no entry keeps its default: error for a hard toolchain failure, warn otherwise. This is how CI gates on a chosen subset without --strict making every warning fatal. A malformed gate is a hard error, so a typo cannot silently un-gate CI: that covers an unknown code, a bad severity, a wrong SHAPE (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`. A result that could not check (a network or toolchain outage) is capped at warn and can never be escalated to error.",
|
|
227
|
+
"type": "object",
|
|
228
|
+
"propertyNames": { "pattern": "^[A-Z][A-Z0-9_]*$" },
|
|
229
|
+
"additionalProperties": {
|
|
230
|
+
"type": "string",
|
|
231
|
+
"enum": ["off", "warn", "error"]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
219
235
|
}
|
|
220
236
|
}
|
|
221
237
|
}
|
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
|
-
}
|