import { createServer as createHttp1Server } from 'node:http';
import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
import { createRequire } from 'node:module';
// Namespace import, NOT `import { stripTypeScriptTypes }`. On Node < 22.13 the
// `stripTypeScriptTypes` named export does not exist, and a NAMED import of a
// missing builtin export is a LINK-TIME SyntaxError that fires before any
// module body runs, which would defeat the Node-version preflight (issue #238)
// by crashing the import of @webjsdev/server itself. A namespace import links
// on every Node (the property is just `undefined` at runtime on old Node), so
// importing @webjsdev/server succeeds and `assertNodeVersion()` at the top of
// createRequestHandler throws the clean "you need Node 24+" message instead.
import * as nodeModule from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';

// Server-side `.ts` imports are handled natively by Node 24+'s default
// type-stripping (`process.features.typescript === 'strip'`). No loader
// hook required. The browser-bound TypeScript request handler uses
// `module.stripTypeScriptTypes` for the same transform, so SSR and
// hydration produce identical JS.
//
// Runtime backing: Node ships `stripTypeScriptTypes` via the `amaro`
// package internally (wraps SWC's WASM TypeScript transform in a
// position-preserving strip-only mode). If the framework ever needs
// to run on Bun, Deno, or another runtime that does NOT expose the
// equivalent built-in, we will need to install `amaro` directly (or
// an equivalent: Sucrase preserves lines but not columns; SWC's
// strip-only also works). The fast-path `stripTs` helper would
// change one import line.
//
// Suppress the one-shot ExperimentalWarning that Node prints the
// first time `stripTypeScriptTypes` is called. The API is committed
// per Node 24's release notes; the warning is a holdover. We keep
// every other warning intact.
const _origEmitWarning = process.emitWarning.bind(process);
process.emitWarning = function (warning, type, code, ctor) {
  const msg = warning && warning.message ? warning.message : String(warning);
  if (
    (type === 'ExperimentalWarning' || (warning && warning.name === 'ExperimentalWarning')) &&
    msg.includes('stripTypeScriptTypes')
  ) {
    return;
  }
  return _origEmitWarning(warning, type, code, ctor);
};

import { buildRouteTable, matchPage, matchApi } from './router.js';
import { generateRouteTypes } from './route-types.js';
import { ssrPage, ssrNotFound } from './ssr.js';
import { loadPageAction, runPageAction } from './page-action.js';
import { handleApi } from './api.js';
import {
  buildActionIndex,
  serveActionStub,
  serveServerOnlyStub,
  invokeAction,
  matchExposedAction,
  matchAllAtPath,
  invokeExposedAction,
  buildPreflightResponse,
  withCors,
  isServerFile,
  hasUseServerDirective,
  hashFile,
} from './actions.js';
import { defaultLogger } from './logger.js';
import { assertNodeVersion } from './node-version.js';
import { applyEnvValidation } from './env-schema.js';
import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from './context.js';
import { buildInfoResponse } from './build-info.js';
import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
import { attachWebSocket } from './websocket.js';
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
import { analyzeElision, elideImportsFromSource } from './component-elision.js';

/** PascalCase → kebab-case for a helpful diagnostic example tag name. */
function kebab(name) {
  return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}

/**
 * Per-request correlation id (issue #239). Honor an inbound `X-Request-Id`
 * from a trusted upstream proxy so a trace id propagates across services; mint
 * a fresh `crypto.randomUUID()` otherwise. The inbound value is length-capped
 * and validated against a conservative token charset so a hostile client
 * cannot inject control chars / a header-splitting payload (the value is
 * echoed back in the `X-Request-Id` response header). On any mismatch we fall
 * back to a minted id rather than trust the junk.
 *
 * @param {Request} req
 * @returns {string}
 */
function resolveRequestId(req) {
  const inbound = req.headers.get('x-request-id');
  if (inbound && inbound.length <= 200 && /^[A-Za-z0-9._-]+$/.test(inbound)) return inbound;
  return crypto.randomUUID();
}

/**
 * Whether a path should be access-logged (issue #239). The framework's own
 * `/__webjs/*` probes, static runtime assets, and the dev SSE reload stream
 * are high-frequency infrastructure traffic, not app requests, so logging them
 * would just spam the access log. App routes (including app-authored
 * `/api/*`) are logged.
 *
 * @param {string} pathname
 * @returns {boolean}
 */
function shouldAccessLog(pathname) {
  return !pathname.startsWith('/__webjs/');
}
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
import { urlFromRequest } from './forwarded.js';
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
import {
  compileRedirectRules,
  applyRedirects,
  readTrailingSlashPolicy,
  applyTrailingSlash,
} from './redirects.js';
import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
import { commitHtmlCache, setAppSourceFingerprint } from './html-cache.js';
import { buildDevErrorFrame } from './dev-error.js';

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'application/javascript; charset=utf-8',
  '.mjs': 'application/javascript; charset=utf-8',
  '.ts': 'application/javascript; charset=utf-8',
  '.mts': 'application/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.svg': 'image/svg+xml',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.webp': 'image/webp',
  '.ico': 'image/x-icon',
  '.txt': 'text/plain; charset=utf-8',
};

/**
 * Cache of stripped `.ts` / `.mts` source.
 * Keyed by absolute file path. Entries expire when mtime changes.
 * Capped at 500 entries to prevent unbounded memory growth in
 * long-running production servers.
 *
 * Stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
 * Position-preserving whitespace replacement. No sourcemap is
 * emitted because every (line, column) maps to itself in the source.
 *
 * Only erasable TypeScript is supported. Non-erasable syntax (`enum`,
 * `namespace` with values, parameter properties, legacy decorators
 * with `emitDecoratorMetadata`, `import = require`) throws at strip
 * time. The `erasable-typescript-only` and `no-non-erasable-typescript`
 * lint rules catch these at edit time. webjs is buildless end-to-end:
 * there is no bundler fallback.
 *
 * The transformed bytes are cached per request handler in `state.tsCache`
 * (a `Map<string, { mtimeMs, code, map }>`), bounded to `TS_CACHE_MAX`
 * entries. The cache is per-handler rather than module-global because the
 * cached code bakes in that handler's elision verdict, so two handlers for
 * the same app with different elision settings must not share it.
 */
const TS_CACHE_MAX = 500;

/**
 * Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
 * what Rails / Next / Astro do out of the box: a scaffolded app with
 * a committed `.env.example` and a developer-copied `.env` should
 * "just work" without the user having to add a dotenv import or set
 * the file path on the CLI.
 *
 * Uses Node 24+'s built-in `process.loadEnvFile`, which is dotenv-
 * compatible and DOES NOT override pre-existing `process.env` values.
 * Calls that hit a missing file or parse error are silenced; the
 * server should still come up cleanly when there's no `.env`.
 *
 * Idempotent: re-running is a no-op for any env var the user already
 * exported (e.g. via the host shell or a process manager). That
 * keeps the "shell-set wins over file" precedence Rails users
 * expect.
 *
 * Must run before any server-only module is loaded by
 * buildActionIndex, since module-init code in `lib/*.server.ts`
 * (e.g. `createAuth({ secret: process.env.AUTH_SECRET })`) reads
 * process.env at import time. createRequestHandler is the
 * single entry point where this is guaranteed.
 *
 * @param {string} appDir
 */
function loadAppEnv(appDir) {
  try {
    if (typeof process.loadEnvFile === 'function') {
      process.loadEnvFile(join(appDir, '.env'));
    }
  } catch {
    // No .env file, malformed file, or Node version without
    // loadEnvFile. Either way, fall through silently: the user
    // may not need any env vars, or they may set them via shell.
  }
}

/**
 * Read the `WEBJS_ELIDE` environment override, if set.
 * `0` / `false` / `off` / `no` (case-insensitive) force elision OFF;
 * `1` / `true` / `on` / `yes` force it ON. Any other value, or an unset
 * variable, returns `undefined` so the caller falls through to the
 * `package.json` switch. The env override is the deploy-time / ops escape
 * hatch: force-disable elision to rule it out while debugging a wrong-strip
 * without editing committed code, or force-enable it regardless of an
 * app's `package.json`. It is also the seam the differential elision test
 * uses to render the same app on and off in one process.
 * @returns {boolean | undefined}
 */
function elideEnvOverride() {
  const raw = process.env.WEBJS_ELIDE;
  if (raw == null || raw === '') return undefined;
  const v = String(raw).trim().toLowerCase();
  if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
  if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
  return undefined;
}

/**
 * Read the project-level elision switch.
 * Precedence: the `WEBJS_ELIDE` env override wins when set, otherwise the
 * `package.json` `{ "webjs": { "elide": false } }` switch disables
 * display-only and inert-route elision app-wide (everything ships, like
 * before the feature existed). Any other value, or an absent key, leaves
 * elision enabled (the default). Re-read on every rebuild so toggling
 * either control takes effect without a server restart.
 * @param {string} appDir
 * @returns {Promise<boolean>}
 */
export async function readElideEnabled(appDir) {
  const override = elideEnvOverride();
  if (override !== undefined) return override;
  try {
    const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
    if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
  } catch {
    // No package.json, malformed JSON, or unreadable. Keep the default.
  }
  return true;
}

/**
 * Read the per-path response-header config (`webjs.headers`) from the
 * app's package.json and compile it to URLPattern rules. A missing,
 * malformed, or unreadable config yields an empty rule set (the secure
 * defaults still apply), never a throw.
 *
 * @param {string} appDir
 * @returns {Promise<ReturnType<typeof compileHeaderRules>>}
 */
export async function readHeaderRules(appDir) {
  try {
    const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
    return compileHeaderRules(pkg);
  } catch {
    return [];
  }
}

/**
 * Read the declarative redirect config (`webjs.redirects`) from the app's
 * package.json and compile it to URLPattern rules (issue #254). A missing,
 * malformed, or unreadable config yields an empty rule set (no redirects),
 * never a throw. Patterns are compiled ONCE here at boot, not per request.
 *
 * @param {string} appDir
 * @returns {Promise<ReturnType<typeof compileRedirectRules>>}
 */
export async function readRedirectRules(appDir) {
  try {
    const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
    return compileRedirectRules(pkg);
  } catch {
    return [];
  }
}

/**
 * Read the trailing-slash policy (`webjs.trailingSlash`) from the app's
 * package.json (issue #255). A missing, malformed, or unreadable config
 * yields `'ignore'` (no canonicalization), never a throw, so an
 * unconfigured app is unchanged.
 *
 * @param {string} appDir
 * @returns {Promise<'never' | 'always' | 'ignore'>}
 */
export async function readTrailingSlashFromApp(appDir) {
  try {
    const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
    return readTrailingSlashPolicy(pkg);
  } catch {
    return 'ignore';
  }
}

/**
 * Read the sub-path base path (`webjs.basePath`) from the app's
 * package.json (issue #256). A missing, malformed, or unreadable config
 * yields `''` (root mount), never a throw, so an unconfigured app is
 * byte-identical to before this feature. Normalized to `''` or
 * `/segment[/segment...]` by `readBasePath`.
 *
 * @param {string} appDir
 * @returns {Promise<string>}
 */
export async function readBasePathFromApp(appDir) {
  try {
    const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
    return readBasePath(pkg);
  } catch {
    return '';
  }
}

/**
 * Read the CSP config (`webjs.csp`) from the app's package.json and
 * normalize it (issue #233). A missing, malformed, or unreadable config
 * yields a disabled config (no nonce minted, no CSP header), never a
 * throw: a broken security knob must fail closed, not take the app down.
 *
 * @param {string} appDir
 * @returns {Promise<ReturnType<typeof readCspConfig>>}
 */
export async function readCspConfigFromApp(appDir) {
  try {
    const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
    return readCspConfig(pkg);
  } catch {
    return readCspConfig(undefined);
  }
}

/**
 * Resolve the request body-size limits (issue #237) from the app's package.json
 * `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env overrides
 * (`WEBJS_MAX_BODY_BYTES` / `WEBJS_MAX_MULTIPART_BYTES`). A missing or
 * unreadable package.json falls through to the secure defaults (env still wins),
 * never a throw.
 *
 * @param {string} appDir
 * @returns {Promise<{ json: number, multipart: number }>}
 */
