diff --git a/framework-dev.md b/framework-dev.md index cea4695c2..472d7b079 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -418,7 +418,7 @@ In development, three error sources push a structured error frame to the open ta **A render frame is scoped to the URL that produced it (#1047).** A `render` frame carries that url, and the browser half is the single gate deciding whether a frame belongs on the page currently being viewed, so an overlay comes down when the client router navigates away and never goes up for someone else's page. Three consequences worth knowing. A speculative link PREFETCH of a throwing page reports no frame at all, so hovering a link cannot break the page you are looking at (the reported symptom: the frame fans out to every open tab over the shared SSE channel, with no navigation anywhere). A render error in one tab raises nothing in a tab viewing a different page. And a successful render of a url supersedes a retained error for that SAME url, so the replay cannot hand a recovered error to a freshly-connected tab; a good render of an unrelated page deliberately leaves it standing. `ts-strip` and `rebuild` frames carry NO url and are never scoped, because they describe a still-broken build rather than one page, so navigation leaves them alone and only the next successful rebuild clears them. The gate is order-independent: the SSE frame is pushed during the render, before the navigation response is even sent, so a frame for the page being navigated TO is held and rendered once the URL advances. A held frame renders only for the navigation it belongs to, so one that arrived while the tab sat idle is dropped rather than painted on a later visit, when the page may well render fine. An idle-time frame comes from a render this tab did not navigate for (another tab's page, a background fetch of some other url), never from a link prefetch, which reports nothing at all. Mechanism: `renderDevOverlay` + `syncDevOverlayToLocation` + `installDevOverlayNavSync` in `packages/server/src/dev-overlay.js`, wired by the dev reload client to the client router's `webjs:navigate` and `popstate` (a navigation finished) plus `webjs:before-cache` (a navigation STARTED, since the router snapshots the page it is leaving first). That last one also detaches the overlay across the snapshot read and re-attaches it a microtask later, so the cached HTML never carries a copy the module does not own; what it must not do is strip the overlay for good, which would tear a `rebuild` overlay off the page on any link click. `packages/core` is untouched by any of it. -**A reload is coalesced (#1397), so after a burst of edits the page reloads once the edits settle** rather than once per saved file. Each save produces two reload signals (the in-process rebuild frame, then a changed boot id when the browser reconnects to the process the dev supervisor restarted), and acting on every one reloads into a server about to be killed again, which is what leaves the page unstyled. The relay holds the reload until the signals stop for 2 seconds, or at most 5 seconds into a sustained burst. So if a reload looks "missing" right after you saved, wait two seconds before looking for a bug: that is the first thing to check. An error overlay is never held, since it is not a reload. +**A reload is coalesced (#1397), so after a burst of edits the page reloads once the edits settle** rather than once per saved file. Each save produces two reload signals (the in-process rebuild frame, then a changed boot id when the browser reconnects to the process the dev supervisor restarted), and acting on every one reloads into a server about to be killed again, which is what leaves the page unstyled. The relay holds the reload until the signals stop for 2 seconds, or at most 5 seconds into a sustained burst. A server that applies edits in place (Bun's one long-lived dev server, or `--no-hot`) marks its frames `inPlace`, and a batch made only of those waits just 300ms (`RELOAD_QUIET_IN_PLACE_MS`, #1575), since no restart follows; a batch holding any restart signal keeps the 2 second window. So if a reload looks "missing" right after you saved on Node, wait two seconds before looking for a bug: that is the first thing to check. An error overlay is never held, since it is not a reload. **The stream is quiet, and only open while a tab is visible (#1507).** A host that sleeps on network quiet (a sandbox that suspends after an idle window, a laptop) counts an in-flight request and bytes on the wire as activity, and the reload stream used to supply both forever: a `: ka` comment every 25 seconds per tab, for every tab open, visible or not. Now `SseHub` writes nothing between events, and each listener shell switches its idle timeout off for that one request (`server.timeout(req, 0)` on Bun, `setTimeout(0)` on node) rather than keeping it busy. The relay in `dev-reload-worker.js` holds its `EventSource` open only while some tab reports itself visible (`visibilitychange`, `pagehide`, a bfcache `pageshow`), closes it when the last one hides, and reopens it when one shows. Nothing is lost meanwhile: `hello` carries `{ boot, seq }`, `seq` counting the reload frames the process has sent, so a reconnect that sees a different boot id or seq reloads. A dropped stream is retried by the relay with backoff (300ms doubling to 30s, reset by the next `hello`), never by the browser's fixed retry, and not at all while every tab is hidden. **An open request is activity too, so quiet is not always enough.** Measured on a pilots sandbox: a machine with a 20 second idle window stayed `running` for minutes with one silent, open SSE request in flight, and suspended about a minute after a plain request with nothing open. A host like that counts the in-flight request for its whole life. For it, `webjs.dev.reloadIdle` (seconds, or `WEBJS_DEV_RELOAD_IDLE`, off by default) makes the relay close the stream after that long with no edit and no interaction in any visible tab; the next interaction (tabs report pointer, key, wheel, touch and focus at most once a second), a tab showing, or an embed-bridge host command reopens it, and the hello seq reloads the page if an edit landed meanwhile. It is opt-in because local editing with the browser untouched is exactly the case live reload exists for. **A frame no stream carried is recovered from the page itself (#1516).** A host that holds the quiet stream across a suspend closes it at the next wake, and the reload the wake caused is written to that dead stream; a fresh relay (a new SharedWorker, or the per-tab fallback) has no previous hello to compare with; a tab that connects after a fanout never got it. So in dev every page renders `` from the `SseHub` (through `setDevReloadState` in `dev-reload-state.js`, set by `startServer`; an embedded `createRequestHandler` has no stream and renders none), the client sends it to the relay as `{ type: 'page' }`, and the relay reloads any tab whose page differs from the server's state on that message and on every hello, targeting only the stale tabs. A tab it has reloaded is recorded as current. Nothing else in `webjs dev` issues a request on a timer: the `/__webjs/version` readiness probe runs only after a reload signal, and prefetch is driven by hover, focus and viewport, not a clock. Tests: `test/dev/browser/reload-worker.test.js`, `test/listener/listener-core.test.js`, `test/e2e/dev-idle-stream.test.mjs`. diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 1216c466b..17a9ec4fb 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -30,7 +30,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | 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 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 | +| `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 → `
`, 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: