@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 +6 -1
- package/package.json +1 -1
- package/src/actions.js +49 -7
- package/src/api.js +16 -1
- package/src/auth.js +25 -3
- package/src/body-limit.js +291 -0
- package/src/build-info.js +59 -0
- package/src/cache-fn.js +40 -0
- package/src/cache-tags.js +147 -0
- package/src/conditional-get.js +183 -0
- package/src/context.js +139 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +397 -31
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/html-cache.js +305 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/session.js +4 -0
- package/src/ssr.js +142 -24
- package/src/vendor.js +9 -6
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
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
|
-
/**
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'))
|
|
415
|
-
|
|
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
|
);
|