export async function readBodyLimitsFromApp(appDir) {
  let pkg;
  try {
    pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
  } catch {
    pkg = undefined;
  }
  return readBodyLimits(pkg);
}

/**
 * Resolve the node:http server timeouts (issue #237) from the app's
 * package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
 * `webjs.keepAliveTimeoutMs` plus the env overrides. A missing or unreadable
 * package.json falls through to the secure defaults (env still wins).
 *
 * @param {string} appDir
 * @returns {Promise<{ requestTimeout: number, headersTimeout: number, keepAliveTimeout: number }>}
 */
export async function readServerTimeoutsFromApp(appDir) {
  let pkg;
  try {
    pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
  } catch {
    pkg = undefined;
  }
  return computeServerTimeouts(pkg);
}

/**
 * Create a reusable, framework-agnostic request handler for a webjs app.
 * The returned `handle(req)` takes a standard `Request` and resolves to a
 * standard `Response`: suitable for Node http, Deno, Bun, Cloudflare Workers,
 * or embedding inside an Express/Fastify app.
 *
 * @param {{
 *   appDir: string,
 *   dev?: boolean,
 *   logger?: import('./logger.js').Logger,
 *   onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
 *   onReload?: () => void,
 *   onDevError?: (frame: object) => void,
 * }} opts
 */
