@webjsdev/server 0.8.9 → 0.8.11

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';
@@ -32,11 +34,14 @@ export {
32
34
  } from './src/vendor.js';
33
35
  export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
34
36
  export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
35
- export { headers, cookies, getRequest, withRequest, cspNonce } from './src/context.js';
37
+ export { headers, cookies, getRequest, withRequest, cspNonce, requestId } 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';
43
+ export { revalidateTag, revalidateTags } from './src/cache-tags.js';
44
+ export { revalidatePath, revalidateAll } from './src/html-cache.js';
40
45
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
41
46
  export { broadcast } from './src/broadcast.js';
42
47
  export { json, readBody } from './src/json.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.11",
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
@@ -287,16 +301,24 @@ export async function serveActionStub(idx, absFile) {
287
301
  * @param {string} hash
288
302
  * @param {string} fnName
289
303
  * @param {Request} req
304
+ * @param {(error: unknown) => void} [onError] best-effort sink (issue #239)
305
+ * invoked when the action throws unexpectedly, BEFORE the sanitized 500 is
306
+ * returned, so an APM integration sees the original error. The caller wraps
307
+ * it so a throwing sink can never affect the response.
290
308
  */
291
- export async function invokeAction(idx, hash, fnName, req) {
309
+ export async function invokeAction(idx, hash, fnName, req, onError) {
292
310
  if (!verifyCsrf(req)) {
293
311
  return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
294
312
  }
295
313
  const file = idx.hashToFile.get(hash);
296
314
  if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
315
+ // Bounded read (issue #237): reject an over-limit RPC body with 413 without
316
+ // buffering it whole (Content-Length fast-reject, plus a streaming cap for a
317
+ // chunked body with no declared length).
318
+ const { tooLarge, text: body } = await readTextBounded(req, jsonBodyLimit());
319
+ if (tooLarge) return payloadTooLarge();
297
320
  let args = [];
298
321
  try {
299
- const body = await req.text();
300
322
  args = body ? getSerializer().deserialize(body) : [];
301
323
  if (!Array.isArray(args)) args = [args];
302
324
  } catch {
@@ -309,6 +331,7 @@ export async function invokeAction(idx, hash, fnName, req) {
309
331
  const result = await fn(...args);
310
332
  return rpcResponse(result ?? null);
311
333
  } catch (e) {
334
+ if (typeof onError === 'function') onError(e);
312
335
  return actionErrorResponse(e, idx.dev);
313
336
  }
314
337
  }
@@ -388,11 +411,23 @@ export function withCors(resp, route, req) {
388
411
  return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers: newHeaders });
389
412
  }
390
413
 
391
- /** @param {string|string[]} configured @param {string} origin */
414
+ /**
415
+ * Match a route's configured origin policy against the request origin,
416
+ * preserving the expose() path's historical contract: `*` returns `true`
417
+ * (literal wildcard), an allowed concrete origin echoes back, and a
418
+ * mismatch returns `null`. Delegates the per-rule decision to the shared
419
+ * cors.js resolver (so RegExp + function policies work here too) but keeps
420
+ * the wildcard-as-`true` and echo-vs-null shape this caller expects.
421
+ *
422
+ * @param {string|string[]|RegExp|((origin: string) => boolean)} configured
423
+ * @param {string} origin
424
+ * @returns {true | string | null}
425
+ */
392
426
  function matchOrigin(configured, origin) {
393
427
  if (configured === '*') return true;
394
- if (Array.isArray(configured)) return configured.includes(origin) ? origin : null;
395
- return configured === origin ? origin : null;
428
+ const resolved = resolveOrigin(configured, origin, false);
429
+ if (!resolved) return null;
430
+ return resolved.allowOrigin === '*' ? true : resolved.allowOrigin;
396
431
  }
397
432
 
398
433
  /**
@@ -402,13 +437,19 @@ function matchOrigin(configured, origin) {
402
437
  * @param {ExposedRoute} route
403
438
  * @param {Record<string,string>} params
404
439
  * @param {Request} req
440
+ * @param {(error: unknown) => void} [onError] best-effort sink (issue #239)
441
+ * invoked when the exposed REST handler throws unexpectedly, BEFORE the
442
+ * sanitized 500 is returned, so an APM integration sees the original error.
443
+ * The caller wraps it so a throwing sink can never affect the response.
405
444
  */
406
- export async function invokeExposedAction(idx, route, params, req) {
445
+ export async function invokeExposedAction(idx, route, params, req, onError) {
407
446
  const url = new URL(req.url);
408
447
  const query = Object.fromEntries(url.searchParams.entries());
409
448
  let body = {};
410
449
  if (req.method !== 'GET' && req.method !== 'HEAD') {
411
- const text = await req.text();
450
+ // Bounded read (issue #237): an over-limit body is a 413 before any parse.
451
+ const { tooLarge, text } = await readTextBounded(req, jsonBodyLimit());
452
+ if (tooLarge) return payloadTooLarge();
412
453
  if (text) {
413
454
  try {
414
455
  const parsed = JSON.parse(text);
@@ -441,6 +482,7 @@ export async function invokeExposedAction(idx, route, params, req) {
441
482
  if (result instanceof Response) return result;
442
483
  return Response.json(result ?? null);
443
484
  } catch (e) {
485
+ if (typeof onError === 'function') onError(e);
444
486
  return actionErrorResponse(e, idx.dev);
445
487
  }
446
488
  }
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, markDynamicAccess } 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();
@@ -219,6 +220,13 @@ export function createAuth(config) {
219
220
  // -- Session read/write ---------------------------------------------------
220
221
 
221
222
  async function readSession(req) {
223
+ // Reading the auth session is per-user (the body branches on the logged-in
224
+ // user), so mark the request dynamic so the server HTML cache excludes the
225
+ // page even when it wrongly set `revalidate`, mirroring getSession() /
226
+ // cookies() / headers(). This closes the auth-path leak (#241): `auth()`
227
+ // reaches here, reads the auth cookie raw, and would otherwise leave the
228
+ // page cacheable so a logged-in body could be served to the next visitor.
229
+ markDynamicAccess();
222
230
  const cookies = parseCookies(req.headers.get('cookie') || '');
223
231
  const raw = cookies[AUTH_COOKIE];
224
232
  if (!raw) return null;
@@ -409,10 +417,24 @@ export function createAuth(config) {
409
417
  const provider = providers.get(seg[1]);
410
418
  if (!provider) return new Response('Unknown provider', { status: 404 });
411
419
  if (provider.type === 'credentials') {
420
+ // Bounded body read (issue #237): the credentials sign-in endpoint is
421
+ // public and unauthenticated, so cap its body to defend against
422
+ // memory exhaustion. Credentials are small fixed-shape JSON / form
423
+ // data, so the JSON / RPC limit applies. An over-limit body is a 413
424
+ // before any parse, and is never buffered whole (see body-limit.js).
425
+ const limits = getBodyLimits();
426
+ const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
412
427
  let body = {};
413
428
  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; }
429
+ if (ct.includes('json')) {
430
+ const { tooLarge, text } = await readTextBounded(req, limit);
431
+ if (tooLarge) return payloadTooLarge();
432
+ body = text ? JSON.parse(text) : {};
433
+ } else if (ct.includes('form')) {
434
+ const { tooLarge, formData } = await readFormDataBounded(req, limit);
435
+ if (tooLarge) return payloadTooLarge();
436
+ for (const [k, v] of formData.entries()) body[k] = v;
437
+ }
416
438
  return signInFn('credentials', body, { req });
417
439
  }
418
440
  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
+ }
@@ -0,0 +1,59 @@
1
+ import { createRequire } from 'node:module';
2
+ import { publishedBuildId } from './importmap.js';
3
+
4
+ /**
5
+ * Build-info / version probe (issue #239).
6
+ *
7
+ * `GET /__webjs/version` returns a small JSON object a deploy can curl to
8
+ * verify which build is live, alongside the existing `/__webjs/health` and
9
+ * `/__webjs/ready` probes. It carries NO secrets: only the framework version,
10
+ * the published importmap build id (the same value the client router reads
11
+ * from `data-webjs-build` to detect a deploy), the running node version, and
12
+ * process uptime. Served before `ensureReady()` like the other probes, so it
13
+ * answers on a cold instance without blocking on the whole-app analysis.
14
+ *
15
+ * The framework version is read once from this package's own `package.json`,
16
+ * the same single-source pattern `requiredNodeMajor()` uses, so it never drifts
17
+ * from the published version.
18
+ */
19
+
20
+ /** @type {string} */
21
+ let _frameworkVersion = '';
22
+ function frameworkVersion() {
23
+ if (_frameworkVersion) return _frameworkVersion;
24
+ try {
25
+ const require = createRequire(import.meta.url);
26
+ const pkg = require('../package.json');
27
+ _frameworkVersion = String(pkg?.version || '');
28
+ } catch {
29
+ _frameworkVersion = '';
30
+ }
31
+ return _frameworkVersion;
32
+ }
33
+
34
+ /**
35
+ * Compose the build-info payload. Pure (takes the moment as an argument) so a
36
+ * test can assert the shape without mocking the clock; the handler calls it
37
+ * with `process.uptime()`.
38
+ *
39
+ * @param {{ uptime?: number }} [opts]
40
+ * @returns {{ version: string, build: string, node: string, uptime: number }}
41
+ */
42
+ export function buildInfo(opts = {}) {
43
+ return {
44
+ version: frameworkVersion(),
45
+ build: publishedBuildId(),
46
+ node: process.version,
47
+ uptime: typeof opts.uptime === 'number' ? opts.uptime : process.uptime(),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Build the `GET /__webjs/version` response. `no-store` so a proxy / browser
53
+ * never caches a stale build fingerprint.
54
+ *
55
+ * @returns {Response}
56
+ */
57
+ export function buildInfoResponse() {
58
+ return Response.json(buildInfo(), { headers: { 'cache-control': 'no-store' } });
59
+ }
package/src/cache-fn.js CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { getStore } from './cache.js';
27
+ import { addKeyToTags } from './cache-tags.js';
27
28
 
28
29
  /**
29
30
  * Wrap an async function with server-side caching.
@@ -33,9 +34,18 @@ import { getStore } from './cache.js';
33
34
  * @param {{
34
35
  * key: string,
35
36
  * ttl?: number,
37
+ * tags?: string[] | ((...args: Parameters<T>) => string[]),
36
38
  * }} opts
37
39
  * - `key`: cache key prefix. Combined with serialized args to form the full key.
38
40
  * - `ttl`: time-to-live in seconds. Default: 60.
41
+ * - `tags`: optional tags this cached result belongs to, for cross-module
42
+ * invalidation via `revalidateTag(tag)`. Either a static `string[]`
43
+ * (every cached entry of this function shares them) or a function
44
+ * `(...args) => string[]` so a per-arg read tags with the entity id
45
+ * (e.g. `tags: (id) => ['post:' + id]`). The result is also recorded
46
+ * under each tag's thin key index so `revalidateTag` can find and
47
+ * evict it later, including arg-specific entries that the no-args
48
+ * `invalidate()` cannot reach.
39
49
  * @returns {T & { invalidate: () => Promise<void> }}
40
50
  * The cached function with the same signature, plus an `invalidate()`
41
51
  * method to manually clear the cache.
@@ -43,6 +53,19 @@ import { getStore } from './cache.js';
43
53
  export function cache(fn, opts) {
44
54
  const prefix = opts.key;
45
55
  const ttlMs = (opts.ttl ?? 60) * 1000;
56
+ const tagsOpt = opts.tags;
57
+
58
+ /**
59
+ * Resolve the tag list for one call. A function form receives the call
60
+ * args (so a per-entity read can tag with the id); a static array is
61
+ * returned as-is. Anything else yields no tags.
62
+ * @param {any[]} args
63
+ * @returns {string[]}
64
+ */
65
+ function tagsFor(args) {
66
+ const raw = typeof tagsOpt === 'function' ? tagsOpt(...args) : tagsOpt;
67
+ return Array.isArray(raw) ? raw.filter((t) => typeof t === 'string' && t) : [];
68
+ }
46
69
 
47
70
  const wrapped = /** @type {T & { invalidate: () => Promise<void> }} */ (
48
71
  async function (...args) {
@@ -58,6 +81,23 @@ export function cache(fn, opts) {
58
81
 
59
82
  const result = await fn(...args);
60
83
  await store.set(cacheKey, JSON.stringify(result), ttlMs);
84
+ // Record tag -> cacheKey in the thin tag index so a later
85
+ // revalidateTag can find and evict this entry (including
86
+ // arg-specific keys the no-args invalidate() cannot reach).
87
+ // Best-effort: the value is already stored, so taggability must
88
+ // never break the cached call. A user tags() function that throws
89
+ // (e.g. reading post.id off a null arg), or an index write that
90
+ // fails, leaves the value cached (just untagged) and returns
91
+ // normally. tagsFor() is INSIDE the try because it runs the
92
+ // user-supplied function.
93
+ try {
94
+ await addKeyToTags(tagsFor(args), cacheKey, ttlMs);
95
+ } catch (err) {
96
+ console.warn(
97
+ `[webjs] cache(${prefix}): tag indexing failed, value is cached ` +
98
+ `but untagged (revalidateTag will not reach it): ${err && err.message ? err.message : err}`
99
+ );
100
+ }
61
101
  return result;
62
102
  }
63
103
  );