@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.
@@ -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 webjs app's package.json. Each key maps to a documented reader (a server reader, or for `dev`/`start` the CLI's readAppTasks). 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 (readElideEnabled in dev.js, readSeedEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js, and the CLI's readAppTasks in packages/cli/lib/app-tasks.js for dev/start, #550). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
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); `regenerate` is read by the server (readRegenerateRules in dev-regenerate.js). 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.",
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
  }
@@ -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
- }