export async function createRequestHandler(opts) {
  // Preflight: webjs needs Node 24+ (built-in TS strip + recursive fs.watch).
  // Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno)
  // gets the actionable message at boot, not a cryptic API failure mid-request.
  assertNodeVersion({ onFail: 'throw' });
  const appDir = resolve(opts.appDir);
  // Load <appDir>/.env into process.env BEFORE anything else.
  // buildActionIndex below imports server-only files (lib/*.server.ts,
  // modules/**/*.server.ts), some of which read process.env at module
  // init (e.g. createAuth reads AUTH_SECRET). Without this call,
  // scaffolded apps with a committed .env.example + .env would fail
  // to boot until the user discovered the missing env-load. See
  // tracker #37.
  loadAppEnv(appDir);
  // Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
  // default-exports a typed schema or a custom validator function; we run it
  // against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
  // imports any server-only module. A failure throws a clear aggregated Error
  // here, so an embedded host rejects at boot and the CLI exits non-zero,
  // failing fast instead of crashing cryptically mid-request. Absent file is a
  // no-op (opt-in). Coerced + defaulted values are written back to process.env.
  await applyEnvValidation(appDir, { dev: !!opts.dev });
  const dev = !!opts.dev;
  const logger = opts.logger || defaultLogger({ dev });
  // APM / Sentry integration point (issue #239). Called whenever the request
  // pipeline catches an unhandled error: the top-level handle() catch (the
  // last-resort 500), an unexpected throw inside the produce() funnel, or a
  // middleware that threw. BEST-EFFORT by contract: a throwing onError is
  // caught here so it can never crash the response, and the framework's own
  // sanitized 500 / existing error behavior is unchanged (the hook is purely
  // additive). The sink receives the error plus the correlation id so it can
  // tie the report to the same id the access log and X-Request-Id carry.
  const onError = typeof opts.onError === 'function' ? opts.onError : null;
  /**
   * Invoke the app's onError sink defensively. The phase is a coarse label of
   * where the pipeline caught the error, for the sink's own grouping.
   * @param {unknown} error
   * @param {Request} request
   * @param {string} phase
   */
  function reportError(error, request, phase) {
    if (!onError) return;
    try {
      onError(error, { request, requestId: getRequestId(), phase });
    } catch (e) {
      logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
    }
  }
  // Sub-path deployment base path (issue #256), read once from the app's
  // package.json `webjs.basePath` and bound into the importmap builder BEFORE
  // setCoreInstall / setVendorEntries so every importmap target (and the
  // recomputed hash) reflects the prefix. Empty (the default) makes both the
  // ingress strip and the emit-side prefix pure no-ops, so an unconfigured app
  // is byte-identical to before this feature.
  const basePathValue = await readBasePathFromApp(appDir);
  await setBasePath(basePathValue);

  const coreDir = locateCoreDir(appDir);
  // Switch the importmap between dist/ bundles and src/ per-file
  // URLs depending on whether the resolved @webjsdev/core install
  // has built bundles on disk. npm-installed copies always do;
  // workspace dev does only after `npm run build:dist`. Without
  // a built dist the server falls back to the historical per-file
  // src/ URLs so dev iteration does not require a build step.
  //
  // Both required bundles must exist. An older @webjsdev/core
  // install built BEFORE the browser-entry split (#119/#128) has
  // `webjs-core.js` but no `webjs-core-browser.js`. Enabling dist
  // mode in that case would route the bare `@webjsdev/core`
  // specifier at a 404 on every page. Require both so a partial
  // dist transparently degrades to src/ mode instead.
  const distDir = join(coreDir, 'dist');
  const distComplete =
    existsSync(join(distDir, 'webjs-core.js')) &&
    existsSync(join(distDir, 'webjs-core-browser.js'));
  await setCoreInstall(coreDir, distComplete);

  // Content-hash asset URLs for immutable caching (issue #243). Bind the
  // asset-hash module to the app + core roots and enable fingerprinting in
  // PROD only. In dev `withAssetHash` stays a pure no-op (never enabled), so
  // the dev importmap / boot / preload output is byte-identical to before.
  // This runs AFTER setBasePath / setCoreInstall, but those recompute the
  // importmap hash with `{ fingerprint: false }`, so the boot-published build
  // id is a stable deploy fingerprint independent of per-file content hashes.
  setAssetRoots({ appDir, coreDir, enabled: !dev });

  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
  // deterministic vendor map that is cheap to read (one file, no analysis, no
  // network). Resolve it AT BOOT and publish the build id immediately so the
  // process advertises a stable, non-empty id from its very first response: a
  // freshly-deployed pinned process is detected as a new deploy by old-deploy
  // clients with zero warmup window. Mirrors Rails importmap (committed pins
  // rendered deterministically at runtime). Pinning stays optional; an unpinned
  // app does no vendor work at boot and publishes its id after the first
  // successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
  // gate, elision) and the UNPINNED jspm resolve stay deferred to the first
  // request, so #143's win is intact; only the cheap committed-file read moves
  // back to boot, and only when a VALID pin exists. A committed pin file is
  // served as-is (elision never prunes it), so the boot-resolved map equals the
  // final served map and the published id is authoritative.
  //
  // Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
  // hasVendorPin is a cheap existence check; a malformed pin (exists but
  // unparseable) must NOT short-circuit here, because resolveVendorImports would
  // then fall through to its bare-import scan thunk, and the boot-time thunk is
  // empty (the real scan is part of the deferred analysis). A broken pin instead
  // falls through to the normal deferred resolve, which carries the real scan
  // thunk and degrades gracefully, exactly as an unpinned app does.
  let bootVendorPinned = false;
  if (hasVendorPin(appDir) && (await readPinFile(appDir))) {
    try {
      const v = await resolveVendorImports(appDir, () => new Set());
      await setVendorEntries(v.imports, v.integrity);
      publishBuildId();
      bootVendorPinned = true;
    } catch (e) {
      // An unexpected failure applying a VALID pin (e.g. setVendorEntries
      // throwing) is non-fatal: leave bootVendorPinned false so the deferred
      // resolve re-attempts on the first request. Boot stays resilient.
      logger.error?.(`[webjs] applying the committed vendor pin at boot failed (will retry on the first request):`, e);
    }
  }

  // Whole-app analysis (module graph, component scan, browser-bound gate,
  // action index, middleware, elision, vendor) is NOT run at boot. It is
  // computed on the first request via ensureReady() below and memoized, so the
  // server starts without walking or reading the app's source, executing any
  // server module, or hitting the network. Only the route table is built
  // eagerly: it is a cheap directory scan (no code reads), and routing, Early
  // Hints, and WebSocket lookups need it available before the first request.
  const routeTable = await buildRouteTable(appDir);

  // Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
  // dev so an editor's tsserver always has up-to-date route types without the
  // developer remembering to run `webjs types`. Best-effort and fire-and-
  // forget: a failure logs and never blocks boot. Re-emitted after each route
  // rebuild (see doRebuild) so adding/removing a route refreshes the types.
  /** @returns {Promise<void>} */
  async function emitRouteTypes() {
    try {
      const { mkdir, writeFile, rename } = await import('node:fs/promises');
      const text = await generateRouteTypes(appDir);
      const outDir = join(appDir, '.webjs');
      await mkdir(outDir, { recursive: true });
      // Write to a temp sibling then rename, so tsserver (which reads this
      // file) never observes a half-written body if two rebuilds race. rename
      // is atomic within the same dir. Both paths sit under the watcher-ignored
      // .webjs/, so neither the temp write nor the rename re-triggers a rebuild.
      const dest = join(outDir, 'routes.d.ts');
      const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`);
      await writeFile(tmp, text);
      await rename(tmp, dest);
    } catch (e) {
      logger.warn?.(`[webjs] could not write .webjs/routes.d.ts (route types): ${e?.message || e}`);
    }
  }
  if (dev) void emitRouteTypes();

  // Per-path response-header rules (issue #232), read once from the
  // app's package.json `webjs.headers`. Static config, so no rebuild
  // re-read; the secure defaults need no config and apply regardless.
  const headerRules = await readHeaderRules(appDir);

  // Declarative redirect rules (issue #254), read once from the app's
  // package.json `webjs.redirects` and compiled to URLPattern rules at
  // boot (never per request). Empty when unconfigured, so an app with no
  // redirects is unchanged. Applied at the very start of request handling,
  // before routing / SSR / asset serving, so a moved URL returns a 308/307
  // immediately.
  const redirectRules = await readRedirectRules(appDir);

  // Trailing-slash policy (issue #255), read once from the app's package.json
  // `webjs.trailingSlash`. Default `'ignore'` (no canonicalization), so an
  // unconfigured app is unchanged; `'never'` (recommended) strips a trailing
  // slash and `'always'` adds one, each via a 308 to the canonical form.
  // Applied in produce() AFTER the declarative redirects, so an explicit
  // redirect rule wins first and the two never loop.
  const trailingSlashPolicy = await readTrailingSlashFromApp(appDir);

  // CSP config (issue #233), read once from the app's package.json
  // `webjs.csp`. OFF by default: when disabled no nonce is minted and no
  // Content-Security-Policy header is set, so an unconfigured app is
  // unchanged. When enabled, `handle()` mints a fresh per-request nonce,
  // makes it the value `cspNonce()` returns (so the SSR'd inline scripts
  // carry it), and sets the matching header carrying the same nonce.
  const cspConfig = await readCspConfigFromApp(appDir);

  // Request body-size limits (issue #237), read once from the app's
  // package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
  // overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
  // unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
  // (used inside route handlers) enforces the same cap the RPC and page-action
  // paths do.
  const bodyLimits = await readBodyLimitsFromApp(appDir);

  const state = {
    routeTable,
    actionIndex: null,
    middleware: null,
    bodyLimits,
    logger,
    moduleGraph: null,
    elidableComponents: new Set(),
    inertRouteModules: new Set(),
    browserBoundFiles: null,
    // Transformed-source cache (stripped TS + applied elision). Per-handler,
    // NOT module-global: the cached bytes bake in THIS handler's elision
    // verdict, so two handlers for the same app with different elision
    // settings (a multi-tenant embedder, or the differential elision test)
    // must not share it, or the second would serve the first's elided source.
    tsCache: new Map(),
    // The most recent unresolved dev error frame (#264), or null. Pushed to the
    // SSE channel for the live overlay and replayed to a freshly-connected tab.
    // Dev-only (never populated when !dev); cleared on a successful rebuild.
    lastDevError: null,
  };

  /**
   * Report a dev error (#264): build a frame and push it to the open tab via
   * the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
   * response or crash the server (a frame-build failure is swallowed). No file
   * path or source is ever built in prod (the early return), so nothing leaks.
   *
   * @param {unknown} error
   * @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string }} [info]
   */
  function reportDevError(error, info = {}) {
    if (!dev) return;
    try {
      const frame = buildDevErrorFrame(error, { ...info, appDir });
      state.lastDevError = frame;
      opts.onDevError?.(frame);
    } catch { /* a frame-build failure must never affect the request path */ }
  }

  // All whole-app analysis is built lazily on the first request, memoized so
  // boot does none of it. It runs in two stages. The deterministic analysis
  // (module graph, component scan + prime, browser-bound gate, action index,
  // middleware, elision) is network-free and, once built, never re-runs unless
  // a rebuild invalidates it; readiness gates on it. Vendor resolution is a
  // SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
  // an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
  // offline or partially-unresolvable app still boots. A transient vendor
  // failure is re-attempted on the NEXT ensureReady call (driven by an incoming
  // request, a readiness probe, or the warm-up), with no background timer: the
  // platform's traffic and probes are the retry loop. `readyError` holds a
  // propagating analysis failure so /__webjs/ready can report it.
  let analysisDone = false;        // deterministic analysis complete (readiness gate)
  // A pinned app applied its FULL vendor map and published the build id at boot
  // (above). The deferred vendor stage still runs once (and after every rebuild)
  // to PRUNE that map to the elision-reachable specifiers, so a pinned app serves
  // the same map an unpinned one does (#197); it does not re-publish the build id
  // (the boot hash stays the deploy fingerprint). An unpinned app starts false and
  // resolves live on the first request.
  let vendorResolved = false;      // vendor map fully resolved/pruned (or permanently tolerated)
  let vendorAttemptedOnce = false; // the first (blocking) vendor attempt has run
  let vendorGen = 0;               // bumped on rebuild; a stale resolve cannot flip vendorResolved
  let readyDone = false;           // mirrors analysisDone; the /__webjs/ready gate
  /** @type {unknown} */
  let readyError = null;
  /** @type {Promise<void> | null} */
  let readyInFlight = null;
  async function ensureReady() {
    // Fully warm: analysis done and vendor resolved. Nothing to do.
    if (analysisDone && vendorResolved) return;
    // A warm pass is in flight (the analysis and/or the FIRST vendor attempt).
    // Await it rather than serving past it: a concurrent early request must get
    // the FINAL importmap, never a half-resolved one. This is what makes the
    // unpinned warmup flawless. The first attempt's jspm resolve is
    // timeout-bounded (vendor.js), so an offline app cannot hang here: on
    // timeout the resolve returns and the response is served with an empty,
    // reload-safe build id, then the retry below completes it. Without this
    // wait, a request arriving mid-resolve would serve a partial map and an
    // empty-then-changing build id, the exact warmup drift that hard-reloads
    // and wipes a half-filled form.
    if (readyInFlight) { await readyInFlight; return; }
    // Analysis warm but the first vendor attempt already completed and failed:
    // re-attempt WITHOUT blocking this request. The single-flight dedupes
    // concurrent attempts; success flips the flag AND publishes the build id.
    // This is the request/probe-driven retry (no timer). Until it succeeds the
    // served build id stays empty (reload-safe), so no navigation hard-reloads.
    if (analysisDone && vendorAttemptedOnce) {
      const gen = vendorGen;
      resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); } }).catch(() => {});
      return;
    }
    // Otherwise run the (single-flighted) full warm: the analysis, then the
    // first vendor attempt, awaited so the first response carries the import map.
    if (!readyInFlight) {
      readyInFlight = (async () => {
        /** @type {Record<string, number>} */
        const t = {};
        let ranAnalysis = false, ranVendor = false;
        const now = () => performance.now();
        try {
          if (!analysisDone) {
            let m = now();
            state.moduleGraph = await buildModuleGraph(appDir);
            t.graph = now() - m; m = now();
            const components = await scanComponents(appDir);
            await primeComponentRegistry(appDir, components);
            t.scan = now() - m; m = now();
            state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);
            t.gate = now() - m; m = now();
            state.actionIndex = await buildActionIndex(appDir, dev);
            t.actions = now() - m; m = now();
            state.middleware = await loadMiddleware(appDir, dev, logger);
            t.middleware = now() - m; m = now();
            const r = (await readElideEnabled(appDir))
              ? await analyzeElision(components, collectRouteModules(state.routeTable),
                  state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
              : { elidableComponents: new Set(), inertRouteModules: new Set() };
            state.elidableComponents = r.elidableComponents;
            state.inertRouteModules = r.inertRouteModules;
            // Fold the elision verdict into app-module content hashes (#243): an
            // app module's served body is elision-transformed, so a verdict flip
            // must bust its `?v` even when its source is byte-identical. A stable
            // string of the sorted elidable + inert paths, RELATIVIZED to appDir
            // so the fingerprint is a property of the app's STRUCTURE, not its
            // filesystem location (two deploys at different absolute paths, or
            // two identical apps, produce the same fingerprint). asset-hash
            // digests it into each app-module hash; '' when nothing is elidable,
            // so a no-elision app's hash stays exactly `sha256(bytes)`.
            {
              // `appDir + sep` boundary (matches asset-hash's containment guard)
              // so a sibling-prefix dir cannot be mis-relativized.
              const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
              const elidedPaths = [
                ...state.elidableComponents,
                ...state.inertRouteModules,
              ].map(rel).sort();
              setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
            }
            // App-source fingerprint for the HTML cache key (#318): a
            // deterministic, location-independent digest of the browser-bound
            // file set's content hashes, so a deploy that changes ONLY an app
            // module's bytes (which the importmap-only build id misses) re-keys
            // the cache instead of serving a body with stale `?v` boot URLs.
            // PROD only (asset-hash is enabled there); '' in dev, where fs.watch
            // handles staleness. Computed AFTER the elision fingerprint, so each
            // per-file hash already reflects the elision verdict (an elision
            // flip changes the served output, so it moves this fingerprint too).
            // This eagerly hashes the WHOLE browser-bound set once (not just the
            // first route's files), but `assetHashFor` is memoized and already
            // runs for `?v` emission in prod, so it stays a one-time bounded cost
            // inside the lazy analysis (never at boot).
            if (!dev && state.browserBoundFiles) {
              const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
              const lines = [...state.browserBoundFiles]
                .map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
                .sort();
              setAppSourceFingerprint(lines.join('\n'));
            } else {
              setAppSourceFingerprint('');
            }
            t.elision = now() - m;
            if (dev) {
              for (const { className, file } of await findOrphanComponents(appDir)) {
                logger.warn?.(
                  `[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
                    `Add \`customElements.define('<tag-name>', ${className});\` or <${kebab(className)}> tags won't upgrade.`,
                );
              }
            }
            analysisDone = true;
            ranAnalysis = true;
          }
          readyError = null;
          if (!vendorResolved) {
            const m = now();
            const gen = vendorGen;
            vendorAttemptedOnce = true;
            const ok = await resolveAndApplyVendor();
            t.vendor = now() - m;
            ranVendor = true;
            // Only memoize success (and only if a rebuild didn't intervene). A
            // transient failure leaves vendorResolved false; the next ensureReady
            // call re-attempts it non-blocking. A permanent unresolvable (jspm
            // 401) reports ok and is tolerated, so it does not loop. On success
            // the importmap is now authoritatively final, so publish the build
            // id: from here every response advertises the same stable value and
            // the client router's deploy detection works without warmup drift.
            // A pinned app published the build id at boot (hash of the committed
            // pin) and the prune only shrinks the served map, so do NOT re-publish
            // (that would drift the id mid-process). An unpinned app publishes its
            // now-final live map here.
            if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); }
          }
          // Readiness reflects a FULLY warm instance: the deterministic analysis
          // AND the first vendor attempt have both completed (note: completed,
          // not necessarily succeeded). A readiness-gated platform (Railway
          // healthcheckPath, k8s readinessProbe) therefore admits traffic only
          // AFTER the build id is published (vendor resolved) or definitively
          // empty (a bounded vendor failure), never DURING the vendor-resolution
          // window. This is what makes warm-up actually protect users: the prior
          // instance keeps serving until the new one is fully warm, so a real
          // request lands on a warm instance with a stable build id instead of
          // racing the resolve. The first vendor attempt is bounded (the jspm
          // fetch timeout in vendor.js), so an offline / CDN-degraded app still
          // becomes ready shortly after that timeout, degraded but reload-safe,
          // which preserves the boot resilience #143 introduced. The gate is the
          // FIRST attempt only: a transient failure still flips readyDone here,
          // so a later non-blocking retry never has to re-open the readiness gate.
          readyDone = true;
          if (ranAnalysis) {
            const ms = (x) => Math.round(x || 0);
            const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
            logger.info?.(
              `[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
                `gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
                `elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
            );
          } else if (ranVendor && vendorResolved) {
            logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
          }
        } catch (e) {
          readyError = e;
          throw e;
        } finally {
          readyInFlight = null;
        }
      })();
    }
    await readyInFlight;
  }

  // All vendor resolves funnel through one single-flight so two never overlap
  // (resolveVendorImports reports a transient failure via a module-global flag
  // that only one in-flight resolve may safely touch). Never rejects; returns
  // the resolve's ok flag (false on a transient failure, applying whatever
  // partial map resolved so the app is no worse off).
  /** @type {Promise<boolean> | null} */
  let vendorResolveInFlight = null;
  function resolveAndApplyVendor() {
    if (vendorResolveInFlight) return vendorResolveInFlight;
    vendorResolveInFlight = (async () => {
      try {
        const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules]));
        const v = await resolveVendorImports(appDir, scan);
        let { imports, integrity } = v;
        if (bootVendorPinned) {
          // resolveVendorImports returns a committed pin VERBATIM (it never runs
          // the scan for a pinned app). Prune it to the elision-reachable
          // specifiers so a pinned app serves the same map an unpinned one does
          // (#197): an elided-only dep like dayjs is dropped. One scan; the pin
          // path skipped it. This runs on the first warm AND after every rebuild,
          // so the pruned map is the single source of truth.
          const reachable = await scan();
          ({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
        }
        await setVendorEntries(imports, integrity);
        return v.ok;
      } catch (e) {
        logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
        return false;
      }
    })().finally(() => { vendorResolveInFlight = null; });
    return vendorResolveInFlight;
  }

  // Optional app-level readiness check. A `readiness.{js,ts}` file at the app
  // root may default-export an async function; /__webjs/ready runs it once the
  // analysis is warm, so readiness can reflect LIVE dependency health (a DB
  // ping, a queue connection) that the static analysis cannot see. Returning
  // false or throwing reports the instance not ready (503), so a readinessProbe
  // holds traffic off an instance whose deps are down. Absent file => analysis-
  // warm is the only gate. The module is cached per build (cleared on rebuild);
  // the function itself runs on every probe so it reflects current state.
  let readinessFn; // undefined = unloaded, null = no file, function = loaded
  async function getReadinessCheck() {
    if (readinessFn !== undefined) return readinessFn;
    let file = null;
    for (const name of ['readiness.ts', 'readiness.js', 'readiness.mts', 'readiness.mjs']) {
      const p = join(appDir, name);
      if (await exists(p)) { file = p; break; }
    }
    if (!file) { readinessFn = null; return null; }
    try {
      const url = pathToFileURL(file).toString();
      const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
      const mod = await import(url + bust);
      readinessFn = typeof mod.default === 'function' ? mod.default : null;
    } catch (e) {
      logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) });
      readinessFn = null;
    }
    return readinessFn;
  }

  // Rebuilds are serialized so a slow rebuild #1 cannot overwrite a fresher
  // rebuild #2's route table when it finally finishes. Without this, two file
  // edits inside one fs.watch debounce window could produce a permanently
  // stale state until the next rebuild.
  let rebuildInFlight = Promise.resolve();

  async function rebuild() {
    rebuildInFlight = rebuildInFlight.then(() => doRebuild()).catch((e) => {
      logger.error?.(`[webjs] rebuild failed:`, e);
      // Push the failure to the open tab so the overlay appears live after a
      // breaking edit, not only on the next manual navigation (#264).
      reportDevError(e, { kind: 'rebuild' });
    });
    return rebuildInFlight;
  }

  async function doRebuild() {
    // The route table is the only eager artifact (cheap directory scan); rebuild
    // it so routing reflects added/removed route files immediately.
    state.routeTable = await buildRouteTable(appDir);
    // Refresh the generated route types (#258) so adding/removing a route file
    // updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
    // best-effort (see emitRouteTypes).
    if (dev) void emitRouteTypes();
    clearVendorCache();
    // Content-hash asset cache (#243): clear so a changed file re-hashes and
    // its emitted `?v` busts the stale immutable copy. A no-op in dev (the
    // module is never enabled there), kept for correctness + the deploy-busts
    // regression test, which clears + re-hashes to observe a changed `?v`.
    clearAssetHashCache();
    state.tsCache.clear();
    // Invalidate the lazy analysis; the next request rebuilds the graph,
    // component scan, gate, action index, middleware, elision, and vendor map.
    // Wait out any in-flight build first so it cannot commit stale results
    // after the reset. A dependency edit can flip an elision verdict without
    // changing an importer's mtime, hence the state.tsCache.clear above.
    if (readyInFlight) { try { await readyInFlight; } catch {} }
    // Bump the vendor generation so a vendor resolve still in flight from the
    // previous build cannot flip vendorResolved against the fresh state.
    vendorGen++;
    analysisDone = false;
    vendorResolved = false;
    vendorAttemptedOnce = false;
    readyDone = false;
    readyError = null;
    readinessFn = undefined; // reload readiness.{js,ts} after a rebuild
    // Optimistically clear the dev error (#264): the rebuild itself only
    // re-scans the route table and INVALIDATES the lazy analysis (the real
    // re-parse / re-strip / re-render happens on the next request), so we do
    // not yet know the edit fixed the error. Clearing it here means a tab that
    // connects now is not replayed a possibly-stale overlay; `onReload` then
    // reloads every open tab, and if the underlying error is still present the
    // reloaded request re-pushes a fresh frame (a brief dismiss-then-reappear
    // flicker on an unrelated edit, self-correcting to the right end state).
    state.lastDevError = null;
    opts.onReload?.();
  }

  /** @param {Request} req */
  function handle(req) {
    return withRequest(req, async () => {
      // Correlation id (issue #239): honor an inbound X-Request-Id from a
      // trusted upstream proxy, else mint a fresh UUID. Stored on the request
      // scope FIRST so everything downstream (the SSR, server actions, the
      // access / error log, the onError sink, the response header) reads the
      // same id, threading one trace id across services.
      const reqId = resolveRequestId(req);
      setRequestId(reqId);

      // CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
      // it on the request scope BEFORE producing the response, so the SSR
      // pipeline's `cspNonce()` reads this exact value and stamps it on the
      // inline boot script, the importmap, and the modulepreload hints.
      // Disabled by default, so no nonce is minted and the response is
      // unchanged. One minted value flows mint -> store -> SSR -> header.
      const nonce = cspConfig.enabled ? mintNonce() : '';
      if (nonce) setCspNonce(nonce);

      // Make the resolved body-size limits (issue #237) readable from the
      // request scope, so `readBody` inside a route handler enforces the same
      // cap the framework's own RPC / page-action body reads do.
      setBodyLimits(state.bodyLimits);

      let pathname = '/';
      try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
      // Sub-path deployment (issue #256): the per-path response-header rules
      // (`webjs.headers`) author their `source` patterns app-root-relative,
      // exactly like `webjs.redirects` / `webjs.trailingSlash` (which the
      // ingress strip in produce() already sees root-relative). So match the
      // header rules against the STRIPPED path too, keeping the whole config
      // surface consistent under a base path. `pathname` itself (used for the
      // access log) stays the RAW path the client hit. No-op when basePath is
      // empty or the request is not under it.
      let headerPathname = pathname;
      if (basePathValue) {
        const s = stripBasePath(pathname, basePathValue);
        if (s !== null) headerPathname = s;
      }
      const startedAt = performance.now();

      let res;
      try {
        res = await produce(req);
      } catch (e) {
        // A throw escaping produce() is the last-resort 500 (every interior
        // path catches its own errors, but a surprise still must not crash the
        // host). Fire the onError sink (best-effort) and emit a sanitized 500,
        // preserving the prior behavior plus the new APM hook.
        reportError(e, req, 'handle');
        logger.error?.('[webjs] request pipeline threw', {
          requestId: reqId,
          method: req.method,
          path: pathname,
          err: e instanceof Error ? e.stack : String(e),
        });
        res = new Response('Server error', { status: 500 });
      }

      // Merge in the secure-by-default headers plus the per-path config
      // (issue #232) as the final step, so app middleware, route
      // handlers, and `expose` headers (already on `res`) always win.
      // Applied to every served response (documents, assets, the core
      // runtime, probes), since the defaults are universally safe.
      let merged = applySecurityHeaders(res, {
        pathname: headerPathname,
        https: webRequestIsHttps(req),
        prod: !dev,
        rules: headerRules,
      });

      // Expose the correlation id on the response (issue #239) so a client /
      // proxy can read it from the X-Request-Id header. Never clobber an id an
      // upstream / the app already set on the response.
      if (!merged.headers.has('x-request-id')) merged.headers.set('x-request-id', reqId);

      // Emit the Content-Security-Policy header carrying the SAME minted
      // nonce the SSR'd scripts got (no drift). Set only when CSP is
      // enabled; never clobber a CSP header the app already set (in
      // middleware, a route handler, or via the webjs.headers config), so
      // an explicit app policy still wins.
      if (nonce && !merged.headers.has('content-security-policy') &&
          !merged.headers.has('content-security-policy-report-only')) {
        // readCspConfig already drops a directive whose name/value carries a
        // control char, so buildCspHeader produces a Headers-safe value. The
        // try/catch is a belt-and-suspenders backstop: a surprise value must
        // never throw the response pipeline (fail closed to no CSP header
        // rather than a self-inflicted 500 on every request).
        try {
          merged.headers.set(cspHeaderName(cspConfig), buildCspHeader(cspConfig, nonce));
        } catch {
          /* a malformed policy must not take the request down: serve without CSP */
        }
      }

      // Server HTML cache write (#241): if the SSR marked this response as a
      // cache candidate (an opted-in `revalidate` page), store the FINAL body
      // now, after segment middleware has added any per-user Set-Cookie (which
      // the funnel's guard re-checks, so a per-user response is not cached) and
      // before conditional-GET can swap it for a bodiless 304. The marker is
      // stripped here regardless. Best-effort: a store failure is swallowed.
      try {
        const reqUrl = new URL(req.url);
        // Sub-path deployment (issue #256): the HTML cache READ (in ssrPage)
        // and `revalidatePath` both key on the app-root-relative path, so key
        // the WRITE on the stripped path too, or a cached page would never
        // hit. No-op when basePath is empty / the path is not under it.
        if (basePathValue) {
          const s = stripBasePath(reqUrl.pathname, basePathValue);
          if (s !== null) reqUrl.pathname = s;
        }
        merged = await commitHtmlCache(req, merged, reqUrl);
      } catch { /* never let the cache write crash the response */ }

      // Conditional GET (RFC 7232, issue #240): attach a content-hash ETag to
      // a cacheable response missing one, and turn a matching If-None-Match
      // into a 304 Not Modified with no body. Applied LAST, after every header
      // (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
      // 304 carries the validators a shared cache and the client router need.
      // A no-store / per-user response, a non-GET/HEAD, and a streaming Suspense
      // body are all skipped (see conditional-get.js). Logged with the final
      // (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
      let conditioned = merged;
      try {
        conditioned = await applyConditionalGet(req, merged);
      } catch { /* never let validator computation crash the response */ }

      // Structured access log (issue #239): ONE info line per handled request
      // at the single response funnel, carrying only method / path / status /
      // duration / requestId (no bodies, no secrets). Suppressed for the
      // framework's own /__webjs/* probe + static traffic so it does not spam.
      // Best-effort: a logger that throws must not take the response down.
      // Use the STRIPPED path for the suppression check (issue #256) so a
      // framework probe at `<basePath>/__webjs/*` is suppressed just like the
      // root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
      if (shouldAccessLog(headerPathname)) {
        try {
          logger.info?.('request', {
            requestId: reqId,
            method: req.method,
            path: pathname,
            status: conditioned.status,
            durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
          });
        } catch { /* never let logging crash the response */ }
      }

      return conditioned;
    });
  }

  /** @param {Request} req */
  function produce(req) {
    return (async () => {
      // Sub-path deployment ingress strip (issue #256). When `webjs.basePath`
      // is set and the request path is under it, STRIP the prefix and rewrite
      // the Request so EVERYTHING downstream (redirects, trailing-slash, the
      // probes, the `/__webjs/*` checks, the source-file gate, route matching,
      // SSR) sees a ROOT-relative path and works UNCHANGED. This single strip
      // is why the rest of the framework needs no per-site changes. A request
      // whose path is NOT under the base path is not for this mounted app, so
      // return a 404 (the safe default for a mounted app). Empty basePath (the
      // default) is a pure pass-through, so an unconfigured app is unchanged.
      if (basePathValue) {
        let reqUrl;
        try { reqUrl = new URL(req.url); } catch { reqUrl = null; }
        if (reqUrl) {
          const stripped = stripBasePath(reqUrl.pathname, basePathValue);
          if (stripped === null) {
            // Not under the base path: this request is not for this app.
            return new Response('Not found', {
              status: 404,
              headers: { 'content-type': 'text/plain; charset=utf-8' },
            });
          }
          if (stripped !== reqUrl.pathname) {
            reqUrl.pathname = stripped;
            // Rewrite the Request with the stripped URL, preserving method,
            // headers, and body. `duplex: 'half'` is required by the spec when
            // a body stream is present on a non-GET/HEAD request.
            const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
            req = new Request(
              reqUrl.toString(),
              /** @type {any} */ ({
                method: req.method,
                headers: req.headers,
                body: hasBody ? req.body : undefined,
                duplex: hasBody ? 'half' : undefined,
                redirect: req.redirect,
                signal: req.signal,
              }),
            );
          }
        }
      }

      // Declarative redirects (issue #254): apply the configured old-path ->
      // new-path rules at the VERY START of request handling, before the
      // probes, routing, SSR, or asset serving. A matched source returns a
      // 308 (permanent, the SEO default) / 307 (temporary) / configured
      // status immediately, so a moved URL never reaches the router.
      // `applyRedirects` skips /__webjs/* itself, so the framework probes /
      // runtime below are never redirected. The secure-header + conditional-GET
      // funnel in handle() still wraps this Response, like any other.
      const redirectResp = applyRedirects(req, redirectRules);
      if (redirectResp) return redirectResp;

      // Trailing-slash canonicalization (issue #255): after the explicit
      // redirects above (so an explicit rule wins first and the two never
      // form a loop), 308-redirect a non-canonical path to the policy's
      // canonical form (`never` strips a trailing slash, `always` adds one).
      // Default `'ignore'` is a no-op. The root `/` and file paths are
      // exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
      // above already skip it). The funnel in handle() still wraps this.
      const slashResp = applyTrailingSlash(req, trailingSlashPolicy);
      if (slashResp) return slashResp;

      // Health and readiness probes are answered BEFORE ensureReady so a probe
      // never blocks on the analysis. `/__webjs/health` is liveness (the
      // process is up and accepting connections). `/__webjs/ready` is 503 until
      // the instance is FULLY warm (the deterministic analysis AND the first
      // vendor attempt have both completed, so the importmap build id is
      // settled), then 200 unless an optional app readiness check
      // (readiness.{js,ts}) reports a dependency down. So a readinessProbe holds
      // traffic off a not-yet-warm or dependency-unhealthy instance, and admits
      // it only once the build id is stable, never mid vendor-resolution.
      // Probing `/__webjs/ready` also kicks off the warm in the background, so
      // an embedder that never called warmup() still warms. The first vendor
      // attempt is bounded (the jspm fetch timeout), so a vendor CDN failure
      // delays readiness only briefly and then admits the instance (degraded but
      // reload-safe); a transient failure is re-attempted on the next request.
      let probePath;
      try { probePath = new URL(req.url).pathname; } catch { probePath = ''; }
      if (probePath === '/__webjs/health') {
        return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
      }
      // Build-info probe (issue #239): which build is live? Answered before
      // ensureReady like the other probes (it depends only on the package
      // version + already-published build id + process info, never the app
      // analysis). No secrets.
      if (probePath === '/__webjs/version') {
        return buildInfoResponse();
      }
      if (probePath === '/__webjs/ready') {
        const noStore = { 'cache-control': 'no-store' };
        if (!readyDone) {
          ensureReady().catch(() => {}); // drive the warm; never block the probe
          const body = readyError
            ? { status: 'error', error: String((readyError && readyError.message) || readyError) }
            : { status: 'pending' };
          return Response.json(body, { status: 503, headers: noStore });
        }
        // Analysis is warm. Consult the optional app readiness check (live
        // dependency health, e.g. a DB ping) if the app provides one.
        const check = await getReadinessCheck();
        if (check) {
          try {
            if ((await check()) === false) {
              return Response.json({ status: 'unready' }, { status: 503, headers: noStore });
            }
          } catch (e) {
            return Response.json(
              { status: 'unready', error: String((e && e.message) || e) },
              { status: 503, headers: noStore },
            );
          }
        }
        return Response.json({ status: 'ok' }, { headers: noStore });
      }
      // Framework-internal static assets (the @webjsdev/core runtime, the dev
      // reload client, downloaded vendor bundles) depend on neither the analysis
      // nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
      // cold instance blocks them behind the first vendor resolve (issue #190),
      // and the core bundle is on every page's boot path, so that stalled first
      // interactivity site-wide. Matched on the decoded path, like handleCore.
      let assetPath = probePath;
      try { assetPath = decodeURIComponent(probePath); } catch { /* keep raw on malformed escape */ }
      // Content-hash fingerprint (#243): a `?v=` query means an immutable,
      // content-addressed asset url. Detected off the raw request url here
      // (probePath was parsed from it above).
      let assetVersioned = false;
      try { assetVersioned = new URL(req.url).searchParams.has('v'); } catch { /* none */ }
      const staticResp = await tryServeFrameworkStatic(assetPath, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: assetVersioned });
      if (staticResp) return staticResp;
      // Build all whole-app analysis on the first request (memoized), before
      // any SSR, module serve, gate check, action dispatch, or middleware runs.
      await ensureReady();
      const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled: cspConfig.enabled });
      if (state.middleware) {
        try {
          return await state.middleware(req, next);
        } catch (e) {
          reportError(e, req, 'middleware');
          logger.error('middleware threw', { err: String(e), requestId: getRequestId() });
          return new Response('Server error', { status: 500 });
        }
      }
      return next();
    })();
  }

  /**
   * Lightweight lookup used by the HTTP layer to emit 103 Early Hints
   * BEFORE running SSR: resolves a pathname to its page-route module URLs
   * without loading them. Returns null for non-page paths.
   *
   * Sub-path deployment (issue #256): the HTTP layer passes the RAW request
   * pathname (still carrying the base path, since the ingress strip happens
   * inside `produce`, not here), so strip it for route matching and prefix
   * the emitted module URLs so the early-hint preloads resolve under the
   * prefix. A path not under the base path yields null (no hints).
   *
   * @param {string} pathname
   */
  function routeFor(pathname) {
    const matchPathname = basePathValue
      ? stripBasePath(pathname, basePathValue)
      : pathname;
    if (matchPathname === null) return null;
    const page = matchPage(state.routeTable, matchPathname);
    if (!page) return null;
    const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
      let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
      const url = rel.split('\\').join('/').replace(/^\/?/, '/');
      // Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
      // the SAME url the body's modulepreload + boot specifiers request (#243).
      // A bare url would warm a different url and waste the hint. No-op in dev.
      // Best-effort: a request landing in the narrow pre-warm window (before the
      // elision verdict is set) hashes against an empty fingerprint, so the hint
      // can mismatch the post-warm body for that one request, only a wasted
      // speculative preload, never a stale body (the body url is authoritative).
      return withAssetHash(withBasePath(url, basePathValue), basePathValue);
    });
    return { moduleUrls };
  }

  return {
    handle,
    rebuild,
    routeFor,
    /**
     * Proactively run the first-request analysis (module graph, component
     * scan, gate, action index, middleware, elision, vendor map) in the
     * background, so a real first request finds it already memoized. Safe to
     * call any number of times and concurrently: the work is single-flighted,
     * so this never duplicates it or races a real request. It is a single
     * best-effort kick: errors are caught and logged rather than thrown (a
     * background warm-up must not crash the process), and whatever failed simply
     * re-runs on the next request or readiness probe (the platform's traffic and
     * probes are the retry loop, so there is no internal backoff). `startServer`
     * calls this once the HTTP server is listening; embedders can call it after
     * their own listen.
     * @returns {Promise<void>}
     */
    warmup: () => ensureReady().catch((e) => logger.error?.(`[webjs] background warm-up failed (will retry on the next request):`, e)),
    /** current route table getter: used by the WebSocket subsystem */
    getRouteTable: () => state.routeTable,
    /** Current unresolved dev error frame (#264), or null. Replayed by
     * startServer to a freshly-connected SSE client so the overlay shows even
     * after a navigation, not only on the breaking edit. Always null in prod. */
    getLastDevError: () => state.lastDevError,
    appDir,
    dev,
    logger,
  };
}

