@webjsdev/server 0.8.63 → 0.8.64

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.
Files changed (47) hide show
  1. package/index.d.ts +7 -2
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/router.js +11 -1
  30. package/src/ssr/client-router-flag.js +42 -0
  31. package/src/ssr/document.js +361 -0
  32. package/src/ssr/env-shim.js +51 -0
  33. package/src/ssr/escape.js +29 -0
  34. package/src/ssr/head.js +857 -0
  35. package/src/ssr/preloads.js +195 -0
  36. package/src/ssr/render.js +1481 -0
  37. package/src/ssr/responses.js +201 -0
  38. package/src/ssr.js +28 -2445
  39. package/src/vendor/audit.js +400 -0
  40. package/src/vendor/integrity.js +245 -0
  41. package/src/vendor/jspm.js +348 -0
  42. package/src/vendor/manifest.js +96 -0
  43. package/src/vendor/pins.js +892 -0
  44. package/src/vendor/providers.js +35 -0
  45. package/src/vendor/resolver.js +225 -0
  46. package/src/vendor/scanner.js +132 -0
  47. package/src/vendor.js +29 -2371
@@ -0,0 +1,1372 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { join, resolve, sep } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ // Server-side `.ts` imports are handled natively by Node 24+'s default
8
+ // type-stripping (`process.features.typescript === 'strip'`) or by Bun. The
9
+ // BROWSER-bound `.ts` request handler erases types via the pluggable stripper in
10
+ // `./ts-strip.js` (Node's built-in `module.stripTypeScriptTypes`, or `amaro` on
11
+ // Bun), so SSR and hydration produce identical JS on either runtime (#508).
12
+ import { buildRouteTable, matchPage } from '../router.js';
13
+ import { generateRouteTypes } from '../route-types.js';
14
+ import { setClientRouterEnabled, setMetadataIconRoutes } from '../ssr.js';
15
+ import {
16
+ buildActionIndex,
17
+ } from '../actions.js';
18
+ import { registerActionHooks } from '../action-seed.js';
19
+ import { readRegenerateRules, isRegenerateOutputPath } from '../dev-regenerate.js';
20
+ import { classifyChangedPath } from '../dev-classify.js';
21
+ import { ensureStripper } from '../ts-strip.js';
22
+ import { defaultLogger } from '../logger.js';
23
+ import { assertNodeVersion } from '../node-version.js';
24
+ import { applyEnvValidation } from '../env-schema.js';
25
+ import { runInstrumentation } from '../instrumentation.js';
26
+ import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from '../context.js';
27
+ import { buildInfoResponse } from '../build-info.js';
28
+ import { mintNonce, buildCspHeader, cspHeaderName } from '../csp.js';
29
+ import { propagateTrustedRemoteIp } from '../rate-limit.js';
30
+ import { reachedBareImports, resolveVendorImports, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from '../vendor.js';
31
+ import { browserEntryFiles } from '../browser-entries.js';
32
+ import { buildModuleGraph, seenFilesFor, appImportsMap, reachableFromEntries } from '../module-graph.js';
33
+ import { primeComponentRegistry, findOrphanComponents, scanComponents } from '../component-scanner.js';
34
+ import { analyzeElision } from '../component-elision.js';
35
+
36
+ import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from '../importmap.js';
37
+ import { stripBasePath, withBasePath } from '../base-path.js';
38
+ import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
39
+ import { resolveActionIdentity } from '../form-action-identity.js';
40
+ import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, resolveAssetUrl } from '../asset-hash.js';
41
+ import { applySecurityHeaders, webRequestIsHttps } from '../headers.js';
42
+ import {
43
+ applyRedirects,
44
+ applyTrailingSlash,
45
+ } from '../redirects.js';
46
+ import { applyConditionalGet } from '../conditional-get.js';
47
+ import { commitHtmlCache, setAppSourceFingerprint } from '../html-cache.js';
48
+ import { buildDevErrorFrame } from '../dev-error.js';
49
+
50
+ import {
51
+ readElideEnabled,
52
+ readSeedEnabled,
53
+ readClientRouterEnabled,
54
+ readHeaderRules,
55
+ readRedirectRules,
56
+ readTrailingSlashFromApp,
57
+ readBasePathFromApp,
58
+ warnOnInvalidWebjsConfig,
59
+ readAllowedOriginsFromApp,
60
+ readCspConfigFromApp,
61
+ readBodyLimitsFromApp,
62
+ } from './config.js';
63
+ import {
64
+ exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
65
+ appTopLevelDirs, locateCoreDir,
66
+ } from './helpers.js';
67
+ import {
68
+ handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
69
+ } from './serve.js';
70
+
71
+ /**
72
+ * A short content digest of a single file's bytes for the app-source deploy
73
+ * signal (#899). Raw `sha256(bytes)`, deliberately independent of `assetHashFor`
74
+ * (which folds the elision fingerprint and memoizes for `?v` emission): the
75
+ * signal only needs "did the source bytes change", and server-only files never
76
+ * enter the asset-hash memo anyway. Returns `''` on a read failure, so a
77
+ * transient error degrades the id rather than throwing during analysis.
78
+ * @param {string} abs
79
+ * @returns {string}
80
+ */
81
+ function fileByteHash(abs) {
82
+ try { return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16); }
83
+ catch { return ''; }
84
+ }
85
+
86
+ /** @type {string | undefined} memoized `@webjsdev/server` version, folded into the app-source signal (#899). */
87
+ let cachedServerVersion;
88
+ /**
89
+ * The installed `@webjsdev/server` version (this package). Folded into the
90
+ * app-source deploy signal so a server-framework release (which alters SSR
91
+ * output but ships no new browser module) turns over the client's stale caches.
92
+ * @returns {string}
93
+ */
94
+ function frameworkServerVersion() {
95
+ if (cachedServerVersion !== undefined) return cachedServerVersion;
96
+ try {
97
+ const v = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
98
+ cachedServerVersion = typeof v === 'string' ? v.replace(/[^\w.-]/g, '').slice(0, 32) : '';
99
+ } catch {
100
+ cachedServerVersion = '';
101
+ }
102
+ return cachedServerVersion;
103
+ }
104
+
105
+ /**
106
+ * Create a reusable, framework-agnostic request handler for a WebJs app.
107
+ * The returned `handle(req)` takes a standard `Request` and resolves to a
108
+ * standard `Response`: suitable for Node http, Deno, Bun, Cloudflare Workers,
109
+ * or embedding inside an Express/Fastify app.
110
+ *
111
+ * @param {{
112
+ * appDir: string,
113
+ * dev?: boolean,
114
+ * logger?: import('../logger.js').Logger,
115
+ * onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
116
+ * onReload?: (verdict?: import('../dev-classify.js').ReloadVerdict) => void,
117
+ * onDevError?: (frame: object) => void,
118
+ * }} opts
119
+ */
120
+ export async function createRequestHandler(opts) {
121
+ // Preflight: WebJs needs Node 24+ (built-in TS strip + recursive fs.watch).
122
+ // Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno)
123
+ // gets the actionable message at boot, not a cryptic API failure mid-request.
124
+ assertNodeVersion({ onFail: 'throw' });
125
+ // Resolve the TS stripper backend at boot (#508): the Node built-in, or amaro
126
+ // on Bun. Doing it here pays the (one-time) amaro import up front and surfaces
127
+ // a missing-amaro error at boot rather than on the first `.ts` request.
128
+ await ensureStripper();
129
+ const appDir = resolve(opts.appDir);
130
+ // Load <appDir>/.env into process.env BEFORE anything else.
131
+ // buildActionIndex below imports server-only files (lib/*.server.ts,
132
+ // modules/**/*.server.ts), some of which read process.env at module
133
+ // init (e.g. createAuth reads AUTH_SECRET). Without this call,
134
+ // scaffolded apps with a committed .env.example + .env would fail
135
+ // to boot until the user discovered the missing env-load. See
136
+ // tracker #37.
137
+ loadAppEnv(appDir);
138
+ // Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
139
+ // default-exports a typed schema or a custom validator function; we run it
140
+ // against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
141
+ // imports any server-only module. A failure throws a clear aggregated Error
142
+ // here, so an embedded host rejects at boot and the CLI exits non-zero,
143
+ // failing fast instead of crashing cryptically mid-request. Absent file is a
144
+ // no-op (opt-in). Coerced + defaulted values are written back to process.env.
145
+ await applyEnvValidation(appDir, { dev: !!opts.dev });
146
+ const dev = !!opts.dev;
147
+ const logger = opts.logger || defaultLogger({ dev });
148
+ // Boot-time `webjs` config validation (#1300). The published JSON Schema used
149
+ // to reach users only through the scaffold's .vscode `$ref`, so a typo'd key
150
+ // was caught in an editor and nowhere else: the key was dropped, the feature
151
+ // stayed at its default, and nothing said so. This runs it once per boot, from
152
+ // the one entry point dev, prod, and an embedded host all share.
153
+ //
154
+ // It WARNS and continues, never throws. A typo costs one feature sitting at
155
+ // its default; a hard boot failure over a schema quibble costs the whole app.
156
+ // A missing / unreadable / unparseable package.json is a silent no-op, like
157
+ // every other `webjs.*` reader in this file.
158
+ await warnOnInvalidWebjsConfig(appDir, logger);
159
+ // Boot-time instrumentation hook (#848): run the optional app-root
160
+ // instrumentation.{js,ts} register() ONCE, after env validation and before
161
+ // the route table / action index are built, so observability plumbing starts
162
+ // before traffic. Any error sink it registers via setOnError composes with
163
+ // the programmatic opts.onError (both fire). Fail-open (a throwing hook is
164
+ // logged, not fatal).
165
+ const { onError: instrumentationOnError } = await runInstrumentation(appDir, { dev, logger });
166
+ // APM / Sentry integration point (issue #239). Called whenever the request
167
+ // pipeline catches an unhandled error: the top-level handle() catch (the
168
+ // last-resort 500), an unexpected throw inside the produce() funnel, or a
169
+ // middleware that threw. BEST-EFFORT by contract: a throwing onError is
170
+ // caught here so it can never crash the response, and the framework's own
171
+ // sanitized 500 / existing error behavior is unchanged (the hook is purely
172
+ // additive). The sink receives the error plus the correlation id so it can
173
+ // tie the report to the same id the access log and X-Request-Id carry.
174
+ // Both the programmatic opts.onError AND any sink an instrumentation.{js,ts}
175
+ // register() installed via setOnError fire (they compose, #848).
176
+ const onErrorSinks = [opts.onError, instrumentationOnError].filter((f) => typeof f === 'function');
177
+ const hasOnError = onErrorSinks.length > 0;
178
+
179
+ /**
180
+ * Invoke every registered onError sink defensively. The phase is a coarse
181
+ * label of where the pipeline caught the error, for the sink's own grouping.
182
+ * @param {unknown} error
183
+ * @param {Request} request
184
+ * @param {string} phase
185
+ */
186
+ function reportError(error, request, phase) {
187
+ if (!hasOnError) return;
188
+ const ctx = { request, requestId: getRequestId(), phase };
189
+ for (const sink of onErrorSinks) {
190
+ try {
191
+ sink(error, ctx);
192
+ } catch (e) {
193
+ logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
194
+ }
195
+ }
196
+ }
197
+
198
+ // Sub-path deployment base path (issue #256), read once from the app's
199
+ // package.json `webjs.basePath` and bound into the importmap builder BEFORE
200
+ // setCoreInstall / setVendorEntries so every importmap target (and the
201
+ // recomputed hash) reflects the prefix. Empty (the default) makes both the
202
+ // ingress strip and the emit-side prefix pure no-ops, so an unconfigured app
203
+ // is byte-identical to before this feature.
204
+ const basePathValue = await readBasePathFromApp(appDir);
205
+ await setBasePath(basePathValue);
206
+
207
+ // Cross-origin allowlist for the action CSRF check (#659), read once.
208
+ const allowedOriginsValue = await readAllowedOriginsFromApp(appDir);
209
+
210
+ // Client-router opt-out (#629): bind it eagerly at handler construction (the
211
+ // same timing as setBasePath above), so a handler's module-global is set
212
+ // before any render even if a request arrives before the first warm. The
213
+ // analysis pass re-reads it each rebuild so a dev toggle takes effect live.
214
+ setClientRouterEnabled(await readClientRouterEnabled(appDir));
215
+
216
+ const coreDir = locateCoreDir(appDir);
217
+ // Switch the importmap between dist/ bundles and src/ per-file
218
+ // URLs depending on whether the resolved @webjsdev/core install
219
+ // has built bundles on disk. npm-installed copies always do;
220
+ // workspace dev does only after `npm run build:dist`. Without
221
+ // a built dist the server falls back to the historical per-file
222
+ // src/ URLs so dev iteration does not require a build step.
223
+ //
224
+ // Both required bundles must exist. An older @webjsdev/core
225
+ // install built BEFORE the browser-entry split (#119/#128) has
226
+ // `webjs-core.js` but no `webjs-core-browser.js`. Enabling dist
227
+ // mode in that case would route the bare `@webjsdev/core`
228
+ // specifier at a 404 on every page. Require both so a partial
229
+ // dist transparently degrades to src/ mode instead.
230
+ const distDir = join(coreDir, 'dist');
231
+ const distComplete =
232
+ existsSync(join(distDir, 'webjs-core.js')) &&
233
+ existsSync(join(distDir, 'webjs-core-browser.js'));
234
+ await setCoreInstall(coreDir, distComplete);
235
+
236
+ // Path-alias imports (#555). Emit the browser importmap scopes for the app's
237
+ // `package.json "imports"` aliases, derived from the SAME map the server
238
+ // resolver (`expandImportAlias`) reads, so SSR and the browser agree. The
239
+ // scaffold ships the single catch-all `"#*": "./*"` (one key, zero
240
+ // maintenance, Bun-safe), which expands to one browser prefix scope per
241
+ // top-level dir (`#lib/` -> `/lib/`, ...); the dir scan is here so a new
242
+ // folder is picked up on boot. An app with no `"imports"` block yields {}
243
+ // (a no-op, byte-identical to before this feature).
244
+ await setImportAliasEntries(importAliasBrowserEntries(appImportsMap(appDir), appTopLevelDirs(appDir)));
245
+
246
+ // Content-hash asset URLs for immutable caching (issue #243). Bind the
247
+ // asset-hash module to the app + core roots and enable fingerprinting in
248
+ // PROD only. In dev `withAssetHash` stays a pure no-op (never enabled), so
249
+ // the dev importmap / boot / preload output is byte-identical to before.
250
+ // This runs AFTER setBasePath / setCoreInstall, but those recompute the
251
+ // importmap hash with `{ fingerprint: false }`, so the boot-published build
252
+ // id is a stable deploy fingerprint independent of per-file content hashes.
253
+ setAssetRoots({ appDir, coreDir, enabled: !dev });
254
+
255
+ // Install the resolver behind the isomorphic `asset()` helper (#1194), the
256
+ // same provider seam `cspNonce()` uses. An author writes
257
+ // `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
258
+ // server; the browser has no provider and returns the path unchanged.
259
+ //
260
+ // The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
261
+ // maps any same-origin path under the PROJECT ROOT, so an app that passed
262
+ // request- or user-derived data here (`asset(user.avatarPath)`) would
263
+ // otherwise read and publish a content hash for `/.env`, `/db/app.db`, or a
264
+ // `.server.ts`, leaking both existence and a fingerprint of the bytes for
265
+ // files the serve path deliberately 404s. Mirror the static-asset route:
266
+ // under `public/`, no traversal. Everything else returns unchanged, having
267
+ // never touched the disk.
268
+ setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
269
+
270
+ // The `'use server'` load hook. Install it NOW, at boot, before any action
271
+ // module is imported (ESM caches by URL, so a module loaded before the hook
272
+ // would never be wrapped). Read once (not per-rebuild): the hook is global
273
+ // and cannot be cleanly un-installed, so toggling needs a restart.
274
+ //
275
+ // It installs UNCONDITIONALLY, and only seed collection (#472) follows the
276
+ // `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
277
+ // what a bound `<form action=${action}>` resolves through, so gating the hook
278
+ // on seeding would mean `webjs.seed: false` silently broke every no-JS form.
279
+ await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
280
+
281
+ // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
282
+ // deterministic vendor map that is cheap to read (one file, no analysis, no
283
+ // network). Resolve it AT BOOT and publish the build id immediately so the
284
+ // process advertises a stable, non-empty id from its very first response: a
285
+ // freshly-deployed pinned process is detected as a new deploy by old-deploy
286
+ // clients with zero warmup window. Mirrors Rails importmap (committed pins
287
+ // rendered deterministically at runtime). Pinning stays optional; an unpinned
288
+ // app does no vendor work at boot and publishes its id after the first
289
+ // successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
290
+ // gate, elision) and the UNPINNED jspm resolve stay deferred to the first
291
+ // request, so #143's win is intact; only the cheap committed-file read moves
292
+ // back to boot, and only when a VALID pin exists. A committed pin file is
293
+ // served as-is (elision never prunes it), so the boot-resolved map equals the
294
+ // final served map and the published id is authoritative.
295
+ //
296
+ // Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
297
+ // hasVendorPin is a cheap existence check; a malformed pin (exists but
298
+ // unparseable) must NOT short-circuit here, because resolveVendorImports would
299
+ // then fall through to its bare-import scan thunk, and the boot-time thunk is
300
+ // empty (the real scan is part of the deferred analysis). A broken pin instead
301
+ // falls through to the normal deferred resolve, which carries the real scan
302
+ // thunk and degrades gracefully, exactly as an unpinned app does.
303
+ let bootVendorPinned = false;
304
+ if (hasVendorPin(appDir) && (await readPinFile(appDir))) {
305
+ try {
306
+ const v = await resolveVendorImports(appDir, () => new Set());
307
+ await setVendorEntries(v.imports, v.integrity);
308
+ publishBuildId();
309
+ bootVendorPinned = true;
310
+ } catch (e) {
311
+ // An unexpected failure applying a VALID pin (e.g. setVendorEntries
312
+ // throwing) is non-fatal: leave bootVendorPinned false so the deferred
313
+ // resolve re-attempts on the first request. Boot stays resilient.
314
+ logger.error?.(`[webjs] applying the committed vendor pin at boot failed (will retry on the first request):`, e);
315
+ }
316
+ }
317
+
318
+ // Whole-app analysis (module graph, component scan, browser-bound gate,
319
+ // action index, middleware, elision, vendor) is NOT run at boot. It is
320
+ // computed on the first request via ensureReady() below and memoized, so the
321
+ // server starts without walking or reading the app's source, executing any
322
+ // server module, or hitting the network. Only the route table is built
323
+ // eagerly: it is a cheap directory scan (no code reads), and routing, Early
324
+ // Hints, and WebSocket lookups need it available before the first request.
325
+ const routeTable = await buildRouteTable(appDir);
326
+ // Auto-linked favicons: tell the head builder which icon metadata routes
327
+ // exist, so `app/icon.*` is linked when the app declares no metadata.icons.
328
+ // Bound here rather than threaded through ssrOpts, matching
329
+ // setClientRouterEnabled; re-bound in doRebuild so adding or deleting the
330
+ // file takes effect without a restart.
331
+ setMetadataIconRoutes(routeTable.metadataRoutes);
332
+
333
+ // Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
334
+ // dev so an editor's tsserver always has up-to-date route types without the
335
+ // developer remembering to run `webjs types`. Best-effort and fire-and-
336
+ // forget: a failure logs and never blocks boot. Re-emitted after each route
337
+ // rebuild (see doRebuild) so adding/removing a route refreshes the types.
338
+ /** @returns {Promise<void>} */
339
+ async function emitRouteTypes() {
340
+ try {
341
+ const { mkdir, writeFile, rename } = await import('node:fs/promises');
342
+ const text = await generateRouteTypes(appDir);
343
+ const outDir = join(appDir, '.webjs');
344
+ await mkdir(outDir, { recursive: true });
345
+ // Write to a temp sibling then rename, so tsserver (which reads this
346
+ // file) never observes a half-written body if two rebuilds race. rename
347
+ // is atomic within the same dir. Both paths sit under the watcher-ignored
348
+ // .webjs/, so neither the temp write nor the rename re-triggers a rebuild.
349
+ const dest = join(outDir, 'routes.d.ts');
350
+ const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`);
351
+ await writeFile(tmp, text);
352
+ await rename(tmp, dest);
353
+ } catch (e) {
354
+ logger.warn?.(`[webjs] could not write .webjs/routes.d.ts (route types): ${e?.message || e}`);
355
+ }
356
+ }
357
+ if (dev) void emitRouteTypes();
358
+
359
+ // Per-path response-header rules (issue #232), read once from the
360
+ // app's package.json `webjs.headers`. Static config, so no rebuild
361
+ // re-read; the secure defaults need no config and apply regardless.
362
+ const headerRules = await readHeaderRules(appDir);
363
+ // Declarative redirect rules (issue #254), read once from the app's
364
+ // package.json `webjs.redirects` and compiled to URLPattern rules at
365
+ // boot (never per request). Empty when unconfigured, so an app with no
366
+ // redirects is unchanged. Applied at the very start of request handling,
367
+ // before routing / SSR / asset serving, so a moved URL returns a 308/307
368
+ // immediately.
369
+ const redirectRules = await readRedirectRules(appDir);
370
+ // Trailing-slash policy (issue #255), read once from the app's package.json
371
+ // `webjs.trailingSlash`. Default `'ignore'` (no canonicalization), so an
372
+ // unconfigured app is unchanged; `'never'` (recommended) strips a trailing
373
+ // slash and `'always'` adds one, each via a 308 to the canonical form.
374
+ // Applied in produce() AFTER the declarative redirects, so an explicit
375
+ // redirect rule wins first and the two never loop.
376
+ const trailingSlashPolicy = await readTrailingSlashFromApp(appDir);
377
+ // CSP config (issue #233), read once from the app's package.json
378
+ // `webjs.csp`. OFF by default: when disabled no nonce is minted and no
379
+ // Content-Security-Policy header is set, so an unconfigured app is
380
+ // unchanged. When enabled, `handle()` mints a fresh per-request nonce,
381
+ // makes it the value `cspNonce()` returns (so the SSR'd inline scripts
382
+ // carry it), and sets the matching header carrying the same nonce.
383
+ const cspConfig = await readCspConfigFromApp(appDir);
384
+ // Request body-size limits (issue #237), read once from the app's
385
+ // package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
386
+ // overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
387
+ // unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
388
+ // (used inside route handlers) enforces the same cap the RPC and form-dispatch
389
+ // paths do.
390
+ const bodyLimits = await readBodyLimitsFromApp(appDir);
391
+
392
+ const state = {
393
+ routeTable,
394
+ actionIndex: null,
395
+ middleware: null,
396
+ bodyLimits,
397
+ logger,
398
+ // Test-mode serve gate (#806). Set ONLY by the browser-test harness
399
+ // (`@webjsdev/server/testing`'s `createBrowserTestHandler`), NEVER by
400
+ // `webjs dev` / `webjs start`. When true the source-serve gate is relaxed
401
+ // so ANY app file under appDir is servable (a component a browser test
402
+ // imports is not route-reachable, so it is absent from `browserBoundFiles`
403
+ // and would 404). The `.server.*` guardrail (source -> RPC/throw stub) and
404
+ // the core / vendor serving are unchanged, so no server source is exposed.
405
+ testMode: opts.testMode === true,
406
+ moduleGraph: null,
407
+ elidableComponents: new Set(),
408
+ inertRouteModules: new Set(),
409
+ importOnlyRouteModules: new Map(),
410
+ // The browser-bound ENTRY files (pages / layouts / boundaries / components),
411
+ // kept alongside the expanded gate set because the vendor scan roots at the
412
+ // entries rather than at the closure.
413
+ browserEntryFiles: new Set(),
414
+ browserBoundFiles: null,
415
+ // Dev live-reload classification (#1398). `shippedFiles` is the transitive
416
+ // closure of what the browser can ACTUALLY load (the gate set minus every
417
+ // module elision drops), `graphFiles` is every app source the graph walked,
418
+ // and `pageFiles` is the route table's pages. Together they are the whole
419
+ // input to `classifyChangedPath`, so a watch event can be answered without
420
+ // reaching into the rest of this state. Dev-only; left empty in prod.
421
+ /** @type {Set<string>} */
422
+ shippedFiles: new Set(),
423
+ /** @type {Set<string>} */
424
+ graphFiles: new Set(),
425
+ /** @type {Set<string>} */
426
+ pageFiles: new Set(),
427
+ // Transformed-source cache (stripped TS + applied elision). Per-handler,
428
+ // NOT module-global: the cached bytes bake in THIS handler's elision
429
+ // verdict, so two handlers for the same app with different elision
430
+ // settings (a multi-tenant embedder, or the differential elision test)
431
+ // must not share it, or the second would serve the first's elided source.
432
+ tsCache: new Map(),
433
+ // The most recent unresolved dev error frame (#264), or null. Pushed to the
434
+ // SSE channel for the live overlay and replayed to a freshly-connected tab.
435
+ // Dev-only (never populated when !dev); cleared on a successful rebuild.
436
+ lastDevError: null,
437
+ // On-request regeneration rules (#967): `webjs.dev.regenerate` from the app's
438
+ // package.json. Dev-only (prod builds its outputs via `start.before`). Loaded
439
+ // now and refreshed on each rebuild so a config edit takes effect without a
440
+ // restart; empty for a plain app, so the serving path is unaffected.
441
+ regenerateRules: dev ? await readRegenerateRules(appDir) : [],
442
+ };
443
+
444
+ // Teach the renderers how to turn a real server-action function into the
445
+ // `<hash>/<fn>` identity a form submits (#1155). The hook path is
446
+ // process-global and needs no index; `state.actionIndex` is consulted only as
447
+ // a hash memo, and by the scan fallback for a runtime with no hook. In a
448
+ // process running several handlers the fallback therefore resolves against
449
+ // the most recently booted one, which is the same scope the asset-url
450
+ // provider already has.
451
+ setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
452
+
453
+ /**
454
+ * Report a dev error (#264): build a frame and push it to the open tab via
455
+ * the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
456
+ * response or crash the server (a frame-build failure is swallowed). No file
457
+ * path or source is ever built in prod (the early return), so nothing leaks.
458
+ *
459
+ * `info.url` stamps a `render` frame with the URL that produced it (#1047), so
460
+ * the browser overlay can refuse a frame for a page the tab is not viewing.
461
+ * A `ts-strip` / `rebuild` frame passes none and stays unscoped.
462
+ *
463
+ * @param {unknown} error
464
+ * @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string, url?: string }} [info]
465
+ */
466
+ function reportDevError(error, info = {}) {
467
+ if (!dev) return;
468
+ try {
469
+ const frame = buildDevErrorFrame(error, { ...info, appDir });
470
+ state.lastDevError = frame;
471
+ opts.onDevError?.(frame);
472
+ } catch { /* ignore */ }
473
+ }
474
+
475
+ // All whole-app analysis is built lazily on the first request, memoized so
476
+ // boot does none of it. It runs in two stages. The deterministic analysis
477
+ // (module graph, component scan + prime, browser-bound gate, action index,
478
+ // middleware, elision) is network-free and, once built, never re-runs unless
479
+ // a rebuild invalidates it; readiness gates on it. Vendor resolution is a
480
+ // SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
481
+ // an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
482
+ // offline or partially-unresolvable app still boots. A transient vendor
483
+ // failure is re-attempted on the NEXT ensureReady call (driven by an incoming
484
+ // request, a readiness probe, or the warm-up), with no background timer: the
485
+ // platform's traffic and probes are the retry loop. `readyError` holds a
486
+ // propagating analysis failure so /__webjs/ready can report it.
487
+ let analysisDone = false;
488
+ // Whether the analysis has EVER completed (#1398). Distinct from
489
+ // `analysisDone`, which `doRebuild` flips false on every edit: the live-reload
490
+ // classifier needs to know the derived sets are POPULATED, not that they are
491
+ // current, because it deliberately classifies against the previous build's
492
+ // graph (see the note in `doRebuild`). Gating it on `analysisDone` turned the
493
+ // feature off for every edit after the first in a burst, since nothing
494
+ // re-warms the analysis until an HTTP request arrives and the relay defers
495
+ // that request by its 2000ms quiet window (#1397), which is longer than the
496
+ // measured inter-save gap. Never reset.
497
+ let analysisEverDone = false;
498
+ // A pinned app applied its FULL vendor map and published the build id at boot
499
+ // (above). The deferred vendor stage still runs once (and after every rebuild)
500
+ // to PRUNE that map to the elision-reachable specifiers, so a pinned app serves
501
+ // the same map an unpinned one does (#197); it does not re-publish the build id
502
+ // (the boot hash stays the deploy fingerprint). An unpinned app starts false and
503
+ // resolves live on the first request.
504
+ let vendorResolved = false;
505
+ let vendorAttemptedOnce = false;
506
+ let vendorGen = 0;
507
+ let readyDone = false;
508
+ /** @type {unknown} */
509
+ let readyError = null;
510
+ /** @type {Promise<void> | null} */
511
+ let readyInFlight = null;
512
+
513
+ async function ensureReady() {
514
+ // Fully warm: analysis done and vendor resolved. Nothing to do.
515
+ if (analysisDone && vendorResolved) return;
516
+ // A warm pass is in flight (the analysis and/or the FIRST vendor attempt).
517
+ // Await it rather than serving past it: a concurrent early request must get
518
+ // the FINAL importmap, never a half-resolved one. This is what makes the
519
+ // unpinned warmup flawless. The first attempt's jspm resolve is
520
+ // timeout-bounded (vendor.js), so an offline app cannot hang here: on
521
+ // timeout the resolve returns and the response is served with an empty,
522
+ // reload-safe build id, then the retry below completes it. Without this
523
+ // wait, a request arriving mid-resolve would serve a partial map and an
524
+ // empty-then-changing build id, the exact warmup drift that hard-reloads
525
+ // and wipes a half-filled form.
526
+ if (readyInFlight) { await readyInFlight; return; }
527
+ // Analysis warm but the first vendor attempt already completed and failed:
528
+ // re-attempt WITHOUT blocking this request. The single-flight dedupes
529
+ // concurrent attempts; success flips the flag AND publishes the build id.
530
+ // This is the request/probe-driven retry (no timer). Until it succeeds the
531
+ // served build id stays empty (reload-safe), so no navigation hard-reloads.
532
+ if (analysisDone && vendorAttemptedOnce) {
533
+ const gen = vendorGen;
534
+ resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); } }).catch(() => {});
535
+ return;
536
+ }
537
+ // Otherwise run the (single-flighted) full warm: the analysis, then the
538
+ // first vendor attempt, awaited so the first response carries the import map.
539
+ if (!readyInFlight) {
540
+ readyInFlight = (async () => {
541
+ /** @type {Record<string, number>} */
542
+ const t = {};
543
+ let ranAnalysis = false, ranVendor = false;
544
+ const now = () => performance.now();
545
+ try {
546
+ if (!analysisDone) {
547
+ let m = now();
548
+ state.moduleGraph = await buildModuleGraph(appDir);
549
+ t.graph = now() - m; m = now();
550
+ const components = await scanComponents(appDir);
551
+ await primeComponentRegistry(appDir, components);
552
+ t.scan = now() - m; m = now();
553
+ state.browserEntryFiles = browserEntryFiles(state.routeTable, components);
554
+ state.browserBoundFiles = reachableFromEntries(state.moduleGraph, [...state.browserEntryFiles], appDir);
555
+ t.gate = now() - m; m = now();
556
+ state.actionIndex = await buildActionIndex(appDir, dev);
557
+ t.actions = now() - m; m = now();
558
+ state.middleware = await loadMiddleware(appDir, dev, logger);
559
+ t.middleware = now() - m; m = now();
560
+ // Client-router opt-out (#629): re-read each pass so toggling
561
+ // `webjs.clientRouter` takes effect on rebuild without a restart.
562
+ setClientRouterEnabled(await readClientRouterEnabled(appDir));
563
+ // Read the switch ONCE: the dev summary below reports it too, and
564
+ // calling readElideEnabled twice per warm re-reads package.json.
565
+ const elideOn = await readElideEnabled(appDir);
566
+ const r = elideOn
567
+ ? await analyzeElision(components, collectRouteModules(state.routeTable),
568
+ state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
569
+ : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map(),
570
+ shippedRouteModules: new Map(), componentVerdicts: new Map() };
571
+ state.elidableComponents = r.elidableComponents;
572
+ state.inertRouteModules = r.inertRouteModules;
573
+ state.importOnlyRouteModules = r.importOnlyRouteModules;
574
+ // Dev live-reload classification (#1398): derive the three sets the
575
+ // watch-event classifier reads. `browserBoundFiles` above is the
576
+ // AUTHORIZATION gate (it walks from every entry, elided ones
577
+ // included, because an elided module's URL must still 404 rather
578
+ // than leak), so it is the wrong input here: the question is what
579
+ // the browser actually HOLDS. Re-walk from the entries elision
580
+ // keeps, and an edit to anything reachable from those is a reload.
581
+ // An import-only page's substituted components are themselves
582
+ // entries, so dropping the page loses none of its reach.
583
+ if (dev) {
584
+ const shippedEntries = [...state.browserEntryFiles].filter((f) =>
585
+ !state.elidableComponents.has(f)
586
+ && !state.inertRouteModules.has(f)
587
+ && !state.importOnlyRouteModules.has(f));
588
+ state.shippedFiles = reachableFromEntries(state.moduleGraph, shippedEntries, appDir);
589
+ state.graphFiles = seenFilesFor(state.moduleGraph);
590
+ state.pageFiles = new Set((state.routeTable.pages || []).map((p) => p.file).filter(Boolean));
591
+ }
592
+ // Fold the elision verdict into app-module content hashes (#243): an
593
+ // app module's served body is elision-transformed, so a verdict flip
594
+ // must bust its `?v` even when its source is byte-identical. A stable
595
+ // string of the sorted elidable + inert paths, RELATIVIZED to appDir
596
+ // so the fingerprint is a property of the app's STRUCTURE, not its
597
+ // filesystem location (two deploys at different absolute paths, or
598
+ // two identical apps, produce the same fingerprint). asset-hash
599
+ // digests it into each app-module hash; '' when nothing is elidable,
600
+ // so a no-elision app's hash stays exactly `sha256(bytes)`.
601
+ {
602
+ // `appDir + sep` boundary (matches asset-hash's containment guard)
603
+ // so a sibling-prefix dir cannot be mis-relativized.
604
+ const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
605
+ const elidedPaths = [
606
+ ...state.elidableComponents,
607
+ ...state.inertRouteModules,
608
+ // An import-only module is dropped from the boot (replaced by its
609
+ // component imports), so a flip in / out of that class must bust
610
+ // the importer's `?v` too.
611
+ ...state.importOnlyRouteModules.keys(),
612
+ ].map(rel).sort();
613
+ setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
614
+ }
615
+ // App-source fingerprint for the HTML cache key (#318): a
616
+ // deterministic, location-independent digest of the browser-bound
617
+ // file set's content hashes, so a deploy that changes ONLY an app
618
+ // module's bytes (which the importmap-only build id misses) re-keys
619
+ // the cache instead of serving a body with stale `?v` boot URLs.
620
+ // PROD only (asset-hash is enabled there); '' in dev, where fs.watch
621
+ // handles staleness. Computed AFTER the elision fingerprint, so each
622
+ // per-file hash already reflects the elision verdict (an elision
623
+ // flip changes the served output, so it moves this fingerprint too).
624
+ // This eagerly hashes the WHOLE browser-bound set once (not just the
625
+ // first route's files), but `assetHashFor` is memoized and already
626
+ // runs for `?v` emission in prod, so it stays a one-time bounded cost
627
+ // inside the lazy analysis (never at boot).
628
+ if (!dev && state.browserBoundFiles) {
629
+ const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
630
+ const lines = [...state.browserBoundFiles]
631
+ .map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
632
+ .sort();
633
+ setAppSourceFingerprint(lines.join('\n'));
634
+ } else {
635
+ setAppSourceFingerprint('');
636
+ }
637
+ // App-source deploy SIGNAL (#899), distinct from the #318 html-cache
638
+ // fingerprint above: hashes ALL app source (the module-graph `seen`
639
+ // set, INCLUDING server-only `.server.ts` that `browserBoundFiles`
640
+ // omits) with a raw per-file byte digest (decoupled from the
641
+ // asset-hash memo/elision machinery), plus the installed
642
+ // `@webjsdev/server` version, so an app-source OR a server-framework
643
+ // deploy moves it. Sorted + appDir-relative for determinism across
644
+ // instances. Drives the client's soft cache-evict (not a reload).
645
+ // PROD only; empty in dev (fs.watch + SSE handle staleness there).
646
+ if (!dev && state.moduleGraph) {
647
+ const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
648
+ const srcLines = [...seenFilesFor(state.moduleGraph)]
649
+ .map((abs) => `${relApp(abs)}:${fileByteHash(abs)}`)
650
+ .sort();
651
+ srcLines.push(`@webjsdev/server:${frameworkServerVersion()}`);
652
+ setAppSourceId(srcLines.join('\n'));
653
+ } else {
654
+ setAppSourceId('');
655
+ }
656
+ t.elision = now() - m;
657
+ if (dev) {
658
+ // An orphan is EITHER shape `findOrphanComponents` reports: no
659
+ // registration call at all, or one whose tag is computed. The
660
+ // scanner matches only a literal tag, so it sees neither, and
661
+ // what is ALWAYS lost is the verdict, the registry entry, and the
662
+ // preload hint. The upgrade is the part that differs, and a
663
+ // computed tag only survives when its importer ships WHOLE (see
664
+ // findOrphanComponents for why that is the exception). The doctor
665
+ // check and `webjs elision` say the same thing.
666
+ for (const { className, file } of await findOrphanComponents(appDir)) {
667
+ logger.warn?.(
668
+ `[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` +
669
+ `(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` +
670
+ `no elision verdict, no registry entry, no preload hint. ` +
671
+ `<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` +
672
+ `upgrades only while its importing module ships whole. ` +
673
+ `Add \`${className}.register('<tag-name>');\` with a literal tag.`,
674
+ );
675
+ }
676
+ // The elision summary (#1308), one server-console line. NOT a
677
+ // browser push: an inert route ships zero application JS, and the
678
+ // most useful manual check an author has is opening the network
679
+ // tab on that route and seeing nothing, which a dev-only boot
680
+ // script would corrupt on exactly the pages this proves. The fact
681
+ // is also app-wide while a browser channel is per-tab, and the
682
+ // per-page detail already has a home in `webjs elision`.
683
+ // Re-emitted after each fs.watch rebuild, because doRebuild sets
684
+ // analysisDone = false and this sits inside that stage.
685
+ logger.info?.(
686
+ elideOn
687
+ ? `[webjs] elision: ${r.elidableComponents.size}/${new Set(components.map((c) => c.file)).size} components elided, ` +
688
+ `${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, ` +
689
+ `${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
690
+ : `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
691
+ );
692
+ }
693
+ analysisDone = true;
694
+ analysisEverDone = true;
695
+ ranAnalysis = true;
696
+ }
697
+ readyError = null;
698
+ if (!vendorResolved) {
699
+ const m = now();
700
+ const gen = vendorGen;
701
+ vendorAttemptedOnce = true;
702
+ const ok = await resolveAndApplyVendor();
703
+ t.vendor = now() - m;
704
+ ranVendor = true;
705
+ // Only memoize success (and only if a rebuild didn't intervene). A
706
+ // transient failure leaves vendorResolved false; the next ensureReady
707
+ // call re-attempts it non-blocking. A permanent unresolvable (jspm
708
+ // 401) reports ok and is tolerated, so it does not loop. On success
709
+ // the importmap is now authoritatively final, so publish the build
710
+ // id: from here every response advertises the same stable value and
711
+ // the client router's deploy detection works without warmup drift.
712
+ // A pinned app published the build id at boot (hash of the committed
713
+ // pin) and the prune only shrinks the served map, so do NOT re-publish
714
+ // (that would drift the id mid-process). An unpinned app publishes its
715
+ // now-final live map here.
716
+ if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); }
717
+ }
718
+ // Readiness reflects a FULLY warm instance: the deterministic analysis
719
+ // AND the first vendor attempt have both completed (note: completed,
720
+ // not necessarily succeeded). A readiness-gated platform (Railway
721
+ // healthcheckPath, k8s readinessProbe) therefore admits traffic only
722
+ // AFTER the build id is published (vendor resolved) or definitively
723
+ // empty (a bounded vendor failure), never DURING the vendor-resolution
724
+ // window. This is what makes warm-up actually protect users: the prior
725
+ // instance keeps serving until the new one is fully warm, so a real
726
+ // request lands on a warm instance with a stable build id instead of
727
+ // racing the resolve. The first vendor attempt is bounded (the jspm
728
+ // fetch timeout in vendor.js), so an offline / CDN-degraded app still
729
+ // becomes ready shortly after that timeout, degraded but reload-safe,
730
+ // which preserves the boot resilience #143 introduced. The gate is the
731
+ // FIRST attempt only: a transient failure still flips readyDone here,
732
+ // so a later non-blocking retry never has to re-open the readiness gate.
733
+ readyDone = true;
734
+ if (ranAnalysis) {
735
+ const ms = (x) => Math.round(x || 0);
736
+ const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
737
+ logger.info?.(
738
+ `[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
739
+ `gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
740
+ `elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
741
+ );
742
+ } else if (ranVendor && vendorResolved) {
743
+ logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
744
+ }
745
+ } catch (e) {
746
+ readyError = e;
747
+ throw e;
748
+ } finally {
749
+ readyInFlight = null;
750
+ }
751
+ })();
752
+ }
753
+ await readyInFlight;
754
+ }
755
+
756
+ // All vendor resolves funnel through one single-flight so two never overlap
757
+ // (resolveVendorImports reports a transient failure via a module-global flag
758
+ // that only one in-flight resolve may safely touch). Never rejects; returns
759
+ // the resolve's ok flag (false on a transient failure, applying whatever
760
+ // partial map resolved so the app is no worse off).
761
+ /** @type {Promise<boolean> | null} */
762
+ let vendorResolveInFlight = null;
763
+ function resolveAndApplyVendor() {
764
+ if (vendorResolveInFlight) return vendorResolveInFlight;
765
+ vendorResolveInFlight = (async () => {
766
+ try {
767
+ const scan = async () => {
768
+ const skip = new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]);
769
+ return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, skip);
770
+ };
771
+ const v = await resolveVendorImports(appDir, scan);
772
+ let { imports, integrity } = v;
773
+ if (bootVendorPinned) {
774
+ // resolveVendorImports returns a committed pin VERBATIM (it never runs
775
+ // the scan for a pinned app). Prune it to the elision-reachable
776
+ // specifiers so a pinned app serves the same map an unpinned one does
777
+ // (#197): an elided-only dep like dayjs is dropped. One scan; the pin
778
+ // path skipped it. This runs on the first warm AND after every rebuild,
779
+ // so the pruned map is the single source of truth.
780
+ const reachable = await scan();
781
+ ({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
782
+ }
783
+ await setVendorEntries(imports, integrity);
784
+ return v.ok;
785
+ } catch (e) {
786
+ logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
787
+ return false;
788
+ }
789
+ })().finally(() => { vendorResolveInFlight = null; });
790
+ return vendorResolveInFlight;
791
+ }
792
+
793
+ // Optional app-level readiness check. A `readiness.{js,ts}` file at the app
794
+ // root may default-export an async function; /__webjs/ready runs it once the
795
+ // analysis is warm, so readiness can reflect LIVE dependency health (a DB
796
+ // ping, a queue connection) that the static analysis cannot see. Returning
797
+ // false or throwing reports the instance not ready (503), so a readinessProbe
798
+ // holds traffic off an instance whose deps are down. Absent file => analysis-
799
+ // warm is the only gate. The module is cached per build (cleared on rebuild);
800
+ // the function itself runs on every probe so it reflects current state.
801
+ let readinessFn;
802
+ async function getReadinessCheck() {
803
+ if (readinessFn !== undefined) return readinessFn;
804
+ let file = null;
805
+ for (const name of ['readiness.ts', 'readiness.js', 'readiness.mts', 'readiness.mjs']) {
806
+ const p = join(appDir, name);
807
+ if (await exists(p)) { file = p; break; }
808
+ }
809
+ if (!file) { readinessFn = null; return null; }
810
+ try {
811
+ const url = pathToFileURL(file).toString();
812
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
813
+ const mod = await import(url + bust);
814
+ readinessFn = typeof mod.default === 'function' ? mod.default : null;
815
+ } catch (e) {
816
+ logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) });
817
+ readinessFn = null;
818
+ }
819
+ return readinessFn;
820
+ }
821
+
822
+ // Rebuilds are serialized so a slow rebuild #1 cannot overwrite a fresher
823
+ // rebuild #2's route table when it finally finishes. Without this, two file
824
+ // edits inside one fs.watch debounce window could produce a permanently
825
+ // stale state until the next rebuild.
826
+ let rebuildInFlight = Promise.resolve();
827
+
828
+ /**
829
+ * @param {import('../dev-classify.js').ReloadVerdict} [verdict] the live-reload
830
+ * classification of the change that triggered this rebuild (#1398). Absent
831
+ * (an embedded host calling `rebuild()`, or any caller with no filename to
832
+ * classify) means a full reload, which is the fail-safe default.
833
+ */
834
+ async function rebuild(verdict) {
835
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict)).catch((e) => {
836
+ logger.error?.(`[webjs] rebuild failed:`, e);
837
+ // Push the failure to the open tab so the overlay appears live after a
838
+ // breaking edit, not only on the next manual navigation (#264).
839
+ reportDevError(e, { kind: 'rebuild' });
840
+ });
841
+ return rebuildInFlight;
842
+ }
843
+
844
+ /** @param {import('../dev-classify.js').ReloadVerdict} [verdict] */
845
+ async function doRebuild(verdict) {
846
+ // The route table is the only eager artifact (cheap directory scan); rebuild
847
+ // it so routing reflects added/removed route files immediately.
848
+ state.routeTable = await buildRouteTable(appDir);
849
+ // Adding or deleting app/icon.* changes whether the head auto-links it.
850
+ setMetadataIconRoutes(state.routeTable.metadataRoutes);
851
+ // Refresh the generated route types (#258) so adding/removing a route file
852
+ // updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
853
+ // best-effort (see emitRouteTypes).
854
+ if (dev) void emitRouteTypes();
855
+ clearVendorCache();
856
+ // Content-hash asset cache (#243): clear so a changed file re-hashes and
857
+ // its emitted `?v` busts the stale immutable copy. A no-op in dev (the
858
+ // module is never enabled there), kept for correctness + the deploy-busts
859
+ // regression test, which clears + re-hashes to observe a changed `?v`.
860
+ clearAssetHashCache();
861
+ state.tsCache.clear();
862
+ // Invalidate the lazy analysis; the next request rebuilds the graph,
863
+ // component scan, gate, action index, middleware, elision, and vendor map.
864
+ // Wait out any in-flight build first so it cannot commit stale results
865
+ // after the reset. A dependency edit can flip an elision verdict without
866
+ // changing an importer's mtime, hence the state.tsCache.clear above.
867
+ if (readyInFlight) { try { await readyInFlight; } catch {} }
868
+ // Bump the vendor generation so a vendor resolve still in flight from the
869
+ // previous build cannot flip vendorResolved against the fresh state.
870
+ vendorGen++;
871
+ analysisDone = false;
872
+ vendorResolved = false;
873
+ vendorAttemptedOnce = false;
874
+ readyDone = false;
875
+ readyError = null;
876
+ readinessFn = undefined;
877
+ // Refresh the on-request regenerate rules (#967) so a `webjs.dev.regenerate`
878
+ // config edit takes effect without a restart, mirroring the elide/router
879
+ // re-reads above. Dev-only; a no-op array in prod.
880
+ if (dev) state.regenerateRules = await readRegenerateRules(appDir);
881
+ // Optimistically clear the dev error (#264): the rebuild itself only
882
+ // re-scans the route table and INVALIDATES the lazy analysis (the real
883
+ // re-parse / re-strip / re-render happens on the next request), so we do
884
+ // not yet know the edit fixed the error. Clearing it here means a tab that
885
+ // connects now is not replayed a possibly-stale overlay; `onReload` then
886
+ // reloads every open tab, and if the underlying error is still present the
887
+ // reloaded request re-pushes a fresh frame (a brief dismiss-then-reappear
888
+ // flicker on an unrelated edit, self-correcting to the right end state).
889
+ state.lastDevError = null;
890
+ // The verdict was computed against the PREVIOUS build's graph, because this
891
+ // rebuild only INVALIDATES the lazy analysis (`analysisDone = false` above)
892
+ // and the fresh graph is not built until the next request. Awaiting
893
+ // `ensureReady()` here to classify against the new one would push the SSE
894
+ // frame roughly 1900ms later on a large app and stack that in front of the
895
+ // relay's quiet window, so it is not worth it. The one case that could
896
+ // exploit the staleness, an edit that adds a brand-new component import to
897
+ // an otherwise morphable page, is closed on the CLIENT: `addNewHeadElements`
898
+ // runs on both boundary tiers and swaps in the changed boot script, which
899
+ // executes and registers the new component.
900
+ opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' });
901
+ }
902
+
903
+ /** @param {Request} req */
904
+ function handle(req) {
905
+ return withRequest(req, async () => {
906
+ // Correlation id (issue #239): honor an inbound X-Request-Id from a
907
+ // trusted upstream proxy, else mint a fresh UUID. Stored on the request
908
+ // scope FIRST so everything downstream (the SSR, server actions, the
909
+ // access / error log, the onError sink, the response header) reads the
910
+ // same id, threading one trace id across services.
911
+ const reqId = resolveRequestId(req);
912
+ setRequestId(reqId);
913
+
914
+ // CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
915
+ // it on the request scope BEFORE producing the response, so the SSR
916
+ // pipeline's `cspNonce()` reads this exact value and stamps it on the
917
+ // inline boot script, the importmap, and the modulepreload hints.
918
+ // Disabled by default, so no nonce is minted and the response is
919
+ // unchanged. One minted value flows mint -> store -> SSR -> header.
920
+ const nonce = cspConfig.enabled ? mintNonce() : '';
921
+ if (nonce) setCspNonce(nonce);
922
+
923
+ // Make the resolved body-size limits (issue #237) readable from the
924
+ // request scope, so `readBody` inside a route handler enforces the same
925
+ // cap the framework's own RPC / form-dispatch body reads do.
926
+ setBodyLimits(state.bodyLimits);
927
+
928
+ let pathname = '/';
929
+ try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
930
+ // Sub-path deployment (issue #256): the per-path response-header rules
931
+ // (`webjs.headers`) author their `source` patterns app-root-relative,
932
+ // exactly like `webjs.redirects` / `webjs.trailingSlash` (which the
933
+ // ingress strip in produce() already sees root-relative). So match the
934
+ // header rules against the STRIPPED path too, keeping the whole config
935
+ // surface consistent under a base path. `pathname` itself (used for the
936
+ // access log) stays the RAW path the client hit. No-op when basePath is
937
+ // empty or the request is not under it.
938
+ let headerPathname = pathname;
939
+ if (basePathValue) {
940
+ const s = stripBasePath(pathname, basePathValue);
941
+ if (s !== null) headerPathname = s;
942
+ }
943
+ const startedAt = performance.now();
944
+
945
+ let res;
946
+ try {
947
+ res = await produce(req);
948
+ } catch (e) {
949
+ // A throw escaping produce() is the last-resort 500 (every interior
950
+ // path catches its own errors, but a surprise still must not crash the
951
+ // host). Fire the onError sink (best-effort) and emit a sanitized 500,
952
+ // preserving the prior behavior plus the new APM hook.
953
+ reportError(e, req, 'handle');
954
+ logger.error?.('[webjs] request pipeline threw', {
955
+ requestId: reqId,
956
+ method: req.method,
957
+ path: pathname,
958
+ err: e instanceof Error ? e.stack : String(e),
959
+ });
960
+ res = new Response('Server error', { status: 500 });
961
+ }
962
+
963
+ // Merge in the secure-by-default headers plus the per-path config
964
+ // (issue #232) as the final step, so app middleware, route
965
+ // handlers, and `expose` headers (already on `res`) always win.
966
+ // Applied to every served response (documents, assets, the core
967
+ // runtime, probes), since the defaults are universally safe.
968
+ let merged = applySecurityHeaders(res, {
969
+ pathname: headerPathname,
970
+ https: webRequestIsHttps(req),
971
+ prod: !dev,
972
+ rules: headerRules,
973
+ });
974
+
975
+ // Expose the correlation id on the response (issue #239) so a client /
976
+ // proxy can read it from the X-Request-Id header. Never clobber an id an
977
+ // upstream / the app already set on the response.
978
+ if (!merged.headers.has('x-request-id')) merged.headers.set('x-request-id', reqId);
979
+
980
+ // Emit the Content-Security-Policy header carrying the SAME minted
981
+ // nonce the SSR'd scripts got (no drift). Set only when CSP is
982
+ // enabled; never clobber a CSP header the app already set (in
983
+ // middleware, a route handler, or via the webjs.headers config), so
984
+ // an explicit app policy still wins.
985
+ if (nonce && !merged.headers.has('content-security-policy') &&
986
+ !merged.headers.has('content-security-policy-report-only')) {
987
+ try {
988
+ // readCspConfig already drops a directive whose name/value carries a
989
+ // control char, so buildCspHeader produces a Headers-safe value. The
990
+ // try/catch is a belt-and-suspenders backstop: a surprise value must
991
+ // never throw the response pipeline (fail closed to no CSP header
992
+ // rather than a self-inflicted 500 on every request).
993
+ merged.headers.set(cspHeaderName(cspConfig), buildCspHeader(cspConfig, nonce));
994
+ } catch {
995
+ /* a malformed policy must not take the request down: serve without CSP */
996
+ }
997
+ }
998
+
999
+ // Server HTML cache write (#241): if the SSR marked this response as a
1000
+ // cache candidate (an opted-in `revalidate` page), store the FINAL body
1001
+ // now, after segment middleware has added any per-user Set-Cookie (which
1002
+ // the funnel's guard re-checks, so a per-user response is not cached) and
1003
+ // before conditional-GET can swap it for a bodiless 304. The marker is
1004
+ // stripped here regardless. Best-effort: a store failure is swallowed.
1005
+ try {
1006
+ const reqUrl = new URL(req.url);
1007
+ // Sub-path deployment (issue #256): the HTML cache READ (in ssrPage)
1008
+ // and `revalidatePath` both key on the app-root-relative path, so key
1009
+ // the WRITE on the stripped path too, or a cached page would never
1010
+ // hit. No-op when basePath is empty / the path is not under it.
1011
+ if (basePathValue) {
1012
+ const s = stripBasePath(reqUrl.pathname, basePathValue);
1013
+ if (s !== null) reqUrl.pathname = s;
1014
+ }
1015
+ merged = await commitHtmlCache(req, merged, reqUrl);
1016
+ } catch { /* ignore */ }
1017
+
1018
+ // Conditional GET (RFC 7232, issue #240): attach a content-hash ETag to
1019
+ // a cacheable response missing one, and turn a matching If-None-Match
1020
+ // into a 304 Not Modified with no body. Applied LAST, after every header
1021
+ // (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
1022
+ // 304 carries the validators a shared cache and the client router need.
1023
+ // A no-store response, a non-GET/HEAD, and a streaming Suspense body are
1024
+ // all skipped (see conditional-get.js). A `private` response is NOT
1025
+ // skipped: private forbids shared storage, not validation (#1140). Logged with the final
1026
+ // (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
1027
+ let conditioned = merged;
1028
+ try {
1029
+ conditioned = await applyConditionalGet(req, merged);
1030
+ } catch { /* ignore */ }
1031
+
1032
+ // Structured access log (issue #239): ONE info line per handled request
1033
+ // at the single response funnel, carrying only method / path / status /
1034
+ // duration / requestId (no bodies, no secrets). Suppressed for the
1035
+ // framework's own /__webjs/* probe + static traffic so it does not spam.
1036
+ // Best-effort: a logger that throws must not take the response down.
1037
+ // Use the STRIPPED path for the suppression check (issue #256) so a
1038
+ // framework probe at `<basePath>/__webjs/*` is suppressed just like the
1039
+ // root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
1040
+ if (shouldAccessLog(headerPathname)) {
1041
+ try {
1042
+ // #1309: fold the dev-only seeding counters into the ONE access-log
1043
+ // line rather than adding a second one. Present only on a response
1044
+ // that carried the header, so only page renders gain the field and
1045
+ // production is unchanged.
1046
+ const seed = dev ? conditioned.headers.get('x-webjs-seed') : null;
1047
+ logger.info?.('request', {
1048
+ requestId: reqId,
1049
+ method: req.method,
1050
+ path: pathname,
1051
+ status: conditioned.status,
1052
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1053
+ ...(seed ? { seed } : {}),
1054
+ });
1055
+ } catch { /* never let logging crash the response */ }
1056
+ }
1057
+ return conditioned;
1058
+ });
1059
+ }
1060
+
1061
+ /** @param {Request} req */
1062
+ async function produce(req) {
1063
+ const rawUrl = new URL(req.url);
1064
+
1065
+ // Sub-path deployment ingress strip (issue #256). When `webjs.basePath`
1066
+ // is set and the request path is under it, STRIP the prefix and rewrite
1067
+ // the Request so EVERYTHING downstream (redirects, trailing-slash, the
1068
+ // probes, the `/__webjs/*` checks, the source-file gate, route matching,
1069
+ // SSR) sees a ROOT-relative path and works UNCHANGED. This single strip
1070
+ // is why the rest of the framework needs no per-site changes. A request
1071
+ // whose path is NOT under the base path is not for this mounted app, so
1072
+ // return a 404 (the safe default for a mounted app). Empty basePath (the
1073
+ // default) is a pure pass-through, so an unconfigured app is unchanged.
1074
+ if (basePathValue) {
1075
+ const stripped = stripBasePath(rawUrl.pathname, basePathValue);
1076
+ if (stripped === null) {
1077
+ // Not under the base path: this request is not for this app.
1078
+ return new Response('Not found', {
1079
+ status: 404,
1080
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1081
+ });
1082
+ }
1083
+ const newUrl = new URL(req.url);
1084
+ newUrl.pathname = stripped;
1085
+ // Rewrite the Request with the stripped URL, preserving method,
1086
+ // headers, and body. `duplex: 'half'` is required by the spec when
1087
+ // a body stream is present on a non-GET/HEAD request.
1088
+ //
1089
+ // SECURITY (#756): this is a fresh Request object, so it is NOT in the
1090
+ // listener's out-of-band trusted-IP WeakMap (the Bun shell stamps the IP
1091
+ // there instead of cloning the Request to set the header). Without
1092
+ // carrying it forward, `clientIp` would fall back to the inbound
1093
+ // `x-webjs-remote-ip` header that the copied `req.headers` still carries,
1094
+ // which a client can spoof. So strip that header on the rebuild and
1095
+ // propagate the framework-trusted IP across the new object, the same
1096
+ // pattern form-dispatch.js uses.
1097
+ const headers = new Headers(req.headers);
1098
+ headers.delete('x-webjs-remote-ip');
1099
+ const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
1100
+ const next = new Request(newUrl, /** @type {any} */ ({
1101
+ method: req.method,
1102
+ headers,
1103
+ body: hasBody ? req.body : undefined,
1104
+ duplex: hasBody ? 'half' : undefined,
1105
+ redirect: req.redirect,
1106
+ signal: req.signal,
1107
+ }));
1108
+ propagateTrustedRemoteIp(req, next);
1109
+ req = next;
1110
+ }
1111
+
1112
+ if (redirectRules.length) {
1113
+ // Declarative redirects (issue #254): apply the configured old-path ->
1114
+ // new-path rules at the VERY START of request handling, before the
1115
+ // probes, routing, SSR, or asset serving. A matched source returns a
1116
+ // 308 (permanent, the SEO default) / 307 (temporary) / configured
1117
+ // status immediately, so a moved URL never reaches the router.
1118
+ // `applyRedirects` skips /__webjs/* itself, so the framework probes /
1119
+ // runtime below are never redirected. The secure-header + conditional-GET
1120
+ // funnel in handle() still wraps this Response, like any other.
1121
+ const redir = applyRedirects(req, redirectRules);
1122
+ if (redir) return redir;
1123
+ }
1124
+
1125
+ if (trailingSlashPolicy !== 'ignore') {
1126
+ // Trailing-slash canonicalization (issue #255): after the explicit
1127
+ // redirects above (so an explicit rule wins first and the two never
1128
+ // form a loop), 308-redirect a non-canonical path to the policy's
1129
+ // canonical form (`never` strips a trailing slash, `always` adds one).
1130
+ // Default `'ignore'` is a no-op. The root `/` and file paths are
1131
+ // exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
1132
+ // above already skip it). The funnel in handle() still wraps this.
1133
+ const canonical = applyTrailingSlash(req, trailingSlashPolicy);
1134
+ if (canonical) return canonical;
1135
+ }
1136
+
1137
+ const url = new URL(req.url);
1138
+ let path;
1139
+ try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
1140
+ // The probes match the RAW pathname, never the decoded one. Decoding first
1141
+ // would make `/__webjs%2Fhealth` answer the liveness probe, which is a
1142
+ // different url that happens to decode to the same string. Guarded, because
1143
+ // an unparseable url must fall through rather than throw out of `produce`.
1144
+ let probePath;
1145
+ try { probePath = new URL(req.url).pathname; } catch { probePath = ''; }
1146
+
1147
+ // Health and readiness probes are answered BEFORE ensureReady so a probe
1148
+ // never blocks on the analysis. `/__webjs/health` is liveness (the
1149
+ // process is up and accepting connections). `/__webjs/ready` is 503 until
1150
+ // the instance is FULLY warm (the deterministic analysis AND the first
1151
+ // vendor attempt have both completed, so the importmap build id is
1152
+ // settled), then 200 unless an optional app readiness check
1153
+ // (readiness.{js,ts}) reports a dependency down. So a readinessProbe holds
1154
+ // traffic off a not-yet-warm or dependency-unhealthy instance, and admits
1155
+ // it only once the build id is stable, never mid vendor-resolution.
1156
+ // Probing `/__webjs/ready` also kicks off the warm in the background, so
1157
+ // an embedder that never called warmup() still warms. The first vendor
1158
+ // attempt is bounded (the jspm fetch timeout), so a vendor CDN failure
1159
+ // delays readiness only briefly and then admits the instance (degraded but
1160
+ // reload-safe); a transient failure is re-attempted on the next request.
1161
+ if (probePath === '/__webjs/health') {
1162
+ return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
1163
+ }
1164
+
1165
+ // Build-info probe (issue #239): which build is live? Answered before
1166
+ // ensureReady like the other probes (it depends only on the package
1167
+ // version + already-published build id + process info, never the app
1168
+ // analysis). No secrets.
1169
+ if (probePath === '/__webjs/version') {
1170
+ return buildInfoResponse();
1171
+ }
1172
+
1173
+ if (probePath === '/__webjs/ready') {
1174
+ const noStore = { 'cache-control': 'no-store' };
1175
+ if (!readyDone) {
1176
+ ensureReady().catch(() => {}); // drive the warm; never block the probe
1177
+ const body = readyError
1178
+ ? { status: 'error', error: String((readyError && readyError.message) || readyError) }
1179
+ : { status: 'pending' };
1180
+ return Response.json(body, { status: 503, headers: noStore });
1181
+ }
1182
+ // Analysis is warm. Consult the optional app readiness check (live
1183
+ // dependency health, e.g. a DB ping) if the app provides one.
1184
+ const check = await getReadinessCheck();
1185
+ if (check) {
1186
+ try {
1187
+ if ((await check()) === false) {
1188
+ return Response.json({ status: 'unready' }, { status: 503, headers: noStore });
1189
+ }
1190
+ } catch (e) {
1191
+ return Response.json(
1192
+ { status: 'unready', error: String((e && e.message) || e) },
1193
+ { status: 503, headers: noStore },
1194
+ );
1195
+ }
1196
+ }
1197
+ return Response.json({ status: 'ok' }, { headers: noStore });
1198
+ }
1199
+
1200
+ // Framework-internal static assets (the @webjsdev/core runtime, the dev
1201
+ // reload client, downloaded vendor bundles) depend on neither the analysis
1202
+ // nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
1203
+ // cold instance blocks them behind the first vendor resolve (issue #190),
1204
+ // and the core bundle is on every page's boot path, so that stalled first
1205
+ // interactivity site-wide. Matched on the decoded path, like handleCore.
1206
+ //
1207
+ // Content-hash fingerprint (#243): a `?v=` query means an immutable,
1208
+ // content-addressed asset url, so it is served `immutable` rather than
1209
+ // with the 1h fallback.
1210
+ const earlyStatic = await tryServeFrameworkStatic(path, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: url.searchParams.has('v') });
1211
+ if (earlyStatic) return earlyStatic;
1212
+
1213
+ // `/public/*` needs neither the module graph nor the vendor importmap, so
1214
+ // in DEV serve it here rather than behind the analysis (#1397). A cold
1215
+ // `/public/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
1216
+ // `ensureReady()`, which is why the stylesheet is the request most exposed
1217
+ // to the next `node --watch` restart. DEV ONLY on purpose: in prod
1218
+ // `/__webjs/ready` already holds traffic off a cold instance until the
1219
+ // analysis and the first vendor attempt have both completed, and hoisting
1220
+ // there would silently un-gate a `/public/*` file that an app middleware
1221
+ // protects. The consequence in dev is that root middleware does not run
1222
+ // for these paths, the same trade the framework statics above already make
1223
+ // in both modes. `state.regenerateRules` is loaded at boot inside
1224
+ // createRequestHandler, so the #967 on-request rebuild is available here,
1225
+ // ahead of ensureReady.
1226
+ if (dev) {
1227
+ const publicResp = await tryServePublicAsset(path, {
1228
+ appDir, dev, versioned: url.searchParams.has('v'), regenerateRules: state.regenerateRules,
1229
+ });
1230
+ if (publicResp) return publicResp;
1231
+ }
1232
+
1233
+ // Build all whole-app analysis on the first request (memoized), before
1234
+ // any SSR, module serve, gate check, action dispatch, or middleware runs.
1235
+ await ensureReady();
1236
+
1237
+ const next = () => handleCore(req, {
1238
+ state,
1239
+ appDir,
1240
+ coreDir,
1241
+ dev,
1242
+ reportError,
1243
+ reportDevError,
1244
+ hasOnError,
1245
+ logger,
1246
+ cspEnabled: cspConfig.enabled,
1247
+ allowedOrigins: allowedOriginsValue,
1248
+ });
1249
+
1250
+ if (state.middleware) {
1251
+ try {
1252
+ return await state.middleware(req, next);
1253
+ } catch (e) {
1254
+ reportError(e, req, 'middleware');
1255
+ logger.error('middleware threw', { err: String(e), requestId: getRequestId() });
1256
+ return new Response('Server error', { status: 500 });
1257
+ }
1258
+ }
1259
+ return next();
1260
+ }
1261
+
1262
+ /**
1263
+ * Lightweight lookup used by the HTTP layer to emit 103 Early Hints
1264
+ * BEFORE running SSR: resolves a pathname to its page-route module URLs
1265
+ * without loading them. Returns null for non-page paths.
1266
+ *
1267
+ * Sub-path deployment (issue #256): the HTTP layer passes the RAW request
1268
+ * pathname (still carrying the base path, since the ingress strip happens
1269
+ * inside `produce`, not here), so strip it for route matching and prefix
1270
+ * the emitted module URLs so the early-hint preloads resolve under the
1271
+ * prefix. A path not under the base path yields null (no hints).
1272
+ *
1273
+ * @param {string} pathname
1274
+ */
1275
+ function routeFor(pathname) {
1276
+ const matchPathname = basePathValue
1277
+ ? stripBasePath(pathname, basePathValue)
1278
+ : pathname;
1279
+ if (matchPathname === null) return null;
1280
+ const page = matchPage(state.routeTable, matchPathname);
1281
+ if (!page) return null;
1282
+ // Mirror ssr.js's boot assembly EXACTLY: drop inert route modules, and
1283
+ // splice an import-only module's component imports in place of the module
1284
+ // (#605). Otherwise the 103 Early Hints would preload the page / layout
1285
+ // modules the body no longer imports (a wasted hint) and miss the component
1286
+ // modules it actually fetches (a double-fetch on the real module).
1287
+ const inert = state.inertRouteModules;
1288
+ const importOnly = state.importOnlyRouteModules;
1289
+ const files = [];
1290
+ const seenFiles = new Set();
1291
+ for (const f of [page.route.file, ...page.route.layouts]) {
1292
+ if (inert && inert.has(f)) continue;
1293
+ const emit = importOnly && importOnly.get(f);
1294
+ for (const t of emit || [f]) if (!seenFiles.has(t)) { seenFiles.add(t); files.push(t); }
1295
+ }
1296
+ const moduleUrls = files.map((f) => {
1297
+ let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
1298
+ const url = rel.split('\\').join('/').replace(/^\/?/, '/');
1299
+ // Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
1300
+ // the SAME url the body's modulepreload + boot specifiers request (#243).
1301
+ // A bare url would warm a different url and waste the hint. No-op in dev.
1302
+ // Best-effort: a request landing in the narrow pre-warm window (before the
1303
+ // elision verdict is set) hashes against an empty fingerprint, so the hint
1304
+ // can mismatch the post-warm body for that one request, only a wasted
1305
+ // speculative preload, never a stale body (the body url is authoritative).
1306
+ return withAssetHash(withBasePath(url, basePathValue), basePathValue);
1307
+ });
1308
+ return { moduleUrls };
1309
+ }
1310
+
1311
+ return {
1312
+ handle,
1313
+ rebuild,
1314
+ routeFor,
1315
+ /**
1316
+ * Proactively run the first-request analysis (module graph, component
1317
+ * scan, gate, action index, middleware, elision, vendor map) in the
1318
+ * background, so a real first request finds it already memoized. Safe to
1319
+ * call any number of times and concurrently: the work is single-flighted,
1320
+ * so this never duplicates it or races a real request. It is a single
1321
+ * best-effort kick: errors are caught and logged rather than thrown (a
1322
+ * background warm-up must not crash the process), and whatever failed simply
1323
+ * re-runs on the next request or readiness probe (the platform's traffic and
1324
+ * probes are the retry loop, so there is no internal backoff). `startServer`
1325
+ * calls this once the HTTP server is listening; embedders can call it after
1326
+ * their own listen.
1327
+ * @returns {Promise<void>}
1328
+ */
1329
+ warmup: () => ensureReady().catch((e) => logger.error?.(`[webjs] background warm-up failed (will retry on the next request):`, e)),
1330
+ /** current route table getter: used by the WebSocket subsystem */
1331
+ getRouteTable: () => state.routeTable,
1332
+ /** Current unresolved dev error frame (#264), or null. Replayed by
1333
+ * startServer to a freshly-connected SSE client so the overlay shows even
1334
+ * after a navigation, not only on the breaking edit. Always null in prod. */
1335
+ getLastDevError: () => state.lastDevError,
1336
+ /** Whether a dev-watcher filename is a `webjs.dev.regenerate` OUTPUT (#967),
1337
+ * so `startServer`'s file watcher (a different scope, with no access to
1338
+ * `state`) can skip a build product the server itself writes and avoid a
1339
+ * spurious reload. Reads the live, rebuild-refreshed rules. */
1340
+ isRegenerateOutput: (filename) => isRegenerateOutputPath(filename, state.regenerateRules),
1341
+ /**
1342
+ * Classify a dev-watcher event into a live-reload verdict (#1398).
1343
+ * `startServer`'s watcher is a different scope with no access to `state`,
1344
+ * exactly like `isRegenerateOutput` above, so the lookup is exposed rather
1345
+ * than the sets.
1346
+ *
1347
+ * `root` is the WATCHED root the event came from, not `appDir`: `fs.watch`
1348
+ * reports a path relative to whatever root it was given, and an extra
1349
+ * `webjs.dev.watch` root (#894) is usually OUTSIDE the app. Resolving those
1350
+ * against `appDir` would manufacture a nonexistent in-app path and lose the
1351
+ * one fact the classifier needs about them.
1352
+ *
1353
+ * Returns the fail-safe `reload` verdict until the first analysis completes.
1354
+ *
1355
+ * @param {string} filename `event.filename`, relative to `root`
1356
+ * @param {string} [root] the watched root, defaulting to the app dir
1357
+ * @returns {import('../dev-classify.js').ReloadVerdict}
1358
+ */
1359
+ classifyWatchPath: (filename, root) => classifyChangedPath(resolve(root || appDir, filename), {
1360
+ appDir,
1361
+ shippedFiles: state.shippedFiles,
1362
+ graphFiles: state.graphFiles,
1363
+ pageFiles: state.pageFiles,
1364
+ analysisReady: analysisEverDone,
1365
+ sep,
1366
+ }),
1367
+ appDir,
1368
+ dev,
1369
+ logger,
1370
+ state,
1371
+ };
1372
+ }