@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/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, stripTypeScriptTypes } from 'node:module';
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 { withRequest } from './context.js';
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
- logger.error('middleware threw', { err: String(e) });
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
- return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req);
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
- const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req);
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 (only for GET/HEAD)
1107
- if (method === 'GET' || method === 'HEAD') {
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 handler = () => ssrPage(page.route, page.params, url, {
1111
- dev, appDir, req, moduleGraph: state.moduleGraph,
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
- return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
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
- const headers = { 'content-type': type };
1376
- if (opts.dev) {
1377
- headers['cache-control'] = 'no-cache';
1378
- } else {
1379
- const etag = `"${(await digestHex('SHA-1', data)).slice(0, 16)}"`;
1380
- headers['etag'] = etag;
1381
- headers['cache-control'] = opts.immutable
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
- const headers = { 'content-type': 'application/javascript; charset=utf-8' };
1410
- if (dev) {
1411
- headers['cache-control'] = 'no-cache';
1412
- } else {
1413
- headers['etag'] = `"${(await digestHex('SHA-1', code)).slice(0, 16)}"`;
1414
- headers['cache-control'] = 'public, max-age=3600';
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
  }