# AGENTS.md for @webjsdev/server

The webjs **server runtime**: dev + prod request handling, SSR,
file-based router, server actions (RPC scanner + endpoint), auth,
session, cache store, rate limiting, WebSocket upgrade, CSRF,
compression, build helper, convention validator.

Framework-wide rules (workflow, JSDoc-in-`packages/`, no-build,
commit conventions, autonomous-mode behaviour, scaffold rules) live
in the **framework root [`../../AGENTS.md`](../../AGENTS.md)** and
apply here. Read that first.

This file only covers what's specific to `@webjsdev/server`.

## Role

Two entry shapes:

- `startServer({ appDir, port, dev })`: owns the HTTP server, used
  by the CLI's `webjs dev` / `webjs start`.
- `createRequestHandler({ appDir })`: returns `{ handle(req): Response }`
  for embedding under Express / Fastify / Bun / Deno / edge.

Both go through the same pipeline: `dev.js` (file serving + TS
transform) → `router.js` (file → route table) → `ssr.js` (page render
with metadata, Suspense, streaming) for HTML, or `api.js` /
`actions.js` for JSON / RPC, or `websocket.js` for `WS` upgrade.

## Module map (`src/`)

| File | What it owns |
|---|---|
| `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. Dev error overlay (#264): `reportDevError(error, info)` builds a frame (via `dev-error.js`) and pushes it to the open tab over the SSE channel as a `webjs-error` event, fed by three sources (the SSR render catch via `ssrOpts.onDevError`, the `tsResponse` strip-failure, and the `rebuild` catch); a successful rebuild clears `state.lastDevError`; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). Dev-only: `reportDevError` early-returns in prod and `/__webjs/reload.js` 404s |
| `router.js` | Scans `app/` once, builds the route table, matches pages + APIs (`buildRouteTable`, `matchPage`, `matchApi`) |
| `route-types.js` | Route-types generator (#258). `generateRouteTypes(appDir)` reuses `buildRouteTable` to emit the `.d.ts` text that augments `@webjsdev/core` (the `WebjsRoutes` href union + `RouteParamMap` per-route params), backing `webjs types` and the dev-startup emit. Pages-only (a `route.{js,ts}` API path is not a navigable href); strips route groups, excludes `_private`; an optional catch-all `[[...x]]` emits both the without-segment and a normalized `[...x]` href key while keeping the doubled literal as the param-map key. Deterministic (sorted keys). Helpers `routeKeyFromDir` / `dynamicSegments` / `paramTypeForKey` / `webjsRoutesKeysForKey` are exported for unit tests |
| `ssr.js` | SSR pipeline: nested layouts, metadata → `<head>`, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the page-action re-render passes 422). Server HTML cache (#241): on a plain GET render it loads the page module once to read `export const revalidate`, serves a cache HIT via `cachedHtmlResponse` (re-minting the CSRF cookie + build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the page-action re-render and partial-nav (`X-Webjs-Have`) requests. **Frame subtree render (#253):** after `renderChain`, when the request carries `x-webjs-frame: <id>` (a `<webjs-frame src>` self-load or a click-driven frame nav) AND the render is non-streamed, it extracts the matching `<webjs-frame id>` subtree from the rendered body via `frame-render.js` and returns ONLY that (byte-equivalent to the client's extraction from a full page, but far fewer bytes); an absent frame id falls through to the full page (the client's `webjs:frame-missing` handles it), and a request with no `x-webjs-frame` header is byte-identical to before |
| `dev-error.js` | Dev error overlay frame builder (#264). `buildDevErrorFrame(error, { kind, appDir, file?, line?, hint? })` returns a JSON-serializable frame (message, parsed `file`/`line`/`column`, a source `codeFrame`, an optional `hint`); `parseStackLocation(stack, appDir)` finds the first app frame (preferring non-`node_modules`, splitting off the dev loader's `?t=` cache-bust query); `readCodeFrame(file, line, column)` reads the source excerpt with a `>` line marker + a caret. PURE (the only side effect is a guarded source read) and DEV-ONLY by the caller's contract, so no path / source is built in prod |
| `frame-render.js` | Server-side `<webjs-frame>` subtree extraction (#253). `requestedFrameId(req)` reads the `x-webjs-frame` header (null when absent, the normal full-page path); `extractFrameSubtree(html, id)` returns the `<webjs-frame id>...</webjs-frame>` slice from rendered HTML verbatim (so byte-equivalent by construction), balancing nested `<webjs-frame>` tags and reading the `id` attribute (not a substring match), or null when the id is absent. Used by `ssr.js`'s frame-render branch |
| `page-action.js` | Page server actions (#244): `loadPageAction` reads a page module's optional `action` export, `runPageAction` parses the form body, runs the action, and maps the `ActionResult` to a response (303 PRG on success, 422 re-render with `actionData` on failure, honoring thrown `redirect()`/`notFound()`). A page action that returns a `Response` DIRECTLY (e.g. a content-negotiated `streamResponse`, #248) is honored verbatim. `dev.js` routes a non-GET/HEAD page request here only when the page exports `action`, wrapped in the page's segment middleware |
| `actions.js` | `.server.js` / `.server.ts` scanner. Generates RPC stubs for browser-bound imports; exposes RPC endpoints; honours `expose()`. **Input validation (#245):** `runValidate(validate, input)` is the shared seam both call paths route through, so a validator attached via `validateInput(fn, ...)` or `expose(spec, fn, { validate })` interprets a `{ success, fieldErrors }` envelope, a throw, and a transform-return identically across transports. `invokeAction` (RPC) runs it on the first arg before the body, returning a `{ success: false, fieldErrors, status: 422 }` envelope as a normal 200 RPC payload on a structured failure (the client reads `result.fieldErrors`) and a sanitized error response on a throw; `invokeExposedAction` (REST) returns a 422 JSON on a structured failure, a 400 (keeping a schema lib's `issues`) on a throw, and replaces the input on a transform-return (back-compat) |
| `api.js` | `route.ts` `GET` / `POST` / `PUT` / `DELETE` handler dispatch |
| `auth.js` | `createAuth()` with Credentials / Google / GitHub providers; JWT signing. `readSession()` (reached by `auth()`) calls `markDynamicAccess()`, so an `auth()`-gated page is auto-excluded from the HTML cache even if it wrongly set `revalidate` (#241, the auth-path leak fix). The session-`user` type is opt-in (#451): the overlay exposes an augmentable `AuthUser` interface and a generic `createAuth<TUser>()`, so `auth().user` can be typed with no cast and typo-checked, defaulting to `Record<string, unknown>` when neither is used (types-only, no runtime effect) |
| `session.js` | `Session` class, cookie + store-backed storage (`cookieSession`, `storeSession`) |
| `cache.js` | Pluggable cache store: `memoryStore` / `redisStore`; `setStore` / `getStore` |
| `file-storage.js` | File storage primitive (#247): the `FileStore` interface + `diskStore` (the streaming local-disk default adapter) + the `setFileStore` / `getFileStore` module singleton, mirroring `cache.js`'s adapter shape. `put` STREAMS a web `File` / `Blob` / `ReadableStream` / `Uint8Array` to disk via `Readable.fromWeb` -> `createWriteStream` -> `pipeline` (never `arrayBuffer()`), recording the content type in a `<file>.meta` sidecar; `get` returns a streaming `{ body, size, contentType }` handle (or null). `assertSafeKey(dir, key)` is the traversal guard (the same `resolve` + `startsWith(dir + sep)` containment as the `/public/*` serve path), rejecting `..` / absolute / leading-slash / NUL / backslash keys BEFORE any fs op. `generateKey(filename?)` mints an opaque `<uuid>.<ext>` key (whitelisted extension only). `signedUrl` / `verifySignedUrl` are the HMAC-SHA256 (base64url) signed-URL helpers (key + expiry signed, constant-time compare). S3-pluggable by the interface shape; no S3 SDK ships |
| `cache-fn.js` | `cache(fn, { key, ttl, tags })` query-caching helper + `invalidate()`. The `tags` option (a `string[]` or a `(args) => string[]` function for per-entity tagging) records a tag -> cacheKey mapping via `cache-tags.js` after each store write |
| `cache-tags.js` | Tag-based invalidation (#242): `revalidateTag(tag)` / `revalidateTags(tags)`. A THIN key-index over the shared store (`cache:tag:<tag>` holds a JSON array of cache keys) so a mutation in any module evicts every `cache()` read tagged with that tag, including arg-specific keys the no-args `invalidate()` cannot reach. The data-side companion to `html-cache.js`'s `revalidatePath` (HTML side); together they are the server cache invalidation surface. Multi-instance caveat mirrors #241 (non-atomic read-modify-write of the JSON array; prefer a short `ttl` as the cross-instance floor) |
| `html-cache.js` | Server HTML response cache (ISR for no-build, #241). OPT-IN per page via `export const revalidate = N` (seconds), read by `readRevalidate`. `readHtmlCache` / `writeHtmlCache` use the shared store under a URL-keyed namespace (`htmlCacheKey`, query-order-normalized, with the in-process generation, the published build id, AND an app-source fingerprint (#318) folded into the key, so `revalidateAll` flushes in one bump and a NEW DEPLOY naturally re-keys so a stale-importmap body is never served). The build id covers a VENDOR change; the app-source fingerprint (`setAppSourceFingerprint`, a deterministic location-independent digest of the browser-bound file set's content hashes, set from `dev.js`'s `ensureReady`, PROD only) covers an APP-MODULE-only change that the importmap-only build id misses, so a Redis-cached `revalidate` page never survives an app-only deploy serving stale `?v` boot URLs while a no-change redeploy keeps the warm cache. `isCacheableResponse` is the defense-in-depth guard (status 200, not streamed, CSP off, no non-framework `Set-Cookie`; the framework `webjs_csrf` cookie alone does not block; the `getSetCookie`-absent fallback fails safe). The cache LOOKUP + opt-in read live in `ssrPage`; the WRITE is a response-funnel step (`commitHtmlCache`) so it re-checks the guards against the FINAL post-middleware response AND skips caching when the render marked the request dynamic via `dynamicAccessed()` (a `cookies()` / `headers()` / `getSession()` / `auth()` read), warning once. `revalidatePath(path)` / `revalidateAll()` are the server-side on-demand eviction surface (distinct from core's client-side `revalidate()`); `revalidateAll` is per-process (a multi-instance Redis deploy leans on the TTL / `revalidatePath`) |
| `rate-limit.js` | `rateLimit({ window, max })` middleware factory |
| `cors.js` | `cors({ origin, credentials, methods, allowedHeaders, exposedHeaders, maxAge })` middleware factory for route handlers / `middleware.js`. Shared origin-resolution + header-building core (`resolveOrigin` / `applyCorsHeaders`) reused by the `expose()` REST path in `actions.js`. Enforces the CORS-spec rule that `credentials: true` forbids a wildcard ACAO (narrows `*` to the reflected origin). |
| `csrf.js` | Double-submit CSRF protection (server-action endpoints) |
| `websocket.js` | WS upgrade handling: invokes `WS` export from `route.ts` |
| `broadcast.js` | `broadcast(topic, msg)` for fan-out messaging |
| `context.js` | AsyncLocalStorage per-request context (`getRequest`, `withRequest`, `headers`, `cookies`). The per-user readers `headers()` / `cookies()` (plus `getSession()` in `session.js` and `readSession()` behind `auth()` in `auth.js`) call `markDynamicAccess()`, so the HTML cache's commit step reads `dynamicAccessed()` and refuses to cache a per-user page that wrongly set `revalidate` (#241). Also exposes the per-request correlation id via `requestId()` (set by the handler with `setRequestId`, #239) and wires the server-side `cspNonce()` provider: returns the per-request nonce `setCspNonce` stored (minted when CSP is on, #233), else falls back to parsing an inbound `Content-Security-Policy` request header |
| `build-info.js` | Build-info / version probe payload (#239). `buildInfo()` composes `{ version, build, node, uptime }` (framework version read once from this package's own `package.json`, `build` from `publishedBuildId()`, no secrets); `buildInfoResponse()` wraps it as the `no-store` JSON `GET /__webjs/version` response. Served in `handle()` before `ensureReady`, like the health / ready probes |
| `redirects.js` | Declarative permanent / temporary redirects (#254). `compileRedirectRules(pkg)` normalizes the `webjs.redirects` package.json key (an array of `{ source, destination, permanent?, statusCode? }`) into URLPattern rules compiled ONCE at boot, dropping any malformed entry with a warning (the #232 fail-safe posture). `applyRedirects(req, rules)` matches the request pathname against the rules, fills `:name` groups from the source into the destination, preserves (and merges) the incoming query string, and returns a 308 (permanent default) / 307 (temporary) / configured-`statusCode` redirect Response on the first match, else null so the request falls through to routing. Skips `/__webjs/*`. Wired in `dev.js`: `readRedirectRules` reads it at boot and `produce()` applies it at the very start of request handling (before the probes / routing / SSR), the secure-header + conditional-GET funnel in `handle()` still wrapping the redirect Response. ALSO hosts the trailing-slash policy (#255): `readTrailingSlashPolicy(pkg)` normalizes the `webjs.trailingSlash` package.json key to `'never'` / `'always'` / `'ignore'` (default `'ignore'`, the non-breaking no-op); `applyTrailingSlash(req, policy)` 308-redirects a non-canonical path to the canonical form (`never` strips a trailing slash, `always` adds one), exempting the root `/`, file paths (last segment has a dot, under `always`), `/__webjs/*`, and network-path references (a path starting with `//` or `/\`, which would otherwise emit a protocol-relative cross-origin `Location`, an open redirect), and preserving query + hash. Wired in `dev.js` right AFTER `applyRedirects` in `produce()` (`readTrailingSlashFromApp` reads it at boot), so an explicit redirect wins first. NOT loop-free: a redirect destination that contradicts the slash policy loops forever (no server guard, the author's responsibility, matching `applyRedirects`) |
| `base-path.js` | Sub-path deployment support (#256). `readBasePath(pkg)` / `normalizeBasePath(raw)` normalize the `webjs.basePath` package.json key to `''` (the root-mount default) or `/segment[/segment...]`, rejecting an unsafe value (`..`, a protocol, a `//host` reference, whitespace, a backslash) to `''` so a typo fails safe. The model is strip-at-ingress + prefix-on-emit, two seams only: `stripBasePath(pathname, basePath)` computes the root-relative path for the ingress strip (returns null when the path is not under the base path, so `dev.js` 404s it), and `withBasePath(url, basePath)` prefixes a framework-emitted same-origin absolute URL (a no-op when empty, leaving a cross-origin `https://` CDN target untouched). The ingress strip is wired in `dev.js`'s `produce()` (it rewrites the Request with the stripped URL, BEFORE `applyRedirects`, so all downstream config + matching sees a root-relative path); the prefix-on-emit is wired in `importmap.js` (`setBasePath` prefixes every importmap target + recomputes the hash) and `ssr.js` (the boot module specifiers, the modulepreload hrefs, the lazy entries, the dev reload src, read via importmap.js's `basePath()`), plus the 103 Early Hints `routeFor` in `dev.js`. Empty basePath is byte-identical to before the feature (guarded by a differential test). Author-written `<a href>` links + client-router nav are NOT prefixed (a documented follow-up) |
| `asset-hash.js` | Content-hash asset URLs for immutable caching (#243, feature 1). `setAssetRoots({ appDir, coreDir, enabled })` binds the app + core roots and enables fingerprinting in PROD only (`dev.js` passes `enabled: !dev`, so dev is a pure no-op). `assetHashFor(absPath)` computes (and memoizes in a `Map<absPath, hash>`) a short 12-hex prefix of a sha-256 over the file BYTES (`node:crypto`, synchronous so the emit hot path stays sync), returning `''` on a read failure (NOT memoized, so a transient failure re-attempts). For a file under `_appDir` it ALSO folds in `_elisionFp` (set by `setElisionFingerprint`, called from `ensureReady`): an app module's served body is elision-transformed (a display-only import stripped), so a verdict flip must bust the importer's `?v` even when its source is byte-identical. The fingerprint is a relativized digest of the elidable + inert set (empty when nothing is elidable, leaving an app module's hash at exactly `sha256(bytes)`); core / `public/` files are never elision-transformed, so they hash over bytes alone. `routeFor` (the 103 Early Hints) applies `withAssetHash` too, so the hint and the body request the same URL. `withAssetHash(url, basePath?)` appends `?v=<hash>` to a framework-emitted SAME-ORIGIN absolute url (the importmap targets, the modulepreload hrefs, the boot specifiers): a NO-OP when disabled (dev), for a CROSS-ORIGIN / protocol-relative / relative url (so a `https://` jspm vendor target keeps its exact url, its #235 SRI key intact, and is never re-fingerprinted; an already-version-named `/__webjs/vendor/*` bundle is left alone too), and when the url does not resolve to a readable same-origin file (1h fallback). Composes with `withBasePath` (basePath then `?v`; it strips the base path before file resolution). `clearAssetHashCache()` is wired into `dev.js`'s rebuild path next to `clearVendorCache` so a changed file re-hashes (the deploy-busts mechanism). The emit is in `importmap.js`'s `buildImportMap` (`fingerprint` flag, true for the SERVED map, false for the internal hash so the build id stays a stable per-deploy fingerprint) and `ssr.js`'s `wrapHead` (boot specifiers + modulepreload hrefs + lazy entries). The SERVE seam is `dev.js`: a request carrying a `?v=` query is served `Cache-Control: public, max-age=31536000, immutable` (vs the 1h fallback) by `fileResponse` (`immutable` flag), `jsModuleResponse` / `tsResponse` (an `immutable` arg), and the core serve in `tryServeFrameworkStatic` (a `versioned` ctx flag); the pathname (query stripped) resolves the file as today, only the cache header changes. Dev stays `no-cache` regardless. **`versionModuleImports(source, importerAbs)` (#369)** is the matching SERVED-SOURCE rewrite: a layout/page/component imports its dependencies with a BARE relative specifier (`import '../components/x.ts'`), and the browser resolves that against the importer's `?v=`-versioned URL WITHOUT inheriting the `?v` query, so it would fetch the un-versioned URL (a different cache key from the `?v=`-versioned modulepreload hint -> wasted preload, double download, 1h cache). The pass appends the target's `?v=<hash>` (the same `assetHashFor` the preload href uses, so the URLs are byte-identical) to every same-origin relative / root-absolute static-import specifier in a served module, matching over a redaction mask (so a templated example import is skipped). Bare specifiers (importmap-resolved, versioned at their target) and `.server.*` stubs (bare-URL, not in the preload set) are left untouched. Run AFTER `elideImportsFromSource` in `jsModuleResponse` / `tsResponse`; a pure no-op when disabled (dev), so dev source stays byte-faithful. |
| `csp.js` | CSP nonce minting + `Content-Security-Policy` header building (#233). `readCspConfig` normalizes the `webjs.csp` package.json key (off by default; `true` = strict default policy, object = custom directives + `reportOnly`); `mintNonce` is the per-request CSPRNG nonce; `buildCspHeader` substitutes the nonce into the policy. Plugs into the #232 `applySecurityHeaders` seam in `dev.js`'s `handle()` |
| `env-schema.js` | Boot-time env-var validation (#236). `validateEnv(schema, env)` is the PURE validator: it checks an env object against a schema (an object of `name -> type-name | options`, supporting `string`/`number`/`boolean`/`url`/`enum`, `required`/`optional`/`default`, `minLength`/`pattern`), collecting ALL errors at once and returning the coerced + defaulted values to write back. A schema may instead be a FUNCTION `(env) => void` (the escape hatch for zod etc.), whose throw becomes the single error. `loadEnvSchema(appDir)` reads the optional app-root `env.{js,ts}` (null when absent, so opt-in); `applyEnvValidation(appDir)` is the side-effecting boot wrapper called from `createRequestHandler` right after the `.env` auto-load: it validates `process.env`, applies coerced values back, and THROWS a clear aggregated Error on failure (CLI exits non-zero, embedded host rejects), consistent with the Node-version preflight. `formatEnvErrors` composes the aggregated message. |
| `node-version.js` | Node-version preflight guard (#238). `checkNodeVersion(current, requiredMajor)` is the PURE comparison; `assertNodeVersion({ onFail })` is the side-effecting wrapper that throws a clear Error (embedded server, called at the top of `createRequestHandler`) or exits non-zero (CLI). The minimum is sourced from this package's own `engines.node` via `requiredNodeMajor()` so it never drifts. Fails fast on an older Node with a message naming the found + required version (the built-in TS strip + recursive `fs.watch` need 24+), instead of a cryptic late failure. For the embedded-host throw to actually fire, `dev.js` namespace-imports `node:module` (`import * as nodeModule`) rather than name-importing `stripTypeScriptTypes`, so importing `@webjsdev/server` LINKS on old Node instead of link-failing before the guard runs (PR #282 fix; the CLI carries its own dependency-free inline guard in `cli/lib/node-preflight.js` for the same reason). |
| `conditional-get.js` | RFC 7232 conditional GET (ETag + If-None-Match -> 304) (#240). `applyConditionalGet(req, res)` is the shared funnel: for a cacheable GET/HEAD response (status 200, `Cache-Control` present and NOT `no-store` / `private`) it attaches a WEAK content-hash `ETag` (`W/"..."`) over the response's OWN body bytes when one is absent, then returns a `304 Not Modified` (no body, validators + caching headers preserved) when the request's `If-None-Match` matches (weak comparison, `*` wildcard, comma lists). `ifNoneMatchSatisfied` is the pure matcher. The ETag is WEAK because it hashes the uncompressed body and `sendWebResponse` reuses it across identity / gzip / br codings, which a strong validator may not do (RFC 7232 2.3.3). **The funnel only reads a body that a serve branch positively marked buffered** via the internal `BUFFERED_MARKER` (`x-webjs-buffered`) header it stamps on a string / bytes body (`htmlResponse` + the non-streaming `streamingHtmlResponse`, `fileResponse`, `jsModuleResponse`, `tsResponse`, `serveDownloadedBundle`). Both internal markers are stripped at the funnel and never reach a client. EXCLUDED: `no-store` / `private` responses (no cross-session 304 on per-user content); non-GET/HEAD; non-200; a genuinely-streamed Suspense body (flagged with `STREAM_MARKER` / `x-webjs-stream` by `ssr.js`); and **any unmarked body, which is how a user `route.{js,ts}` handler returning a `ReadableStream` (incl. an SSE `text/event-stream` that never ends) is never buffered into memory or awaited forever**. A web `Response` exposes a `ReadableStream` body for a string and a live stream alike, so the explicit marker is the only safe discriminator. Wired once at the response funnel in `dev.js`'s `handle()` (AFTER `applySecurityHeaders` + the X-Request-Id / CSP header steps), so it covers SSR HTML pages, static assets, app source modules, and the core / vendor runtime uniformly. The serve branches no longer compute their own ETag; the funnel is the single ETag authority (dev + prod). |
| `body-limit.js` | Request body-size limits (413) + node:http server timeouts (#237). `readBodyLimits` resolves the JSON/RPC (`webjs.maxBodyBytes`, default 1 MiB) and form/multipart (`webjs.maxMultipartBytes`, default 10 MiB) caps from package.json + the `WEBJS_MAX_BODY_BYTES` / `WEBJS_MAX_MULTIPART_BYTES` env overrides (env wins, `0` disables). `computeServerTimeouts` resolves `requestTimeout` (30s) / `headersTimeout` (20s) / `keepAliveTimeout` (5s), clamping `headersTimeout` strictly under `requestTimeout` per node semantics. `readBytesBounded` / `readTextBounded` / `readFormDataBounded` are the single bounded-read funnel every body-read site (RPC in `actions.js`, `readBody` in `json.js`, the page-action form in `page-action.js`) routes through: a `Content-Length` over the limit is a fast reject, a chunked body is counted while streaming and abandoned past the cap, so an over-limit body is never buffered whole. `BodyLimitError` (caught and mapped to 413 by `api.js`) is how `readBody` inside a route handler signals over-limit; the RPC / page-action paths return `payloadTooLarge()` inline |
| `testing.js` | handle() test-harness helpers (#267), exported from `index.js` AND the `./testing` subpath. THIN builders over `createRequestHandler(...).handle()`: `testRequest(handle, path, init?)` fires a native Request through the real pipeline; `getCsrf(handle)` mints a `{ token, cookie, header }` pair off the first SSR response (reusing `csrf.js`'s `CSRF_COOKIE` / `CSRF_HEADER`); `loginAndGetCookies(handle, creds)` drives the REAL credentials login (`/api/auth/signin/credentials`) and captures the genuine signed session `Set-Cookie`; `actionEndpoint(appDir, file, fn)` computes the `/__webjs/action/<hash>/<fn>` path via `hashFile` (the same scheme the stub uses); `invokeActionForTest(app, file, fn, args)` round-trips an action through that REAL endpoint (serializer + CSRF + prod error sanitization), and `rawActionRequest(...)` returns the raw Response (no throw, `omitCsrf` for the 403 case). No test-framework machinery; reuses the real serializer (`serializer.js` -> `@webjsdev/core`) and cookie/header names, never a fake |
| `serializer.js` | Default serializer + `setSerializer` / `getSerializer` for the RPC wire format |
| `json.js` | `json()` + `readBody()` content-negotiation helpers |
| `sitemap.js` | Sitemap helpers (#276). `sitemap(entries)` serializes an array of `{ url, lastModified?, changeFrequency?, priority? }` into spec-valid `<urlset>` XML (XML-escaping each url, formatting `lastModified` as a W3C datetime, validating priority 0..1 + the changefreq enum, skipping a urlless entry); `sitemapIndex(sitemaps)` builds the `<sitemapindex>` for sharding a site past the 50k-URL limit. Both pure + dependency-free; the `app/sitemap.{js,ts}` default export returns the string, which `dev.js` serves as `application/xml`. Exported from `index.js` |
| `stream.js` | Server-side stream-action builders (#248). `stream.append/prepend/before/after/replace/update/remove(target, content?)` compose the `<webjs-stream action target>` HTML (one `<template>` per insert action) the client `renderStream` / `<webjs-stream>` element applies surgically; `streamResponse(...parts)` wraps them in a `Response` carrying `STREAM_MIME` (`text/vnd.webjs-stream.html`); `acceptsStream(req)` reports whether the request negotiated the stream path (its `Accept` carries the MIME), the seam an app branches on so a JS-off form returns a normal render. The target id is attribute-escaped. Pure + dependency-free; the content is NOT escaped (server-authored HTML). A page `action` returning `streamResponse` is honored verbatim by `page-action.js` (`runPageAction` returns a returned `Response` as-is). Exported from `index.js` |
| `check.js` | Convention validator backing `webjs check`. Correctness-only; rules include `no-browser-globals-in-render`, `no-non-erasable-typescript`, `no-server-import-in-browser-module` (a page / layout / component that SHIPS to the browser, i.e. the build does NOT elide it, must not transitively import a server-only `.server.{ts,js}` UTILITY: that import becomes a throw-at-load stub in the browser and crashes the page at runtime while passing typecheck. Reuses the build's own elision verdict (`analyzeElision` over the module graph, scanned components, and route table) so it fires ONLY on modules that genuinely ship; a display-only page the framework elides is never flagged. Also covers shipping components and the always-shipped `error` / `loading` / `not-found` route modules (never elided). Skips `'use server'` ACTIONS, which resolve to a working RPC stub and are the legitimate way to call the server from a shipping module, and skips imports written inside code-example strings (the module-graph scanner masks string-embedded `import`s, so a docs `<pre>` sample never becomes an edge). Known gap: a DYNAMIC `import('./x.server.ts')` is not matched (the scanner tracks only static import/export-from), consistent with the framework-wide graph; that crash is deferred to call time.) |
| `vendor.js` | Resolve bare-specifier npm deps. `resolveVendorImports(appDir, getBareImports)` reads `.webjs/vendor/importmap.json` if present (committed pin file) and short-circuits BEFORE running the bare-import scan; only when there is no pin file does it invoke the `getBareImports` thunk (the whole-app `scanBareImports` walk) and call `api.jspm.io/generate`. `jspmGenerate` resolves the WHOLE install set in ONE generate call (a single `install[]` array) so jspm computes one mutually-consistent graph (#446): a direct dep and a transitive that needs a newer version of the same package share one URL instead of skewing (direct pinned local, transitive floating to jspm-latest -> missing-export crash). Per-package isolation survives as a FALLBACK ONLY: on a permanent 401 (an unresolvable private/server-only install) it probes each install alone, drops the unresolvable one(s), and re-runs the unified call over the resolvable subset so survivors stay coherent; on a transient 5xx/network failure it serves merged per-install fragments and flags the resolve for retry (`lastLiveResolveFailed`). `pinAll` builds the same `install[]` and calls the same `jspmGenerate`, and now ALSO persists the flattened transitive entries the unified resolve returns (`derivePinParts` recovers their pkg/version/subpath from the resolved URL), so a `webjs vendor pin` snapshot and the live runtime importmap agree on the same specifier->URL set for a given dep set (the vendor-runtime parity invariant). So a pinned app does no vendor static analysis at boot (runtime-first); the elision-aware prune of a pinned map (`prunePinToReachable`) runs lazily in `ensureReady`, not at boot. **SRI integrity (sha384, keyed by the FINAL URL) is returned on BOTH paths.** The pin path returns the committed `integrity` verbatim; the LIVE path computes it after resolving via `computeLiveIntegrity`, which fetches each cross-origin (`https://`) target and hashes the raw bytes (`fetchLiveIntegrity` -> `sha384Integrity`), skipping same-origin `/__webjs/...` targets (#235). Bounded (parallel with a small concurrency cap + a per-fetch timeout) and FAIL-OPEN: a bundle fetch failure skips that one URL's integrity and emits a single count-based `console.warn`, never breaking the resolve, so a CDN hiccup cannot take the app down. Hashes are cached per process by URL (`liveIntegrityCache`, cleared by `clearVendorCache`) so a re-resolve does not re-fetch an immutable bundle; this is NOT a persistent cache (that is the pin file's job). The returned `integrity` map keys on the same FINAL URL that `vendorIntegrityFor(url)` looks up, so ssr.js's `integrityAttr` / importmap emission fires for free on the live path too. Backs the `webjs vendor pin / unpin / list / audit / outdated / update` CLI surface plus the `--from <provider>` (jspm, jsdelivr, unpkg, skypack) and `--download` modes. `--download` mode also serves cached bundle files from `.webjs/vendor/`. After a `pin`, the CLI calls `ensureVendorCommittable(appDir)`: vendoring is opt-in, so the pins are meant for source control, and a `.gitignore` that excludes `.webjs/` would swallow them. The helper probes `git check-ignore`; if the pin output is ignored it heals the app's own `.gitignore` (rewrites a bare `.webjs` directory exclusion to the contents-glob form, then appends the `!.webjs/vendor/` negation, then re-probes and reverts if a broader rule still wins), and otherwise is a clean no-op. It NEVER fabricates a `.gitignore` (a parent-repo or `.git/info/exclude` source yields a printed notice instead), so the no-vendor default is untouched. **Importmap-coherence validation (#450):** `checkImportmapCoherence(imports, { getManifest })` inspects a PRODUCED importmap (it does NOT re-resolve, that is #446's job) and for each resolved package checks that the version pinned for every OTHER resolved package it declares a dependency / peer range on satisfies that range, returning conflicts naming both packages, the range, and the pinned version (defense-in-depth for a hand-edited pin, a partial vendor pin, or the #446 skew). Pure in `(imports, getManifest)` and built on the dependency-free `satisfiesSemverRange` + `extractPinnedVersions` (version parsed from the importmap URL), so the SAME pinned dep set yields the SAME verdict over a live importmap and a vendored `.webjs/vendor/importmap.json` (the runtime-vs-vendored parity invariant). Surfaced via `webjs doctor` (warn-only), degrades to `unverified` when a manifest is unavailable, and never warns on a semver range shape it cannot statically evaluate. |
| `module-graph.js` | Dependency graph for transitive preload hints. Both walks (`transitiveDeps` for preloads, `reachableFromEntries` for the auth gate) stop at `.server.*` boundaries, so a preload set is always a subset of the servable set. The import scanner masks string / template-literal content (`redactStringsAndTemplates`) so an `import`/`export … from` shown as example code inside an `html\`\`` template is not counted as a real edge. |
| `importmap.js` | Browser import-map builder. `setCoreInstall(coreDir, distMode)` binds the importmap to the resolved `@webjsdev/core` install and runs `buildCoreEntries()`, which reads the package's `package.json` and derives one importmap line per exported subpath from its `exports` field, picking the `default` condition in dist mode and the `source` (`src/*.js`) condition otherwise. In dist mode the browser surface is ONE self-contained bundle: the `exports` `default` for the always-load browser subpaths (`/directives`, `/context`, `/task`, `/client-router`) all point at `dist/webjs-core-browser.js`, so those entries plus the bare specifier collapse onto that single file (each import picks its named exports from it) instead of a fan of per-subpath bundles + code-split chunks. `/lazy-loader` keeps its own file (on-demand). In src/dev mode each subpath stays granular (`src/*.js`) since there is no bundle to collapse into. `dev.js` calls `setCoreInstall` at boot based on `existsSync(coreDir/dist/webjs-core.js) && existsSync(coreDir/dist/webjs-core-browser.js)`. The bare `@webjsdev/core` specifier always points at the BROWSER entry (`index-browser.js` or `dist/webjs-core-browser.js`); the slim entry drops `renderToString`, `renderToStream`, `expose`, `getExposed`, and `setCspNonceProvider` so server-only bytes do not ride the wire. Node-side consumers resolve via the package.json exports and still get the full `index.js`. `buildImportMap({ fingerprint })` content-hashes each same-origin target via `asset-hash.js`'s `withAssetHash` when `fingerprint` is true (the served map); the internal `importMapHash()` computation passes `false` so the published build id stays a stable per-deploy fingerprint independent of per-file hashes (#243). `vendorPreconnectOrigins(max?)` derives the cross-origin vendor CDN origins from the resolved vendor map (`_extraEntries`), most-common first + bounded, for the auto vendor preconnect (#243): returns `[]` for a same-origin pinned / empty map. |
| `component-scanner.js` | Maps every webjs component class to its browser-visible URL |
| `component-elision.js` | Static analyser deciding which display-only component modules can be elided from the browser, plus the serve-time side-effect-import stripper. Conservative denylist of interactivity signals (single source of truth) |
| `js-scan.js` | Shared lexical scanners (`redactStringsAndTemplates`, `extractWebComponentClassBodies`, `matchClosingBrace`) used by `check.js` and `component-elision.js` |
| `fs-walk.js` | Async recursive directory walker |
| `logger.js` | `defaultLogger` (JSON-shaped in prod, pretty in dev) |

## Public exports

See [`index.js`](./index.js) and [`package.json` exports](./package.json).
The `./check` subpath is exported separately so the CLI's `webjs check`
can load it without booting the full server. The `./testing` subpath
publishes the handle() test-harness helpers (`src/testing.js`, #267) so an
app's tests can import them as `@webjsdev/server/testing` (they are also
re-exported from the main entry). The
`./webjs-config.schema.json` subpath publishes the config JSON Schema
(below) so an editor can resolve it from
`node_modules/@webjsdev/server/webjs-config.schema.json`.

### Type overlay (#310)

The package ships a hand-authored `.d.ts` overlay plus `types` export
conditions, so a `strict` + `nodenext` TypeScript app's `import { ... } from
'@webjsdev/server'` resolves real types instead of TS7016. The runtime stays
plain `.js` + JSDoc; the overlay is types-only with zero runtime cost.

- [`index.d.ts`](./index.d.ts) types every named export of `index.js`. The
  high-traffic public API (`createRequestHandler`, `startServer`, `cors`,
  `cache`, `createAuth`, `rateLimit`, `sitemap` / `sitemapIndex`, `Session`,
  `json`, `readBody`, the `revalidate*` family, the context helpers,
  `memoryStore` / `redisStore` / `getStore` / `setStore`, the auth providers)
  is precisely typed from each source function's JSDoc; lower-traffic internals
  (scanner / importmap / module-graph / vendor) get reasonable structural
  declarations. It REUSES the core prop / metadata types
  (`PageProps` / `LayoutProps` / `RouteHandlerContext`) rather than redefining
  them, and defines the one server-owned shared type, `ActionResult<T>`
  (`@webjsdev/core` does not export it). The `./testing` types are pulled in via
  `export * from './src/testing.d.ts'` (no duplication).
- [`src/check.d.ts`](./src/check.d.ts) types the `./check` subpath
  (`checkConventions`, `RULES`, `Violation`).
- [`src/testing.d.ts`](./src/testing.d.ts) types the `./testing` subpath (the
  handle() harness helpers).

In `package.json`, the top-level `"types"` plus each `exports` entry's `types`
condition (FIRST in the object, as nodenext requires) wire the resolution; the
top-level `index.d.ts` is added to the `files` allowlist (`src/*.d.ts` ships via
the globbed `src`). **A new export added to `index.js` MUST get a declaration in
`index.d.ts`,** enforced by the drift test
`packages/server/test/types/exports-drift.test.mjs` (asserts the declared set
equals the runtime export set); the type fixture + TS7016 counterfactual live at
the repo-root `test/types/server-exports.test-d.ts` + `test/types/server-types.test.mjs`
(every `.test-d.ts` lives there, outside `packages/`, so the buildless-no-`.ts`
invariant does not flag it).

## The `webjs` package.json config block (typed surface, #259)

The `webjs.*` keys an app sets in `package.json` are typed and validated
in THREE co-located places that MUST stay in lockstep:

1. **The JSON Schema**, [`webjs-config.schema.json`](./webjs-config.schema.json)
   (in this package's `files` allowlist + `exports`). `additionalProperties:
   false` flags an unknown / typo'd key; the scaffold's `.vscode/settings.json`
   associates it with the `webjs` property of `package.json` so VS Code
   validates the block natively. This is the primary "fails-open -> diagnosed"
   fix: a typo used to be silently dropped to the default.
2. **The TS type** `WebjsConfig` in
   `packages/core/src/webjs-config.d.ts` (re-exported from
   `@webjsdev/core`), the typed reference an agent or human authors against.
3. **The reader functions** that consume each key: `readElideEnabled`
   (`dev.js`, `elide`), `compileHeaderRules` (`headers.js`, `headers`),
   `compileRedirectRules` / `readTrailingSlashPolicy` (`redirects.js`,
   `redirects` / `trailingSlash`), `readBasePath` (`base-path.js`,
   `basePath`), `readCspConfig` (`csp.js`, `csp`), and
   `readBodyLimits` / `computeServerTimeouts` (`body-limit.js`, the byte
   caps + timeouts).

**To add or change a `webjs.*` key, update all three (schema + type +
reader), and the `KNOWN_KEYS` list in the drift test.** The drift test
`test/config/webjs-config-schema.test.js` asserts the schema property set
and the reader key set never diverge (a counterfactual unknown key proves
`additionalProperties:false` would flag it); the type fixture
`test/types/webjs-config.test-d.ts` asserts the type matches.

## Package-specific invariants

1. **Source-file branch is gated by the browser-bound module graph.**
   `dev.js` walks the import graph from every page / layout / error /
   loading / not-found / component entry to produce
   `state.browserBoundFiles`. This is computed **lazily on the first
   request** (in `ensureReady()`, memoized) rather than at boot, and
   re-derived after each `fs.watch` rebuild; `handle()` awaits
   `ensureReady()` before the source-file branch runs, so the Set is
   always populated by the time it is read. The source-file branch only
   serves paths whose resolved absolute file is in that Set; everything
   else 404s before any filesystem operation. Same model as Next.js's
   bundler manifest, derived statically (now on first request, not at
   boot). The `module-graph.js` module exports `reachableFromEntries`
   as the reusable BFS helper.
   The walk stops AT `.server.{js,ts,mjs,mts}` boundaries: the
   server file itself stays in the Set (its URL yields the stub via
   invariant 2), but its outgoing edges are not followed. Files
   imported only by a server file are never legitimately fetched by
   the browser; including them would be over-permissive. The walker
   enters `_*` directories (the `_private` / `_components` /
   `_lib` convention is a router-ignore, not a graph-ignore).
   The preload-hint walk (`transitiveDeps`) applies the SAME
   `.server.*` stop, so the framework never emits a `modulepreload`
   for a file the gate then 404s (the preload set is a subset of the
   servable set). Edges themselves are scanned off a string/template
   redaction mask, so an `import`/`export … from` printed as example
   code inside an `html\`\`` template (the docs site does this) is not
   mistaken for a real dependency.
2. **Server-file source is unreachable from the browser.** `dev.js`
   re-verifies every in-graph JS/TS request against the path-level
   server-file predicate (filename suffix `.server.{js,ts,mjs,mts}`)
   before serving bytes. A server file ALWAYS responds with a
   generated stub, never its source, regardless of route-index state,
   FS race conditions, or developer error. The stub variant depends
   on whether the file declares `'use server'`: a server action (with
   the directive) returns the RPC stub; a server-only utility
   (without) returns a throw-at-load stub. The `'use server'`
   directive WITHOUT the extension is silently ignored at the runtime
   layer (a `webjs check` lint rule flags it instead) and the file
   serves as plain source. The guardrail runs INSIDE the graph gate
   as defense in depth (a file reaches the guardrail only if a client
   import names it; the graph then re-checks the extension). Regression
   tests live at `test/guardrails/server-file-guardrail.test.js`.
3. **File router has no manifest.** `buildRouteTable()` walks `app/`
   at boot; route invalidation in dev is via `fs.watch` (Node 24+ built-in, recursive) → SSE.
   The route table is the only eager ANALYSIS artifact (a cheap directory
   scan, no code reads). Boot does two other trivial loads, plus a third
   only when the app commits a vendor pin, none of which read app source or
   make a network call. The two unconditional ones are `setCoreInstall`
   (one read of `@webjsdev/core`'s OWN `package.json` to seed the browser
   import map, in `importmap.js`) and the `.env` auto-load (Node's
   `process.loadEnvFile` into `process.env`, before any server-only module is
   imported). The conditional fourth item is the **pinned vendor read**: when
   `.webjs/vendor/importmap.json` exists (`hasVendorPin`), boot reads that
   committed, deterministic config file (a local read, no jspm call),
   `setVendorEntries`, and `publishBuildId()`, so a pinned process advertises
   a stable `data-webjs-build` from its first response and a freshly-deployed
   pinned instance is detected as a new deploy by old-deploy clients with zero
   warmup window. So the complete list of eager boot work is: the route-table
   scan, the core `package.json` read, the `.env` load, and (pinned apps only)
   the committed vendor importmap read. Everything else (module graph,
   browser-bound gate, action index, middleware, elision, and the UNPINNED
   vendor resolve, which needs the bare-import scan plus a jspm call) is built
   lazily on the first request via `ensureReady()` in `dev.js`, so boot reads
   no app source, executes no server module, walks no graph, and makes no
   network call.
   `ensureReady()` is single-flighted and memoized; the handler exposes
   `warmup()` (which calls it), and `startServer` fires `warmup()`
   fire-and-forget once the HTTP server is listening, so the analysis runs
   in the background ahead of a real first request without delaying
   readiness. `warmup()` is a single best-effort kick: a failure is caught and
   logged, not thrown, and whatever failed simply re-runs on the next request
   or readiness probe. There is NO internal retry timer or backoff; the
   platform's traffic and probes are the retry loop. Analysis runs in two
   stages: a deterministic stage (graph, scan, gate, action index, middleware,
   elision) and a vendor stage (a pinned app reads the committed importmap and
   prunes it to the elision-reachable specifiers via `prunePinToReachable`, so it
   serves the same map an unpinned app would; an unpinned app auto-fetches jspm
   with the elision-excluded scan). **Readiness gates on a FULLY warm instance:
   the deterministic stage AND the first vendor attempt both completed (note:
   completed, not necessarily succeeded).** A readiness-gated platform (Railway
   `healthcheckPath`, a k8s readinessProbe) therefore admits traffic only after
   the importmap build id is published (vendor resolved) or definitively empty
   (a bounded vendor failure), never DURING the vendor-resolution window. This
   is what makes `warmup()` 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 or CDN-degraded app still becomes ready shortly after that timeout
   (degraded but reload-safe, see the build-id note below), preserving the boot
   resilience. Readiness gates on the FIRST attempt only: a TRANSIENT vendor
   failure (network / timeout / jspm 5xx) still flips readiness, then is
   re-attempted on the next `ensureReady` call, non-blocking, with a `vendorGen`
   guard so a rebuild cannot let a stale resolve win. A permanent unresolvable
   (jspm 401 for a private / workspace / server-only dep) reports ok and is
   tolerated. `ensureReady()` logs a one-line per-pass timing breakdown so a
   slow first request is diagnosable.
   **Build-id stability (post-deploy reload safety).** The client router reads
   `data-webjs-build` / the `X-Webjs-Build` header to detect a real deploy and
   hard-reload (so a partial swap never resolves modules against a stale
   importmap). That id is the PUBLISHED build id (`publishedBuildId`), promoted
   only when the importmap is authoritatively final: at boot for a pinned app,
   or after the first successful vendor resolve for an unpinned one. While the
   map is still warming it stays empty, and the router treats an empty id on
   either side as "version unknown" and never hard-reloads against it. So the
   warmup window can never flip the id from empty to a value mid-session and
   trigger a destructive reload that wipes a half-filled form. A real
   cross-deploy still reloads, because both sides then carry non-empty,
   differing ids. Concurrent early requests await the in-flight first resolve
   (no bypass), so the first served response already carries the final map.
   **Probes:** `/__webjs/health` is liveness (always 200 once listening);
   `/__webjs/ready` is readiness (503 until fully warm, i.e. analysis plus the
   first vendor attempt, then 200). An optional `readiness.{js,ts}` at the app
   root default-exports an async check that `/ready` runs once warm (returning
   `false` or throwing yields 503), so readiness can gate on live dependency
   health (e.g. a DB ping) that the static analysis cannot see. Both are
   answered in `handle()` BEFORE `ensureReady`, so a probe never blocks on the
   analysis.
   **Framework-internal static assets are also served before `ensureReady`.**
   `tryServeFrameworkStatic` (called in `handle()` right after the probes)
   serves `/__webjs/core/*` (the `@webjsdev/core` runtime, resolved from the
   boot-set `coreDir`), `/__webjs/reload.js`, and downloaded `/__webjs/vendor/*`
   bundles without awaiting the analysis or the vendor importmap, because they
   depend on neither. Otherwise a cold instance gated the core runtime (on every
   page's boot path) behind the first vendor resolve, stalling first
   interactivity site-wide for up to the jspm timeout (#190). Like the probes,
   these bypass app middleware (`state.middleware` is not even loaded until
   `ensureReady` completes); that is correct, since they are framework
   infrastructure the app needs to function, not app routes. `handleCore` keeps
   a fallback call so it stays correct if entered directly.
4. **One pluggable cache store, four built-in consumers.** `cache.js`
   is shared by `cache-fn.js`, `session.js` (store-backed), and
   `rate-limit.js`. A single `setStore(redisStore({…}))` call at
   startup switches all of them to Redis.
5. **`webjs check` is part of this package** (`src/check.js`). New
   rules go there; tests in `test/check.test.js`.
6. **No `node:*` imports in code reachable from the browser.** The
   browser bundle is built from `@webjsdev/core` only.
7. **Display-only component AND inert-route elision is conservative.**
   `analyzeElision` in `component-elision.js` computes, lazily on the
   first request (inside `ensureReady()`) and again after each rebuild,
   (a) the set of component modules that are purely
   display-only, and (b) the set of page/layout route modules that are
   inert (do no client work even transitively). The serving branch in
   `dev.js` strips side-effect imports of display-only components from the
   browser-served source; `ssr.js` drops inert page/layout modules from
   the boot script's `moduleUrls` entirely, so a fully-static route ships
   zero application JS. Preload hints for elided modules drop too, and their
   importmap entries drop too, for a live-resolved AND a pinned app alike. The
   live path excludes elided components from the bare-import scan; a committed
   `.webjs/vendor/importmap.json` is applied verbatim at boot (for a stable
   build id) and then, once elision is known, pruned to the specifiers still
   reachable from non-elided modules via `prunePinToReachable` in `ensureReady`
   (issue #197). So a pinned app and an unpinned app serve the SAME map. The
   advertised build id stays the boot-published hash of the committed pin (a
   deploy fingerprint) and is not re-published, so only the served map shrinks
   and the warmup window cannot drift the id.
   This is progressive-enhancement-safe by construction:
   the SSR'd HTML is the baseline, swap markers are static comments, and
   navigation/forms fall back to native browser behavior, so removing
   inert JS never changes behavior.
   The whole pass is gated by the project-level `webjs.elide` switch in
   `package.json` (`readElideEnabled` in `dev.js`, re-read on every
   rebuild). `{ "webjs": { "elide": false } }` skips `analyzeElision`
   entirely, leaving both sets empty so nothing is stripped and the
   importmap keeps every vendor dep. The switch is pure opt-out (default
   enabled); any value other than the literal `false` keeps elision on.
   A `WEBJS_ELIDE` env override wins over the `package.json` switch
   (`0`/`false`/`off`/`no` force off, `1`/`true`/`on`/`yes` force on, any
   other value falls through), the deploy-time escape hatch and the seam
   the differential elision test uses to render one app on and off in a
   single process. The invariant that elision never changes observable
   output is verified differentially by
   `test/elision/differential-elision.test.js` (SSR layer) plus the
   `differential elision` e2e cases: the same corpus routes rendered on vs
   off must yield identical SSR HTML (modulo the boot-script + modulepreload
   JS set) and identical post-hydration DOM and behaviour, so any wrong-strip
   fails the diff regardless of its cause (this is the continuous guard for
   the #169 / #179 bug classes).
   The analysis is a denylist that biases toward shipping: a false
   "display-only" verdict breaks the page, a false "interactive" verdict
   only misses an optimization, so anything ambiguous ships. The signal
   lists in `component-elision.js` are the single source of truth and
   must grow whenever core adds an interactivity surface (enforced by
   `test/elision/lifecycle-coverage.test.js`). Only side-effect imports
   are stripped; binding imports are always preserved. Tests live in
   `test/elision/`. Cross-module observation of an elided element's
   registration (a shipping `whenDefined('tag')`, a CSS `tag:defined`
   rule, or an `instanceof TheClass`) would break if the component were
   elided, since elision skips the `customElements.define`. The three
   statically visible forms ARE detected (`WHEN_DEFINED_RE`,
   `TAG_DEFINED_RE`, `INSTANCEOF_RE` in `component-elision.js`, the last
   mapped to a tag via the component class name): any graph-reachable
   module observing a tag forces that component to ship. Verdict-safe (it
   only ever ships more). The residual caveat is the part static analysis
   cannot see (a dynamic tag string, a `:defined` rule in an external
   stylesheet outside the module graph), documented in
   `agent-docs/components.md`; for those, add an interactivity signal.

## Tests

Tests for this package live in **`packages/server/test/`**,
organised by feature: `routing/`, `api/`, `actions/`, `auth/`,
`session/`, `cache/`, `rate-limit/`, `csrf/`, `cors/`,
`broadcast/`, `websocket/`, `check/`, `guardrails/`,
`module-graph/`, `scanner/`, `elision/`, `vendor/`, `env/`, `dev/`,
`forwarded/`, `body-limit/`, `redirects/`, `base-path/`, `file-storage/`,
`testing/`.

Cross-package tests that exercise the SSR pipeline, scaffolds,
or full app boots live at the repo root in `test/ssr/`,
`test/scaffolds/`, `test/examples/blog/`, etc. See
[`../../agent-docs/testing.md`](../../agent-docs/testing.md).

Run `npm test` from the repo root.

---

Framework-wide rules and full API reference:

@../../AGENTS.md