/**
 * Start a webjs HTTP server. Thin wrapper around `createRequestHandler`.
 *
 * Speaks plain HTTP/1.1. TLS termination + HTTP/2 to the browser is
 * expected to be handled by a reverse proxy (PaaS edge, nginx, Caddy,
 * etc.) sitting in front of this process. See the deployment docs for
 * the recommended topology.
 *
/**
 * Paths under the app root whose changes must NOT trigger a dev rebuild.
 * `node_modules` / `.git` are noise. `.webjs/` is the framework's generated
 * artefact dir (the #258 routes.d.ts and the vendor pin) that the dev server
 * itself writes on startup and on every rebuild, so without this skip the
 * write fires a watch event, triggers a rebuild, re-writes the file, and loops
 * forever. `prisma/dev*` / `prisma/migrations` churn during db:migrate. The
 * prisma branch is prefix-only (no trailing separator) so the SQLite sidecars
 * `prisma/dev.db` / `prisma/dev.db-journal` match too; the others stay
 * separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
 *
 * @param {string} filename relative path from an fs.watch `event.filename`
 * @returns {boolean} true when the change should be ignored
 */
export function shouldIgnoreWatchPath(filename) {
  return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/.test(filename || '');
}

/**
 * @param {{
 *   appDir: string,
 *   port?: number,
 *   dev?: boolean,
 *   compress?: boolean,
 *   logger?: import('./logger.js').Logger,
 *   onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
 * }} opts
 */
