@webjsdev/server 0.8.9 → 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/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { startServer, createRequestHandler } from './src/dev.js';
2
+ export { assertNodeVersion, checkNodeVersion, requiredNodeMajor, parseMajor, parseRequiredMajor } from './src/node-version.js';
3
+ export { validateEnv, formatEnvErrors, loadEnvSchema, applyEnvValidation } from './src/env-schema.js';
2
4
  export { buildRouteTable, matchPage, matchApi } from './src/router.js';
3
5
  export { ssrPage, ssrNotFound } from './src/ssr.js';
4
6
  export { handleApi } from './src/api.js';
@@ -35,6 +37,7 @@ export { scanComponents, primeComponentRegistry, extractComponents, findOrphanCo
35
37
  export { headers, cookies, getRequest, withRequest, cspNonce } from './src/context.js';
36
38
  export { defaultLogger } from './src/logger.js';
37
39
  export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
40
+ export { cors, resolveOrigin, applyCorsHeaders } from './src/cors.js';
38
41
  export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
39
42
  export { cache } from './src/cache-fn.js';
40
43
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/actions.js CHANGED
@@ -6,6 +6,20 @@ import { getExposed } from '@webjsdev/core';
6
6
  import { walk } from './fs-walk.js';
7
7
  import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
8
8
  import { getSerializer } from './serializer.js';
9
+ import { resolveOrigin } from './cors.js';
10
+ import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
11
+ import { getBodyLimits } from './context.js';
12
+
13
+ /**
14
+ * The JSON / RPC body cap in effect for the current request: the per-request
15
+ * limit the handler stamped, or the secure default outside a request scope (a
16
+ * direct unit-test invocation). `0` disables the cap.
17
+ * @returns {number}
18
+ */
19
+ function jsonBodyLimit() {
20
+ const limits = getBodyLimits();
21
+ return limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
22
+ }
9
23
 
10
24
  /**
11
25
  * Internal RPC wire-format content type. Distinguishes webjs action
@@ -294,9 +308,13 @@ export async function invokeAction(idx, hash, fnName, req) {
294
308
  }
295
309
  const file = idx.hashToFile.get(hash);
296
310
  if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
311
+ // Bounded read (issue #237): reject an over-limit RPC body with 413 without
312
+ // buffering it whole (Content-Length fast-reject, plus a streaming cap for a
313
+ // chunked body with no declared length).
314
+ const { tooLarge, text: body } = await readTextBounded(req, jsonBodyLimit());
315
+ if (tooLarge) return payloadTooLarge();
297
316
  let args = [];
298
317
  try {
299
- const body = await req.text();
300
318
  args = body ? getSerializer().deserialize(body) : [];
301
319
  if (!Array.isArray(args)) args = [args];
302
320
  } catch {
@@ -388,11 +406,23 @@ export function withCors(resp, route, req) {
388
406
  return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers: newHeaders });
389
407
  }
390
408
 
391
- /** @param {string|string[]} configured @param {string} origin */
409
+ /**
410
+ * Match a route's configured origin policy against the request origin,
411
+ * preserving the expose() path's historical contract: `*` returns `true`
412
+ * (literal wildcard), an allowed concrete origin echoes back, and a
413
+ * mismatch returns `null`. Delegates the per-rule decision to the shared
414
+ * cors.js resolver (so RegExp + function policies work here too) but keeps
415
+ * the wildcard-as-`true` and echo-vs-null shape this caller expects.
416
+ *
417
+ * @param {string|string[]|RegExp|((origin: string) => boolean)} configured
418
+ * @param {string} origin
419
+ * @returns {true | string | null}
420
+ */
392
421
  function matchOrigin(configured, origin) {
393
422
  if (configured === '*') return true;
394
- if (Array.isArray(configured)) return configured.includes(origin) ? origin : null;
395
- return configured === origin ? origin : null;
423
+ const resolved = resolveOrigin(configured, origin, false);
424
+ if (!resolved) return null;
425
+ return resolved.allowOrigin === '*' ? true : resolved.allowOrigin;
396
426
  }
397
427
 
398
428
  /**
@@ -408,7 +438,9 @@ export async function invokeExposedAction(idx, route, params, req) {
408
438
  const query = Object.fromEntries(url.searchParams.entries());
409
439
  let body = {};
410
440
  if (req.method !== 'GET' && req.method !== 'HEAD') {
411
- const text = await req.text();
441
+ // Bounded read (issue #237): an over-limit body is a 413 before any parse.
442
+ const { tooLarge, text } = await readTextBounded(req, jsonBodyLimit());
443
+ if (tooLarge) return payloadTooLarge();
412
444
  if (text) {
413
445
  try {
414
446
  const parsed = JSON.parse(text);
package/src/api.js CHANGED
@@ -25,7 +25,22 @@ export async function handleApi(route, params, webRequest, dev) {
25
25
  });
26
26
  }
27
27
  /** @type any */ (webRequest).params = params;
28
- const result = await handler(webRequest, { params });
28
+ let result;
29
+ try {
30
+ result = await handler(webRequest, { params });
31
+ } catch (e) {
32
+ // A route handler that read its body via `readBody` (json.js) over the
33
+ // size limit (issue #237) throws a BodyLimitError; surface it as 413 rather
34
+ // than a generic 500. Detected via a marker so a cross-module-copy
35
+ // instanceof miss never downgrades it.
36
+ if (e && /** @type any */ (e).webjsBodyLimit) {
37
+ return new Response('Payload Too Large', {
38
+ status: 413,
39
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
40
+ });
41
+ }
42
+ throw e;
43
+ }
29
44
  if (result instanceof Response) return result;
30
45
  // Convenience: allow returning plain objects as JSON.
31
46
  return Response.json(result);
package/src/auth.js CHANGED
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import { getStore } from './cache.js';
11
- import { getRequest } from './context.js';
11
+ import { getRequest, getBodyLimits } from './context.js';
12
+ import { readTextBounded, readFormDataBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
12
13
 
13
14
  const enc = new TextEncoder();
14
15
  const dec = new TextDecoder();
@@ -409,10 +410,24 @@ export function createAuth(config) {
409
410
  const provider = providers.get(seg[1]);
410
411
  if (!provider) return new Response('Unknown provider', { status: 404 });
411
412
  if (provider.type === 'credentials') {
413
+ // Bounded body read (issue #237): the credentials sign-in endpoint is
414
+ // public and unauthenticated, so cap its body to defend against
415
+ // memory exhaustion. Credentials are small fixed-shape JSON / form
416
+ // data, so the JSON / RPC limit applies. An over-limit body is a 413
417
+ // before any parse, and is never buffered whole (see body-limit.js).
418
+ const limits = getBodyLimits();
419
+ const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
412
420
  let body = {};
413
421
  const ct = req.headers.get('content-type') || '';
414
- if (ct.includes('json')) body = await req.json();
415
- else if (ct.includes('form')) { const fd = await req.formData(); for (const [k, v] of fd.entries()) body[k] = v; }
422
+ if (ct.includes('json')) {
423
+ const { tooLarge, text } = await readTextBounded(req, limit);
424
+ if (tooLarge) return payloadTooLarge();
425
+ body = text ? JSON.parse(text) : {};
426
+ } else if (ct.includes('form')) {
427
+ const { tooLarge, formData } = await readFormDataBounded(req, limit);
428
+ if (tooLarge) return payloadTooLarge();
429
+ for (const [k, v] of formData.entries()) body[k] = v;
430
+ }
416
431
  return signInFn('credentials', body, { req });
417
432
  }
418
433
  if (provider.type === 'oauth') return oauthRedirect(provider, { req });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Request body-size limits (413) and node:http server timeouts (issue #237).
3
+ *
4
+ * webjs's prod server reads request bodies on three paths: the server-action
5
+ * RPC endpoint (actions.js), `route.{js,ts}` handlers via `readBody` (json.js),
6
+ * and the no-JS page-action form path (page-action.js). Without a cap, an
7
+ * uncapped body is a memory-exhaustion vector. This module is the SINGLE place
8
+ * that decides the limit and performs a bounded read, so every body-read site
9
+ * enforces it uniformly.
10
+ *
11
+ * Two ideas, both web-standard / node:http-native, no library:
12
+ *
13
+ * 1. A bounded read. `readTextBounded` / `readFormDataBounded` reject a body
14
+ * over the configured limit with a 413 WITHOUT buffering the whole thing:
15
+ * a `Content-Length` over the limit is a fast reject (the body is never
16
+ * read), and a chunked/streamed body without `Content-Length` is counted
17
+ * while it streams and abandoned the moment it crosses the limit (so the
18
+ * process never holds more than roughly one chunk past the limit).
19
+ *
20
+ * 2. Server timeouts. `computeServerTimeouts` derives the `requestTimeout`,
21
+ * `headersTimeout`, and `keepAliveTimeout` values applied to the node:http
22
+ * server in `startServer`, with secure production defaults and config / env
23
+ * overrides. node semantics: `headersTimeout` MUST be < `requestTimeout`
24
+ * (node measures the header-receipt deadline from the start of the request,
25
+ * so a headers deadline at or above the whole-request deadline can never
26
+ * fire), so the helper clamps it below `requestTimeout` when a config sets
27
+ * them inconsistently.
28
+ */
29
+
30
+ /** Default JSON / RPC body cap: 1 MiB. Generous for an action payload. */
31
+ export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
32
+
33
+ /**
34
+ * Default form / multipart body cap: 10 MiB. A form submission (the page-action
35
+ * path) may legitimately carry more than a JSON RPC call (a textarea, a small
36
+ * upload), so it gets a separate, higher, still-bounded limit. Large file
37
+ * uploads are a distinct concern (#247) with their own streaming story.
38
+ */
39
+ export const DEFAULT_MAX_MULTIPART_BYTES = 10 * 1024 * 1024;
40
+
41
+ /**
42
+ * Thrown by `readBody` (json.js) when a route handler's body exceeds the limit.
43
+ * The RPC and page-action paths return a 413 Response inline, but `readBody`
44
+ * runs INSIDE a user route handler and returns parsed data, so it signals the
45
+ * over-limit case by throwing. The API dispatcher (`handleApi`) catches this and
46
+ * maps it to a 413, so a handler that just does `await readBody(req)` gets the
47
+ * correct status with no extra code.
48
+ */
49
+ export class BodyLimitError extends Error {
50
+ constructor() {
51
+ super('Payload Too Large');
52
+ this.name = 'BodyLimitError';
53
+ /** Marker so `handleApi` can detect it without an instanceof across module copies. */
54
+ this.webjsBodyLimit = true;
55
+ }
56
+ }
57
+
58
+ /** requestTimeout: time to receive the ENTIRE request (headers + body). 30s. */
59
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
60
+
61
+ /**
62
+ * headersTimeout: time to receive the request headers. Must be < requestTimeout
63
+ * (node measures both from the same request start, so an equal-or-greater value
64
+ * never fires). 20s is comfortably under the 30s whole-request deadline.
65
+ */
66
+ export const DEFAULT_HEADERS_TIMEOUT_MS = 20_000;
67
+
68
+ /** keepAliveTimeout: idle time before closing a kept-alive socket. 5s. */
69
+ export const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 5_000;
70
+
71
+ /**
72
+ * Read a non-negative integer from an env var, or undefined when unset / blank /
73
+ * not a finite non-negative integer. A value of `0` is honored (it disables the
74
+ * limit / timeout), so the check is on parseability, not truthiness.
75
+ *
76
+ * @param {string | undefined} raw
77
+ * @returns {number | undefined}
78
+ */
79
+ function envInt(raw) {
80
+ if (raw == null || raw === '') return undefined;
81
+ const n = Number(raw);
82
+ if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return undefined;
83
+ return n;
84
+ }
85
+
86
+ /**
87
+ * Read a non-negative integer from a package.json `webjs.<key>` value, or
88
+ * undefined when absent / not a finite non-negative integer.
89
+ *
90
+ * @param {unknown} pkg parsed package.json (or any object)
91
+ * @param {string} key the `webjs.<key>` field name
92
+ * @returns {number | undefined}
93
+ */
94
+ function pkgInt(pkg, key) {
95
+ const v =
96
+ pkg && typeof pkg === 'object' && /** @type {any} */ (pkg).webjs
97
+ ? /** @type {any} */ (pkg).webjs[key]
98
+ : undefined;
99
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || !Number.isInteger(v)) {
100
+ return undefined;
101
+ }
102
+ return v;
103
+ }
104
+
105
+ /**
106
+ * Resolve the body-size limits. Precedence: env override wins, then the
107
+ * package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` config, then the
108
+ * secure defaults. A value of `0` (from env or config) disables that limit, the
109
+ * deliberate opt-out (e.g. an app fronted by an edge that already caps bodies).
110
+ *
111
+ * WEBJS_MAX_BODY_BYTES -> json / rpc cap
112
+ * WEBJS_MAX_MULTIPART_BYTES -> form / multipart cap
113
+ *
114
+ * @param {unknown} pkg parsed package.json (or any object)
115
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts] injectable env (tests)
116
+ * @returns {{ json: number, multipart: number }} resolved byte limits (0 = off)
117
+ */
118
+ export function readBodyLimits(pkg, opts = {}) {
119
+ const env = opts.env || process.env;
120
+ const json =
121
+ envInt(env.WEBJS_MAX_BODY_BYTES) ??
122
+ pkgInt(pkg, 'maxBodyBytes') ??
123
+ DEFAULT_MAX_BODY_BYTES;
124
+ const multipart =
125
+ envInt(env.WEBJS_MAX_MULTIPART_BYTES) ??
126
+ pkgInt(pkg, 'maxMultipartBytes') ??
127
+ DEFAULT_MAX_MULTIPART_BYTES;
128
+ return { json, multipart };
129
+ }
130
+
131
+ /**
132
+ * Resolve the node:http server timeouts. Precedence mirrors `readBodyLimits`:
133
+ * env override, then package.json `webjs.requestTimeoutMs` /
134
+ * `webjs.headersTimeoutMs` / `webjs.keepAliveTimeoutMs`, then the defaults. A
135
+ * value of `0` disables that timeout (node's own "no limit" sentinel).
136
+ *
137
+ * WEBJS_REQUEST_TIMEOUT_MS
138
+ * WEBJS_HEADERS_TIMEOUT_MS
139
+ * WEBJS_KEEP_ALIVE_TIMEOUT_MS
140
+ *
141
+ * node semantics enforced here: `headersTimeout` MUST be strictly less than
142
+ * `requestTimeout` to fire (both deadlines run from the same request start). So
143
+ * when a non-zero `headersTimeout` is >= a non-zero `requestTimeout`, clamp it
144
+ * to just under `requestTimeout` rather than silently shipping a dead timeout.
145
+ *
146
+ * @param {unknown} pkg parsed package.json (or any object)
147
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts] injectable env (tests)
148
+ * @returns {{ requestTimeout: number, headersTimeout: number, keepAliveTimeout: number }}
149
+ */
150
+ export function computeServerTimeouts(pkg, opts = {}) {
151
+ const env = opts.env || process.env;
152
+ const requestTimeout =
153
+ envInt(env.WEBJS_REQUEST_TIMEOUT_MS) ??
154
+ pkgInt(pkg, 'requestTimeoutMs') ??
155
+ DEFAULT_REQUEST_TIMEOUT_MS;
156
+ let headersTimeout =
157
+ envInt(env.WEBJS_HEADERS_TIMEOUT_MS) ??
158
+ pkgInt(pkg, 'headersTimeoutMs') ??
159
+ DEFAULT_HEADERS_TIMEOUT_MS;
160
+ const keepAliveTimeout =
161
+ envInt(env.WEBJS_KEEP_ALIVE_TIMEOUT_MS) ??
162
+ pkgInt(pkg, 'keepAliveTimeoutMs') ??
163
+ DEFAULT_KEEP_ALIVE_TIMEOUT_MS;
164
+ // Keep headersTimeout strictly under requestTimeout so it can actually fire.
165
+ // Both are measured from the same request start; a headers deadline at or
166
+ // above the whole-request deadline is dead. Skip when either is 0 (disabled).
167
+ if (requestTimeout > 0 && headersTimeout >= requestTimeout) {
168
+ headersTimeout = Math.max(1, requestTimeout - 1000);
169
+ }
170
+ return { requestTimeout, headersTimeout, keepAliveTimeout };
171
+ }
172
+
173
+ /**
174
+ * A 413 Payload Too Large response, returned by every body-read site when the
175
+ * bounded read trips the limit. Tiny plain-text body so it stays content-type
176
+ * agnostic; the caller never needs to vary it.
177
+ *
178
+ * @returns {Response}
179
+ */
180
+ export function payloadTooLarge() {
181
+ return new Response('Payload Too Large', {
182
+ status: 413,
183
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Read a request body as bytes, bounded by `limit`. The single funnel both text
189
+ * and FormData readers go through.
190
+ *
191
+ * - `limit <= 0` disables the cap (read the whole body).
192
+ * - A `Content-Length` header over the limit is a FAST REJECT: the body is
193
+ * never touched, so an attacker-declared huge upload costs nothing.
194
+ * - Otherwise the body stream is read chunk by chunk and the running total is
195
+ * checked AFTER each chunk. The moment it crosses the limit the read is
196
+ * abandoned (the stream reader is cancelled) and `tooLarge` is returned, so
197
+ * a chunked body with no `Content-Length` can never buffer more than the
198
+ * bytes already read (roughly limit + one chunk), not the full payload.
199
+ *
200
+ * @param {Request} req
201
+ * @param {number} limit max bytes (0 / negative = unlimited)
202
+ * @returns {Promise<{ tooLarge: boolean, bytes: Uint8Array | null }>}
203
+ */
204
+ export async function readBytesBounded(req, limit) {
205
+ // Fast reject on a declared Content-Length over the limit: never read a byte.
206
+ if (limit > 0) {
207
+ const cl = req.headers.get('content-length');
208
+ if (cl != null) {
209
+ const declared = Number(cl);
210
+ if (Number.isFinite(declared) && declared > limit) {
211
+ return { tooLarge: true, bytes: null };
212
+ }
213
+ }
214
+ }
215
+
216
+ const body = req.body;
217
+ if (!body) return { tooLarge: false, bytes: new Uint8Array(0) };
218
+
219
+ const reader = body.getReader();
220
+ /** @type {Uint8Array[]} */
221
+ const chunks = [];
222
+ let total = 0;
223
+ try {
224
+ for (;;) {
225
+ const { done, value } = await reader.read();
226
+ if (done) break;
227
+ if (!value) continue;
228
+ total += value.byteLength;
229
+ // Enforce WHILE reading so a no-Content-Length stream can't buffer past
230
+ // the limit: bail the instant the running total crosses it.
231
+ if (limit > 0 && total > limit) {
232
+ // Stop pulling more bytes; release the upstream so the socket can close.
233
+ try { await reader.cancel(); } catch { /* already closed */ }
234
+ return { tooLarge: true, bytes: null };
235
+ }
236
+ chunks.push(value);
237
+ }
238
+ } finally {
239
+ try { reader.releaseLock(); } catch { /* reader already released */ }
240
+ }
241
+
242
+ // Concatenate the collected chunks into one buffer.
243
+ const out = new Uint8Array(total);
244
+ let offset = 0;
245
+ for (const c of chunks) {
246
+ out.set(c, offset);
247
+ offset += c.byteLength;
248
+ }
249
+ return { tooLarge: false, bytes: out };
250
+ }
251
+
252
+ /**
253
+ * Read a request body as text, bounded by `limit`. Used by the RPC endpoint,
254
+ * `readBody`, and the exposed-action REST path, all of which then parse the
255
+ * text (webjs wire or JSON).
256
+ *
257
+ * @param {Request} req
258
+ * @param {number} limit max bytes (0 / negative = unlimited)
259
+ * @returns {Promise<{ tooLarge: boolean, text: string }>}
260
+ */
261
+ export async function readTextBounded(req, limit) {
262
+ const { tooLarge, bytes } = await readBytesBounded(req, limit);
263
+ if (tooLarge) return { tooLarge: true, text: '' };
264
+ const text = bytes && bytes.byteLength ? new TextDecoder().decode(bytes) : '';
265
+ return { tooLarge: false, text };
266
+ }
267
+
268
+ /**
269
+ * Read a request body as `FormData`, bounded by `limit`. Used by the page-action
270
+ * form path. Reconstructs a bounded Request from the already-read bytes and
271
+ * defers to the platform `formData()` parser, so multipart and
272
+ * urlencoded bodies are decoded exactly as before, just size-checked first.
273
+ *
274
+ * @param {Request} req
275
+ * @param {number} limit max bytes (0 / negative = unlimited)
276
+ * @returns {Promise<{ tooLarge: boolean, formData: FormData | null }>}
277
+ */
278
+ export async function readFormDataBounded(req, limit) {
279
+ const { tooLarge, bytes } = await readBytesBounded(req, limit);
280
+ if (tooLarge) return { tooLarge: true, formData: null };
281
+ const ct = req.headers.get('content-type') || '';
282
+ // Hand the bounded bytes back to a fresh Request so its standard formData()
283
+ // parser (multipart boundary handling, urlencoded decoding) runs unchanged.
284
+ const bounded = new Request(req.url, {
285
+ method: 'POST',
286
+ headers: ct ? { 'content-type': ct } : undefined,
287
+ body: bytes && bytes.byteLength ? bytes : undefined,
288
+ });
289
+ const formData = await bounded.formData();
290
+ return { tooLarge: false, formData };
291
+ }
package/src/context.js CHANGED
@@ -10,7 +10,17 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
10
10
  *
11
11
  * Strictly server-side: importing this module on the client is a bug.
12
12
  *
13
- * @typedef {{ req: Request }} Store
13
+ * `cspNonce` holds the per-request CSP nonce when CSP is enabled
14
+ * (issue #233). It is minted in the request handler and written here via
15
+ * `setCspNonce`, so the same value the `Content-Security-Policy` header
16
+ * carries is what `cspNonce()` returns for the inline boot script.
17
+ *
18
+ * `bodyLimits` holds the resolved request body-size caps (issue #237) so
19
+ * `readBody` (used inside `route.{js,ts}` handlers, which have no handle to the
20
+ * server state) can enforce the same limit the RPC and page-action paths do. The
21
+ * handler writes it per request via `setBodyLimits`.
22
+ *
23
+ * @typedef {{ req: Request, cspNonce?: string, bodyLimits?: { json: number, multipart: number } }} Store
14
24
  */
15
25
 
16
26
  /** @type {AsyncLocalStorage<Store>} */
@@ -33,10 +43,52 @@ export function getRequest() {
33
43
  }
34
44
 
35
45
  /**
36
- * Server-only implementation of the CSP nonce reader: pulls the
37
- * current request from AsyncLocalStorage, parses the
38
- * `script-src 'nonce-...'` value from its CSP header, returns ''
39
- * when none in scope.
46
+ * Set the per-request CSP nonce on the current AsyncLocalStorage store.
47
+ * Called by the request handler when CSP is enabled, AFTER it mints the
48
+ * nonce and BEFORE the page renders, so `cspNonce()` returns this exact
49
+ * value during SSR (the same value the response's
50
+ * `Content-Security-Policy` header carries: one source, no drift).
51
+ *
52
+ * A no-op outside a request scope, or when CSP is disabled (the handler
53
+ * simply never calls it, so the store's `cspNonce` stays undefined and
54
+ * `cspNonce()` falls through to '').
55
+ *
56
+ * @param {string} nonce
57
+ */
58
+ export function setCspNonce(nonce) {
59
+ const store = als.getStore();
60
+ if (store) store.cspNonce = nonce;
61
+ }
62
+
63
+ /**
64
+ * Set the per-request resolved body-size limits on the current store (issue
65
+ * #237). The handler computes them once at boot (`readBodyLimits`) and stamps
66
+ * them on every request so `readBody` (json.js), which runs inside route
67
+ * handlers with no access to the server state, can enforce the same cap.
68
+ *
69
+ * @param {{ json: number, multipart: number }} limits
70
+ */
71
+ export function setBodyLimits(limits) {
72
+ const store = als.getStore();
73
+ if (store) store.bodyLimits = limits;
74
+ }
75
+
76
+ /**
77
+ * Read the per-request body-size limits, or null outside a request scope.
78
+ * @returns {{ json: number, multipart: number } | null}
79
+ */
80
+ export function getBodyLimits() {
81
+ return als.getStore()?.bodyLimits ?? null;
82
+ }
83
+
84
+ /**
85
+ * Server-only implementation of the CSP nonce reader. Returns the
86
+ * per-request nonce that the handler MINTED and stored (issue #233) when
87
+ * CSP is enabled. Falls back to parsing an INBOUND
88
+ * `Content-Security-Policy` request header (the legacy consume-only
89
+ * behaviour) so an app sitting behind a proxy that already mints a nonce
90
+ * still works without enabling webjs's own CSP. Returns '' when neither
91
+ * is in scope.
40
92
  *
41
93
  * The public `cspNonce()` function lives in `@webjsdev/core` so user
42
94
  * layouts / pages can import it without dragging server-only deps
@@ -46,18 +98,16 @@ export function getRequest() {
46
98
  * `cspNonce()` returns '' (empty `nonce=""` attribute, browser
47
99
  * ignores it).
48
100
  */
49
- // The regex captures the first `nonce-...` token anywhere in the CSP
50
- // header. Webjs uses a single per-request nonce shared across all
51
- // directives that emit it (the standard CSP3 single-nonce model),
52
- // so reading the first match is correct. If a future caller emits
53
- // styled inline content under a separate style nonce, this reader
54
- // would need to become directive-scoped. Kept identical to the
55
- // matching helper in ssr.js so both paths interpret the header the
56
- // same way.
101
+ // The regex fallback captures the first `nonce-...` token anywhere in the
102
+ // inbound CSP header. Webjs uses a single per-request nonce shared across
103
+ // all directives that emit it (the standard CSP3 single-nonce model), so
104
+ // reading the first match is correct. Kept identical to the matching
105
+ // helper in ssr.js so both paths interpret the header the same way.
57
106
  setCspNonceProvider(() => {
58
- const req = als.getStore()?.req;
59
- if (!req) return '';
60
- const csp = req.headers.get('content-security-policy') || '';
107
+ const store = als.getStore();
108
+ if (!store) return '';
109
+ if (typeof store.cspNonce === 'string') return store.cspNonce;
110
+ const csp = store.req?.headers.get('content-security-policy') || '';
61
111
  const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
62
112
  return match ? match[1] : '';
63
113
  });