@webjsdev/server 0.8.10 → 0.8.12

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,7 +1,6 @@
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
6
  import { createRequire } from 'node:module';
@@ -48,6 +47,7 @@ process.emitWarning = function (warning, type, code, ctor) {
48
47
  };
49
48
 
50
49
  import { buildRouteTable, matchPage, matchApi } from './router.js';
50
+ import { generateRouteTypes } from './route-types.js';
51
51
  import { ssrPage, ssrNotFound } from './ssr.js';
52
52
  import { loadPageAction, runPageAction } from './page-action.js';
53
53
  import { handleApi } from './api.js';
@@ -68,7 +68,8 @@ import {
68
68
  import { defaultLogger } from './logger.js';
69
69
  import { assertNodeVersion } from './node-version.js';
70
70
  import { applyEnvValidation } from './env-schema.js';
71
- import { withRequest, setCspNonce, setBodyLimits } from './context.js';
71
+ import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from './context.js';
72
+ import { buildInfoResponse } from './build-info.js';
72
73
  import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
73
74
  import { attachWebSocket } from './websocket.js';
74
75
  import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
@@ -80,10 +81,51 @@ import { analyzeElision, elideImportsFromSource } from './component-elision.js';
80
81
  function kebab(name) {
81
82
  return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
82
83
  }
83
- import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js';
84
+
85
+ /**
86
+ * Per-request correlation id (issue #239). Honor an inbound `X-Request-Id`
87
+ * from a trusted upstream proxy so a trace id propagates across services; mint
88
+ * a fresh `crypto.randomUUID()` otherwise. The inbound value is length-capped
89
+ * and validated against a conservative token charset so a hostile client
90
+ * cannot inject control chars / a header-splitting payload (the value is
91
+ * echoed back in the `X-Request-Id` response header). On any mismatch we fall
92
+ * back to a minted id rather than trust the junk.
93
+ *
94
+ * @param {Request} req
95
+ * @returns {string}
96
+ */
97
+ function resolveRequestId(req) {
98
+ const inbound = req.headers.get('x-request-id');
99
+ if (inbound && inbound.length <= 200 && /^[A-Za-z0-9._-]+$/.test(inbound)) return inbound;
100
+ return crypto.randomUUID();
101
+ }
102
+
103
+ /**
104
+ * Whether a path should be access-logged (issue #239). The framework's own
105
+ * `/__webjs/*` probes, static runtime assets, and the dev SSE reload stream
106
+ * are high-frequency infrastructure traffic, not app requests, so logging them
107
+ * would just spam the access log. App routes (including app-authored
108
+ * `/api/*`) are logged.
109
+ *
110
+ * @param {string} pathname
111
+ * @returns {boolean}
112
+ */
113
+ function shouldAccessLog(pathname) {
114
+ return !pathname.startsWith('/__webjs/');
115
+ }
116
+ import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
117
+ import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
84
118
  import { urlFromRequest } from './forwarded.js';
85
119
  import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
120
+ import {
121
+ compileRedirectRules,
122
+ applyRedirects,
123
+ readTrailingSlashPolicy,
124
+ applyTrailingSlash,
125
+ } from './redirects.js';
86
126
  import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
127
+ import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
128
+ import { commitHtmlCache } from './html-cache.js';
87
129
 
88
130
  const MIME = {
89
131
  '.html': 'text/html; charset=utf-8',
@@ -227,6 +269,61 @@ export async function readHeaderRules(appDir) {
227
269
  }
228
270
  }
229
271
 
272
+ /**
273
+ * Read the declarative redirect config (`webjs.redirects`) from the app's
274
+ * package.json and compile it to URLPattern rules (issue #254). A missing,
275
+ * malformed, or unreadable config yields an empty rule set (no redirects),
276
+ * never a throw. Patterns are compiled ONCE here at boot, not per request.
277
+ *
278
+ * @param {string} appDir
279
+ * @returns {Promise<ReturnType<typeof compileRedirectRules>>}
280
+ */
281
+ export async function readRedirectRules(appDir) {
282
+ try {
283
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
284
+ return compileRedirectRules(pkg);
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Read the trailing-slash policy (`webjs.trailingSlash`) from the app's
292
+ * package.json (issue #255). A missing, malformed, or unreadable config
293
+ * yields `'ignore'` (no canonicalization), never a throw, so an
294
+ * unconfigured app is unchanged.
295
+ *
296
+ * @param {string} appDir
297
+ * @returns {Promise<'never' | 'always' | 'ignore'>}
298
+ */
299
+ export async function readTrailingSlashFromApp(appDir) {
300
+ try {
301
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
302
+ return readTrailingSlashPolicy(pkg);
303
+ } catch {
304
+ return 'ignore';
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Read the sub-path base path (`webjs.basePath`) from the app's
310
+ * package.json (issue #256). A missing, malformed, or unreadable config
311
+ * yields `''` (root mount), never a throw, so an unconfigured app is
312
+ * byte-identical to before this feature. Normalized to `''` or
313
+ * `/segment[/segment...]` by `readBasePath`.
314
+ *
315
+ * @param {string} appDir
316
+ * @returns {Promise<string>}
317
+ */
318
+ export async function readBasePathFromApp(appDir) {
319
+ try {
320
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
321
+ return readBasePath(pkg);
322
+ } catch {
323
+ return '';
324
+ }
325
+ }
326
+
230
327
  /**
231
328
  * Read the CSP config (`webjs.csp`) from the app's package.json and
232
329
  * normalize it (issue #233). A missing, malformed, or unreadable config
@@ -294,6 +391,7 @@ export async function readServerTimeoutsFromApp(appDir) {
294
391
  * appDir: string,
295
392
  * dev?: boolean,
296
393
  * logger?: import('./logger.js').Logger,
394
+ * onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
297
395
  * onReload?: () => void,
298
396
  * }} opts
299
397
  */
@@ -321,6 +419,39 @@ export async function createRequestHandler(opts) {
321
419
  await applyEnvValidation(appDir, { dev: !!opts.dev });
322
420
  const dev = !!opts.dev;
323
421
  const logger = opts.logger || defaultLogger({ dev });
422
+ // APM / Sentry integration point (issue #239). Called whenever the request
423
+ // pipeline catches an unhandled error: the top-level handle() catch (the
424
+ // last-resort 500), an unexpected throw inside the produce() funnel, or a
425
+ // middleware that threw. BEST-EFFORT by contract: a throwing onError is
426
+ // caught here so it can never crash the response, and the framework's own
427
+ // sanitized 500 / existing error behavior is unchanged (the hook is purely
428
+ // additive). The sink receives the error plus the correlation id so it can
429
+ // tie the report to the same id the access log and X-Request-Id carry.
430
+ const onError = typeof opts.onError === 'function' ? opts.onError : null;
431
+ /**
432
+ * Invoke the app's onError sink defensively. The phase is a coarse label of
433
+ * where the pipeline caught the error, for the sink's own grouping.
434
+ * @param {unknown} error
435
+ * @param {Request} request
436
+ * @param {string} phase
437
+ */
438
+ function reportError(error, request, phase) {
439
+ if (!onError) return;
440
+ try {
441
+ onError(error, { request, requestId: getRequestId(), phase });
442
+ } catch (e) {
443
+ logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
444
+ }
445
+ }
446
+ // Sub-path deployment base path (issue #256), read once from the app's
447
+ // package.json `webjs.basePath` and bound into the importmap builder BEFORE
448
+ // setCoreInstall / setVendorEntries so every importmap target (and the
449
+ // recomputed hash) reflects the prefix. Empty (the default) makes both the
450
+ // ingress strip and the emit-side prefix pure no-ops, so an unconfigured app
451
+ // is byte-identical to before this feature.
452
+ const basePathValue = await readBasePathFromApp(appDir);
453
+ await setBasePath(basePathValue);
454
+
324
455
  const coreDir = locateCoreDir(appDir);
325
456
  // Switch the importmap between dist/ bundles and src/ per-file
326
457
  // URLs depending on whether the resolved @webjsdev/core install
@@ -387,11 +518,53 @@ export async function createRequestHandler(opts) {
387
518
  // Hints, and WebSocket lookups need it available before the first request.
388
519
  const routeTable = await buildRouteTable(appDir);
389
520
 
521
+ // Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
522
+ // dev so an editor's tsserver always has up-to-date route types without the
523
+ // developer remembering to run `webjs types`. Best-effort and fire-and-
524
+ // forget: a failure logs and never blocks boot. Re-emitted after each route
525
+ // rebuild (see doRebuild) so adding/removing a route refreshes the types.
526
+ /** @returns {Promise<void>} */
527
+ async function emitRouteTypes() {
528
+ try {
529
+ const { mkdir, writeFile, rename } = await import('node:fs/promises');
530
+ const text = await generateRouteTypes(appDir);
531
+ const outDir = join(appDir, '.webjs');
532
+ await mkdir(outDir, { recursive: true });
533
+ // Write to a temp sibling then rename, so tsserver (which reads this
534
+ // file) never observes a half-written body if two rebuilds race. rename
535
+ // is atomic within the same dir. Both paths sit under the watcher-ignored
536
+ // .webjs/, so neither the temp write nor the rename re-triggers a rebuild.
537
+ const dest = join(outDir, 'routes.d.ts');
538
+ const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`);
539
+ await writeFile(tmp, text);
540
+ await rename(tmp, dest);
541
+ } catch (e) {
542
+ logger.warn?.(`[webjs] could not write .webjs/routes.d.ts (route types): ${e?.message || e}`);
543
+ }
544
+ }
545
+ if (dev) void emitRouteTypes();
546
+
390
547
  // Per-path response-header rules (issue #232), read once from the
391
548
  // app's package.json `webjs.headers`. Static config, so no rebuild
392
549
  // re-read; the secure defaults need no config and apply regardless.
393
550
  const headerRules = await readHeaderRules(appDir);
394
551
 
552
+ // Declarative redirect rules (issue #254), read once from the app's
553
+ // package.json `webjs.redirects` and compiled to URLPattern rules at
554
+ // boot (never per request). Empty when unconfigured, so an app with no
555
+ // redirects is unchanged. Applied at the very start of request handling,
556
+ // before routing / SSR / asset serving, so a moved URL returns a 308/307
557
+ // immediately.
558
+ const redirectRules = await readRedirectRules(appDir);
559
+
560
+ // Trailing-slash policy (issue #255), read once from the app's package.json
561
+ // `webjs.trailingSlash`. Default `'ignore'` (no canonicalization), so an
562
+ // unconfigured app is unchanged; `'never'` (recommended) strips a trailing
563
+ // slash and `'always'` adds one, each via a 308 to the canonical form.
564
+ // Applied in produce() AFTER the declarative redirects, so an explicit
565
+ // redirect rule wins first and the two never loop.
566
+ const trailingSlashPolicy = await readTrailingSlashFromApp(appDir);
567
+
395
568
  // CSP config (issue #233), read once from the app's package.json
396
569
  // `webjs.csp`. OFF by default: when disabled no nonce is minted and no
397
570
  // Content-Security-Policy header is set, so an unconfigured app is
@@ -656,6 +829,10 @@ export async function createRequestHandler(opts) {
656
829
  // The route table is the only eager artifact (cheap directory scan); rebuild
657
830
  // it so routing reflects added/removed route files immediately.
658
831
  state.routeTable = await buildRouteTable(appDir);
832
+ // Refresh the generated route types (#258) so adding/removing a route file
833
+ // updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
834
+ // best-effort (see emitRouteTypes).
835
+ if (dev) void emitRouteTypes();
659
836
  clearVendorCache();
660
837
  state.tsCache.clear();
661
838
  // Invalidate the lazy analysis; the next request rebuilds the graph,
@@ -679,6 +856,14 @@ export async function createRequestHandler(opts) {
679
856
  /** @param {Request} req */
680
857
  function handle(req) {
681
858
  return withRequest(req, async () => {
859
+ // Correlation id (issue #239): honor an inbound X-Request-Id from a
860
+ // trusted upstream proxy, else mint a fresh UUID. Stored on the request
861
+ // scope FIRST so everything downstream (the SSR, server actions, the
862
+ // access / error log, the onError sink, the response header) reads the
863
+ // same id, threading one trace id across services.
864
+ const reqId = resolveRequestId(req);
865
+ setRequestId(reqId);
866
+
682
867
  // CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
683
868
  // it on the request scope BEFORE producing the response, so the SSR
684
869
  // pipeline's `cspNonce()` reads this exact value and stamps it on the
@@ -693,20 +878,58 @@ export async function createRequestHandler(opts) {
693
878
  // cap the framework's own RPC / page-action body reads do.
694
879
  setBodyLimits(state.bodyLimits);
695
880
 
696
- const res = await produce(req);
881
+ let pathname = '/';
882
+ try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
883
+ // Sub-path deployment (issue #256): the per-path response-header rules
884
+ // (`webjs.headers`) author their `source` patterns app-root-relative,
885
+ // exactly like `webjs.redirects` / `webjs.trailingSlash` (which the
886
+ // ingress strip in produce() already sees root-relative). So match the
887
+ // header rules against the STRIPPED path too, keeping the whole config
888
+ // surface consistent under a base path. `pathname` itself (used for the
889
+ // access log) stays the RAW path the client hit. No-op when basePath is
890
+ // empty or the request is not under it.
891
+ let headerPathname = pathname;
892
+ if (basePathValue) {
893
+ const s = stripBasePath(pathname, basePathValue);
894
+ if (s !== null) headerPathname = s;
895
+ }
896
+ const startedAt = performance.now();
897
+
898
+ let res;
899
+ try {
900
+ res = await produce(req);
901
+ } catch (e) {
902
+ // A throw escaping produce() is the last-resort 500 (every interior
903
+ // path catches its own errors, but a surprise still must not crash the
904
+ // host). Fire the onError sink (best-effort) and emit a sanitized 500,
905
+ // preserving the prior behavior plus the new APM hook.
906
+ reportError(e, req, 'handle');
907
+ logger.error?.('[webjs] request pipeline threw', {
908
+ requestId: reqId,
909
+ method: req.method,
910
+ path: pathname,
911
+ err: e instanceof Error ? e.stack : String(e),
912
+ });
913
+ res = new Response('Server error', { status: 500 });
914
+ }
915
+
697
916
  // Merge in the secure-by-default headers plus the per-path config
698
917
  // (issue #232) as the final step, so app middleware, route
699
918
  // handlers, and `expose` headers (already on `res`) always win.
700
919
  // Applied to every served response (documents, assets, the core
701
920
  // runtime, probes), since the defaults are universally safe.
702
- let pathname = '/';
703
- try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
704
- const merged = applySecurityHeaders(res, {
705
- pathname,
921
+ let merged = applySecurityHeaders(res, {
922
+ pathname: headerPathname,
706
923
  https: webRequestIsHttps(req),
707
924
  prod: !dev,
708
925
  rules: headerRules,
709
926
  });
927
+
928
+ // Expose the correlation id on the response (issue #239) so a client /
929
+ // proxy can read it from the X-Request-Id header. Never clobber an id an
930
+ // upstream / the app already set on the response.
931
+ if (!merged.headers.has('x-request-id')) merged.headers.set('x-request-id', reqId);
932
+
710
933
  // Emit the Content-Security-Policy header carrying the SAME minted
711
934
  // nonce the SSR'd scripts got (no drift). Set only when CSP is
712
935
  // enabled; never clobber a CSP header the app already set (in
@@ -725,13 +948,129 @@ export async function createRequestHandler(opts) {
725
948
  /* a malformed policy must not take the request down: serve without CSP */
726
949
  }
727
950
  }
728
- return merged;
951
+
952
+ // Server HTML cache write (#241): if the SSR marked this response as a
953
+ // cache candidate (an opted-in `revalidate` page), store the FINAL body
954
+ // now, after segment middleware has added any per-user Set-Cookie (which
955
+ // the funnel's guard re-checks, so a per-user response is not cached) and
956
+ // before conditional-GET can swap it for a bodiless 304. The marker is
957
+ // stripped here regardless. Best-effort: a store failure is swallowed.
958
+ try {
959
+ const reqUrl = new URL(req.url);
960
+ // Sub-path deployment (issue #256): the HTML cache READ (in ssrPage)
961
+ // and `revalidatePath` both key on the app-root-relative path, so key
962
+ // the WRITE on the stripped path too, or a cached page would never
963
+ // hit. No-op when basePath is empty / the path is not under it.
964
+ if (basePathValue) {
965
+ const s = stripBasePath(reqUrl.pathname, basePathValue);
966
+ if (s !== null) reqUrl.pathname = s;
967
+ }
968
+ merged = await commitHtmlCache(req, merged, reqUrl);
969
+ } catch { /* never let the cache write crash the response */ }
970
+
971
+ // Conditional GET (RFC 7232, issue #240): attach a content-hash ETag to
972
+ // a cacheable response missing one, and turn a matching If-None-Match
973
+ // into a 304 Not Modified with no body. Applied LAST, after every header
974
+ // (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
975
+ // 304 carries the validators a shared cache and the client router need.
976
+ // A no-store / per-user response, a non-GET/HEAD, and a streaming Suspense
977
+ // body are all skipped (see conditional-get.js). Logged with the final
978
+ // (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
979
+ let conditioned = merged;
980
+ try {
981
+ conditioned = await applyConditionalGet(req, merged);
982
+ } catch { /* never let validator computation crash the response */ }
983
+
984
+ // Structured access log (issue #239): ONE info line per handled request
985
+ // at the single response funnel, carrying only method / path / status /
986
+ // duration / requestId (no bodies, no secrets). Suppressed for the
987
+ // framework's own /__webjs/* probe + static traffic so it does not spam.
988
+ // Best-effort: a logger that throws must not take the response down.
989
+ // Use the STRIPPED path for the suppression check (issue #256) so a
990
+ // framework probe at `<basePath>/__webjs/*` is suppressed just like the
991
+ // root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
992
+ if (shouldAccessLog(headerPathname)) {
993
+ try {
994
+ logger.info?.('request', {
995
+ requestId: reqId,
996
+ method: req.method,
997
+ path: pathname,
998
+ status: conditioned.status,
999
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1000
+ });
1001
+ } catch { /* never let logging crash the response */ }
1002
+ }
1003
+
1004
+ return conditioned;
729
1005
  });
730
1006
  }
731
1007
 
732
1008
  /** @param {Request} req */
733
1009
  function produce(req) {
734
1010
  return (async () => {
1011
+ // Sub-path deployment ingress strip (issue #256). When `webjs.basePath`
1012
+ // is set and the request path is under it, STRIP the prefix and rewrite
1013
+ // the Request so EVERYTHING downstream (redirects, trailing-slash, the
1014
+ // probes, the `/__webjs/*` checks, the source-file gate, route matching,
1015
+ // SSR) sees a ROOT-relative path and works UNCHANGED. This single strip
1016
+ // is why the rest of the framework needs no per-site changes. A request
1017
+ // whose path is NOT under the base path is not for this mounted app, so
1018
+ // return a 404 (the safe default for a mounted app). Empty basePath (the
1019
+ // default) is a pure pass-through, so an unconfigured app is unchanged.
1020
+ if (basePathValue) {
1021
+ let reqUrl;
1022
+ try { reqUrl = new URL(req.url); } catch { reqUrl = null; }
1023
+ if (reqUrl) {
1024
+ const stripped = stripBasePath(reqUrl.pathname, basePathValue);
1025
+ if (stripped === null) {
1026
+ // Not under the base path: this request is not for this app.
1027
+ return new Response('Not found', {
1028
+ status: 404,
1029
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1030
+ });
1031
+ }
1032
+ if (stripped !== reqUrl.pathname) {
1033
+ reqUrl.pathname = stripped;
1034
+ // Rewrite the Request with the stripped URL, preserving method,
1035
+ // headers, and body. `duplex: 'half'` is required by the spec when
1036
+ // a body stream is present on a non-GET/HEAD request.
1037
+ const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
1038
+ req = new Request(
1039
+ reqUrl.toString(),
1040
+ /** @type {any} */ ({
1041
+ method: req.method,
1042
+ headers: req.headers,
1043
+ body: hasBody ? req.body : undefined,
1044
+ duplex: hasBody ? 'half' : undefined,
1045
+ redirect: req.redirect,
1046
+ signal: req.signal,
1047
+ }),
1048
+ );
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ // Declarative redirects (issue #254): apply the configured old-path ->
1054
+ // new-path rules at the VERY START of request handling, before the
1055
+ // probes, routing, SSR, or asset serving. A matched source returns a
1056
+ // 308 (permanent, the SEO default) / 307 (temporary) / configured
1057
+ // status immediately, so a moved URL never reaches the router.
1058
+ // `applyRedirects` skips /__webjs/* itself, so the framework probes /
1059
+ // runtime below are never redirected. The secure-header + conditional-GET
1060
+ // funnel in handle() still wraps this Response, like any other.
1061
+ const redirectResp = applyRedirects(req, redirectRules);
1062
+ if (redirectResp) return redirectResp;
1063
+
1064
+ // Trailing-slash canonicalization (issue #255): after the explicit
1065
+ // redirects above (so an explicit rule wins first and the two never
1066
+ // form a loop), 308-redirect a non-canonical path to the policy's
1067
+ // canonical form (`never` strips a trailing slash, `always` adds one).
1068
+ // Default `'ignore'` is a no-op. The root `/` and file paths are
1069
+ // exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
1070
+ // above already skip it). The funnel in handle() still wraps this.
1071
+ const slashResp = applyTrailingSlash(req, trailingSlashPolicy);
1072
+ if (slashResp) return slashResp;
1073
+
735
1074
  // Health and readiness probes are answered BEFORE ensureReady so a probe
736
1075
  // never blocks on the analysis. `/__webjs/health` is liveness (the
737
1076
  // process is up and accepting connections). `/__webjs/ready` is 503 until
@@ -751,6 +1090,13 @@ export async function createRequestHandler(opts) {
751
1090
  if (probePath === '/__webjs/health') {
752
1091
  return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
753
1092
  }
1093
+ // Build-info probe (issue #239): which build is live? Answered before
1094
+ // ensureReady like the other probes (it depends only on the package
1095
+ // version + already-published build id + process info, never the app
1096
+ // analysis). No secrets.
1097
+ if (probePath === '/__webjs/version') {
1098
+ return buildInfoResponse();
1099
+ }
754
1100
  if (probePath === '/__webjs/ready') {
755
1101
  const noStore = { 'cache-control': 'no-store' };
756
1102
  if (!readyDone) {
@@ -790,12 +1136,13 @@ export async function createRequestHandler(opts) {
790
1136
  // Build all whole-app analysis on the first request (memoized), before
791
1137
  // any SSR, module serve, gate check, action dispatch, or middleware runs.
792
1138
  await ensureReady();
793
- const next = () => handleCore(req, { state, appDir, coreDir, dev });
1139
+ const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, cspEnabled: cspConfig.enabled });
794
1140
  if (state.middleware) {
795
1141
  try {
796
1142
  return await state.middleware(req, next);
797
1143
  } catch (e) {
798
- logger.error('middleware threw', { err: String(e) });
1144
+ reportError(e, req, 'middleware');
1145
+ logger.error('middleware threw', { err: String(e), requestId: getRequestId() });
799
1146
  return new Response('Server error', { status: 500 });
800
1147
  }
801
1148
  }
@@ -808,14 +1155,25 @@ export async function createRequestHandler(opts) {
808
1155
  * BEFORE running SSR: resolves a pathname to its page-route module URLs
809
1156
  * without loading them. Returns null for non-page paths.
810
1157
  *
1158
+ * Sub-path deployment (issue #256): the HTTP layer passes the RAW request
1159
+ * pathname (still carrying the base path, since the ingress strip happens
1160
+ * inside `produce`, not here), so strip it for route matching and prefix
1161
+ * the emitted module URLs so the early-hint preloads resolve under the
1162
+ * prefix. A path not under the base path yields null (no hints).
1163
+ *
811
1164
  * @param {string} pathname
812
1165
  */
813
1166
  function routeFor(pathname) {
814
- const page = matchPage(state.routeTable, pathname);
1167
+ const matchPathname = basePathValue
1168
+ ? stripBasePath(pathname, basePathValue)
1169
+ : pathname;
1170
+ if (matchPathname === null) return null;
1171
+ const page = matchPage(state.routeTable, matchPathname);
815
1172
  if (!page) return null;
816
1173
  const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
817
1174
  let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
818
- return rel.split('\\').join('/').replace(/^\/?/, '/');
1175
+ const url = rel.split('\\').join('/').replace(/^\/?/, '/');
1176
+ return withBasePath(url, basePathValue);
819
1177
  });
820
1178
  return { moduleUrls };
821
1179
  }
@@ -855,12 +1213,32 @@ export async function createRequestHandler(opts) {
855
1213
  * etc.) sitting in front of this process. See the deployment docs for
856
1214
  * the recommended topology.
857
1215
  *
1216
+ /**
1217
+ * Paths under the app root whose changes must NOT trigger a dev rebuild.
1218
+ * `node_modules` / `.git` are noise. `.webjs/` is the framework's generated
1219
+ * artefact dir (the #258 routes.d.ts and the vendor pin) that the dev server
1220
+ * itself writes on startup and on every rebuild, so without this skip the
1221
+ * write fires a watch event, triggers a rebuild, re-writes the file, and loops
1222
+ * forever. `prisma/dev*` / `prisma/migrations` churn during db:migrate. The
1223
+ * prisma branch is prefix-only (no trailing separator) so the SQLite sidecars
1224
+ * `prisma/dev.db` / `prisma/dev.db-journal` match too; the others stay
1225
+ * separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
1226
+ *
1227
+ * @param {string} filename relative path from an fs.watch `event.filename`
1228
+ * @returns {boolean} true when the change should be ignored
1229
+ */
1230
+ export function shouldIgnoreWatchPath(filename) {
1231
+ return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/.test(filename || '');
1232
+ }
1233
+
1234
+ /**
858
1235
  * @param {{
859
1236
  * appDir: string,
860
1237
  * port?: number,
861
1238
  * dev?: boolean,
862
1239
  * compress?: boolean,
863
1240
  * logger?: import('./logger.js').Logger,
1241
+ * onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
864
1242
  * }} opts
865
1243
  */
866
1244
  export async function startServer(opts) {
@@ -889,18 +1267,9 @@ export async function startServer(opts) {
889
1267
  // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
890
1268
  // Node 24. No external dep needed.
891
1269
  //
892
- // fs.watch returns relative paths in event.filename. We apply
893
- // the same ignore filter chokidar used before: skip
894
- // node_modules, .git, and prisma's dev artefacts (dev.db,
895
- // dev.db-journal, migrations/) which the dev server writes
896
- // during db:migrate and would otherwise loop.
897
- //
898
- // The prisma branch uses prefix-only matching (no required
899
- // trailing separator) so the SQLite sidecar files like
900
- // `prisma/dev.db` and `prisma/dev.db-journal` are ignored too.
901
- // node_modules / .git stay separator-anchored so unrelated
902
- // names like `node_modules.bak/foo` don't get caught.
903
- const IGNORE = /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/;
1270
+ // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
1271
+ // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
1272
+ // prisma's dev artefacts so a file the dev server itself writes never loops.
904
1273
  const rebuild = debounce(() => app.rebuild(), 80);
905
1274
  watcherAbort = new AbortController();
906
1275
  (async () => {
@@ -908,7 +1277,7 @@ export async function startServer(opts) {
908
1277
  const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
909
1278
  for await (const event of events) {
910
1279
  const filename = event.filename || '';
911
- if (IGNORE.test(filename)) continue;
1280
+ if (shouldIgnoreWatchPath(filename)) continue;
912
1281
  rebuild();
913
1282
  }
914
1283
  } catch (err) {
@@ -932,8 +1301,11 @@ export async function startServer(opts) {
932
1301
  try {
933
1302
  const url = urlFromRequest(req);
934
1303
 
935
- // SSE: handled specially; doesn't fit the req→Response model.
936
- if (url.pathname === '/__webjs/events') {
1304
+ // SSE: handled specially; doesn't fit the req→Response model. Match the
1305
+ // base-path-stripped pathname so the reload stream answers at
1306
+ // `<basePath>/__webjs/events` under a sub-path deploy (#256). With no
1307
+ // basePath this is a pure pass-through (the bare path still matches).
1308
+ if (stripBasePath(url.pathname, basePath()) === '/__webjs/events') {
937
1309
  if (!dev) { res.writeHead(404); res.end(); return; }
938
1310
  res.writeHead(200, {
939
1311
  'content-type': 'text/event-stream',
@@ -1052,7 +1424,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
1052
1424
  // Dev live-reload client.
1053
1425
  if (path === '/__webjs/reload.js') {
1054
1426
  if (!dev) return new Response('Not found', { status: 404 });
1055
- return new Response(RELOAD_CLIENT_JS, {
1427
+ return new Response(reloadClientJs(basePath()), {
1056
1428
  headers: { 'content-type': 'application/javascript; charset=utf-8' },
1057
1429
  });
1058
1430
  }
@@ -1111,7 +1483,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
1111
1483
  }
1112
1484
 
1113
1485
  async function handleCore(req, ctx) {
1114
- const { state, appDir, coreDir, dev } = ctx;
1486
+ const { state, appDir, coreDir, dev, reportError, cspEnabled } = ctx;
1115
1487
  const url = new URL(req.url);
1116
1488
  // Decode percent-encoded characters so filesystem lookups match real
1117
1489
  // filenames. Dynamic route segments like `[slug]` and route groups like
@@ -1136,7 +1508,10 @@ async function handleCore(req, ctx) {
1136
1508
  const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
1137
1509
  if (actMatch) {
1138
1510
  if (method !== 'POST') return new Response('POST only', { status: 405 });
1139
- return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req);
1511
+ // Pass the onError sink (issue #239): a server action that throws
1512
+ // unexpectedly is reported to the APM hook before the sanitized 500.
1513
+ const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
1514
+ return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
1140
1515
  }
1141
1516
 
1142
1517
  // expose()d server actions (first-class REST), with optional CORS support.
@@ -1157,7 +1532,12 @@ async function handleCore(req, ctx) {
1157
1532
  } else {
1158
1533
  const exposed = matchExposedAction(state.actionIndex, method, path);
1159
1534
  if (exposed) {
1160
- const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req);
1535
+ // Pass the onError sink (issue #239): an exposed REST handler that throws
1536
+ // unexpectedly is reported to the APM hook before the sanitized 500, the
1537
+ // same as the RPC action path (phase 'action' covers both server-action
1538
+ // invocation shapes).
1539
+ const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
1540
+ const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req, onActionError);
1161
1541
  return withCors(resp, exposed.route, req);
1162
1542
  }
1163
1543
  }
@@ -1281,6 +1661,7 @@ async function handleCore(req, ctx) {
1281
1661
  });
1282
1662
  }
1283
1663
  } catch (e) {
1664
+ if (reportError) reportError(e, req, 'metadata');
1284
1665
  if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
1285
1666
  return new Response('Internal error', { status: 500 });
1286
1667
  }
@@ -1309,6 +1690,14 @@ async function handleCore(req, ctx) {
1309
1690
  elidableComponents: state.elidableComponents,
1310
1691
  inertRouteModules: state.inertRouteModules,
1311
1692
  notFoundFile: state.routeTable.notFound,
1693
+ // Server HTML cache (#241): a CSP-enabled page emits a fresh
1694
+ // per-request nonce into its body, so its bytes vary per request and
1695
+ // it must never be HTML-cached. Pass the flag so the cache guard skips
1696
+ // it. CSP is off by default, so the common case stays cacheable.
1697
+ cspEnabled,
1698
+ // onError sink (issue #239): a page render error that becomes a 500 is
1699
+ // reported to the APM hook with the active request's correlation id.
1700
+ onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
1312
1701
  };
1313
1702
  if (method === 'GET' || method === 'HEAD') {
1314
1703
  const handler = () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
@@ -1577,16 +1966,15 @@ async function fileResponse(abs, opts) {
1577
1966
  try {
1578
1967
  const data = await readFile(abs);
1579
1968
  const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
1580
- const headers = { 'content-type': type };
1581
- if (opts.dev) {
1582
- headers['cache-control'] = 'no-cache';
1583
- } else {
1584
- const etag = `"${(await digestHex('SHA-1', data)).slice(0, 16)}"`;
1585
- headers['etag'] = etag;
1586
- headers['cache-control'] = opts.immutable
1969
+ // The body is fully buffered (read into `data`), so opt it into the
1970
+ // conditional-GET funnel, which is the single place that hashes the bytes
1971
+ // into a weak ETag and honors If-None-Match -> 304 (dev + prod alike).
1972
+ const headers = { 'content-type': type, [BUFFERED_MARKER]: '1' };
1973
+ headers['cache-control'] = opts.dev
1974
+ ? 'no-cache'
1975
+ : opts.immutable
1587
1976
  ? 'public, max-age=31536000, immutable'
1588
1977
  : 'public, max-age=3600';
1589
- }
1590
1978
  return new Response(data, { status: 200, headers });
1591
1979
  } catch {
1592
1980
  return new Response('Not found', { status: 404 });
@@ -1611,13 +1999,13 @@ async function jsModuleResponse(abs, dev, elideOpts) {
1611
1999
  const code = elideImportsFromSource(
1612
2000
  source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
1613
2001
  );
1614
- const headers = { 'content-type': 'application/javascript; charset=utf-8' };
1615
- if (dev) {
1616
- headers['cache-control'] = 'no-cache';
1617
- } else {
1618
- headers['etag'] = `"${(await digestHex('SHA-1', code)).slice(0, 16)}"`;
1619
- headers['cache-control'] = 'public, max-age=3600';
1620
- }
2002
+ // Buffered (string) body, so opt into the conditional-GET funnel for the
2003
+ // weak ETag + 304 (see fileResponse).
2004
+ const headers = {
2005
+ 'content-type': 'application/javascript; charset=utf-8',
2006
+ 'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
2007
+ [BUFFERED_MARKER]: '1',
2008
+ };
1621
2009
  return new Response(code, { status: 200, headers });
1622
2010
  }
1623
2011
 
@@ -1670,6 +2058,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
1670
2058
  headers: {
1671
2059
  'content-type': 'application/javascript; charset=utf-8',
1672
2060
  'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
2061
+ [BUFFERED_MARKER]: '1',
1673
2062
  },
1674
2063
  });
1675
2064
  }
@@ -1726,6 +2115,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
1726
2115
  headers: {
1727
2116
  'content-type': 'application/javascript; charset=utf-8',
1728
2117
  'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
2118
+ [BUFFERED_MARKER]: '1',
1729
2119
  },
1730
2120
  });
1731
2121
  }
@@ -1851,7 +2241,17 @@ function locatePackageDir(appDir, pkgName) {
1851
2241
  return null;
1852
2242
  }
1853
2243
 
1854
- const RELOAD_CLIENT_JS = `// webjs dev reload client
1855
- const es = new EventSource('/__webjs/events');
2244
+ /**
2245
+ * The dev live-reload client. The `EventSource` URL is a framework-emitted
2246
+ * same-origin path, so it must carry the base path under a sub-path deploy
2247
+ * (#256), like the importmap targets and the RPC stub. No-op when basePath
2248
+ * is empty.
2249
+ * @param {string} bp the normalized base path (`''` = no-op)
2250
+ * @returns {string}
2251
+ */
2252
+ function reloadClientJs(bp) {
2253
+ return `// webjs dev reload client
2254
+ const es = new EventSource(${JSON.stringify(withBasePath('/__webjs/events', bp))});
1856
2255
  es.addEventListener('reload', () => location.reload());
1857
2256
  `;
2257
+ }