export async function startServer(opts) {
  const dev = !!opts.dev;
  const port = opts.port ?? 8080;
  // Compression default: on in prod, off in dev (cheaper to debug raw bytes).
  const compress = opts.compress ?? !dev;
  const logger = opts.logger || defaultLogger({ dev });

  /** @type {Set<import('node:http').ServerResponse>} */
  const sseClients = new Set();
  const app = await createRequestHandler({
    ...opts,
    logger,
    onReload: () => {
      for (const res of sseClients) {
        try { res.write(`event: reload\ndata: now\n\n`); } catch {}
      }
    },
    // Dev error overlay (#264): push a frame to every open tab over the SAME
    // SSE channel. A distinct `webjs-error` event name (NOT `error`, which is
    // EventSource's native connection-error event) carries the JSON frame.
    onDevError: (frame) => {
      const data = JSON.stringify(frame);
      for (const res of sseClients) {
        try { res.write(`event: webjs-error\ndata: ${data}\n\n`); } catch {}
      }
    },
  });

  /** @type {AbortController | null} */
  let watcherAbort = null;
  if (dev) {
    // Watch the app root recursively via Node's built-in
    // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
    // Node 24. No external dep needed.
    //
    // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
    // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
    // prisma's dev artefacts so a file the dev server itself writes never loops.
    const rebuild = debounce(() => app.rebuild(), 80);
    watcherAbort = new AbortController();
    (async () => {
      try {
        const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
        for await (const event of events) {
          const filename = event.filename || '';
          if (shouldIgnoreWatchPath(filename)) continue;
          rebuild();
        }
      } catch (err) {
        if (err && /** @type any */(err).name !== 'AbortError') {
          logger.warn({ err }, 'file watcher exited');
        }
      }
    })();
  }

  // SSE keepalive: send a comment frame every 25s to defeat proxy idle timeouts.
  // Cheap (no event listeners on the client side) and safe: comments are ignored.
  const keepalive = setInterval(() => {
    for (const res of sseClients) {
      try { res.write(`: ka\n\n`); } catch {}
    }
  }, 25_000);
  keepalive.unref();

  const server = makeHttpServer(async (req, res) => {
    try {
      const url = urlFromRequest(req);

      // SSE: handled specially; doesn't fit the req→Response model. Match the
      // base-path-stripped pathname so the reload stream answers at
      // `<basePath>/__webjs/events` under a sub-path deploy (#256). With no
      // basePath this is a pure pass-through (the bare path still matches).
      if (stripBasePath(url.pathname, basePath()) === '/__webjs/events') {
        if (!dev) { res.writeHead(404); res.end(); return; }
        res.writeHead(200, {
          'content-type': 'text/event-stream',
          'cache-control': 'no-cache',
          connection: 'keep-alive',
        });
        res.write(`event: hello\ndata: webjs\n\n`);
        sseClients.add(res);
        // Replay an unresolved dev error (#264) so a tab that connects AFTER the
        // breaking edit (e.g. opened via a fresh navigation) still shows the
        // overlay, not only the tab that was open when the error fired.
        const pending = app.getLastDevError?.();
        if (pending) {
          try { res.write(`event: webjs-error\ndata: ${JSON.stringify(pending)}\n\n`); } catch {}
        }
        res.socket?.on('close', () => sseClients.delete(res));
        return;
      }

      // 103 Early Hints: before running SSR, send preload hints for the
      // page's module URLs so the browser can begin fetching them while
      // the server is still computing the body. Skipped in dev (file churn
      // would send stale URLs after rebuilds) and for non-GET/HEAD.
      if (
        !dev &&
        (req.method === 'GET' || req.method === 'HEAD') &&
        typeof res.writeEarlyHints === 'function'
      ) {
        const match = app.routeFor(url.pathname);
        if (match && match.moduleUrls.length) {
          try {
            res.writeEarlyHints({
              link: match.moduleUrls.map((u) => `<${u}>; rel=modulepreload`),
            });
          } catch (e) {
            logger.warn('writeEarlyHints failed', { err: String(e) });
          }
        }
      }

      const webReq = toWebRequest(req, url);
      const resp = await app.handle(webReq);
      await sendWebResponse(res, resp, req, { compress });
    } catch (e) {
      logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
      if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
      res.end(dev && e instanceof Error ? `webjs error: ${e.stack}` : 'Internal server error');
    }
  });

  // Inbound server timeouts (issue #237), node:http built-ins. Defends against
  // slowloris and hung connections: `requestTimeout` bounds the time to receive
  // the WHOLE request, `headersTimeout` the time to receive just the headers
  // (node measures both from the same request start, so it is kept strictly
  // under requestTimeout to actually fire), and `keepAliveTimeout` the idle
  // window before a kept-alive socket is closed. Secure production defaults,
  // overridable via `webjs.requestTimeoutMs` / `headersTimeoutMs` /
  // `keepAliveTimeoutMs` in package.json or the matching WEBJS_*_MS env vars.
  // A value of 0 disables that timeout (node's own no-limit sentinel).
  const timeouts = await readServerTimeoutsFromApp(app.appDir);
  server.requestTimeout = timeouts.requestTimeout;
  server.headersTimeout = timeouts.headersTimeout;
  server.keepAliveTimeout = timeouts.keepAliveTimeout;

  // WebSocket upgrade handling: any route.js that exports `WS` becomes a
  // WebSocket endpoint at its URL.
  attachWebSocket(server, () => app.getRouteTable(), { dev, logger });

  server.listen(port, () => {
    logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
    // The server is now accepting connections; warm the first-request analysis
    // in the background so a real first request finds it memoized. Fire-and-
    // forget: listening (and thus readiness probes / load-balancer health) does
    // not wait on it, and a failure here does not bring the process down.
    app.warmup();
  });

  const shutdown = gracefulShutdown(server, sseClients, logger);
  process.once('SIGINT', () => shutdown('SIGINT'));
  process.once('SIGTERM', () => shutdown('SIGTERM'));

  // Catch-all process handlers: log, but don't tear the process down on a
  // single mishandled promise. Uncaught exceptions are different: state may be
  // corrupted, so log + start an orderly shutdown rather than continuing.
  installProcessHandlers(logger, () => shutdown('uncaughtException'));

  return {
    server,
    close: () => new Promise((r) => {
      if (watcherAbort) watcherAbort.abort();
      server.close(() => r());
    }),
  };
}

