AGENTS.md@packages/server · git:20261007.b7a14ad · 2026-10-07 · sha256 f38c21b05c1323ba

AGENTS.md@packages/server git:20261007.b7a14adC

Immutable. This exact content never changes and is served at /api/v1/blob/f38c21b05c1323ba.

# 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` | Barrel re-exporting dev server and request handler sub-modules (`dev/config.js`, `dev/helpers.js`, `dev/handler.js`, `dev/serve.js`, `dev/server.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. **Root middleware resolution:** `loadMiddleware` tries `middleware.ts` / `.js` / `.mts` / `.mjs` beside `appDir` in that order (`.ts` first, matching the dev supervisor's watch order). It used to look up the single literal `middleware.js`, so a root `middleware.ts`, what the scaffold writes and what the docs document, was silently never loaded: no error, no warning, indistinguishable from an app with no middleware. Tests: `test/dev/root-middleware-resolution.test.js` plus the cross-runtime `test/bun/root-middleware.mjs`. 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`; the page-render branch stamps its frame with `withBasePath(url.pathname, basePath()) + url.search` so the browser gate can compare it against `location` (the RAW pathname, and the base path put back, since the ingress strip removed it), SKIPS the hook entirely for a request carrying `x-webjs-prefetch: 1` so a speculative link prefetch never raises an overlay or becomes the retained error, and after a successful GET render clears `state.lastDevError` when it is still the same object AND names this url, so the replay cannot resurrect a superseded error while a good render of an unrelated page leaves a still-current one standing (#1047); `reloadClientJs` also calls `installDevOverlayNavSync()` so the overlay tracks the page on screen; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). **Graceful reload (#893):** a reload never paints into a half-restarted server (on Node the dev supervisor briefly restarts the process on an app edit), so the client gates every reload on a `/__webjs/version` readiness probe (probe-then-reload, instant under an in-process reload, waits out a restart), and a reconnect after a drop is itself treated as an edit signal so an app edit whose in-process reload frame was killed with the old process still reloads without a manual refresh (the SSE `hello` carries a short `retry: 300` so the reconnect is prompt). The reconnect-reload lives in the `dev-reload-worker.js` relay (shared connection) and the per-tab `__webjsDirectEvents` fallback. **Reload coalescing (#1397):** an agent saves several files a second or two apart, and EACH save produces TWO reload signals (the in-process `reload` frame from the fs.watch rebuild, then a changed boot id when the browser reconnects to the process the dev supervisor restarted, measured 429ms apart with 1071ms between saves), so acting on every one reloads into a server about to be killed again and the page ends up unstyled. Both signals now route through ONE debounced emitter in the relay, which reloads once the signals stop for `RELOAD_QUIET_MS` (2000ms, above the measured inter-save gap so a realistic burst collapses, and landing just past the 1900ms analysis warm so the wait overlaps work the reload would have blocked on anyway) or, for a batch made only of frames a server marked `inPlace` (it applies edits without restarting: Bun's one long-lived dev server, `--no-hot`), after `RELOAD_QUIET_IN_PLACE_MS` (300ms, #1575), or at the latest `RELOAD_MAX_HOLD_MS` (5000ms) after the FIRST signal of the batch, so a sustained burst still repaints rather than freezing on stale content. The cap timer is armed once per batch and never re-armed, which is what makes it measure from the first signal instead of sliding with the burst. `webjs-error` is NOT debounced (an overlay has to appear at once and it is not a reload) and the cached error is cleared on the signal rather than on the debounced emit, so a tab connecting mid-window is not replayed an overlay for an error the rebuild already fixed. The debounce must live browser-side because the server process dies on every edit, so no server-side timer can span a burst; the SharedWorker survives, being keyed by script URL. The per-tab `__webjsDirectEvents` fallback now RUNS THE SAME RELAY over a shim port rather than re-implementing the boot-id rule, so the boot-id rule, the error cache, and the debounce are one implementation with nothing to drift. Each tab still gates its reload on the `/__webjs/version` readiness probe, and `isRegenerateOutputPath` still suppresses a regenerate-output write upstream of the emitter. **Reload classification (#1398):** a full reload is heavier than most dev edits need, because pages and layouts never hydrate, so a freshly rendered page is the complete truth for them and nothing in the browser can be stale after a re-render. The watcher therefore CLASSIFIES the changed file (`dev-classify.js`) into a ternary verdict, and the browser picks the lightest correct response: `page` (morph the deepest shared boundary, keeping scroll and the hydrated state of components outside it), `shell` (re-render and replace the whole body, keeping scroll), or `reload` (exactly what it always did). The verdict walks the MODULE GRAPH, not the path shape, because a page that imports a client-effecting util ships whole (#605 / #963) and a util under `lib/utils/` reachable from a component is a component edit whose path says nothing. The input sets are derived in `ensureReady()` from the elision pass the analysis already ran: `state.shippedFiles` is the closure reachable from the entries elision KEEPS (not `browserBoundFiles`, which is the authorization gate and deliberately walks elided entries too), plus `state.graphFiles` and `state.pageFiles`. `startServer`'s watcher holds the STRONGEST verdict across its 80ms debounce window and hands it to `rebuild(verdict)`, which forwards it to `onReload`; a caller with no verdict (an embedded host calling `app.rebuild()`) reloads. The verdict is computed against the PREVIOUS build's graph, since the rebuild only invalidates the lazy analysis; the one case that could exploit that, an edit adding a brand-new component import to an otherwise morphable page, is closed on the client by `addNewHeadElements`, which swaps in the changed boot script. The SSE `reload` frame carries the verdict as a single-line JSON payload, and BOTH ends fail safe: an absent or malformed one is emitted as `reload` here and resolved to `reload` in the relay, so a running tab and a restarted server can never skew into a lighter response than the change deserves. The relay takes the STRONGEST verdict in a coalesced batch and never the last, and a changed boot id contributes `reload` unconditionally rather than no verdict, because a restart carries no filename and a burst can hold a page edit whose frame was delivered next to a component edit whose frame died with the old process. The REACH is narrower than it sounds and is stated honestly in the runtime docs: the morph needs the server process to survive the edit, so it applies on Bun's `bun --hot`, under `--no-hot`, and for edits outside Node's five restart dirs, while a Node dev restart is always a full reload. Component edits stay a full reload by design: `customElements.define` is once-per-tag, so a morph would apply new markup wired to the old class. **Stylesheet re-request (#1398):** a swap never re-requests a stylesheet on its own, because `mergeHead` preserves them unconditionally (#936), `addNewHeadElements` is add-only, and the dev href carries no content hash, so the link node is kept by identity. A full reload used to do that asking, which is how `webjs.dev.regenerate` (#967) ever ran, since it rebuilds a stale output ON REQUEST; without it an edit that adds a utility class morphs in with no backing rule until a manual reload. `dev-styles.js` is the browser half, inlined into the served client the same way `dev-overlay.js` and `dev-reload-worker.js` are, and an applied refresh calls it. Its load and error branches are deliberately ASYMMETRIC: on load the OLD link is dropped so the page never flashes unstyled, and on error the NEW one is dropped and the working sheet kept, since a failed re-request (a mid-restart server, a renamed file) must never leave the page with no stylesheet at all, which is the #936 / #1400 failure mode. It collapses duplicates by an identity of every attribute EXCEPT the href query, which folds away the bare-href link the head merge re-appends while keeping an author's `media`-scoped link to the same file. Behaviour test: `test/dev/browser/refresh-styles.test.js`, against real link elements and real load / error events. Tests: `test/dev/classify-watch-path.test.js` (the pure ladder), `test/dev/classify-live.test.js` (the frame end to end), `test/dev/browser/reload-worker.test.js` (the relay), and the cross-runtime `test/bun/dev-morph-verdict.mjs`. **Extra watch roots (#894):** `startServer`'s recursive `fs.watch` also follows the dirs in `webjs.dev.watch` (`readDevWatchPathsFromApp`), for content the app reads from OUTSIDE its appDir (blog markdown in a repo-root `blog/`). Dev-only: `reportDevError` early-returns in prod and `/__webjs/reload.js` 404s. **Listener shell (#511):** `startServer` builds a shared `SseHub` + `ListenerContext`, then selects a shell by `serverRuntime()`: `startNodeListener` (in this file, the node:http path: `toWebRequest` -> `app.handle` -> `sendWebResponse`, 103 Early Hints, node WS via `attachWebSocket`, node:http timeouts) on Node, or the dynamically-imported `startBunListener` (`listener-bun.js`) on Bun. The SSE registry/fanout, the live-reload predicate, the WS module loader, and the lifecycle wiring live in `listener-core.js` so the two shells share them. `isCompressible` (used by `sendWebResponse`) also moved there |
| `router.js` | Scans `app/` once, builds the route table, matches pages + APIs (`buildRouteTable`, `matchPage`, `matchApi`). Page order is set by `compareSpecificity` (#750): POSITIONAL specificity (per URL segment, static `0` < dynamic `1` < catch-all `2` via `segKind`, lexicographic on the kind arrays with shorter-prefix-first), so the catch-all kind is lowest AT ITS POSITION (a literal-prefixed catch-all like `docs/[[...slug]]` outranks an all-dynamic `[org]/[repo]`), NOT a global catch-all-last bucket, then a stable alphabetical `routeDir` tiebreak. This replaces the old coarse 3-bucket `dynScore` whose same-bucket ties resolved by fs-walk order (so `/[org]/[repo]` vs `/[user]/settings` could match the wrong page). `matchPage` returns the first pattern that matches in that deterministic order. The table also carries the app-ROOT convention files that are not router stems: `instrumentationClient` (#1399, resolved here rather than by each caller, because a consumer that built its own table and forgot to attach it lost a browser-bound entry and inverted the vendor pin-is-a-superset invariant) |
| `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` | Barrel re-exporting SSR render, document assembly, and head/metadata sub-modules (`ssr/render.js`, `ssr/document.js`, `ssr/head.js`, `ssr/responses.js`, `ssr/preloads.js`, `ssr/escape.js`, `ssr/env-shim.js`, `ssr/client-router-flag.js`). `ssr/responses.js` builds the cached, buffered and streaming Responses; `ssr/preloads.js` is the pure module-graph walk behind the preload hints; `ssr/escape.js` holds the ONE `escapeAttr` / `escapeHtml` pair the SSR path uses, which is deliberate: the split briefly produced three copies and two of them widened to escape `>` as well, which moves the served bytes and every ETag with them. `ssr/env-shim.js` and `ssr/client-router-flag.js` are leaves that exist so `head.js` and `render.js` can share them without importing each other. Nested layouts, metadata → `<head>`, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the form-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 build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the form-action re-render and partial-nav (`X-Webjs-Have`) requests. **Partial responses are never shared-cacheable (#1140):** a reduced `X-Webjs-Have` body is sliced by a request header, so `privateFragment(res)` rewrites its `Cache-Control` to `private` (stripping `public` / `s-maxage` / `proxy-revalidate`, and any qualified `private="field"`, which per RFC 9111 would leave the response shared-storable). `Vary: X-Webjs-Have` is still sent, but as belt-and-braces: it is not the guarantee, because Cloudflare and others honour only `Accept-Encoding`. The helper is quote-aware (a comma inside `no-cache="Set-Cookie,X-Foo"` is not a separator), fails CLOSED on an absent header (no `Cache-Control` is heuristically shared-storable), and is exported for unit testing (not re-exported from the package index, so it is not public API). The fragment KEEPS its ETag, which is why `conditional-get.js` no longer excludes `private`. A non-200 (the 422 form-action re-render, which carries the submitter's own field values) never inherits the page's `cacheControl`. **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. The sliced response is positively MARKED `x-webjs-frame: <id>` on the way out (#1407) and the two fall-throughs are not, because the client's speculative cache keys an entry by the frame it asked for and cannot infer the shape from the bytes (a page whose first element is a frame looks exactly like a subtree); a reduced `X-Webjs-Have` fragment is self-describing via its leading boundary comment, a frame subtree is not, so the server states it. **Vendor modulepreload (#754):** `reachedVendorSpecifiers(graph, shippedEntryFiles, componentUrls, appDir, elidable, serverFiles)` collects the bare specifiers (`bareImports(graph)`) reached by the page's SHIPPED modules. Its walk ROOTS are the boot's actually-shipped set: the caller passes the absolute paths of `moduleUrls` (which already drops INERT page/layout modules and substitutes an IMPORT-ONLY page with its components) plus the rendered `componentUrls`, then it walks the non-elided transitive closure and collects each reached file's bare imports, skipping server files (the `.server.*` suffix AND the `serverFiles` action index). Because the roots are the shipped set, a vendor reached ONLY through a dropped module, a dropped page's SSR-only DIRECT vendor import OR its SSR-only RELATIVE HELPER's vendor, is never collected (pages/layouts are not importable, so nothing that ships reaches them), so there is no over-fetch. `vendorPreloadTargets` (importmap.js) maps that set to `{ href, integrity }`, and `wrapHead` emits one `<link rel="modulepreload">` per target (no `fp()` rewrite, deduped against the app module/component preloads, with `crossorigin` + `integrity`), flattening the cross-origin CDN waterfall one level. Only reached + non-elided + pinned vendors are hinted. **App-module modulepreload shipped-roots (#780):** `deduplicatedPreloads` (the app-module + component preload walk) roots at the SAME shipped set as the vendor walk. `shippedRoots` (the absolute paths of `moduleUrls`) is computed once and passed to BOTH `deduplicatedPreloads` and `reachedVendorSpecifiers`, NOT the raw `[route.file, ...route.layouts]`. So a module reached ONLY through a dropped page/layout (its SSR-only DIRECT app import OR its SSR-only RELATIVE HELPER) gets no `modulepreload` hint, the app-module analog of the #754 vendor over-fetch. A module that also ships another way (a component shared with a live route, or one reached through an import-only page's substituted components) is still reached via a real shipped root, so its hint stays (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped page/layout URLs themselves, so this closes the TRANSITIVE gap only |
| `dev-error.js` | Dev error overlay frame builder (#264). `buildDevErrorFrame(error, { kind, appDir, file?, line?, hint?, url? })` returns a JSON-serializable frame (message, parsed `file`/`line`/`column`, a source `codeFrame`, an optional `hint`, and an optional `url`); `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. `url` (#1047) is the request URL that produced the frame, `null` when absent: only a `render` frame carries one, and it is what lets the browser overlay refuse a frame for a page the tab is not viewing. A `ts-strip` / `rebuild` frame passes none and stays unscoped, because it describes a still-broken build rather than one page. 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 |
| `dev-styles.js` | The stylesheet re-request for the in-place dev refresh (#1398, #1535), the BROWSER half, a browser-safe module (no node imports) that `reloadClientJs` inlines verbatim after an `export`-keyword strip, so the browser test drives the exact shipping code. `preloadStyles(root?, now?, timeoutMs?)` inserts a cache-busted replacement BESIDE every same-origin stylesheet and resolves once each has loaded or failed (or after `timeoutMs`, default 10s); the reload client swaps the markup in only THEN and calls the returned `commit()`, which drops each old link whose replacement loaded plus any same-identity copy the head merge appended. Swapping first (the old `refreshStyles` order) painted a new utility class with no rule for the whole `webjs.dev.regenerate` compile (56 frames for an 800ms compile, #1535). A replacement that fails removes itself and the working sheet stays (#936, #1400). De-dupes by every attribute except the href query, so a `media="print"` sibling is kept. Tests: `test/dev/browser/refresh-styles.test.js`, `test/e2e/dev-css-flash.test.mjs` (frame sampler, Node and `WEBJS_E2E_RUNTIME=bun`) |
| `port-in-use.js` | A taken port fails fast (#1527). `reusePortRequested()` reads `WEBJS_REUSE_PORT` (the only way either listener sets `SO_REUSEPORT`; Bun's own default with `development: false` is on, which let a second server bind silently). `portInUseError(port, cause)` builds the `EADDRINUSE` error both listeners throw, its message naming the holder found by `findPortHolder` (`/proc/net/tcp{,6}` LISTEN inode, then `/proc/*/fd`; best effort, Linux only, null otherwise). `startNodeListener` now awaits the bind, so a failure rejects `startServer`; the CLI prints it and exits 98, which the dev supervisor treats as final. Tests: `test/listener/port-in-use.test.js`, `test/bun/port-in-use.mjs`. |
| `dev/watch-tree.js` | The dev live-reload watcher (#1521). `watchTree(root, { ignore, onEvent, onError })` watches the root non-recursively plus each non-ignored top-level dir recursively through `dev/watch-recursive.js` (#1529: on Linux under Node one non-recursive watch per directory, because Node 24's per-file recursive watcher goes deaf to a file once `sed -i` or an atomic save replaces it; a byte-identical copy lives in `@webjsdev/cli` `lib/`) (a dir created later is followed), so `node_modules` is never walked (about 275ms and 10k per-file watches per boot on Node 24 Linux before), and every `fs.watch` error goes to `onError` instead of becoming an uncaughtException. `isWatchError(err)` (`syscall === 'watch'`) is what `installProcessHandlers` (`listener-core.js`) checks in dev to log a stray watcher error as a warning instead of shutting down; prod keeps every uncaught exception fatal. Tests: `test/dev/watch-tree.test.js`, `test/dev/watch-error-guard.test.js`, `test/bun/dev-watch-error.mjs`. |
| `dev/watch-ignore.js` | What the dev watcher never turns into a rebuild or a reload beyond the framework's own artefacts: tool output nothing serves (`*.log`, `coverage/`, `.cache/`, `.nyc_output/`, `test-results/`, `playwright-report/`, `.turbo/`, swap and backup files, `.DS_Store`) and whatever the watched root's `.gitignore` ignores (the common subset: comments, `!`, trailing `/`, anchoring `/`, `*` `**` `?`; `.env*` excluded since the server reads it). `npm run dev > dev.log` used to reload the page on every log line. The CLI supervisor's `shouldIgnoreRestartPath` skips the same output. Tests: `test/dev/watch-ignore.test.js`, cross-runtime `test/bun/dev-ignore-unserved.mjs`; the release gate writes a `dev.log` and a `coverage/` file after its edits and asserts no reload frame. |
| `dev-classify.js` | The dev live-reload classifier (#1398). Pure, state-free, and import-free so the ladder is unit-testable without booting a server. `classifyChangedPath(abs, ctx)` returns `{ v, by, why }` off a first-match ladder: a cold analysis reloads (Vite's pessimistic seed), a path outside `appDir` is an extra `webjs.dev.watch` root and takes `shell`, a path in the SHIPPED closure reloads (the transitive answer, and the rung that catches a page which ships whole and an action a shipping component imports), a page file morphs, anything else the graph walked is server-only and takes `shell`, and everything else reloads (a new file, a deleted file, and every `public/` asset, which reloads because `public/` is outside the module graph so the server cannot tell what the file is or what depends on it). `strongerVerdict(a, b)` compares by index into `RELOAD_VERDICTS` (`['reload', 'shell', 'page']`, strongest first), treating `null` as weakest and an unrecognised name as strongest, so a batch can only ever over-reload |
| `dev-overlay.js` | The BROWSER half of the dev error overlay (#264), a browser-safe module (no node imports) that `reloadClientJs` inlines verbatim after an `export`-keyword strip, so the browser test drives the exact shipping code. `renderDevOverlay(frame, currentPath?)` builds the card with `createElement` + `textContent` ONLY (never `innerHTML`, so a hostile message / path / code frame is inert text); `dismissDevOverlay()` takes it down and forgets any held frame, which is what the Dismiss button calls. **URL scope (#1047):** a url-stamped `render` frame for a different path than `currentPath` (default `location.pathname + location.search`, compared encoding-tolerantly) is NOT rendered. The gate runs BEFORE any removal, so a refused frame neither paints nor wipes a live `rebuild` / `ts-strip` overlay. It cannot simply drop the frame either, because the SSE frame is pushed during the render, before the navigation response is sent, so it arrives while `location` is still the old page: a refused frame goes to a PENDING slot that `syncDevOverlayToLocation(currentPath?)` re-evaluates on the next navigation, which renders it only if the url matches AND it arrived during the navigation now finishing, and drops it otherwise (consume-once, so retention is bounded with no timer). The second half of that condition is what stops a frame held from an IDLE render (another tab's page, a background fetch of some other url, never a link prefetch since that reports nothing) painting on a later visit to that url, when the page may render fine; `markDevOverlayNavStart()` is the nav-start marker it counts. `installDevOverlayNavSync(opts?)` wires all of it to the client router's `webjs:navigate` (applied nav) + `popstate` (a snapshot-cache restore returns before `webjs:navigate`) + `webjs:before-cache` (the nav START, since `snapshotCurrent` runs at the top of every navigation and form submission), and returns an uninstall thunk. Two things keep a snapshot copy of the overlay from becoming an undismissable card (the router's tier-4 popstate restore `replaceChildren`s the body from cached HTML, and a parsed copy is not the node this module holds, so nothing could remove it and its Dismiss button has no listener). `before-cache` DETACHES the overlay across the router's synchronous `outerHTML` read and re-attaches it a microtask later, so the cached HTML carries none. That is what covers the hard ordering: under an opt-in view transition the router defers the body swap PAST the sync, so a copy the swap inserts would arrive after any sweep had already run. The sync additionally SWEEPS any `[data-webjs-error-overlay]` element it does not own, as the backstop for a copy from anywhere else (a snapshot cached before this client installed its listeners, or any other wholesale body replacement). What `before-cache` must NOT do is strip the overlay for good, which the event's own contract invites: it fires on EVERY navigation, so that would tear a `rebuild` / `ts-strip` overlay off the page on any link click while the build was still broken. `packages/core` is untouched: the fix rides existing events |
| `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 |
| `form-dispatch.js` | Form-submission dispatch (#1155, replacing the page `action` export of #244): `runFormAction` verifies Origin, parses the bounded form body, resolves the `__webjs_action` identity, runs the action's declared `validate` + `middleware`, evicts its `invalidates` tags, and maps the `ActionResult` to a response (303 PRG on success, 422 re-render with `actionData` on failure, honoring thrown `redirect()`/`notFound()`/`forbidden()`/`unauthorized()`). An action that returns a `Response` DIRECTLY (e.g. a content-negotiated `streamResponse`, #248) is honored verbatim; a STREAMED return is refused (a submission has no frame consumer). A submission carrying no identity is a 405, an unresolvable hash is a 422 re-render with `ACTION_SKEW_MESSAGE` plus the typed values, and a module that throws at import is a sanitized 500 (never mislabelled as skew). `dev.js` routes EVERY non-GET/HEAD page request here, wrapped in the page's segment middleware. **Two detect-only `onError` signals (#1307):** `WEBJS_FORM_ACTION_MISSING` for a parseable form body carrying no identity (the 405 above), and `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its QUERY STRING, which means a submission holding a bound action's identity went out as a GET so the action never ran. Nothing in the framework puts that field in a url, and a bound submitter now carries its own `formmethod="post"`, so what reaches the second signal is an explicit `formmethod="get"` / `method="get"` the author wrote and the renderer deliberately honours; it is the production counterpart to the dev-time client guard. Both keep rendering their normal response (answering differently on a query parameter would let any visitor turn any page into a different status), carry field NAMES and never values, and dedupe per process on code plus method plus matched ROUTE with a 256-entry cap. Keying on the route PATTERN rather than the pathname is load-bearing: keyed on the pathname a dynamic route yields unbounded keys, so a few hundred crafted requests would fill the cap and permanently silence the diagnostics |
| `form-action-identity.js` | The `<hash>/<fn>` identity a bound form submits (#1155). `resolveActionIdentity` maps a real action function to its identity via the always-on `'use server'` load-hook registry (`action-seed.js`), with a module-scan fallback only on a runtime that installs no hook; `lookupActionIdentity` maps a submitted identity back to `{ file, fnName, module }`, distinguishing an unknown hash (skew) from an unknown export (404) and from an import failure (`load-failed`). The scheme is the RPC endpoint's, so both transports resolve the same string to the same function |
| `action-seed.js` | SSR action-result seeding (#472, Bun-enabled #529). `registerActionHooks({ seed, dev })` (async, `dev` gating only the determinism assertion below) installs a load hook chosen by `serverRuntime()`: Node's synchronous `module.registerHooks`, or a `Bun.plugin` `onLoad` on Bun (the glue is in `action-seed-bun.js`, dynamically imported so `Bun.*` never loads on Node). For a `'use server'` `*.server.*` module the hook returns a transparent FACADE re-exporting each function wrapped in a `Proxy` (`__actionWrap`); the faceting decision (`isSeedCandidate`) + facade source (`buildSeedFacade`) are runtime-neutral, so both runtimes emit the identical seed. (Bun's `onLoad` must return an object for every filter match, so the non-facet cases serve the raw source.) The Proxy records `(file, fn, args) -> result` into an ambient `AsyncLocalStorage` collector when one is active (a pure passthrough otherwise, so the RPC endpoint path is untouched, and any metadata attached to the function forwards through the Proxy). `collectSeeds(fn)` runs the SSR render inside a fresh collector; `buildSeedScript(collector, { dev, reason })` serializes it into an HTML-escaped `<script type="application/json" id="__webjs-seeds">` block (keyed `hashFile(file)/fn/stringify(args)`, the exact key the client stub looks up); in DEV it stamps a `data-webjs-dev` marker (`ok` / `streamed` / `drop`) and emits the block even when EMPTY, which is the only dev signal the browser can trust (#1309); a serializer throw emits the exported `SEED_DROP_BLOCK` so the browser can name that cause, and `ssr.js` excludes it from `emitted` so the header still reports the divergence. Dev observability (#1309): `recordSeed` warns once per action function when one render records two DIFFERENT results for the same FULL key (compared `Object.is` then through the same serializer, in its own try/catch so a diagnostic failure never drops the seed), and `ssr.js` stamps `X-Webjs-Seed` (`off` / `html-cache` / `collected=<m>, emitted=<n>` / `... streamed`) which `dev.js` folds into the access-log line as `seed`. `seedingEnabled()` gates the `ssr.js` emitter. Installed at boot by `dev.js` UNCONDITIONALLY, because action IDENTITY rides the same hook (#1155: `<form action=${action}>` resolves through `actionIdentitiesOf`, which returns EVERY `{file, fnName}` a function was exported as, defining module first, so the form resolver can prefer the defining module and fall back to an indexed re-exporter; `webjs.seed: false` must not break a no-JS form). Only seed COLLECTION follows the switch (`readSeedEnabled`, default on; `webjs.seed` / `WEBJS_SEED`), and with it off `__actionWrap` registers identity and returns the function unwrapped rather than building a Proxy. Fail-open on every axis: a key miss degrades to a normal RPC (never wrong data), and the facade emits an `export * from '?webjs-seed-orig'` catch-all (#535) so a named export the `extractExportNames` regex MISSES (a destructuring `export const { a }`, an exotic re-export) flows through unwrapped (it still works over a normal RPC, just is not seeded) instead of resolving to `undefined` and crashing the importer. An explicit wrapped `export const NAME` shadows the matching star binding, so an enumerated export is still wrapped and seeded. The per-runtime hook split (Node `registerHooks` vs `Bun.plugin`) is intentional and irreducible: intercepting a native ESM import has no shared API, and a load-hook facade runs pre-evaluation so it cannot use authoritative runtime enumeration. A streamable result (#489) is skipped (`isStreamable`), so a streaming action invoked during SSR neither records a non-serializable value nor drops the page's other seeds. No source transform, no build step |
| `action-seed-bun.js` | Bun install for SSR action seeding (#529). `installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe })` registers a `Bun.plugin` `onLoad` (the Bun analog of Node's `module.registerHooks` load hook), dynamically imported by `registerActionHooks` only on Bun so the `Bun.*` global never loads on Node (same isolation as `listener-bun.js`). Reuses the runtime-neutral faceting helpers; the only Bun glue is the plugin shell + `Bun.file` source read. Note: a Bun `onLoad` MUST return a `{ contents, loader }` for every filter match (returning `undefined` to defer is an error), so the passthrough / non-`use server` / facet-skip cases serve the raw source with the extension loader. |
| `actions.js` | `.server.js` / `.server.ts` scanner. Generates RPC stubs for browser-bound imports and serves the RPC endpoints. `buildActionIndex` is a pure file -> hash mapping (loads no module). The generated stub reads the SSR seed before its `fetch` (#472): `__call(fn, args)` computes `stringify(args)` (both the seed lookup key and the RPC body) and returns `takeSeed(hash, fn, key)` on a hit, else POSTs; so a seeded first call does no network and a later refetch / arg-change misses and goes to RPC. **Input validation (#245):** `runValidate(validate, input)` is the shared seam the RPC path and the `route()` REST adapter route through, so a validator declared via the `validate` config export (#488) 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 (#749: `actionErrorResponse` returns a GENERIC message + a correlation `digest` in prod via `action-error.js`, never the raw message, the full error logged server-side; `redirect()` / `notFound()` sentinels pass through). A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter in `action-route.js`). **Streaming results (#489):** when a completed action returns a streamable value (`isStreamable` from `action-stream.js`), `invokeAction` returns `streamActionResponse` instead of buffering; the generated stub carries a `__readStream` decode path its `__handle` dispatches to on the `application/vnd.webjs+stream` content type |
| `action-route.js` | The optional `route(action, opts?)` adapter (#488): wraps a plain `'use server'` action as a `route.ts`-style handler `(req, ctx?) => Promise<Response>`, merging `{ ...query, ...params, ...jsonBody }` into the single input object, running the optional `opts.validate` through the shared `runValidate` seam (422 on a structured failure, 400 on a thrown validator), and dispatching through `runWithActionSignal` + the `opts.middleware` chain. A returned `Response` passes through verbatim; otherwise the result is `Response.json`'d. The always-works baseline is a hand-written `route.ts` that imports and calls the action; this adapter is the one-liner convenience |
| `action-stream.js` | Streaming RPC results (#489), server side. `isStreamable(value)` detects a return value that should stream (a web `ReadableStream`, an async iterable, an async generator, a Node `Readable`; NOT a plain object / array / string / Promise / ActionResult). `streamActionResponse(source, { signal, onError, headers })` builds the `application/vnd.webjs+stream` `Response`: a back-pressured `ReadableStream` of frames (one rich-serialized CHUNK per source value via core's `encodeFrame`, a terminal END, or an ERROR frame carrying a SANITIZED message if the source throws mid-flight (#749: a generic message + a `digest` in prod via `action-error.js`, the real message in dev, a `redirect()` / `notFound()` sentinel passed through), since the 200 status is already sent). Back-pressure is real (chunks are pulled + serialized one at a time as the consumer reads); an aborted request signal (#492, client disconnect / superseded render) returns the source iterator, stopping a server generator. Wired in `actions.js`'s `invokeAction` after the action runs: a COMPLETED action whose result `isStreamable` streams (never cached / ETagged / seeded; a mutation still emits `X-Webjs-Invalidate`), a middleware short-circuit falls through to normal verb handling. The generated client stub's `__handle` branches on the stream content type and returns an async iterable the caller `for await`s |
| `action-error.js` | Server-action error sanitization (#749). `errorDigest(err)` is a short correlation hash (Web Crypto via `crypto-utils.js`) of the error message + stack, returned to the client AND logged server-side so a generic client error maps to the full log line; `isControlFlowThrow(err)` detects the `redirect()` / `notFound()` `__webjs` sentinels (passed through, not sanitized); `GENERIC_ERROR_MESSAGE` is the client-facing prod string. A leaf module so both the buffered path (`actions.js` `actionErrorResponse`) and the streaming path (`action-stream.js` error frame) import it without a cycle. The prod contract: a thrown action NEVER returns its raw message to the client (Postgres constraint names, internal IPs, fs paths are not author-controlled); a user-facing message rides the `ActionResult` `{ success: false, error }` envelope instead. The generated client stub and `invokeActionForTest` attach `digest` to the thrown error |
| `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 cached VALUE and the arg-fingerprint KEY both go through `@webjsdev/core` `stringify` / `parse` (the same rich format the RPC wire uses), NOT JSON, so a warm hit is byte-faithful to a cold miss (a `Date` stays a `Date`, a `Map` stays a `Map`) and distinct `Map` / `Set` args do not collide to one key (#748). It uses core `stringify` / `parse` DIRECTLY, not `getSerializer()`, so cache fidelity is independent of any custom wire serializer. The value key carries a format-version segment (`cache:<CACHE_FORMAT>:<prefix>[:<args>]`, distinct from the `cache:tag:` index namespace), so a pre-upgrade old-format (unversioned JSON) entry is a guaranteed MISS and expires by its own TTL rather than being deserialized as a lossy value; bump `CACHE_FORMAT` on any future encoding change. 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 the set 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. **Atomic when the store exposes `setAdd` / `setMembers` (#752):** the index is a real SET (`SADD` on Redis, a native `Set` in the memory store), so a concurrent add to the same tag (across Redis instances OR interleaved in-process at the old read/write await gap) cannot lose an entry, and `revalidateTag` reliably evicts every tagged key. A custom store WITHOUT those optional primitives falls back to the previous non-atomic JSON-array read-modify-write (the #241 caveat: prefer a short `ttl` as the cross-instance floor). The data-side companion to `html-cache.js`'s `revalidatePath` (HTML side, still carries the older non-atomic caveat as a separate follow-up); together they are the server cache invalidation surface |
| `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 `Set-Cookie`; 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. **The key also carries `url.origin` (#1097).** `ctx.url` is built from `X-Forwarded-Host` / `X-Forwarded-Proto`, which a proxy forwards rather than strips, so without the origin in the key one request carrying `X-Forwarded-Host: evil.example` baked an attacker-chosen origin into the SHARED body and every later visitor to that path was served it (a poisoned og:image / canonical / OAuth callback / absolute asset URL) until the entry expired. A single-host deploy has one origin and so one key per URL (unchanged hit rate); a multi-host deploy gets correctly separated entries. `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`). Because a bare path no longer names ONE key, `revalidatePath` resolves the origin from the AMBIENT REQUEST (exact for the ordinary server-action flow, since the mutation runs inside the triggering user's request, which is on the same origin as the pages being evicted), and an ABSOLUTE url names it outright for a caller with no ambient request (a cron job, a queue worker). A bare path with no ambient request WARNS ONCE naming the path instead of silently evicting nothing. The absolute form strips `webjs.basePath` (entries key on the app-root-relative path, since the WRITE strips it), so the caller passes the PUBLIC url and still hits the stored entry. There is deliberately NO process-local registry of origins-seen standing in for the absolute form: the origin comes from the client-supplied `X-Forwarded-Host`, so such a registry is attacker-writable, and a flood of hostile hosts against a cold process would either fill a bounded one before the real origin was ever seen or grow an unbounded one, silently breaking eviction either way |
| `rate-limit.js` | `rateLimit({ window, max })` middleware factory |
| `cors.js` | `cors({ origin, credentials, methods, allowedHeaders, exposedHeaders, maxAge })` middleware factory for route handlers / `middleware.js`. Exports a shared origin-resolution + header-building core (`resolveOrigin` / `applyCorsHeaders`). Enforces the CORS-spec rule that `credentials: true` forbids a wildcard ACAO (narrows `*` to the reflected origin). |
| `csrf.js` | Origin / `Sec-Fetch-Site` CSRF protection for server-action endpoints (`verifyOrigin`), plus `readAllowedOrigins` (the `webjs.allowedOrigins` reader). No token cookie, so SSR HTML carries no `Set-Cookie` and a public-`Cache-Control` page is CDN-cacheable. `requestHost` resolves the request host through `forwarded.js`'s `trustProxy()` (#1104) rather than reading `x-forwarded-host` unconditionally, so this file holds no trust posture of its own. It was never a CSRF hole (a browser cannot set `x-forwarded-host` or `Origin`, and a non-browser client that can carries no victim SameSite cookies; the rationale is preserved in the source), the fix is that the flag now means one thing across the package. `requestHost` feeds ONLY the legacy no-`Sec-Fetch-Site` fallback, so the primary path is unchanged; with the flag set on a proxied deploy (a misconfiguration) that fallback compares `Origin` against the raw `Host` and can now reject a legitimate cross-host request, which is the flag's intended meaning |
| `websocket.js` | node:http WS upgrade handling: invokes the `WS` export from `route.ts` over the `ws` library. Shares the `route.ts` module resolution (`loadWsModule`) with the Bun WS path via `listener-core.js`. **IP-trust on the upgrade (#778):** `buildRequestFromUpgrade` strips any inbound (spoofable) `x-webjs-remote-ip` and stamps the real `req.socket.remoteAddress` via `setTrustedRemoteIp` (the authoritative WeakMap `clientIp` reads first), so a `WS` handler calling `clientIp(req)` gets the trusted peer, not a client-supplied header. The WS-seam analog of the #773 fetch-path fix (the upgrade check runs before the fetch handler's stamp, so it was never stamped before). |
| `listener-core.js` | Runtime-neutral listener core (#511): the pieces both listener shells share so they cannot drift. `serverRuntime()` picks the shell (`'bun'` when `process.versions.bun` is set, else `'node'`); `SseHub` is the SSE registry + fanout (the connected-client Set, `helloFrame()`, `reload(verdict)` / `devError()`, and NO keepalive since #1507: the stream is silent between events and each shell switches its idle timeout off for that one request, `server.timeout(req, 0)` on Bun and `setTimeout(0)` on node, so a host that sleeps on network quiet can sleep; `reload` also stamps a per-process `seq` that `hello` repeats, so a relay that paused while every tab was hidden can tell it missed an edit; each shell supplying a thin client wrapper over its own transport; since #1398 `reload` emits the change's `{ v, by, why }` classification as a single-line JSON `data:` payload, matching `devError`, and an absent or malformed verdict is emitted as `reload` so the fail-safe holds on the emitting end too); `isEventsPath` is the base-path-aware live-reload predicate; `isCompressible` is the shared compressible-media-type set; the **compression seam (#517, #756)** `negotiateEncoding` (prefers `br` > `gzip` > `deflate`) + `createCompressor` (a streaming `node:zlib` Transform, native on Bun, so BOTH shells get brotli) + `compressBufferSync` (the SYNC counterpart for an already-buffered body, byte-identical output) + `readBufferedOrStream` (peeks a body to classify single-bounded-chunk buffered vs genuinely streamed, so the Bun shell can sync-compress a buffered body and skip the stream bridge, #756; it RACES the classifying second read against a macrotask sentinel rather than awaiting it, so a genuinely streamed body, Suspense / streamed action, is handed back immediately and its response head + first byte are NOT withheld until the far-off second chunk) + `varyWithAcceptEncoding` are shared so the node and Bun shells compress identically; `loadWsModule` loads a `route.ts` for its `WS` export (shared by `websocket.js` and the Bun WS path); `installProcessHandlers` + `makeShutdown` are the neutral process-handler + graceful-shutdown wiring (`makeShutdown` takes a `closeServer()` thunk so node `server.close` and Bun `server.stop(true)` both fit). **The shutdown's exit code (#1092):** a FAILED drain is always a failure, so a rejected `closeServer()` and a drain still hanging at the 10s deadline both exit 1 whatever started them; when the drain SUCCEEDS the code reports WHY the process is going down, so a signal-driven stop exits 0 provided nothing crashed while it drained and a FATAL one (`installProcessHandlers` calling `onFatal`, which passes `{ fatal: true }`) exits 1 even though the drain was clean, since a supervisor reads only the code (systemd `Restart=on-failure`, Docker, Railway). A fatal arriving MID-DRAIN upgrades the verdict too (the re-entrancy guard drops the second call, so the code is read at exit time rather than captured at entry; upgrade-only, so a later signal cannot launder a crash into a success). So 0 means an operator asked for the stop AND it drained cleanly AND nothing crashed on the way out. It used to exit 0 unconditionally, which also swallowed every `test/bun/*.mjs` proof script's failed assertion (a top-level throw arrives at the `uncaughtException` handler on BOTH runtimes, and those scripts pass a `quiet` logger, so the CI step went green and silent on a real regression). Guarded cross-runtime by `test/bun/proof-exit-code.mjs`, which asserts all three arms (a failed assertion is non-zero, a passing script is 0, SIGTERM is still 0), so the guard cannot pass by making everything fail. |
| `listener-bun.js` | The `Bun.serve` listener shell (#511), dynamically imported by `dev.js` only when `serverRuntime()` is `'bun'` (so the `Bun.*` global is never referenced on Node). `startBunListener(ctx)` hands the app's `handle(req): Response` straight to `Bun.serve({ fetch })`, skipping the node:http `toWebRequest` / `sendWebResponse` bridge (~1.9x more req/s on the LISTENING PATH ONLY, a trivial-handler microbenchmark, NOT end-to-end: render dominates a real SSR page, see `scripts/bench-listener.mjs`, #756). **Hot-path overhead reductions (#756):** the remote IP is stamped OUT OF BAND via `setTrustedRemoteIp` (a WeakMap `clientIp` reads in preference to the header), eliminating the per-request `new Request(req, { headers })` clone; and a BUFFERED response (peeked via `readBufferedOrStream`) is compressed SYNCHRONOUSLY (`compressBufferSync`), skipping the per-response web -> node -> web stream bridge, which remains only for genuinely streamed bodies (the sync path and the streaming bridge share the same algo + params, so WITHIN a runtime a buffered body and a streamed one compress identically; across runtimes the exact gzip/deflate bytes can differ since Bun's bundled zlib is not Node's build, which is fine as each response is self-describing via `content-encoding`). Feature parity via the shared core: SSE over a streaming `Response`, WS upgrade via `server.upgrade` with a `BunWsAdapter` that re-exposes the node `ws`-library EventEmitter contract (`.on('message')` / `.send()` / `.readyState`) over Bun's `ServerWebSocket` (the handler request is IP-stamped from `server.requestIP(req)` via `setTrustedRemoteIp` and `upgradeRequest` drops any inbound `x-webjs-remote-ip`, so `clientIp` in a `WS` handler is spoof-proof, #778, mirroring the fetch path's `stampRemoteIp`), **brotli/gzip/deflate via `node:zlib`** (the shared `createCompressor` / `compressBufferSync`, native on Bun, so the Bun path serves brotli too, #517), the #237 timeout mapped to Bun's single `idleTimeout`, and proxy-IP via `server.requestIP`. **Reverse-proxy URL correction (#1090):** `Bun.serve` hands over a `Request` whose url is the internal `http://container` view, so `forwardedRequest` rebuilds it from `applyForwarded` (`forwarded.js`) when `X-Forwarded-Proto` / `X-Forwarded-Host` change the origin, the Bun analog of the node shell building its `Request` from `urlFromRequest`. The rebuild is skipped (same URL instance returned) when nothing changes, so an unproxied app keeps the #756 no-clone hot path; the trusted IP rides across via `propagateTrustedRemoteIp`, and `bunUpgrade` applies the same correction to the WS handler request. Without it every absolute URL an app derived came out `http://` behind a TLS-terminating proxy. 103 Early Hints are node-only (Bun.serve has no informational-response API). |
| `forwarded.js` | Reverse-proxy URL resolution (`X-Forwarded-Proto` / `X-Forwarded-Host`), and the OWNER of the package's proxy-trust posture. `trustProxy()` is the one place `WEBJS_NO_TRUST_PROXY=1` is read (#1104), consumed by all three readers of the forwarded HOST / PROTO: `readForwarded` here, `webRequestIsHttps` (headers.js), and `requestHost` (csrf.js), which used to read the header unconditionally so the flag was honored in two places out of three. `clientIp` (rate-limit.js) is a consumer too (#1254), so the flag is now ABSOLUTE across every forwarded-header read in the package: its per-call `rateLimit({ trustProxy: true })` opt-in reads `x-forwarded-for` / `cf-connecting-ip` / `x-real-ip` only while the flag is unset, and under the flag it falls back to the framework-stamped peer and warns once. The direction rule is that the env switch only ever SUBTRACTS trust and the per-call option can only add it, so an over-firing override degrades to a shared bucket instead of leaving a limiter keyed on a client-chosen header. Takes the first value of a comma-separated chain (the value closest to the client). Two entry points over a shared `readForwarded` (trust switch, allowed schemes, comma-chain rule) + `resolveOrigin` (host layering), so the HEADER and ORIGIN decisions cannot drift (what each shell RECEIVES still differs: node gets an origin-form target it treats as a path, Bun gets a url its own parser resolved, so an absolute-form request line routes differently between them; the origin is decided identically for both, and a client authority that disagrees with `Host` is discarded): `urlFromRequest(req)` for a node `IncomingMessage` (used by `startNodeListener` + `websocket.js`) and `applyForwarded(url, headers)` for a web `Request`'s `Headers` (used by the Bun shell, #1090). `applyForwarded` returns the SAME url instance when nothing changes, which is the caller's signal to skip rebuilding the request. Without this, a TLS-terminating proxy leaves `ctx.url.origin` as `http://internal-host`, breaking og:image tags, OAuth callback URLs, canonical tags, and any app code building an absolute URL. **Trust-model caveat the module header spells out (#1097):** the "an attacker cannot inject these without going through the proxy" argument holds for `X-Forwarded-Proto`, which a proxy always overwrites, but NOT for `X-Forwarded-Host`, which neither Cloudflare nor Railway strips. So anything SHARED that is derived from the resulting origin must be keyed by that origin, which is why `html-cache.js` folds `url.origin` into every key. |
| `listener-types.js` | Types only: the `ListenerContext` typedef `startServer` passes to whichever shell it selects (the node:http path in `dev.js`, the Bun path in `listener-bun.js`). |
| `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). Every drop warns via a module-private `warnDrop`, at BOTH levels: the `webjs.redirects` key when present but not an array, and each malformed entry. An ABSENT key is the default and stays silent, which is why the presence test reads the block explicitly instead of off an `&&` chain (a chain short-circuits to whatever link failed, so it warned about a key nobody wrote). `headers.js` mirrors the PRESENCE test exactly, but has a third drop level (config, rule, directive) where redirects has two, since a redirect entry carries no directives. `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 carry it, plus two further `stripBasePath` consumers on the CACHE path (`dev.js`'s HTML-cache write and `html-cache.js`'s `revalidatePath`, both keying on the app-root-relative path so a mounted app hits its own cache): `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; that rewrite is a FRESH Request, so it strips the inbound `x-webjs-remote-ip` header and carries the framework-trusted IP forward via `propagateTrustedRemoteIp` (#773), else on Bun, where the listener stamps the IP out of band on the ORIGINAL request, `clientIp` would fall back to the spoofable copied header); 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. **`resolveAssetUrl(path, basePath?)` (#1194)** is the server body behind core's isomorphic `asset()` helper (installed via `setAssetUrlProvider` at `dev.js` boot), and it carries a STRICTER contract than `withAssetHash`: it resolves ONLY `public/` paths and refuses traversal, because an app may pass request- or user-derived data (`asset(user.avatarPath)`), and `resolveUrlToFile` would otherwise map any same-origin path under the PROJECT ROOT, publishing a content hash for `/.env` or a `.server.ts` and leaking both its existence and a fingerprint of its bytes. It also splits a `#fragment` before hashing, so the query lands ahead of it. Anything it refuses is returned unchanged WITHOUT touching the disk. |
| `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. |
| `webjs-config-validate.js` | Boot-time validation of the app's `webjs` package.json block (#1300). `validateWebjsBlock(schema, value)` is the PURE structural validator (top-level key membership under `additionalProperties: false`, `enum` membership, `boolean` / `integer` leaves), promoted out of `test/config/webjs-config-schema.test.js` so the boot path could use it too; that test now imports it and its assertions run byte-identically, which is what proves the move was faithful. `validateAppWebjsConfig(pkg)` loads the packaged `webjs-config.schema.json` (memoized) and returns one message per problem, `[]` for an absent or non-object block. `dev.js`'s `warnOnInvalidWebjsConfig(appDir, logger)` calls it once from `createRequestHandler`, so dev, prod, and an embedded host share one call site, and emits ONE aggregated `logger.warn`. It WARNS and never throws: a typo costs one feature its default, a boot failure costs the whole app. Deliberately NOT a replacement for the CLI's `readDoctorPolicy` gate validation, which runs where the server may not resolve (#954), checks `DOCTOR_CODES` membership the schema cannot express, and fails CLOSED. Adds NO `webjs.*` key, so the three-surface lockstep below and the `KNOWN_KEYS` drift test are untouched. Tests: `test/config/webjs-config-validate.test.js`, `test/bun/webjs-config-validate.mjs` |
| `dev-source-locations.js` | Dev source locations (#1499). `annotateSourceLocations(source, file)` lexes a JS / TS module (comments, strings, regex vs division, nested template literals) and inserts ` data-webjs-src="<file>:<line>"` right after each element opening tag name in the STATIC text of `html` tagged templates, never adding a newline. Skips closing tags, comments, attribute values, raw-text bodies, `svg` / `math` descendants, document and head elements, and `css` / `svg` templates. Fails open (returns the source unchanged). `isSourceLocationCandidate` excludes `node_modules`, `*.server.*` and files outside the app. Applied to served app modules by `dev/serve.js` when the handler was built with `WEBJS_SOURCE_LOCATIONS=1` under dev. |
| `dev-import.js` | `devImportSpecifier(file, dev)`: the specifier every dev re-import uses (#1550; pages, layouts, route handlers, action modules, middleware, metadata routes, env schema, instrumentation, readiness, WS routes). In dev it carries a `?t=` query naming the file's CONTENT (#1575; a random one per call made every request a new, never-freed module instance): on the file URL under Node, on the PLAIN absolute path under Bun, which drops a `file://` specifier's query (so a URL-plus-query cache-bust was a silent no-op there). A path holding `?` or `#` falls back to the file URL. Outside dev it is the plain file URL. `devImport(file, dev)` imports through it and never re-imports a specifier that failed (on Bun under `--hot` that never settles). Tests: `test/dev/dev-import.test.js` (runs on both runtimes, including a real re-import round trip). |
| `process-shared.js` | `shared(name, make)`: one value per process under `Symbol.for('webjs.server.<name>')` (#1590). Every piece of state an app reaches through this package's API (the request, action-signal, action-middleware, instrumentation and seed `AsyncLocalStorage`s, action identity, the default cache store, the html-cache generation, the session map, broadcast clients) goes through it, because after a `bun --hot` reset the app imports a fresh copy of the package while the first run's server keeps serving. New app-facing module state MUST use it. |
| `dev/hot-host.js`, `dev/bun-app-source.js` | One dev server per process under `bun --hot` (#1575). `startServer` registers a host on `globalThis` (`Symbol.for('webjs.dev.hotHosts')`); a re-run (the CLI's `lib/dev-hot-rerun.js`, or a second `startServer`) calls `host.rerun()`, which re-derives the analysis without a reload frame, and exits the child when `@webjsdev/server` itself changed. App modules load through a fresh-reading `Bun.plugin` (`bun-app-source.js`; Bun keeps a replaced file's old source). There is deliberately NO `onResolve` for `#` aliases (a Bun runtime `onResolve` result reaches a dynamic `import()` as a broken `file:/...` path, the 0.8.85/0.8.86 regression); instead both plugins rewrite an app `#` specifier to the absolute path when the file exists (`rewriteAppAliases`), since Bun's directory cache goes stale for a new file, so the watcher pokes a temp-dir sentinel for a registry reset when an edited module is imported by another (`needsRegistryReset` on the handler), new, or `*.server.*`. Tests: `test/dev/hot-reload-robustness.test.js`, end to end `test/bun/dev-reload-stress.mjs` (both runtimes) over `scripts/dev-reload-stress.mjs`. |
| `dev-source-locations-hook.js` | The SSR half of #1499: `registerSourceLocationHook(appDir)` installs a process-global load hook scoped to registered app roots (`module.registerHooks` calling `nextLoad` first, so `.ts` keeps `module-typescript` and Node still strips; on Bun a `Bun.plugin` whose filter excludes `node_modules` and `*.server.*` by lookahead, so the `'use server'` seed plugin keeps every server module whatever the registration order). Installed by `createRequestHandler` in dev only. |
| `dev-embed.js` | The dev embed bridge, server half (#1498). `parseEmbedOrigins` normalizes `WEBJS_EMBED_ORIGINS` (http(s) origins only; a wildcard, bare host or other scheme is dropped with a warning). `setEmbedOrigins` is the module-level switch `createRequestHandler` sets at construction (the parsed value in dev, `[]` otherwise), read by `embedScriptTag`, which `ssr/head.js`'s `wrapHead` emits right after the public-env shim when `opts.dev` and origins are set (with the request's CSP nonce; `''` otherwise, so the off case is byte-identical). `allowEmbedFraming` drops `X-Frame-Options` and widens a CSP `frame-ancestors`; `dev/handler.js` applies it last in the header merge, in dev only. |
| `dev-embed-client.js` | The dev embed bridge, browser half (#1498). `installEmbedBridge(origins)` posts `ready` / `navigate` / `console` / `error` / `network` / `server-error` / `select` messages to an allowed parent and handles `navigate` / `reload` / `inspect` / `hold` host commands. `select` carries the click's modifier keys, `hold` (#1532) sets the `__webjsEmbedHold` page global (persisted in `sessionStorage`) and on release calls the reload client's `__webjsDevReleaseHold`, and a reload of the same path restores the scroll saved at `pagehide`. Import-free and comment-light by contract: `dev-embed.js` reads this file, strips its comments and `export`, and inlines it, so the browser test drives the shipping code. `server-error` relays the `webjs:dev-overlay` event `dev-overlay.js`'s `renderDevOverlay` dispatches once an overlay is actually on screen. |
| `dev-reload-hold.js` | The live-reload hold gate (#1532), inlined into the reload client (`reloadClientJs` in `dev/helpers.js`) like `dev-styles.js`. `createReloadHold(apply, isHeld)`: while held, `offer(verdict)` keeps the strongest verdict (`reload` > `shell` > `page`, unknown = `reload`); `release()` applies that one reload, or nothing. Tests: `test/dev/reload-hold.test.js`, the browser `dev-embed.test.js`, e2e `test/e2e/dev-embed.test.mjs`. |
| `ts-strip.js` | Pluggable TypeScript stripper (#508), the seam that lets webjs run on both Node and Bun. `stripTypeScript(source)` erases types in place (position-preserving, no sourcemap) via the backend `ensureStripper()` resolves once at boot: Node 24+'s built-in `module.stripTypeScriptTypes` (zero deps, the default), or `amaro` on a runtime lacking it (Bun), lazily imported. Node's built-in is itself a wrapper over amaro's `strip-only` mode, so the output is BYTE-IDENTICAL (a unit test asserts it). `amaro` is an `optionalDependency` of this package: a Node-only install that prunes optionals still runs (it never loads amaro); a Bun install gets it. `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend. Namespace-imports `node:module` (not a named `stripTypeScriptTypes` import) so the module LINKS on a runtime lacking the builtin (Bun, old Node) instead of link-failing before the runtime check (the PR #282 link-safety pattern, now living here). Also hosts the one-shot `stripTypeScriptTypes` ExperimentalWarning suppression, co-located with the call. `dev.js`'s `stripTs` delegates here and `createRequestHandler` calls `ensureStripper()` at boot. |
| `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. **Admits Bun (#508):** when `process.versions.bun` is set and no explicit `current` is passed, the gate is a no-op (Bun gets its TS strip from `amaro` via `ts-strip.js` and `node:*` from its compat layer, even though it reports a Node version string). The link-safety pattern (namespace-import `node:module`, not a named `stripTypeScriptTypes` import) now lives in `ts-strip.js` since that is where the built-in is reached (PR #282 reasoning; the CLI carries its own dependency-free inline guard in `cli/lib/node-preflight.js`). |
| `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`) 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` responses (storage forbidden, so nothing to validate). `private` IS validated: it forbids SHARED storage, not validation, and the ETag hashes that response's own body (#1140); 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 form submission in `form-dispatch.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 / form-dispatch 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; `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 + the Origin CSRF check + prod error sanitization), modelling a same-origin browser POST (`Sec-Fetch-Site: same-origin`); `rawActionRequest(...)` returns the raw Response (no throw, `crossOrigin: true` to model a cross-site request for the 403 case). No test-framework machinery; reuses the real serializer (`serializer.js` -> `@webjsdev/core`) and cookie/header names, never a fake. **Browser-test harness (#806):** `createBrowserTestHandler(appDir)` -> `{ handle, warmup, importmapHtml }` is what the scaffold's `web-test-runner.config.js` proxies module requests to, so a browser test can import a real `.ts` component that imports a `'use server'` action. It lazily builds `createRequestHandler({ appDir, dev: true, testMode: true })` (lazy import so the rest of `./testing` stays light of the full server + `ws`). `testMode` (a `state` flag read at the `dev.js` serve gate) relaxes the source-serve gate so ANY app file under appDir is servable (a component a test imports IS browser-bound, but a non-component helper/fixture it imports is not); the `.server.*` source guardrail is unchanged, so no server source leaks. Set ONLY here, never by `webjs dev` / `start`. `importmapHtml()` is `importMapTag()` (call after warmup). E2E-verified against real Chromium in `test/e2e/browser-harness.test.mjs` |
| `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`. `robots(config)` (#1564) serializes Next's `MetadataRoute.Robots` object to robots.txt, stripping newlines from values. Exported from `index.js` |
| `metadata-routes.js` | Metadata route dispatch (#1564). `runMetadataRoute` calls the route with `{ request, url, siteUrl, pages }` (`siteUrl` = `SITE_URL`, else the request origin) and turns a Response / string / sitemap ARRAY / robots OBJECT into the response. `listPages(routeTable)` backs `pages()`: static pages, dynamic pages exporting `generateSitemapParams()`, minus pages whose STATIC metadata chain says noindex (generateMetadata is not called: it may need the request). Tests: `test/seo/seo.test.js`, `test/bun/seo-metadata.mjs` |
| `ssr/seo.js` | Search and share defaults (#1564). `applySeoDefaults` runs at the end of `collectMetadata`: `SITE_URL` as default `metadataBase`, canonical = site URL + pathname (never from the request host, which a client can set), og:title/description/type/url, nearest `opengraph-image` / `twitter-image` route as the image, `twitter:card`. Adds nothing to a page with no metadata and no site URL. `openGraphPairs` / `twitterPairs` serialize Next's shapes for `head.js`. The image routes are recorded by `setMetadataIconRoutes` (head.js) on boot and rebuild |
| `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 form-bound action returning `streamResponse` is honored verbatim by `form-dispatch.js` (`runFormAction` returns a returned `Response` as-is). Note the distinction from a STREAMED return value (an async iterable, #489), which the form path refuses. Exported from `index.js` |
| `check.js` | Convention validator backing `webjs check`. Thin barrel re-exporting modular sub-modules under `src/check/` (`rules.js`, `helpers.js`, `runner.js`, `runner-support.js`, and the six `rules-*.js` rule groups). Correctness-only; rules include `use-server-exports-callable` (#464: a `.server.{js,ts}` file declaring `'use server'` must export at least one CALLABLE; the registrar registers only function exports, so a file exporting zero functions, or only a non-function `const` / a type / only verb config, registers nothing and the call 404s silently. Asserts "exports a callable", not "returns a value" (a void or `redirect()`-throwing action is fine); conservative on a re-export or a factory-produced const, which might be a function. The complement of `use-server-needs-extension` and `one-action-per-configured-file`), `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). Scope note for dynamic imports (#751): a string-literal `import('./x.ts')` is now a GATE edge (servable), but this rule's server-import detection still runs over STATIC edges, so a dynamic `import('./x.server.ts')` of a no-`'use server'` utility is not flagged (its throw-at-load is deferred to call time). A computed `import(expr)` is not a check rule at all (it would false-positive on a valid computed npm / reachable-app import, failing the check-is-correctness-only line); the dev server surfaces it with a 404 hint when the target 404s.) |
| `vendor.js` | Resolve bare-specifier npm deps. Thin barrel re-exporting modular sub-modules under `src/vendor/` (`scanner.js`, `manifest.js`, `providers.js`, `integrity.js`, `jspm.js`, `pins.js`, `audit.js`, `resolver.js`). `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 and call `api.jspm.io/generate`. **The scan is ROOTED IN THE MODULE GRAPH (#1399), not in a filesystem walk.** Only files reachable from a browser-bound entry (`browser-entries.js`'s `browserEntryFiles`, the same entry set the dev server's authorization gate uses) contribute a specifier, so a build script under `scripts/`, a tooling config, a test helper, and an unreferenced module all drop out by REACHABILITY rather than by name; there is no exclusion list any more (the old one was open-ended and had already gone stale). Two entry points share one implementation: out-of-process callers (`pinAll`, `webjs doctor`) call `scanBareImports(appDir)`, which builds its own graph + route table + component scan (roughly 70-200ms on the in-repo apps) and applies NO elision pruning; the dev server calls `reachedBareImports(graph, entries, appDir, skip)` with the analysis it already has plus its elision skip set. That asymmetry is deliberate and load-bearing: the pin side is a SUPERSET of the runtime side by construction (same roots, no prune), which is exactly the relation `prunePinToReachable` needs to intersect a committed pin down to the runtime answer (#197). Because the specifiers now come from the graph's own blanked-mask scanner (`bareImports`), an `import` written as example text inside a template literal is no longer a phantom vendor edge, and the framework runs ONE vendor-specifier scanner instead of two that disagreed (the importmap and the #754 modulepreload hints now share it). The scan keeps framework-internal packages off the jspm path BEFORE the generate call: `@webjsdev/core` is served locally (the `BUILTIN` set), and the server-only `@webjsdev/cli` / `@webjsdev/server` / `@webjsdev/mcp` (the `FRAMEWORK_SERVER_ONLY` set) are excluded (#713) so a server-only package never reaches jspm, leaving the 401 fallback below for genuine third-party private deps. `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). **Found-but-uninstalled diagnostic (#953):** the version gate skips any scanned specifier whose package is not installed (`getPackageVersion` returns nothing), and `pinAll` returns those skipped specifiers in `droppedUnresolvable`, DISTINCT from `noBareImports` (a genuinely empty scan). A partial pin carries `droppedUnresolvable` alongside the written `pins`; an all-dropped set returns it with no pin file. The CLI branches on it to name the specifiers and point at `npm install` instead of the misleading "no bare-specifier npm imports found". No change to WHAT gets pinned (an uninstalled package was never pinnable), so vendor-runtime parity holds. 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 runs its regexes over the FULLY-BLANKED mask (`redactStringsAndTemplates(src, true)`, which blanks every comment / string / template / regex body to spaces, delimiters kept) and reads each specifier back from raw `src` via the match's group indices (the `d` flag). Scanning over the blanked mask means a stray `import`/`export` word inside ANY literal or comment (example code in an `html\`\`` template, a `// comment`, a `/regex/`) can never anchor a regex match whose lazy body spans across the literal's closing delimiter into the NEXT real `from '<spec>'` and consumes it, silently dropping that real edge (a differential test against a real AST caught this class across the comment / template / regex delimiters, #753). A differential test (`packages/server/test/scanner-fuzz/`) proves the lexer's import-edge, `register()` / `customElements.define()`, and WebComponent class-body extraction agrees with a real TypeScript parse over the repo corpus plus adversarial fixtures (the register/class corpus tolerates only verdict-safe over-matches, never a MISS). **Dynamic-import edges (#751):** a string-literal `import('./widget.ts')` (matched by `DYNAMIC_IMPORT_RE`, same `#`-alias rules as the static scan) is tracked as a SEPARATE edge class kept in a `WeakMap` keyed by the graph (read via `dynamicEdges(graph)`). The dynamic scan does NOT run over the fully-blanked mask the static scan uses (which blanks `${...}` template-hole code too): it runs over `redactToPlaceholders(src)` (#918), which keeps hole code readable so a dynamic import written directly inside a hole (`html\`${import('./x.ts')}\``, real code in an expression position) is captured instead of 404ing, while string / template-TEXT bodies are tokenized to `__STR_<idx>__` (the specifier recovered from the `literals` array) and comment / regex bodies blanked, so a dynamic import written as literal TEXT is still not an edge. The static `IMPORT_RE` / `EXPORT_FROM_RE` stay on the blanked mask (a statement cannot appear in a `${}` hole, so only the dynamic form needs hole-awareness) and `DYNAMIC_IMPORT_RE` is tightly anchored on `import(` (no lazy cross-delimiter span), so the placeholder scan does not reopen the #753 swallow class. `reachableFromEntries` (the gate) unions these in so a lazily-imported app module is servable instead of 404ing, and a dynamically-imported module's own static subtree is walked too; but `transitiveDeps` (preload) and the elision analysis stay on the STATIC graph only, so a dynamic import is admitted-but-not-preloaded (lazy by author intent) and never flips an elision verdict. The `.server.*` boundary holds for dynamic edges (a dynamic `import('./x.server.ts')` is admitted as a stub, not traversed into). A computed `import(expr)` cannot be captured and stays out (a `webjs check` warning surfaces it). **`#` path-alias expansion (#555):** `appImportsMap(appDir)` reads + caches the app's `package.json "imports"` map, and `expandImportAlias(spec, appDir)` expands a matching `#`-prefixed specifier (e.g. `#lib/db.server.ts` under the scaffold's catch-all `"#*": "./*"`) to its real app-relative target. `resolveImport` calls it BEFORE the relative branch and `parseFile` lets alias specs through, so the graph / auth gate / elision / `no-server-import-in-browser-module` all see the REAL path (an alias cannot launder a `.server.ts` past the boundary). Key-shape-agnostic (wildcard + exact, any base); `IMPORTS_CACHE` is cleared per appDir on each `buildModuleGraph`. **Bare (npm vendor) edges (#754):** a bare specifier (`dayjs`, `@scope/pkg/sub`) is NOT a static graph edge (the gate / elision are unchanged), but the exact specifier is recorded per file in a SEPARATE `WeakMap` keyed by the graph (read via `bareImports(graph)`) so `ssr.js` can map it to a vendor importmap URL and emit a `modulepreload` (flattening the CDN waterfall one level). `node:` builtins + protocol specifiers are excluded; because the scan runs over the fully-blanked mask (#753), a `from '<spec>'` written inside example code cannot become a phantom vendor edge (the earlier #754 quote-position guard is subsumed by the blanked-mask scan). `reachableBareSpecifiers(graph, entryFiles, appDir, skip?)` composes those edges with a `reachableFromEntries`-shaped walk to answer "which vendor specifiers could the browser load from these entries", which is what `vendor.js` builds the importmap from (#1399). It follows DYNAMIC edges as well as static ones, unlike `transitiveDeps`: a lazily `import('./chart.ts')`-ed module must not be PRELOADED, but its bare specifier still has to resolve when the import finally runs, so it belongs in the importmap. `.server.*` files are reached but their bare imports are not collected (the source never ships, so a DB driver behind a server file must never be vendored), and a `skip` file is neither collected nor traversed into, so an elided-only specifier drops out along with the subtree behind it (#170 / #197). |
| `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`, 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. **`#` alias browser scopes (#555):** `importAliasBrowserEntries(importsMap, topLevelDirs)` derives the browser importmap entries for the app's `"imports"` aliases, derived from the SAME map the server resolver reads (lockstep). The scaffold's catch-all `"#*": "./*"` expands into one trailing-slash prefix scope per top-level dir (`#lib/` -> `/lib/`, ...; a bare `#` cannot prefix-match, so dev.js's `appTopLevelDirs` scan supplies the dirs and a new folder is covered on the next boot); a per-dir or exact key maps directly. `setImportAliasEntries` binds them at boot and folds them into `buildImportMap`. **`vendorPreloadTargets(specifiers)` (#754):** maps a set of reached bare specifiers to `[{ href, integrity }]` taken DIRECTLY from `buildImportMap().imports[spec]` (byte-identical to the importmap target, so the browser does not double-fetch) + the matching `integrity`; excludes `@webjsdev/core*` (same-origin, already on the boot path), dedups by href, and DROPS a specifier absent from the importmap (unpinned / unreached / elided, so no over-fetch). `ssr.js` feeds it the reached vendor set and emits a `modulepreload` per target. |
| `browser-entries.js` | `browserEntryFiles(routeTable, components)`: the browser-bound ENTRY files, every module the client boot can import directly (page / layout, the error / loading / not-found / forbidden / unauthorized boundaries, the two root-only boundaries, instrumentation-client, and every discovered component, since a `static lazy` one is fetched by the lazy loader rather than imported by a page). Its own module because two consumers root at it: `dev.js` expands it through `reachableFromEntries` into the serve-time authorization gate, and `vendor.js` expands it through `reachableBareSpecifiers` into the importmap (#1399), so an out-of-process `pinAll` / `webjs doctor` roots at exactly the entries the running server does without booting one. It FORWARDS the route table's fields rather than discovering any of them, so every entry has to be on the table before it is called. `instrumentation-client.*` is the one that is not a router stem, and it was attached by `dev.js` alone until #1399: the vendor scan builds its own table, so it silently lost that entry, which inverted the pin-is-a-superset invariant and left a pinned app with no importmap entry for the first module its boot imports. `buildRouteTable` resolves it now, so a new consumer cannot reintroduce the miss. Anything else added to the table later belongs there for the same reason |
| `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). `analyzeElision` also returns `shippedRouteModules` (#646): for each page/layout that ships whole (neither inert nor import-only), the first client-effecting blocker that pins it (a non-component on a component-free path from the module, #963, or `null` when the module's own code is the cause) plus a human `reason`. It further returns `componentVerdicts` (#1308): per component FILE, its sorted tag list, whether it ships, and the EVIDENCE that forced it (`own` / `observed` / `closure` / `render` / `import` / `unreadable`, first match wins) plus the module that did the forcing. Both are projections of what the passes already computed, never a second analysis. A reporting layer over the existing verdict, consumed by the `webjs doctor` advisory |
| `elision-report.js` | `analyzeAppElision(appDir)` (#646, #1308): builds the module graph + runs `analyzeElision` ONCE, returning the WHOLE verdict as a sorted, app-relative, JSON-serializable `{ analysed, skipped, components, routeModules, orphans, summary }`. Both directions: which component modules the browser never downloads and why each shipped one ships, which page/layout is inert / import-only / shipped, and every orphan class that gets no verdict at all. Consumed by `webjs elision` (plus `--json`), the MCP `list_elision` tool, and BOTH `webjs doctor` elision checks, which share one call so the graph is built once. `skipped` names why nothing was analysed (`no-app` / `elide-off` / `unanalysable`), and the report caches nothing (every consumer runs once and exits). A reporting layer over the analysis, NOT a build (webjs is no-build) |
| `elision-differential.js` | The differential primitives shared by the framework's own guard and the app-facing one (#1308): `maskJsSet(html)` (the ONE definition of the JS-loaded set, so `test/elision/differential-elision.test.js` and `webjs elision --verify` can never disagree about what the invariant means) and `staticPageRoutes(table)` (the dynamic-free render corpus). A leaf: no filesystem, no module graph |
| `js-scan.js` | Shared lexical scanners (`redactStringsAndTemplates`, `redactToPlaceholders`, `extractWebComponentClassBodies`, `matchClosingBrace`) used by `check.js`, `component-scanner.js`, and `component-elision.js`. **`matchClosingBrace` keeps a STACK of frames, not one flat depth counter** (#1307): a `${` hole is a code context nested inside a template, so it pushes its own frame and the `}` that closes it pops back into the template instead of counting toward the block being matched. The flat counter incremented at `${` and never decremented (the closing `}` arrived while still in template state), so a class body containing an interpolated template was unmatchable. It stayed latent because every caller passes a position-preserving MASK in which holes are already blanked, so none ever fed it a `${`. `redactToPlaceholders` (#634) masks comments and replaces each string / template body with a `__STR_<idx>__` placeholder (originals returned in a `literals` array, `${...}` holes scanned as code), so the component scanner and the elision import / side-effect scanners see a real top-level `register(...)` / `import` while an identical token shown inside a code-sample string is inert |
| `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` / `robots`, `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`), `readSeedEnabled` (`dev.js`, `seed`),
   `readClientRouterEnabled` (`dev.js`, `clientRouter`),
   `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), `readAllowedOrigins` (`csrf.js`, `allowedOrigins`),
   `readRegenerateRules` (`dev-regenerate.js`, `dev.regenerate`, #967), and
   `readDevWatchPathsFromApp` (`dev.js`, `dev.watch`, #894), and
   `readDevToolingFromApp` (`dev/config.js`, `dev.embedOrigins` /
   `dev.sourceLocations` / `dev.reloadIdle`, #1504, #1507; the env vars override it). Six readers
   live in the CLI rather than here: `readAppTasks`
   (`packages/cli/lib/app-tasks.js`, `dev.before` / `dev.parallel` /
   `start.before`, #550), `readDbCommands` (`packages/cli/lib/app-tasks.js`,
   `db`, #1468), `readCiConfig` (`packages/cli/lib/ci-config.js`, `ci.steps`,
   #1471, the local-CI step list `webjs ci` runs), `readDoctorPolicy`
   (`packages/cli/lib/doctor.js`, `doctor.gate`, #1257), and two more inside
   `lib/doctor.js` that read keys the server also reads, so a change to
   either key's semantics needs BOTH implementations updated:
   `checkStaticAssetFreshness` re-reads `dev.regenerate`, and
   `readAppBasePath` is a hand-maintained port of `readBasePath`'s
   normalization (its own docblock says "ported rather than imported").

   **THIS list is the canonical one.** It used to be duplicated in the JSON
   Schema's `$comment`, the `WebjsConfig` docblock, and the drift test's
   docblock, and every copy drifted independently (three review rounds on
   #1257 each found a different stale one). Those three now POINT here
   instead of enumerating, so there is one list to update. Two things the
   copies kept getting wrong, worth stating outright: the split is per SUB-KEY,
   not per top-level key (`dev` has CLI-read task sub-keys AND two
   server-read ones), and where the reader lives changes nothing about what
   the lockstep owes, since every key sits in the same `webjs` block and so
   must be in the schema + type + `KNOWN_KEYS` regardless (or
   `additionalProperties:false` would flag a valid app).

**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.

## Module size and barrel splits

`dev.js`, `ssr.js`, `check.js` and `vendor.js` are barrels over sibling
directories. The rule is in
`../../.agents/skills/webjs/references/module-structure.md`: target 800 lines,
around 1000 at the most, barrels exempt, no CI guard, and a split is a MOVE
rather than a rewrite.

Two traps are specific to this package:

1. **A relative `import.meta.url` walk breaks when a file moves deeper.**
   `locateCoreDir`'s workspace fallback walked three levels up to reach
   `packages/`; moving it into `dev/` made that four. The wrong path resolved to
   a directory that does not exist and every `/__webjs/core/*` request 404d,
   while the importmap still pointed there.
2. **The Bun parity hook matches on path.**
   `.claude/hooks/require-bun-parity-with-runtime-src.sh` matched the literal
   `/ssr\.js` and `/dev\.js`, so a nested `ssr/head.js` or `dev/app-config.js`
   matched nothing and SILENTLY skipped the gate. Widen the pattern in the same
   PR that creates the directory.

Behaviour this package owns that a rewrite quietly changes, all caught only by
tests rather than by the export surface: the `no-store` headers and response
shapes on `/__webjs/health` and `/__webjs/ready`, the top-level middleware
wrapper in the request path, the `durationMs` field on the structured access
log, and the unconditional csp-nonce hole in the SSR head template (making it
conditional leaves a CSP-off document one newline shorter than a CSP-on one).

## 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; `dev/watch-tree.js` watches the root plus each non-ignored top-level dir, so `node_modules` is never walked, and turns a per-path watch error into a warning, #1521) → 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, the seed-switch read +
   load-hook install (`readSeedEnabled` + `registerActionHooks`, #472 + #1155, always installed,
   which must precede any action-module import; it reads `package.json` and
   installs the seed load hook, `module.registerHooks` on Node or a `Bun.plugin`
   `onLoad` on Bun, #529, executing no app source), 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. Concurrent early
   requests await the in-flight first resolve (no bypass), so the first served
   response already carries the final map.
   **Two content-based deploy signals (#899).** The importmap hash alone misses
   an app-only or SSR-only deploy (syntax highlighting, a template edit): the map
   is byte-identical, so the client kept serving stale pre-deploy HTML on soft
   nav. WebJs now emits TWO automatic, content-derived signals (no env var, the
   no-build Rails+Turbo model where the served content IS the version), each with
   the right response. **Reload signal `data-webjs-build` / `X-Webjs-Build`** =
   `importMapHash()` folded with the installed `@webjsdev/core` version, via
   `publishBuildId()` (`_publishedBuildId = <hash>.c<coreVersion>`, `_coreVersion`
   read in `setCoreInstall`). It changes on a vendor pin or a core release
   (browser-shipped code the running page cannot hot-swap), so the client
   HARD-RELOADS. `importMapHash()` itself is untouched (still `fingerprint:false`,
   independent of the per-file `?v` hashes owned by `asset-hash.js`). **Evict
   signal `data-webjs-src` / `X-Webjs-Src`** = a content hash of ALL app source
   (the module-graph `seenFilesFor(graph)` set INCLUDING server-only `.server.ts`,
   raw per-file byte digests, computed in `dev.js`'s analysis) folded with the
   installed `@webjsdev/server` version, published via `setAppSourceId` /
   `appSourceId()`. It changes on an app-source change or a server-framework
   release (SSR output moved, no new browser code), so the client EVICTS its
   snapshot/prefetch caches and re-fetches softly, NO reload. Both are empty
   until analysis is final (the client reads empty as "unknown" and never acts).
   The #318 `_appSourceFp` html-cache fingerprint (browser-bound) is a SEPARATE,
   unchanged mechanism; the app-source SIGNAL does not touch it.
   **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.
   **In DEV, `/public/*` is served there too** (#1397), along with the `/sw.js`
   + `/offline.html` root remaps and `/favicon.ico`, through
   `tryServePublicAsset` called right after `tryServeFrameworkStatic`. A static
   asset needs neither the module graph nor the vendor importmap, and on the
   website app a cold `/public/tailwind.css` measured 1907ms of which 1900ms was
   `ensureReady()`, which is why the stylesheet is the request most exposed to
   the next dev restart and why a burst of edits leaves the page
   unstyled. This is DEV ONLY on purpose: in prod `/__webjs/ready` already holds
   traffic off a cold instance until the analysis and the first vendor attempt
   have both completed, and hoisting there would silently un-gate a `/public/*`
   file an app middleware protects. The consequence to know about is that in dev
   root middleware does NOT run for those paths, the same trade the framework
   statics above already make in both modes. The `handleCore` branch is the LIVE
   path in prod and the fallback in dev, and both call sites share the one
   `tryServePublicAsset`, so the directory-traversal containment guard exists in
   exactly one place and cannot drift between two copies.
4. **One pluggable cache store, four built-in consumers.** `cache.js`
   is shared by `cache-fn.js`, `cache-tags.js`, `session.js` (store-backed),
   and `rate-limit.js`. A single `setStore(redisStore({…}))` call at
   startup switches all of them to Redis. The store interface is
   `get` / `set` / `delete` / `increment` plus the OPTIONAL atomic-set pair
   `setAdd` / `setMembers` (#752, used by the tag index): a store that omits
   them still works, falling back to the non-atomic JSON path, so a custom
   adapter is never required to implement the optional methods.
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, inert-route, AND import-only-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, (b) the set of page/layout route modules that are
   inert (do no client work even transitively), and (c) the set of
   **import-only** page/layout modules (#605): a module whose own code does no
   client work and whose only paths to client work run through shipping
   components, mapped to the component files to emit in its place. Since a
   page/layout never hydrates, an import-only module is just the import-graph
   carrier for its components, so the boot emits those component modules
   directly and drops the module. The condition is a PATH-AWARE truncated walk
   (#963), NOT a closure subset test: the BFS stops descending at a shipping
   component (it is emitted in the module's place, so its subtree loads
   regardless), which means a client-effecting NON-component reachable only
   THROUGH shipping components (the module-scope-signal-in-its-own-file idiom
   of invariant 5) does not block import-only. A client-effecting
   non-component on a component-free path (a self-executing helper, a
   `client-router` import, a reactive helper the page reaches directly or via
   plain helpers) still keeps the whole module, because dropping it would lose
   that side effect. The re-emit is the component FRONTIER of that walk: the
   shipping components reached without passing through another shipping
   component (so a component imported but only conditionally rendered still
   registers; one nested behind another emitted component loads via its
   importer instead of being re-emitted). A `static lazy` component in that
   frontier is registered with the lazy loader in the boot (`observeLazy`)
   instead of imported (#1524). The same holds one level down: a side-effect
   import of a `static lazy` component (`state.lazyComponentFiles`, from the
   scanner's `declaresLazy`) is served as an `observeLazy({...})` registration
   by `deferLazyImportsFromSource`, and both preload walks skip the lazy file
   and the subtree reachable only through it, so the server keeps the import
   for SSR while the browser loads the module on first sight. 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 (and splices an import-only module's
   component URLs in place of the module), so a fully-static route ships
   zero application JS and an import-only route ships only its interactive leaves.
   A page/layout NEVER hydrates, so its detection differs from a component's in
   two ways the analyser must respect (#623), or a route module is wrongly pinned
   to the browser by a false positive: (1) its `html` TEMPLATE content is SSR
   output, not module client work, so the `@event` / client-global scans run on
   the template-REDACTED source for route modules (an inline `<script>`'s
   `document` / `localStorage` runs from the rendered HTML, never from loading
   the module; a genuine module-scope `document.x` OUTSIDE any template still
   flags), while COMPONENT detection keeps scanning template content (a
   component's `@event` IS its signal); and (2) a `#`-alias side-effect import
   (`import '#components/x.ts'`, the idiomatic component registration) resolves to
   a LOCAL file via the app's `package.json` "imports", so it is expanded with
   `expandImportAlias` and treated like a relative import, not a bare npm package
   (the `#`-imported file still rides the closure and is flagged on its own merits
   if it does real client work). A module-scope pure-data constructor
   (`new Set([...])` / `Map` / `Date` / `RegExp` / typed array / `URL`) is inert
   data, not a side effect; any other constructor (`new WebSocket()` / `Worker()`)
   still ships. A `function` declaration's parameter list and the expression
   body of an arrow initializing a `const` / `let` / `var` declarator are
   dropped from the depth-0 frame before the call scan
   (`blankDeclarationOnlyCode`, #1567), so `now = Date.now()` and
   `(m) => Math.round(m) / MICROS` are declarations, not load-time calls; the
   arrow body ends early (at a newline) whenever continuation is not provable,
   because ending late could swallow a real statement. The vendor bare-import scan (`extractPackageName`) treats a
   `#`-alias specifier as local and skips it outright (it does not expand the
   map), so a `#` import is never sent to the resolver; the rare alias mapped to
   a real package (`"#x": "some-pkg"`) is consequently not vendored, an accepted
   limitation since the scaffold's catch-all `"#*": "./*"` is always local.
   Every scan reads TYPE-ERASED source: `analyzeElision` runs a `.ts` / `.mts` /
   `.tsx` module through the framework's own stripper (`eraseTypesForScan`)
   before scanning it, because the scans are lexical and TS annotations are
   call-shaped often enough to matter (`readonly (readonly [number, number,
   number])[]` is an identifier immediately followed by `(`, which the
   module-scope scan read as a top-level call, pinning every importing route
   module, #1423). The stripper rather than a lexical annotation matcher, so the
   analyser and the runtime agree by construction, and it is position-preserving,
   so every offset the other scans depend on survives. The extension set is the
   union of the TypeScript-carrying extensions in the three filters that seed
   `allFiles`, NOT the servable set: `scanComponents` admits `/\.m?[jt]sx?$/`,
   wider than the graph walker and the router, so a `.tsx` component reaches the
   analysis and narrowing to `.ts` / `.mts` silently un-erases it. A non-TS file,
   and a source the stripper rejects (non-erasable syntax, which invariant 10
   forbids, or real JSX in a `.tsx`), are scanned as authored, the conservative
   direction; a missing stripper BACKEND is the one failure that warns, since it
   un-erases the whole app rather than one file. Results are memoized per file
   and validated against the source, so a dev rebuild re-strips only what
   changed. Consequence for a DIRECT caller of `hasModuleScopeSideEffect` /
   `analyzeComponentSource`: both still take source as given, so handing either
   one `.ts` as authored opts back into that false positive.
   Import-only modules join the elision fingerprint (a verdict flip busts `?v`)
   and the bare-import scan exclusion (an SSR-only page import is no longer
   vendored), like inert modules. `collectRouteModules` (`dev.js`) feeds only
   page + layout files to the analysis, so error / loading / not-found modules
   are never inert / import-only and always ship. 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), KEEPING the transitive dependencies of every kept package
   (#1518): the prune walks each kept package's installed `package.json`
   (`getPackageDeps` in `vendor/manifest.js`, resolved from the dependent's own
   directory) and keeps every pin entry it reaches, because app code never
   imports a transitive like `style-mod` or `@lezer/lr` and pruning them broke
   the page. When a kept package is not on disk it keeps every pin entry the app
   does not itself declare. 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 two
   guard tests: `test/elision/lifecycle-coverage.test.js` (prototype hooks
   and methods, via prototype introspection) and
   `test/elision/sigil-coverage.test.js` (template binding sigils, which
   are single-sourced in core's `BINDING_PREFIXES` and classified here as
   `SSR_DROPPED_PREFIXES` or `ROUND_TRIP_PREFIXES`, plus the
   `INTERACTIVITY_STATIC_FIELDS` registry). 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 (an OBSERVER that computes the tag it waits for, a
   `:defined` rule in an external stylesheet outside the module graph, a
   consumer reaching the element through a string selector),
   documented in the skill's `references/components.md` and asserted in
   `test/elision/residual-contract.test.js`; for those, add an
   interactivity signal, in practice `static interactive = true`. A
   component's OWN registration tag is a different case: invariant 3
   requires a LITERAL, because `scanComponents` matches only a literal, so
   a `Class.register(tagVar)` component is never in the component set at
   all, gets no verdict, and `static interactive = true` cannot rescue it
   (nothing consults the analyser for a component the scanner never saw).
   That shape surfaces as an `orphans` row in `analyzeAppElision` and a
   `webjs doctor` warning, not as an elision verdict. `findOrphanComponents`
   reports TWO shapes under that one name, so any message about it must cover
   both: a class with a computed registration tag, and a class with no
   registration call at all (the original forgot-to-register case the dev
   server has always warned about).
   `webjs elision` is the inspection surface for all of this: it prints
   the per-module verdict with the evidence behind each ship, and
   `webjs elision --verify` runs THIS differential over an arbitrary app's
   own route table, so an app proves the invariant locally instead of
   inheriting a guarantee it cannot check.

## 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/`, `seed/`.

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
[`references/testing.md`](../../.agents/skills/webjs/references/testing.md) in the skill.

Run `npm test` from the repo root.

---

Framework-wide rules and full API reference:

@../../AGENTS.md