@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/src/dev.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { createServer as createHttp1Server } from 'node:http';
|
|
2
2
|
import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { digestHex } from './crypto-utils.js';
|
|
5
4
|
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
6
5
|
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
|
|
7
|
-
import { createRequire
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
// Namespace import, NOT `import { stripTypeScriptTypes }`. On Node < 22.13 the
|
|
8
|
+
// `stripTypeScriptTypes` named export does not exist, and a NAMED import of a
|
|
9
|
+
// missing builtin export is a LINK-TIME SyntaxError that fires before any
|
|
10
|
+
// module body runs, which would defeat the Node-version preflight (issue #238)
|
|
11
|
+
// by crashing the import of @webjsdev/server itself. A namespace import links
|
|
12
|
+
// on every Node (the property is just `undefined` at runtime on old Node), so
|
|
13
|
+
// importing @webjsdev/server succeeds and `assertNodeVersion()` at the top of
|
|
14
|
+
// createRequestHandler throws the clean "you need Node 24+" message instead.
|
|
15
|
+
import * as nodeModule from 'node:module';
|
|
8
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
17
|
|
|
10
18
|
// Server-side `.ts` imports are handled natively by Node 24+'s default
|
|
@@ -40,6 +48,7 @@ process.emitWarning = function (warning, type, code, ctor) {
|
|
|
40
48
|
|
|
41
49
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
42
50
|
import { ssrPage, ssrNotFound } from './ssr.js';
|
|
51
|
+
import { loadPageAction, runPageAction } from './page-action.js';
|
|
43
52
|
import { handleApi } from './api.js';
|
|
44
53
|
import {
|
|
45
54
|
buildActionIndex,
|
|
@@ -56,7 +65,11 @@ import {
|
|
|
56
65
|
hashFile,
|
|
57
66
|
} from './actions.js';
|
|
58
67
|
import { defaultLogger } from './logger.js';
|
|
59
|
-
import {
|
|
68
|
+
import { assertNodeVersion } from './node-version.js';
|
|
69
|
+
import { applyEnvValidation } from './env-schema.js';
|
|
70
|
+
import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from './context.js';
|
|
71
|
+
import { buildInfoResponse } from './build-info.js';
|
|
72
|
+
import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
|
|
60
73
|
import { attachWebSocket } from './websocket.js';
|
|
61
74
|
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
|
|
62
75
|
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
|
|
@@ -67,8 +80,44 @@ import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
|
67
80
|
function kebab(name) {
|
|
68
81
|
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
69
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Per-request correlation id (issue #239). Honor an inbound `X-Request-Id`
|
|
86
|
+
* from a trusted upstream proxy so a trace id propagates across services; mint
|
|
87
|
+
* a fresh `crypto.randomUUID()` otherwise. The inbound value is length-capped
|
|
88
|
+
* and validated against a conservative token charset so a hostile client
|
|
89
|
+
* cannot inject control chars / a header-splitting payload (the value is
|
|
90
|
+
* echoed back in the `X-Request-Id` response header). On any mismatch we fall
|
|
91
|
+
* back to a minted id rather than trust the junk.
|
|
92
|
+
*
|
|
93
|
+
* @param {Request} req
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function resolveRequestId(req) {
|
|
97
|
+
const inbound = req.headers.get('x-request-id');
|
|
98
|
+
if (inbound && inbound.length <= 200 && /^[A-Za-z0-9._-]+$/.test(inbound)) return inbound;
|
|
99
|
+
return crypto.randomUUID();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a path should be access-logged (issue #239). The framework's own
|
|
104
|
+
* `/__webjs/*` probes, static runtime assets, and the dev SSE reload stream
|
|
105
|
+
* are high-frequency infrastructure traffic, not app requests, so logging them
|
|
106
|
+
* would just spam the access log. App routes (including app-authored
|
|
107
|
+
* `/api/*`) are logged.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} pathname
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
function shouldAccessLog(pathname) {
|
|
113
|
+
return !pathname.startsWith('/__webjs/');
|
|
114
|
+
}
|
|
70
115
|
import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js';
|
|
71
116
|
import { urlFromRequest } from './forwarded.js';
|
|
117
|
+
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
118
|
+
import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
|
|
119
|
+
import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
|
|
120
|
+
import { commitHtmlCache } from './html-cache.js';
|
|
72
121
|
|
|
73
122
|
const MIME = {
|
|
74
123
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -194,6 +243,81 @@ export async function readElideEnabled(appDir) {
|
|
|
194
243
|
return true;
|
|
195
244
|
}
|
|
196
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Read the per-path response-header config (`webjs.headers`) from the
|
|
248
|
+
* app's package.json and compile it to URLPattern rules. A missing,
|
|
249
|
+
* malformed, or unreadable config yields an empty rule set (the secure
|
|
250
|
+
* defaults still apply), never a throw.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} appDir
|
|
253
|
+
* @returns {Promise<ReturnType<typeof compileHeaderRules>>}
|
|
254
|
+
*/
|
|
255
|
+
export async function readHeaderRules(appDir) {
|
|
256
|
+
try {
|
|
257
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
258
|
+
return compileHeaderRules(pkg);
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Read the CSP config (`webjs.csp`) from the app's package.json and
|
|
266
|
+
* normalize it (issue #233). A missing, malformed, or unreadable config
|
|
267
|
+
* yields a disabled config (no nonce minted, no CSP header), never a
|
|
268
|
+
* throw: a broken security knob must fail closed, not take the app down.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} appDir
|
|
271
|
+
* @returns {Promise<ReturnType<typeof readCspConfig>>}
|
|
272
|
+
*/
|
|
273
|
+
export async function readCspConfigFromApp(appDir) {
|
|
274
|
+
try {
|
|
275
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
276
|
+
return readCspConfig(pkg);
|
|
277
|
+
} catch {
|
|
278
|
+
return readCspConfig(undefined);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve the request body-size limits (issue #237) from the app's package.json
|
|
284
|
+
* `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env overrides
|
|
285
|
+
* (`WEBJS_MAX_BODY_BYTES` / `WEBJS_MAX_MULTIPART_BYTES`). A missing or
|
|
286
|
+
* unreadable package.json falls through to the secure defaults (env still wins),
|
|
287
|
+
* never a throw.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} appDir
|
|
290
|
+
* @returns {Promise<{ json: number, multipart: number }>}
|
|
291
|
+
*/
|
|
292
|
+
export async function readBodyLimitsFromApp(appDir) {
|
|
293
|
+
let pkg;
|
|
294
|
+
try {
|
|
295
|
+
pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
296
|
+
} catch {
|
|
297
|
+
pkg = undefined;
|
|
298
|
+
}
|
|
299
|
+
return readBodyLimits(pkg);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolve the node:http server timeouts (issue #237) from the app's
|
|
304
|
+
* package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
|
|
305
|
+
* `webjs.keepAliveTimeoutMs` plus the env overrides. A missing or unreadable
|
|
306
|
+
* package.json falls through to the secure defaults (env still wins).
|
|
307
|
+
*
|
|
308
|
+
* @param {string} appDir
|
|
309
|
+
* @returns {Promise<{ requestTimeout: number, headersTimeout: number, keepAliveTimeout: number }>}
|
|
310
|
+
*/
|
|
311
|
+
export async function readServerTimeoutsFromApp(appDir) {
|
|
312
|
+
let pkg;
|
|
313
|
+
try {
|
|
314
|
+
pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
315
|
+
} catch {
|
|
316
|
+
pkg = undefined;
|
|
317
|
+
}
|
|
318
|
+
return computeServerTimeouts(pkg);
|
|
319
|
+
}
|
|
320
|
+
|
|
197
321
|
/**
|
|
198
322
|
* Create a reusable, framework-agnostic request handler for a webjs app.
|
|
199
323
|
* The returned `handle(req)` takes a standard `Request` and resolves to a
|
|
@@ -204,10 +328,15 @@ export async function readElideEnabled(appDir) {
|
|
|
204
328
|
* appDir: string,
|
|
205
329
|
* dev?: boolean,
|
|
206
330
|
* logger?: import('./logger.js').Logger,
|
|
331
|
+
* onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
|
|
207
332
|
* onReload?: () => void,
|
|
208
333
|
* }} opts
|
|
209
334
|
*/
|
|
210
335
|
export async function createRequestHandler(opts) {
|
|
336
|
+
// Preflight: webjs needs Node 24+ (built-in TS strip + recursive fs.watch).
|
|
337
|
+
// Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno)
|
|
338
|
+
// gets the actionable message at boot, not a cryptic API failure mid-request.
|
|
339
|
+
assertNodeVersion({ onFail: 'throw' });
|
|
211
340
|
const appDir = resolve(opts.appDir);
|
|
212
341
|
// Load <appDir>/.env into process.env BEFORE anything else.
|
|
213
342
|
// buildActionIndex below imports server-only files (lib/*.server.ts,
|
|
@@ -217,8 +346,40 @@ export async function createRequestHandler(opts) {
|
|
|
217
346
|
// to boot until the user discovered the missing env-load. See
|
|
218
347
|
// tracker #37.
|
|
219
348
|
loadAppEnv(appDir);
|
|
349
|
+
// Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
|
|
350
|
+
// default-exports a typed schema or a custom validator function; we run it
|
|
351
|
+
// against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
|
|
352
|
+
// imports any server-only module. A failure throws a clear aggregated Error
|
|
353
|
+
// here, so an embedded host rejects at boot and the CLI exits non-zero,
|
|
354
|
+
// failing fast instead of crashing cryptically mid-request. Absent file is a
|
|
355
|
+
// no-op (opt-in). Coerced + defaulted values are written back to process.env.
|
|
356
|
+
await applyEnvValidation(appDir, { dev: !!opts.dev });
|
|
220
357
|
const dev = !!opts.dev;
|
|
221
358
|
const logger = opts.logger || defaultLogger({ dev });
|
|
359
|
+
// APM / Sentry integration point (issue #239). Called whenever the request
|
|
360
|
+
// pipeline catches an unhandled error: the top-level handle() catch (the
|
|
361
|
+
// last-resort 500), an unexpected throw inside the produce() funnel, or a
|
|
362
|
+
// middleware that threw. BEST-EFFORT by contract: a throwing onError is
|
|
363
|
+
// caught here so it can never crash the response, and the framework's own
|
|
364
|
+
// sanitized 500 / existing error behavior is unchanged (the hook is purely
|
|
365
|
+
// additive). The sink receives the error plus the correlation id so it can
|
|
366
|
+
// tie the report to the same id the access log and X-Request-Id carry.
|
|
367
|
+
const onError = typeof opts.onError === 'function' ? opts.onError : null;
|
|
368
|
+
/**
|
|
369
|
+
* Invoke the app's onError sink defensively. The phase is a coarse label of
|
|
370
|
+
* where the pipeline caught the error, for the sink's own grouping.
|
|
371
|
+
* @param {unknown} error
|
|
372
|
+
* @param {Request} request
|
|
373
|
+
* @param {string} phase
|
|
374
|
+
*/
|
|
375
|
+
function reportError(error, request, phase) {
|
|
376
|
+
if (!onError) return;
|
|
377
|
+
try {
|
|
378
|
+
onError(error, { request, requestId: getRequestId(), phase });
|
|
379
|
+
} catch (e) {
|
|
380
|
+
logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
222
383
|
const coreDir = locateCoreDir(appDir);
|
|
223
384
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
224
385
|
// URLs depending on whether the resolved @webjsdev/core install
|
|
@@ -285,10 +446,32 @@ export async function createRequestHandler(opts) {
|
|
|
285
446
|
// Hints, and WebSocket lookups need it available before the first request.
|
|
286
447
|
const routeTable = await buildRouteTable(appDir);
|
|
287
448
|
|
|
449
|
+
// Per-path response-header rules (issue #232), read once from the
|
|
450
|
+
// app's package.json `webjs.headers`. Static config, so no rebuild
|
|
451
|
+
// re-read; the secure defaults need no config and apply regardless.
|
|
452
|
+
const headerRules = await readHeaderRules(appDir);
|
|
453
|
+
|
|
454
|
+
// CSP config (issue #233), read once from the app's package.json
|
|
455
|
+
// `webjs.csp`. OFF by default: when disabled no nonce is minted and no
|
|
456
|
+
// Content-Security-Policy header is set, so an unconfigured app is
|
|
457
|
+
// unchanged. When enabled, `handle()` mints a fresh per-request nonce,
|
|
458
|
+
// makes it the value `cspNonce()` returns (so the SSR'd inline scripts
|
|
459
|
+
// carry it), and sets the matching header carrying the same nonce.
|
|
460
|
+
const cspConfig = await readCspConfigFromApp(appDir);
|
|
461
|
+
|
|
462
|
+
// Request body-size limits (issue #237), read once from the app's
|
|
463
|
+
// package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
|
|
464
|
+
// overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
|
|
465
|
+
// unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
|
|
466
|
+
// (used inside route handlers) enforces the same cap the RPC and page-action
|
|
467
|
+
// paths do.
|
|
468
|
+
const bodyLimits = await readBodyLimitsFromApp(appDir);
|
|
469
|
+
|
|
288
470
|
const state = {
|
|
289
471
|
routeTable,
|
|
290
472
|
actionIndex: null,
|
|
291
473
|
middleware: null,
|
|
474
|
+
bodyLimits,
|
|
292
475
|
logger,
|
|
293
476
|
moduleGraph: null,
|
|
294
477
|
elidableComponents: new Set(),
|
|
@@ -555,6 +738,134 @@ export async function createRequestHandler(opts) {
|
|
|
555
738
|
/** @param {Request} req */
|
|
556
739
|
function handle(req) {
|
|
557
740
|
return withRequest(req, async () => {
|
|
741
|
+
// Correlation id (issue #239): honor an inbound X-Request-Id from a
|
|
742
|
+
// trusted upstream proxy, else mint a fresh UUID. Stored on the request
|
|
743
|
+
// scope FIRST so everything downstream (the SSR, server actions, the
|
|
744
|
+
// access / error log, the onError sink, the response header) reads the
|
|
745
|
+
// same id, threading one trace id across services.
|
|
746
|
+
const reqId = resolveRequestId(req);
|
|
747
|
+
setRequestId(reqId);
|
|
748
|
+
|
|
749
|
+
// CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
|
|
750
|
+
// it on the request scope BEFORE producing the response, so the SSR
|
|
751
|
+
// pipeline's `cspNonce()` reads this exact value and stamps it on the
|
|
752
|
+
// inline boot script, the importmap, and the modulepreload hints.
|
|
753
|
+
// Disabled by default, so no nonce is minted and the response is
|
|
754
|
+
// unchanged. One minted value flows mint -> store -> SSR -> header.
|
|
755
|
+
const nonce = cspConfig.enabled ? mintNonce() : '';
|
|
756
|
+
if (nonce) setCspNonce(nonce);
|
|
757
|
+
|
|
758
|
+
// Make the resolved body-size limits (issue #237) readable from the
|
|
759
|
+
// request scope, so `readBody` inside a route handler enforces the same
|
|
760
|
+
// cap the framework's own RPC / page-action body reads do.
|
|
761
|
+
setBodyLimits(state.bodyLimits);
|
|
762
|
+
|
|
763
|
+
let pathname = '/';
|
|
764
|
+
try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
|
|
765
|
+
const startedAt = performance.now();
|
|
766
|
+
|
|
767
|
+
let res;
|
|
768
|
+
try {
|
|
769
|
+
res = await produce(req);
|
|
770
|
+
} catch (e) {
|
|
771
|
+
// A throw escaping produce() is the last-resort 500 (every interior
|
|
772
|
+
// path catches its own errors, but a surprise still must not crash the
|
|
773
|
+
// host). Fire the onError sink (best-effort) and emit a sanitized 500,
|
|
774
|
+
// preserving the prior behavior plus the new APM hook.
|
|
775
|
+
reportError(e, req, 'handle');
|
|
776
|
+
logger.error?.('[webjs] request pipeline threw', {
|
|
777
|
+
requestId: reqId,
|
|
778
|
+
method: req.method,
|
|
779
|
+
path: pathname,
|
|
780
|
+
err: e instanceof Error ? e.stack : String(e),
|
|
781
|
+
});
|
|
782
|
+
res = new Response('Server error', { status: 500 });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Merge in the secure-by-default headers plus the per-path config
|
|
786
|
+
// (issue #232) as the final step, so app middleware, route
|
|
787
|
+
// handlers, and `expose` headers (already on `res`) always win.
|
|
788
|
+
// Applied to every served response (documents, assets, the core
|
|
789
|
+
// runtime, probes), since the defaults are universally safe.
|
|
790
|
+
let merged = applySecurityHeaders(res, {
|
|
791
|
+
pathname,
|
|
792
|
+
https: webRequestIsHttps(req),
|
|
793
|
+
prod: !dev,
|
|
794
|
+
rules: headerRules,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Expose the correlation id on the response (issue #239) so a client /
|
|
798
|
+
// proxy can read it from the X-Request-Id header. Never clobber an id an
|
|
799
|
+
// upstream / the app already set on the response.
|
|
800
|
+
if (!merged.headers.has('x-request-id')) merged.headers.set('x-request-id', reqId);
|
|
801
|
+
|
|
802
|
+
// Emit the Content-Security-Policy header carrying the SAME minted
|
|
803
|
+
// nonce the SSR'd scripts got (no drift). Set only when CSP is
|
|
804
|
+
// enabled; never clobber a CSP header the app already set (in
|
|
805
|
+
// middleware, a route handler, or via the webjs.headers config), so
|
|
806
|
+
// an explicit app policy still wins.
|
|
807
|
+
if (nonce && !merged.headers.has('content-security-policy') &&
|
|
808
|
+
!merged.headers.has('content-security-policy-report-only')) {
|
|
809
|
+
// readCspConfig already drops a directive whose name/value carries a
|
|
810
|
+
// control char, so buildCspHeader produces a Headers-safe value. The
|
|
811
|
+
// try/catch is a belt-and-suspenders backstop: a surprise value must
|
|
812
|
+
// never throw the response pipeline (fail closed to no CSP header
|
|
813
|
+
// rather than a self-inflicted 500 on every request).
|
|
814
|
+
try {
|
|
815
|
+
merged.headers.set(cspHeaderName(cspConfig), buildCspHeader(cspConfig, nonce));
|
|
816
|
+
} catch {
|
|
817
|
+
/* a malformed policy must not take the request down: serve without CSP */
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Server HTML cache write (#241): if the SSR marked this response as a
|
|
822
|
+
// cache candidate (an opted-in `revalidate` page), store the FINAL body
|
|
823
|
+
// now, after segment middleware has added any per-user Set-Cookie (which
|
|
824
|
+
// the funnel's guard re-checks, so a per-user response is not cached) and
|
|
825
|
+
// before conditional-GET can swap it for a bodiless 304. The marker is
|
|
826
|
+
// stripped here regardless. Best-effort: a store failure is swallowed.
|
|
827
|
+
try {
|
|
828
|
+
const reqUrl = new URL(req.url);
|
|
829
|
+
merged = await commitHtmlCache(req, merged, reqUrl);
|
|
830
|
+
} catch { /* never let the cache write crash the response */ }
|
|
831
|
+
|
|
832
|
+
// Conditional GET (RFC 7232, issue #240): attach a content-hash ETag to
|
|
833
|
+
// a cacheable response missing one, and turn a matching If-None-Match
|
|
834
|
+
// into a 304 Not Modified with no body. Applied LAST, after every header
|
|
835
|
+
// (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
|
|
836
|
+
// 304 carries the validators a shared cache and the client router need.
|
|
837
|
+
// A no-store / per-user response, a non-GET/HEAD, and a streaming Suspense
|
|
838
|
+
// body are all skipped (see conditional-get.js). Logged with the final
|
|
839
|
+
// (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
|
|
840
|
+
let conditioned = merged;
|
|
841
|
+
try {
|
|
842
|
+
conditioned = await applyConditionalGet(req, merged);
|
|
843
|
+
} catch { /* never let validator computation crash the response */ }
|
|
844
|
+
|
|
845
|
+
// Structured access log (issue #239): ONE info line per handled request
|
|
846
|
+
// at the single response funnel, carrying only method / path / status /
|
|
847
|
+
// duration / requestId (no bodies, no secrets). Suppressed for the
|
|
848
|
+
// framework's own /__webjs/* probe + static traffic so it does not spam.
|
|
849
|
+
// Best-effort: a logger that throws must not take the response down.
|
|
850
|
+
if (shouldAccessLog(pathname)) {
|
|
851
|
+
try {
|
|
852
|
+
logger.info?.('request', {
|
|
853
|
+
requestId: reqId,
|
|
854
|
+
method: req.method,
|
|
855
|
+
path: pathname,
|
|
856
|
+
status: conditioned.status,
|
|
857
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
858
|
+
});
|
|
859
|
+
} catch { /* never let logging crash the response */ }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return conditioned;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** @param {Request} req */
|
|
867
|
+
function produce(req) {
|
|
868
|
+
return (async () => {
|
|
558
869
|
// Health and readiness probes are answered BEFORE ensureReady so a probe
|
|
559
870
|
// never blocks on the analysis. `/__webjs/health` is liveness (the
|
|
560
871
|
// process is up and accepting connections). `/__webjs/ready` is 503 until
|
|
@@ -574,6 +885,13 @@ export async function createRequestHandler(opts) {
|
|
|
574
885
|
if (probePath === '/__webjs/health') {
|
|
575
886
|
return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
|
|
576
887
|
}
|
|
888
|
+
// Build-info probe (issue #239): which build is live? Answered before
|
|
889
|
+
// ensureReady like the other probes (it depends only on the package
|
|
890
|
+
// version + already-published build id + process info, never the app
|
|
891
|
+
// analysis). No secrets.
|
|
892
|
+
if (probePath === '/__webjs/version') {
|
|
893
|
+
return buildInfoResponse();
|
|
894
|
+
}
|
|
577
895
|
if (probePath === '/__webjs/ready') {
|
|
578
896
|
const noStore = { 'cache-control': 'no-store' };
|
|
579
897
|
if (!readyDone) {
|
|
@@ -613,17 +931,18 @@ export async function createRequestHandler(opts) {
|
|
|
613
931
|
// Build all whole-app analysis on the first request (memoized), before
|
|
614
932
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
615
933
|
await ensureReady();
|
|
616
|
-
const next = () => handleCore(req, { state, appDir, coreDir, dev });
|
|
934
|
+
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, cspEnabled: cspConfig.enabled });
|
|
617
935
|
if (state.middleware) {
|
|
618
936
|
try {
|
|
619
937
|
return await state.middleware(req, next);
|
|
620
938
|
} catch (e) {
|
|
621
|
-
|
|
939
|
+
reportError(e, req, 'middleware');
|
|
940
|
+
logger.error('middleware threw', { err: String(e), requestId: getRequestId() });
|
|
622
941
|
return new Response('Server error', { status: 500 });
|
|
623
942
|
}
|
|
624
943
|
}
|
|
625
944
|
return next();
|
|
626
|
-
});
|
|
945
|
+
})();
|
|
627
946
|
}
|
|
628
947
|
|
|
629
948
|
/**
|
|
@@ -684,6 +1003,7 @@ export async function createRequestHandler(opts) {
|
|
|
684
1003
|
* dev?: boolean,
|
|
685
1004
|
* compress?: boolean,
|
|
686
1005
|
* logger?: import('./logger.js').Logger,
|
|
1006
|
+
* onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
|
|
687
1007
|
* }} opts
|
|
688
1008
|
*/
|
|
689
1009
|
export async function startServer(opts) {
|
|
@@ -800,6 +1120,20 @@ export async function startServer(opts) {
|
|
|
800
1120
|
}
|
|
801
1121
|
});
|
|
802
1122
|
|
|
1123
|
+
// Inbound server timeouts (issue #237), node:http built-ins. Defends against
|
|
1124
|
+
// slowloris and hung connections: `requestTimeout` bounds the time to receive
|
|
1125
|
+
// the WHOLE request, `headersTimeout` the time to receive just the headers
|
|
1126
|
+
// (node measures both from the same request start, so it is kept strictly
|
|
1127
|
+
// under requestTimeout to actually fire), and `keepAliveTimeout` the idle
|
|
1128
|
+
// window before a kept-alive socket is closed. Secure production defaults,
|
|
1129
|
+
// overridable via `webjs.requestTimeoutMs` / `headersTimeoutMs` /
|
|
1130
|
+
// `keepAliveTimeoutMs` in package.json or the matching WEBJS_*_MS env vars.
|
|
1131
|
+
// A value of 0 disables that timeout (node's own no-limit sentinel).
|
|
1132
|
+
const timeouts = await readServerTimeoutsFromApp(app.appDir);
|
|
1133
|
+
server.requestTimeout = timeouts.requestTimeout;
|
|
1134
|
+
server.headersTimeout = timeouts.headersTimeout;
|
|
1135
|
+
server.keepAliveTimeout = timeouts.keepAliveTimeout;
|
|
1136
|
+
|
|
803
1137
|
// WebSocket upgrade handling: any route.js that exports `WS` becomes a
|
|
804
1138
|
// WebSocket endpoint at its URL.
|
|
805
1139
|
attachWebSocket(server, () => app.getRouteTable(), { dev, logger });
|
|
@@ -920,7 +1254,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
920
1254
|
}
|
|
921
1255
|
|
|
922
1256
|
async function handleCore(req, ctx) {
|
|
923
|
-
const { state, appDir, coreDir, dev } = ctx;
|
|
1257
|
+
const { state, appDir, coreDir, dev, reportError, cspEnabled } = ctx;
|
|
924
1258
|
const url = new URL(req.url);
|
|
925
1259
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
926
1260
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -945,7 +1279,10 @@ async function handleCore(req, ctx) {
|
|
|
945
1279
|
const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
|
|
946
1280
|
if (actMatch) {
|
|
947
1281
|
if (method !== 'POST') return new Response('POST only', { status: 405 });
|
|
948
|
-
|
|
1282
|
+
// Pass the onError sink (issue #239): a server action that throws
|
|
1283
|
+
// unexpectedly is reported to the APM hook before the sanitized 500.
|
|
1284
|
+
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1285
|
+
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
|
|
949
1286
|
}
|
|
950
1287
|
|
|
951
1288
|
// expose()d server actions (first-class REST), with optional CORS support.
|
|
@@ -966,7 +1303,12 @@ async function handleCore(req, ctx) {
|
|
|
966
1303
|
} else {
|
|
967
1304
|
const exposed = matchExposedAction(state.actionIndex, method, path);
|
|
968
1305
|
if (exposed) {
|
|
969
|
-
|
|
1306
|
+
// Pass the onError sink (issue #239): an exposed REST handler that throws
|
|
1307
|
+
// unexpectedly is reported to the APM hook before the sanitized 500, the
|
|
1308
|
+
// same as the RPC action path (phase 'action' covers both server-action
|
|
1309
|
+
// invocation shapes).
|
|
1310
|
+
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1311
|
+
const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req, onActionError);
|
|
970
1312
|
return withCors(resp, exposed.route, req);
|
|
971
1313
|
}
|
|
972
1314
|
}
|
|
@@ -1090,6 +1432,7 @@ async function handleCore(req, ctx) {
|
|
|
1090
1432
|
});
|
|
1091
1433
|
}
|
|
1092
1434
|
} catch (e) {
|
|
1435
|
+
if (reportError) reportError(e, req, 'metadata');
|
|
1093
1436
|
if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
|
|
1094
1437
|
return new Response('Internal error', { status: 500 });
|
|
1095
1438
|
}
|
|
@@ -1103,17 +1446,39 @@ async function handleCore(req, ctx) {
|
|
|
1103
1446
|
return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
|
|
1104
1447
|
}
|
|
1105
1448
|
|
|
1106
|
-
// Page route
|
|
1107
|
-
|
|
1449
|
+
// Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
|
|
1450
|
+
// is only routed here when the page module exports an `action` (#244); the
|
|
1451
|
+
// action runs inside the page's segment middleware, then either PRG-redirects
|
|
1452
|
+
// (303) on success, re-renders the same page (422) with field errors on
|
|
1453
|
+
// failure, or honors a thrown redirect()/notFound(). Without an `action`
|
|
1454
|
+
// export, a non-GET/HEAD request falls through to the 404 below, unchanged.
|
|
1455
|
+
{
|
|
1108
1456
|
const page = matchPage(state.routeTable, path);
|
|
1109
1457
|
if (page) {
|
|
1110
|
-
const
|
|
1111
|
-
dev, appDir,
|
|
1458
|
+
const ssrOpts = {
|
|
1459
|
+
dev, appDir, moduleGraph: state.moduleGraph,
|
|
1112
1460
|
serverFiles: state.actionIndex.fileToHash,
|
|
1113
1461
|
elidableComponents: state.elidableComponents,
|
|
1114
1462
|
inertRouteModules: state.inertRouteModules,
|
|
1115
|
-
|
|
1116
|
-
|
|
1463
|
+
notFoundFile: state.routeTable.notFound,
|
|
1464
|
+
// Server HTML cache (#241): a CSP-enabled page emits a fresh
|
|
1465
|
+
// per-request nonce into its body, so its bytes vary per request and
|
|
1466
|
+
// it must never be HTML-cached. Pass the flag so the cache guard skips
|
|
1467
|
+
// it. CSP is off by default, so the common case stays cacheable.
|
|
1468
|
+
cspEnabled,
|
|
1469
|
+
// onError sink (issue #239): a page render error that becomes a 500 is
|
|
1470
|
+
// reported to the APM hook with the active request's correlation id.
|
|
1471
|
+
onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
|
|
1472
|
+
};
|
|
1473
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
1474
|
+
const handler = () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
1475
|
+
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
1476
|
+
}
|
|
1477
|
+
const loaded = await loadPageAction(page.route.file, dev);
|
|
1478
|
+
if (loaded) {
|
|
1479
|
+
const handler = () => runPageAction(page.route, page.params, url, loaded, req, ssrOpts);
|
|
1480
|
+
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
1481
|
+
}
|
|
1117
1482
|
}
|
|
1118
1483
|
}
|
|
1119
1484
|
|
|
@@ -1372,16 +1737,15 @@ async function fileResponse(abs, opts) {
|
|
|
1372
1737
|
try {
|
|
1373
1738
|
const data = await readFile(abs);
|
|
1374
1739
|
const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1740
|
+
// The body is fully buffered (read into `data`), so opt it into the
|
|
1741
|
+
// conditional-GET funnel, which is the single place that hashes the bytes
|
|
1742
|
+
// into a weak ETag and honors If-None-Match -> 304 (dev + prod alike).
|
|
1743
|
+
const headers = { 'content-type': type, [BUFFERED_MARKER]: '1' };
|
|
1744
|
+
headers['cache-control'] = opts.dev
|
|
1745
|
+
? 'no-cache'
|
|
1746
|
+
: opts.immutable
|
|
1382
1747
|
? 'public, max-age=31536000, immutable'
|
|
1383
1748
|
: 'public, max-age=3600';
|
|
1384
|
-
}
|
|
1385
1749
|
return new Response(data, { status: 200, headers });
|
|
1386
1750
|
} catch {
|
|
1387
1751
|
return new Response('Not found', { status: 404 });
|
|
@@ -1406,13 +1770,13 @@ async function jsModuleResponse(abs, dev, elideOpts) {
|
|
|
1406
1770
|
const code = elideImportsFromSource(
|
|
1407
1771
|
source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
1408
1772
|
);
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
}
|
|
1773
|
+
// Buffered (string) body, so opt into the conditional-GET funnel for the
|
|
1774
|
+
// weak ETag + 304 (see fileResponse).
|
|
1775
|
+
const headers = {
|
|
1776
|
+
'content-type': 'application/javascript; charset=utf-8',
|
|
1777
|
+
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
1778
|
+
[BUFFERED_MARKER]: '1',
|
|
1779
|
+
};
|
|
1416
1780
|
return new Response(code, { status: 200, headers });
|
|
1417
1781
|
}
|
|
1418
1782
|
|
|
@@ -1440,7 +1804,7 @@ async function exists(p) {
|
|
|
1440
1804
|
* @returns {Promise<string>}
|
|
1441
1805
|
*/
|
|
1442
1806
|
async function stripTs(source, _abs) {
|
|
1443
|
-
return stripTypeScriptTypes(source);
|
|
1807
|
+
return nodeModule.stripTypeScriptTypes(source);
|
|
1444
1808
|
}
|
|
1445
1809
|
|
|
1446
1810
|
/**
|
|
@@ -1465,6 +1829,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
|
|
|
1465
1829
|
headers: {
|
|
1466
1830
|
'content-type': 'application/javascript; charset=utf-8',
|
|
1467
1831
|
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
1832
|
+
[BUFFERED_MARKER]: '1',
|
|
1468
1833
|
},
|
|
1469
1834
|
});
|
|
1470
1835
|
}
|
|
@@ -1521,6 +1886,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
|
|
|
1521
1886
|
headers: {
|
|
1522
1887
|
'content-type': 'application/javascript; charset=utf-8',
|
|
1523
1888
|
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
1889
|
+
[BUFFERED_MARKER]: '1',
|
|
1524
1890
|
},
|
|
1525
1891
|
});
|
|
1526
1892
|
}
|