/**
 * The core request → response pipeline, minus middleware.
 * @param {Request} req
 * @param {{state: any, appDir: string, coreDir: string, dev: boolean}} ctx
 */
/**
 * Serve framework-internal static assets that depend on NEITHER the whole-app
 * analysis NOR the vendor importmap: the `@webjsdev/core` runtime files, the
 * dev reload client, and (in `--download` pin mode) the committed vendor
 * bundles. `handle()` calls this BEFORE `ensureReady()`, so a cold instance
 * returns them immediately instead of blocking on the first vendor resolve
 * (issue #190). The core bundle is on every page's boot path, so coupling it
 * to the jspm resolve stalled first interactivity site-wide on a cold instance.
 *
 * Like the health / readiness probes (also answered pre-`ensureReady`), these
 * bypass app middleware. That is correct: they are framework infrastructure the
 * app needs to function, not app routes, and `state.middleware` is not even
 * loaded until `ensureReady()` completes.
 *
 * @param {string} path decoded pathname
 * @param {string} method upper-cased HTTP method
 * @param {{ coreDir: string, appDir: string, dev: boolean, versioned?: boolean }} ctx
 *   `versioned` is true when the request carried a `?v=` query (a
 *   content-hash-fingerprinted url, issue #243); the core module is then served
 *   `immutable` (1 year) instead of the 1h fallback, since the hash in the url
 *   busts the cache on the next deploy.
 * @returns {Promise<Response|null>} a Response, or null when path is not one of these assets
 */
async function tryServeFrameworkStatic(path, method, ctx) {
  const { coreDir, appDir, dev, versioned } = ctx;

  // Dev live-reload client.
  if (path === '/__webjs/reload.js') {
    if (!dev) return new Response('Not found', { status: 404 });
    return new Response(reloadClientJs(basePath()), {
      headers: { 'content-type': 'application/javascript; charset=utf-8' },
    });
  }

  // Core module: /__webjs/core/*
  //
  // ETag + ~1h max-age, NOT immutable. The URL path is un-versioned
  // (`/__webjs/core/src/render-client.js` etc.), so bumping `@webjsdev/core`
  // ships different bytes at the same URL. An `immutable` cache-control
  // directive at an edge CDN (Cloudflare, Vercel, Fly) keeps the prior bytes
  // pinned for up to a year, which silently bricks the next deploy: browsers
  // load the old client renderer against a server emitting the new SSR shape,
  // and any exports added in the bump (e.g., the slot.js entry points landed
  // for 0.6.0) resolve to undefined in the cached file.
  // Regression: 2026-05-20, ui.webjs.dev tier-2 components after
  // @webjsdev/core 0.5.0 -> 0.6.0 republish.
  if (path.startsWith('/__webjs/core/')) {
    const rel = path.slice('/__webjs/core/'.length);
    const abs = resolve(coreDir, rel);
    // Trailing-separator boundary check, not a raw string prefix: a raw
    // `startsWith(coreDir)` would admit a sibling like `@webjsdev/core-evil`,
    // reachable via an encoded slash (`..%2f`, which survives URL normalization
    // and then decodes to `../`). Match the public-root branch's guard.
    if (abs !== coreDir && !abs.startsWith(coreDir + sep)) {
      return new Response('forbidden', { status: 403 });
    }
    // A `?v=<hash>` request is content-addressed, so serve it `immutable` (#243):
    // the hash busts the cache on the next core bump, so the regression the
    // un-versioned 1h cap guards against (a stale immutable core after a bump)
    // cannot recur. An un-fingerprinted request keeps the 1h fallback.
    return fileResponse(abs, { dev, immutable: !!versioned });
  }

  // Vendor URL handler for `webjs vendor pin --download` mode only. In default
  // pin mode (or no-pin mode) the importmap routes bare imports straight to
  // ga.jspm.io URLs and the browser bypasses this server entirely. When the
  // user ran `webjs vendor pin --download`, the importmap has local
  // `/__webjs/vendor/<file>.js` URLs and this serves the committed bundle files
  // from `.webjs/vendor/`. These are read-only static content: allow GET/HEAD
  // for the normal fetch, OPTIONS for any cross-origin preflight (204 with the
  // same Allow header rather than 405, which some intermediaries treat as a
  // hard failure even for a CORS probe), and 405 everything else.
  if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
    if (method === 'OPTIONS') {
      return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
    }
    if (method !== 'GET' && method !== 'HEAD') {
      return new Response(null, { status: 405, headers: { allow: 'GET, HEAD, OPTIONS' } });
    }
    const filename = path.slice('/__webjs/vendor/'.length);
    const resp = await serveDownloadedBundle(filename, appDir, dev);
    if (method === 'HEAD') {
      // HEAD must return same headers as GET with no body.
      return new Response(null, { status: resp.status, headers: resp.headers });
    }
    return resp;
  }

  return null;
}

async function handleCore(req, ctx) {
  const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled } = ctx;
  const url = new URL(req.url);
  // Decode percent-encoded characters so filesystem lookups match real
  // filenames. Dynamic route segments like `[slug]` and route groups like
  // `(marketing)` contain chars that browsers percent-encode in URLs
  // (`%5B`, `%5D`, `%28`, `%29`). Without decoding, the server joins the
  // encoded path with the app directory → file not found → 404 → no JS
  // loads → no interactivity.
  let path;
  try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
  const method = req.method.toUpperCase();
  // Content-hash fingerprint (#243): a `?v=` query marks a content-addressed
  // asset url that may be served `immutable` (the hash busts the cache on a
  // byte change). The pathname (query stripped by `url.pathname`) resolves the
  // file as today; only the cache header changes when `?v` is present.
  const versioned = url.searchParams.has('v');

  // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) and the
  // framework-internal static assets (`/__webjs/core/*`, `/__webjs/reload.js`,
  // downloaded `/__webjs/vendor/*`) are served in `handle()` BEFORE ensureReady,
  // so they are not repeated here. This fallback covers the (currently
  // unreachable) case of handleCore being entered for one of those assets, so
  // the routing stays correct if a future caller bypasses the early path.
  const frameworkStatic = await tryServeFrameworkStatic(path, method, { coreDir, appDir, dev, versioned });
  if (frameworkStatic) return frameworkStatic;

  // Internal server-action RPC endpoint
  const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
  if (actMatch) {
    if (method !== 'POST') return new Response('POST only', { status: 405 });
    // Pass the onError sink (issue #239): a server action that throws
    // unexpectedly is reported to the APM hook before the sanitized 500.
    const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
    return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
  }

  // expose()d server actions (first-class REST), with optional CORS support.
  if (method === 'OPTIONS') {
    const allAtPath = matchAllAtPath(state.actionIndex, path);
    if (allAtPath.length) {
      const corsRoute = allAtPath.find((r) => r.cors);
      const methods = [...new Set(allAtPath.map((r) => r.method))];
      if (corsRoute) {
        // Preflight: respond with cors headers + the union of methods at this path.
        const preflight = buildPreflightResponse(corsRoute, req);
        const newHeaders = new Headers(preflight.headers);
        newHeaders.set('access-control-allow-methods', `${methods.join(', ')}, OPTIONS`);
        return new Response(null, { status: preflight.status, headers: newHeaders });
      }
      return new Response(null, { status: 204, headers: { allow: `${methods.join(', ')}, OPTIONS` } });
    }
  } else {
    const exposed = matchExposedAction(state.actionIndex, method, path);
    if (exposed) {
      // Pass the onError sink (issue #239): an exposed REST handler that throws
      // unexpectedly is reported to the APM hook before the sanitized 500, the
      // same as the RPC action path (phase 'action' covers both server-action
      // invocation shapes).
      const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
      const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req, onActionError);
      return withCors(resp, exposed.route, req);
    }
  }

  // Static: /public/*
  if (path.startsWith('/public/') || path === '/favicon.ico') {
    const p = path === '/favicon.ico' ? '/public/favicon.ico' : path;
    const abs = join(appDir, p);
    // Containment check. `join` normalises `..` segments, so a path
    // like `/public/%2E%2E/secret/x.svg` decodes (after URL parsing,
    // which doesn't touch `%2E`) to `/public/../secret/x.svg` and
    // `join(appDir, ...)` resolves it to `appDir/secret/x.svg`. The
    // resulting `abs` could be inside `appDir` but OUTSIDE `appDir/
    // public/`, exposing files the user reasonably thought were
    // private under their non-public directories. Reject anything
    // that doesn't stay under `appDir/public/` (and the favicon
    // exception, which is already validated above).
    const publicRoot = join(appDir, 'public') + sep;
    if (!abs.startsWith(publicRoot)) {
      return new Response(null, { status: 404 });
    }
    // A `?v=<hash>` public asset is content-addressed -> immutable (#243).
    if (await exists(abs)) return fileResponse(abs, { dev, immutable: versioned });
  }

  // User source modules (served as ES modules, with action-file rewriting).
  //
  // Authorization gate: only files reachable from a browser-bound entry
  // (page, layout, error, loading, not-found, component) via the module
  // graph are servable. Same posture as Next.js, where the bundler's
  // manifest is the source of truth for what the browser may fetch.
  // Anything not in the set (node_modules/, top-level package.json,
  // scripts/, etc.) 404s here regardless of whether the file exists on
  // disk. The `.server.{js,ts}` stub guardrail runs below as a
  // defense-in-depth layer.
  if (method === 'GET' && /\.(js|mjs|ts|mts|css|svg|png|jpg|jpeg|gif|webp|json|ico|txt)$/.test(path)) {
    let abs = join(appDir, path);
    // When the browser asks for `.js`, allow falling through to a sibling
    // `.ts` (the TypeScript-with-"allowImportingTsExtensions: false" pattern).
    if (!(await exists(abs)) && /\.js$/.test(abs)) {
      const tsAbs = abs.replace(/\.js$/, '.ts');
      if (await exists(tsAbs)) abs = tsAbs;
      else {
        const mtsAbs = abs.replace(/\.js$/, '.mts');
        if (await exists(mtsAbs)) abs = mtsAbs;
      }
    }
    // Gate: must be in the browser-bound module graph. Server-action
    // files (.server.{js,ts}) get a stub via the guardrail below; they
    // ARE included in browserBoundFiles because client code imports
    // them by path (the import rewrites to an RPC stub at request time).
    const inGraph = state.browserBoundFiles && state.browserBoundFiles.has(abs);
    if (abs.startsWith(appDir) && inGraph && (await exists(abs))) {
      // Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
      // MUST NEVER be served as source to the browser. The extension is
      // the path-level boundary; we re-verify it on every request (not
      // just rely on the action-index snapshot, which is built on the first
      // request and refreshed on rebuild) so files created later, FS races,
      // or developer error never punch through.
      //
      // What the browser gets depends on the file's `'use server'` status:
      //   - With `'use server'` => server action: a generated RPC stub
      //     whose exports POST to /__webjs/action/:hash/:fn.
      //   - Without `'use server'` => server-only utility: a stub that
      //     throws at module load with a clear error. The file's source
      //     never reaches the browser either way.
      if (isServerFile(abs)) {
        if (await hasUseServerDirective(abs)) {
          // Lazily ensure the index knows about this file so serveActionStub
          // can mint a stable hash and function list.
          if (!state.actionIndex.fileToHash.has(abs)) {
            const h = await hashFile(abs);
            state.actionIndex.fileToHash.set(abs, h);
            state.actionIndex.hashToFile.set(h, abs);
          }
          const stub = await serveActionStub(state.actionIndex, abs);
          return new Response(stub, {
            headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
          });
        }
        const relPath = relative(appDir, abs);
        const stub = serveServerOnlyStub(relPath);
        return new Response(stub, {
          headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
        });
      }
      // TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
      // Both module paths also strip side-effect imports of display-only
      // components so the browser never downloads their JS.
      const elideOpts = {
        moduleGraph: state.moduleGraph,
        elidableComponents: state.elidableComponents,
        appDir,
      };
      // A `?v=<hash>` app-module request is content-addressed -> immutable
      // (#243); an un-fingerprinted request keeps the 1h fallback.
      if (/\.m?ts$/.test(abs)) {
        return tsResponse(abs, dev, elideOpts, state.tsCache, versioned, reportDevError);
      }
      if (/\.m?js$/.test(abs)) {
        return jsModuleResponse(abs, dev, elideOpts, versioned);
      }
      return fileResponse(abs, { dev, immutable: versioned });
    }
  }

  // Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
  if (method === 'GET' && state.routeTable.metadataRoutes) {
    const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path);
    if (meta) {
      try {
        const mod = await import(pathToFileURL(meta.file).toString() + (dev ? `?t=${Date.now()}` : ''));
        if (mod.default) {
          const result = await mod.default();
          // If the function returns a Response, use it directly.
          if (result instanceof Response) return result;
          // If it returns a string, determine content type from the URL path.
          const ct = path.endsWith('.xml') ? 'application/xml; charset=utf-8'
            : path.endsWith('.txt') ? 'text/plain; charset=utf-8'
            : path.endsWith('.json') ? 'application/json; charset=utf-8'
            : 'application/octet-stream';
          return new Response(typeof result === 'string' ? result : JSON.stringify(result), {
            headers: { 'content-type': ct, 'cache-control': dev ? 'no-cache' : 'public, max-age=3600' },
          });
        }
      } catch (e) {
        if (reportError) reportError(e, req, 'metadata');
        if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
        return new Response('Internal error', { status: 500 });
      }
    }
  }

  // API route (route.js handler)
  const api = matchApi(state.routeTable, path);
  if (api) {
    const handler = () => handleApi(api.route, api.params, req, dev);
    return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
  }

  // Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
  // is only routed here when the page module exports an `action` (#244); the
  // action runs inside the page's segment middleware, then either PRG-redirects
  // (303) on success, re-renders the same page (422) with field errors on
  // failure, or honors a thrown redirect()/notFound(). Without an `action`
  // export, a non-GET/HEAD request falls through to the 404 below, unchanged.
  {
    const page = matchPage(state.routeTable, path);
    if (page) {
      const ssrOpts = {
        dev, appDir, moduleGraph: state.moduleGraph,
        serverFiles: state.actionIndex.fileToHash,
        elidableComponents: state.elidableComponents,
        inertRouteModules: state.inertRouteModules,
        notFoundFile: state.routeTable.notFound,
        // Server HTML cache (#241): a CSP-enabled page emits a fresh
        // per-request nonce into its body, so its bytes vary per request and
        // it must never be HTML-cached. Pass the flag so the cache guard skips
        // it. CSP is off by default, so the common case stays cacheable.
        cspEnabled,
        // onError sink (issue #239): a page render error that becomes a 500 is
        // reported to the APM hook with the active request's correlation id.
        onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
        // Dev error overlay (#264): a render crash pushes a frame to the open
        // tab. Dev-only (reportDevError early-returns in prod), so no source
        // leaks. Distinct from onError (the APM sink), which always fires.
        onDevError: dev ? (e) => reportDevError(e, { kind: 'render' }) : undefined,
      };
      if (method === 'GET' || method === 'HEAD') {
        const handler = () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
        return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
      }
      const loaded = await loadPageAction(page.route.file, dev);
      if (loaded) {
        const handler = () => runPageAction(page.route, page.params, url, loaded, req, ssrOpts);
        return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
      }
    }
  }

  // Fallback: content-negotiated 404
  if (wantsJson(req, path)) {
    return Response.json({ error: 'Not found', path }, { status: 404 });
  }
  return ssrNotFound(state.routeTable.notFound, { dev, appDir, req, url });
}

/** @param {Request} req @param {string} path */
function wantsJson(req, path) {
  const accept = req.headers.get('accept') || '';
  if (accept.includes('application/json') && !accept.includes('text/html')) return true;
  if (path.startsWith('/api/') || path.startsWith('/__webjs/')) return true;
  return false;
}

/**
 * Chain segment-level middleware.js (outermost first) around a handler.
 * Each middleware is `(req, next) => Response`. If any throws, log and 500.
 *
 * @param {Request} req
 * @param {string[]} files   absolute paths of middleware.js files, outermost → innermost
 * @param {() => Promise<Response>} terminal
 * @param {boolean} dev
 */
async function runWithSegmentMiddleware(req, files, terminal, dev) {
  if (!files || !files.length) return terminal();
  const handlers = [];
  for (const f of files) {
    try {
      const url = pathToFileURL(f).toString();
      const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
      const mod = await import(url + bust);
      if (typeof mod.default === 'function') handlers.push(mod.default);
    } catch {
      // Bad middleware file: skip; top-level error handler will catch real problems.
    }
  }
  let i = 0;
  const next = () => {
    if (i >= handlers.length) return terminal();
    const fn = handlers[i++];
    return fn(req, next);
  };
  return next();
}

/**
 * Load the optional top-level `middleware.js`.
 * @param {string} appDir
 * @param {boolean} dev
 * @param {import('./logger.js').Logger} logger
 */
async function loadMiddleware(appDir, dev, logger) {
  const file = join(appDir, 'middleware.js');
  if (!(await exists(file))) return null;
  const url = pathToFileURL(file).toString();
  const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
  try {
    const mod = await import(url + bust);
    return typeof mod.default === 'function' ? mod.default : null;
  } catch (e) {
    logger.error('failed to load middleware.js', { err: String(e) });
    return null;
  }
}

/**
 * Install signal handlers that stop accepting new connections, close SSE
 * clients, and exit once in-flight requests drain.
 * @param {import('node:http').Server} server
 * @param {Set<import('node:http').ServerResponse>} sseClients
 * @param {import('./logger.js').Logger} logger
 */
/**
 * Create a plain HTTP/1.1 server. webjs deploys are expected to sit
 * behind a reverse proxy (PaaS edge, nginx, Caddy, etc.) that handles
 * TLS termination and speaks HTTP/2 to clients: Node's http2 module
 * doesn't need to be involved on the framework side.
 *
 * @param {(req: any, res: any) => void} handler
 */
function makeHttpServer(handler) {
  return createHttp1Server(handler);
}

/**
 * Install once-only process error handlers. Idempotent across multiple
 * `startServer` calls in the same process.
 *
 * @param {import('./logger.js').Logger} logger
 * @param {() => void} onFatal
 */
function installProcessHandlers(logger, onFatal) {
  if (/** @type any */ (globalThis).__webjsProcHandlers) return;
  /** @type any */ (globalThis).__webjsProcHandlers = true;
  process.on('unhandledRejection', (reason) => {
    logger.error('unhandledRejection', {
      err: reason instanceof Error ? reason.stack || reason.message : String(reason),
    });
  });
  process.on('uncaughtException', (err) => {
    logger.error('uncaughtException', { err: err.stack || err.message });
    // Begin orderly shutdown; process state may be corrupt.
    try { onFatal(); } catch {}
  });
}

function gracefulShutdown(server, sseClients, logger) {
  let shuttingDown = false;
  return (signal) => {
    if (shuttingDown) return;
    shuttingDown = true;
    logger.info(`received ${signal}, shutting down`);
    for (const res of sseClients) {
      try { res.end(); } catch {}
    }
    sseClients.clear();
    server.close((err) => {
      if (err) {
        logger.error('server close error', { err: String(err) });
        process.exit(1);
      }
      logger.info('bye');
      process.exit(0);
    });
    // Hard-fail after 10s if we can't drain.
    setTimeout(() => {
      logger.warn('shutdown timed out, forcing exit');
      process.exit(1);
    }, 10_000).unref();
  };
}

/* ------------ helpers ------------ */

/** @param {import('node:http').IncomingMessage} req @param {URL} url */
function toWebRequest(req, url) {
  const method = (req.method || 'GET').toUpperCase();
  /** @type {Record<string,string>} */
  const headers = {};
  for (const [k, v] of Object.entries(req.headers)) {
    // Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`).
    // They're parsed separately into req.method / req.url and are rejected
    // by the standard Headers class if we pass them through verbatim.
    if (k.startsWith(':')) continue;
    // Strip any inbound `x-webjs-remote-ip` header so clients cannot
    // spoof the framework-stamped client IP that rate-limit's
    // `clientIp(req, { trustProxy: false })` reads. We rewrite it
    // below from the actual TCP socket. Node's IncomingMessage
    // always lowercases header keys, so a literal compare is enough.
    if (k === 'x-webjs-remote-ip') continue;
    headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
  }
  // Stamp the framework-trusted remote IP from the socket. Read by
  // `clientIp(req)` (rate-limit.js) as the bucket key when
  // `trustProxy: false` (the safe default).
  const remoteIp = req.socket?.remoteAddress;
  if (remoteIp) headers['x-webjs-remote-ip'] = remoteIp;
  let body;
  if (method !== 'GET' && method !== 'HEAD') {
    body = new ReadableStream({
      start(controller) {
        req.on('data', (chunk) => controller.enqueue(chunk));
        req.on('end', () => controller.close());
        req.on('error', (e) => controller.error(e));
      },
    });
  }
  return new Request(url, /** @type any */ ({ method, headers, body, duplex: 'half' }));
}

/**
 * @param {import('node:http').ServerResponse} res
 * @param {Response} webRes
 * @param {import('node:http').IncomingMessage} [req]
 * @param {{ compress?: boolean }} [opts]
 */
async function sendWebResponse(res, webRes, req, opts) {
  /** @type {Record<string,string | string[]>} */
  const headers = {};
  // Preserve multi-value headers (Set-Cookie) via getSetCookie when available.
  if (typeof /** @type any */ (webRes.headers).getSetCookie === 'function') {
    const cookies = /** @type any */ (webRes.headers).getSetCookie();
    if (cookies.length) headers['set-cookie'] = cookies;
  }
  webRes.headers.forEach((v, k) => {
    if (k === 'set-cookie') return;
    headers[k] = v;
  });

  // Negotiate compression.
  let compressor = null;
  if (opts?.compress && req && webRes.body && isCompressible(headers['content-type'])) {
    const accept = String(req.headers['accept-encoding'] || '');
    if (/(?:^|,\s*)br(?:;|,|$)/.test(accept)) {
      compressor = createBrotliCompress({
        params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
      });
      headers['content-encoding'] = 'br';
    } else if (/(?:^|,\s*)gzip(?:;|,|$)/.test(accept)) {
      compressor = createGzip({ level: 6 });
      headers['content-encoding'] = 'gzip';
    }
    if (compressor) {
      headers['vary'] = 'Accept-Encoding';
      delete headers['content-length'];
    }
  }

  res.writeHead(webRes.status, headers);
  if (!webRes.body) { res.end(); return; }

  if (compressor) {
    compressor.pipe(res);
    const reader = webRes.body.getReader();
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        compressor.write(value);
      }
    } finally {
      compressor.end();
    }
    return;
  }

  const reader = webRes.body.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    res.write(value);
  }
  res.end();
}

/** @param {string | string[] | undefined} contentType */
function isCompressible(contentType) {
  if (!contentType) return false;
  const ct = Array.isArray(contentType) ? contentType[0] : contentType;
  return /^(?:text\/|application\/(?:javascript|json|xml|wasm|manifest)|image\/svg\+xml)/i.test(ct);
}

/**
 * Read a file and return a Response with appropriate caching.
 * Dev: no-cache (always revalidate).
 * Prod: ETag + ~1h max-age for user files; `immutable` bumps to 1 year.
 *
 * @param {string} abs
 * @param {{ dev: boolean, immutable: boolean }} opts
 */
async function fileResponse(abs, opts) {
  try {
    const data = await readFile(abs);
    const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
    // The body is fully buffered (read into `data`), so opt it into the
    // conditional-GET funnel, which is the single place that hashes the bytes
    // into a weak ETag and honors If-None-Match -> 304 (dev + prod alike).
    const headers = { 'content-type': type, [BUFFERED_MARKER]: '1' };
    headers['cache-control'] = opts.dev
      ? 'no-cache'
      : opts.immutable
        ? 'public, max-age=31536000, immutable'
        : 'public, max-age=3600';
    return new Response(data, { status: 200, headers });
  } catch {
    return new Response('Not found', { status: 404 });
  }
}

/**
 * Serve a plain `.js` / `.mjs` browser module, stripping side-effect
 * imports of display-only components. Mirrors {@link fileResponse}'s
 * headers but reads as text so the source can be transformed. Used only
 * for files that exist as `.js` on disk (TS apps usually hit
 * {@link tsResponse} via the .js to .ts sibling rewrite instead).
 *
 * @param {string} abs
 * @param {boolean} dev
 * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
 * @param {boolean} [immutable]  true for a `?v=<hash>` content-addressed request (#243):
 *   serve `immutable` (1 year) instead of the 1h fallback. Dev stays `no-cache`.
 */
async function jsModuleResponse(abs, dev, elideOpts, immutable) {
  let source;
  try { source = await readFile(abs, 'utf8'); }
  catch { return new Response('Not found', { status: 404 }); }
  let code = elideImportsFromSource(
    source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
  );
  // Version same-origin relative import specifiers so the URL the browser
  // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
  // A no-op in dev (fingerprinting disabled).
  code = versionModuleImports(code, abs);
  // Buffered (string) body, so opt into the conditional-GET funnel for the
  // weak ETag + 304 (see fileResponse).
  const headers = {
    'content-type': 'application/javascript; charset=utf-8',
    'cache-control': dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
    [BUFFERED_MARKER]: '1',
  };
  return new Response(code, { status: 200, headers });
}

async function exists(p) {
  try { await stat(p); return true; } catch { return false; }
}

/**
 * Strip TypeScript types from `source` via Node's built-in
 * `module.stripTypeScriptTypes`. Position-preserving whitespace
 * replacement: no sourcemap is needed because every (line, column)
 * maps to itself in the source.
 *
 * Only erasable TypeScript is supported. Non-erasable syntax
 * (`enum`, `namespace` with values, parameter properties, legacy
 * decorators with `emitDecoratorMetadata`, `import = require`)
 * throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` from Node and the
 * dev server returns the error to the caller. The
 * `erasable-typescript-only` and `no-non-erasable-typescript` lint
 * rules catch these at edit time. There is no bundler fallback;
 * webjs is buildless end-to-end.
 *
 * @param {string} source
 * @param {string} _abs  (unused; preserved for symmetry with prior signature)
 * @returns {Promise<string>}
 */
async function stripTs(source, _abs) {
  return nodeModule.stripTypeScriptTypes(source);
}

/**
 * Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
 * Result is cached by mtime in the handler's own `cache` so subsequent
 * requests are instant; a file edit invalidates naturally. `elideOpts`
 * additionally strips side-effect imports of display-only components from
 * the served code, which is exactly why `cache` is the per-handler
 * `state.tsCache` and not a module-global: the cached bytes bake in this
 * handler's elision verdict.
 *
 * @param {string} abs
 * @param {boolean} dev
 * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
 * @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
 * @param {boolean} [immutable]  true for a `?v=<hash>` content-addressed request (#243):
 *   serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
 *   the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
 */
async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError) {
  // The body bytes are identical with or without `?v`; only the cache header
  // changes, so the per-mtime cache stays a single entry.
  const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
  const st = await stat(abs);
  const cached = cache.get(abs);
  if (cached && cached.mtimeMs === st.mtimeMs) {
    return new Response(cached.code, {
      headers: {
        'content-type': 'application/javascript; charset=utf-8',
        'cache-control': cacheControl,
        [BUFFERED_MARKER]: '1',
      },
    });
  }
  const source = await readFile(abs, 'utf8');
  let code;
  try {
    code = await stripTs(source, abs);
  } catch (err) {
    // Node's stripTypeScriptTypes throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
    // for enum, namespace with values, parameter properties, legacy
    // decorators with emitDecoratorMetadata, and import = require.
    // Return a clean 500 with the file path and a pointer at the
    // erasable-typescript-only lint rule rather than letting the
    // error bubble up unstyled.
    if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
      // Log full detail server-side regardless of mode so operators
      // see what went wrong in their logs.
      // eslint-disable-next-line no-console
      console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
      // Dev error overlay (#264): a TS strip failure breaks only the CLIENT
      // module fetch (the page still SSRs, so hydration is silently dead and
      // the hint below is buried in a JS comment). Push a frame so the open tab
      // shows the overlay with the offending file + the no-non-erasable hint.
      reportDevError?.(err, {
        kind: 'ts-strip',
        file: abs,
        hint: 'webjs is buildless: only erasable TypeScript is supported. Replace enum / namespace-with-values / parameter-property / legacy-decorator / import = require with their erasable equivalents. Run `webjs check` (no-non-erasable-typescript rule).',
      });
      const msg = dev
        // Dev: include the file path and Node's error message so the
        // developer's browser tooling can point them at the offending
        // construct. Replace `*` + `/` with `*\\/` so a path or
        // message containing the comment-close sequence cannot
        // terminate the wrapper comment early.
        ? `[webjs] non-erasable TypeScript in ${abs}: ${err.message}\n\n` +
          `webjs is buildless: only erasable TS syntax is supported. ` +
          `Replace enum / namespace / parameter-property / legacy-decorator / ` +
          `import = require constructs with their erasable equivalents. ` +
          `Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
          `Docs: https://docs.webjs.com/docs/typescript`
        // Prod: terse, no path leak, no Node-message leak (Node's
        // message can include source snippets). Operators get the
        // detail in server logs above.
        : `[webjs] server error transforming a .ts response. Check server logs.`;
      return new Response(`/* ${msg.replace(/\*\//g, '*\\/')} */`, {
        status: 500,
        headers: { 'content-type': 'application/javascript; charset=utf-8' },
      });
    }
    throw err;
  }
  if (elideOpts) {
    code = elideImportsFromSource(
      code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
    );
  }
  // Version same-origin relative import specifiers so the URL the browser
  // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
  // A no-op in dev (fingerprinting disabled). Cached with the elision result:
  // PROD files are static within a deploy, so the baked `?v` stays correct.
  code = versionModuleImports(code, abs);
  // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
  if (cache.size >= TS_CACHE_MAX) {
    const oldest = cache.keys().next().value;
    cache.delete(oldest);
  }
  cache.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
  return new Response(code, {
    headers: {
      'content-type': 'application/javascript; charset=utf-8',
      'cache-control': cacheControl,
      [BUFFERED_MARKER]: '1',
    },
  });
}

function debounce(fn, ms) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

/**
 * Walk the route table + component scanner to collect every file the
 * browser may legitimately fetch as an ES module, then expand via the
 * module graph into the full transitive closure.
 *
 * This is webjs's equivalent of Next.js's bundler-produced page
 * manifest, derived lazily on the first request (and re-derived on every
 * rebuild) instead of at compile time. The dev server's source-file branch uses the returned
 * Set as an authorization gate: in-set → served (subject to the
 * .server.{js,ts} stub guardrail); out-of-set → 404.
 *
 * Browser-bound entries:
 *   - page.{js,ts,mjs,mts}        (re-runs on client for hydration)
 *   - layout.{js,ts,mjs,mts}      (same)
 *   - error.{js,ts,mjs,mts}       (same)
 *   - loading.{js,ts,mjs,mts}     (same)
 *   - not-found.{js,ts,mjs,mts}   (same)
 *   - component files discovered by the scanner (eager + lazy)
 *
 * Server-only entries (NOT in the set):
 *   - route.{js,ts}   (API handlers, never fetched as JS module)
 *   - middleware.{js,ts}
 *   - metadata routes (sitemap.js, robots.js, manifest.js, …)
 *   - .server.{js,ts} files (browser gets a stub, not the source)
 *
 * Components are passed in (rather than rescanned) so the caller can
 * share one scan with `primeComponentRegistry`. Saves a full
 * appDir walk on each analysis (the first request and every rebuild).
 *
 * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
 * @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
 * @param {Awaited<ReturnType<typeof scanComponents>>} components
 * @param {string} appDir
 * @returns {Set<string>}
 */
/**
 * Collect every page + layout file across the route table. These are the
 * modules the client boot script imports, and thus the candidates for
 * inert-route elision (dropping a module that does no client work).
 * `route.{js,ts}` / middleware / metadata are excluded: they never ship.
 *
 * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
 * @returns {string[]}
 */
function collectRouteModules(routeTable) {
  /** @type {Set<string>} */
  const mods = new Set();
  for (const page of routeTable.pages || []) {
    if (page.file) mods.add(page.file);
    for (const f of page.layouts || []) mods.add(f);
  }
  return [...mods];
}

function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
  /** @type {Set<string>} */
  const entries = new Set();
  for (const page of routeTable.pages) {
    if (page.file) entries.add(page.file);
    for (const f of page.layouts || []) entries.add(f);
    for (const f of page.errors || []) entries.add(f);
    for (const f of page.loadings || []) entries.add(f);
  }
  if (routeTable.notFound) entries.add(routeTable.notFound);
  if (routeTable.notFounds) {
    for (const f of routeTable.notFounds.values()) entries.add(f);
  }
  // Lazy components live in the registry but no page imports their
  // class directly; the lazy-loader fetches their module URLs on
  // viewport entry. Add every discovered component file as an entry so
  // the graph walk covers both eager and lazy paths.
  for (const c of components) entries.add(c.file);
  return reachableFromEntries(moduleGraph, [...entries], appDir);
}

/**
 * Find the absolute directory of the `@webjsdev/core` package, regardless of
 * whether we're running from the monorepo or an installed copy.
 * @param {string} appDir
 */
function locateCoreDir(appDir) {
  try {
    const require = createRequire(join(appDir, 'package.json'));
    const pkgPath = require.resolve('@webjsdev/core/package.json');
    return dirname(pkgPath);
  } catch {}
  const here = fileURLToPath(import.meta.url);
  return resolve(here, '..', '..', '..', 'core');
}

/**
 * Find an npm package's installed root folder in the app's node_modules graph.
 * @param {string} appDir
 * @param {string} pkgName
 * @returns {string | null}
 */
function locatePackageDir(appDir, pkgName) {
  // Many packages lock down `./package.json` in their exports field, so we
  // resolve the bare specifier (always exported) and trim back to the
  // folder named pkgName.
  const match = '/node_modules/' + pkgName + '/';
  const tryFrom = (from) => {
    const require = createRequire(from);
    const entry = require.resolve(pkgName).split(sep).join('/');
    const at = entry.lastIndexOf(match);
    if (at < 0) return null;
    return entry.slice(0, at + match.length - 1).split('/').join(sep);
  };
  try { const d = tryFrom(join(appDir, 'package.json')); if (d) return d; } catch {}
  try { const d = tryFrom(fileURLToPath(import.meta.url)); if (d) return d; } catch {}
  return null;
}

/**
 * The dev live-reload client. The `EventSource` URL is a framework-emitted
 * same-origin path, so it must carry the base path under a sub-path deploy
 * (#256), like the importmap targets and the RPC stub. No-op when basePath
 * is empty.
 * @param {string} bp the normalized base path (`''` = no-op)
 * @returns {string}
 */
// The overlay renderer source, read once + `export`-stripped so it inlines as
// plain functions into the classic reload-client script. Sharing the one source
// (`dev-overlay.js`, which the browser test imports directly) means the test
// drives the EXACT code that ships, with no drift (#264).
const DEV_OVERLAY_SRC = readFileSync(new URL('./dev-overlay.js', import.meta.url), 'utf8')
  .replace(/^export /gm, '');

function reloadClientJs(bp) {
  // The overlay renderer uses textContent throughout (never innerHTML), so the
  // error message / code frame can never inject markup (#264). Served only in
  // dev (the /__webjs/reload.js branch 404s in prod), so it never reaches a
  // production page.
  return `// webjs dev reload client
${DEV_OVERLAY_SRC}
const es = new EventSource(${JSON.stringify(withBasePath('/__webjs/events', bp))});
es.addEventListener('reload', () => location.reload());
es.addEventListener('webjs-error', (e) => {
  let f; try { f = JSON.parse(e.data); } catch (_) { return; }
  renderDevOverlay(f);
});
`;
}
