From 52873bae26049d02a671d705fb03a03c62a8a1f1 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 17:55:00 +0530 Subject: [PATCH] fix: settle an in-place dev reload in 300ms instead of 2s The browser relay held every reload for a 2s quiet window so a burst would not reload into a Node server about to be restarted. Bun's dev server now applies edits in place, so that wait was most of the save-to-paint time (3.8s measured in a hosted preview). Frames from a server that reloads in place say so, and a batch made only of those settles after 300ms; any restart signal keeps the 2s window and the 5s cap is unchanged. Refs #1575 Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- framework-dev.md | 2 +- packages/server/AGENTS.md | 2 +- packages/server/src/dev-reload-worker.js | 36 ++++++++++++++-- packages/server/src/dev/server.js | 4 +- packages/server/src/listener-core.js | 10 ++++- .../test/dev/browser/reload-worker.test.js | 43 ++++++++++++++++++- .../test/dev/hot-reload-robustness.test.js | 7 +++ 7 files changed, 95 insertions(+), 9 deletions(-) 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: ` (a `` self-load or a click-driven frame nav) AND the render is non-streamed, it extracts the matching `` 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: ` 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 `` 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 | diff --git a/packages/server/src/dev-reload-worker.js b/packages/server/src/dev-reload-worker.js index c003732d1..592e54923 100644 --- a/packages/server/src/dev-reload-worker.js +++ b/packages/server/src/dev-reload-worker.js @@ -36,6 +36,18 @@ */ export const RELOAD_QUIET_MS = 2000; +/** + * The quiet window for a batch made only of in-process frames from a server + * that reloads IN PLACE (#1575): Bun's one long-lived dev server, or any + * `--no-hot` server. No restart follows such a frame, so nothing is worth + * waiting 2000ms for, and that wait was most of the save-to-paint time (3.8s + * measured in a hosted preview). 300ms still folds a save's few frames and a + * quick burst into one reload. A batch holding any restart signal (a changed + * boot id, a stale page, a frame from a restarting server) keeps the full + * `RELOAD_QUIET_MS`, since the restarted process is still warming. + */ +export const RELOAD_QUIET_IN_PLACE_MS = 300; + /** * The longest a reload is ever held, measured from the FIRST signal of a batch * (#1397). A quiet window alone would freeze the page on stale content for a @@ -116,6 +128,19 @@ export function pageIsStale(page, boot, seq) { } /** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */ +/** + * Whether a `reload` frame comes from a server that reloads in place (#1575). + * Absent or unreadable means it may restart, the safe (slower) reading. + * @param {string} data + * @returns {boolean} + */ +export function frameInPlace(data) { + try { + const o = JSON.parse(data); + return !!(o && o.inPlace === true); + } catch (_) { return false; } +} + function reloadSeq(data) { try { const o = JSON.parse(data); @@ -190,6 +215,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) { let batchAll = false; /** @type {Set} tabs whose page fell behind, reloaded on their own */ const batchPorts = new Set(); + /** Whether the batch holds a signal from a server that restarts (#1575). */ + let batchRestart = false; function emitReload() { if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; } @@ -200,6 +227,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) { const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p)); batchAll = false; batchPorts.clear(); + batchRestart = false; for (const p of targets) { // The tab applies this reload, so from here its page reflects the state // the relay knows now; a later hello compares against that. @@ -219,11 +247,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) { * @param {string} verdict * @param {any} [port] reload only this tab (its page fell behind); omitted, every tab */ - function requestReload(verdict, port) { + function requestReload(verdict, port, inPlace) { if (port === undefined) batchAll = true; else batchPorts.add(port); if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)]; + // One restart signal anywhere in the batch keeps the long window (#1575). + if (!inPlace) batchRestart = true; if (quietTimer !== null) timers.clearTimeout(quietTimer); - quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS); + quietTimer = timers.setTimeout(emitReload, batchRestart ? RELOAD_QUIET_MS : RELOAD_QUIET_IN_PLACE_MS); if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS); } @@ -278,7 +308,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) { lastError = null; const n = reloadSeq(e.data); if (n !== null) lastSeq = n; - requestReload(parseVerdict(e.data)); + requestReload(parseVerdict(e.data), undefined, frameInPlace(e.data)); }); es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); }); // The stream dropped (a restarting server, a network blip, a host that diff --git a/packages/server/src/dev/server.js b/packages/server/src/dev/server.js index da380b56a..953fc5b2a 100644 --- a/packages/server/src/dev/server.js +++ b/packages/server/src/dev/server.js @@ -273,7 +273,9 @@ export async function startServer(opts) { // shells via listener-core.js, so live-reload + the dev error overlay behave // identically on both). Built before the handler so its onReload / onDevError // callbacks can fan out through it. - const hub = new SseHub(); + // A Bun hot host, or a server outside the dev supervisor (`--no-hot`, an + // embedder), applies an edit in place; a supervised Node child is restarted. + const hub = new SseHub({ inPlace: dev && (!!hotKey || process.env.__WEBJS_DEV_CHILD !== '1') }); // Pages render the stream's state into a meta tag (#1516), so the relay can // re-check a page against the server on every reconnect. if (dev) setDevReloadState(() => ({ boot: DEV_BOOT_ID, seq: hub.seq })); diff --git a/packages/server/src/listener-core.js b/packages/server/src/listener-core.js index 39bccaba0..9f4025678 100644 --- a/packages/server/src/listener-core.js +++ b/packages/server/src/listener-core.js @@ -351,7 +351,13 @@ export function loadWsModule(file, dev) { * tab was hidden can tell, on reconnecting, that it missed an edit. */ export class SseHub { - constructor() { + /** + * @param {{ inPlace?: boolean }} [opts] `inPlace`: this server applies an + * edit without restarting its process (#1575), so its reload frames say so + * and the browser relay need not wait for a restart to settle. + */ + constructor({ inPlace = false } = {}) { + this.inPlace = inPlace; /** @type {Set<{ send: (s: string) => void, close: () => void }>} */ this.clients = new Set(); /** Reload frames sent by this process (#1507). */ @@ -399,7 +405,7 @@ export class SseHub { reload(verdict) { const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' }; this.seq++; - this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq })}\n\n`); + this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq, ...(this.inPlace ? { inPlace: true } : {}) })}\n\n`); } /** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */ diff --git a/packages/server/test/dev/browser/reload-worker.test.js b/packages/server/test/dev/browser/reload-worker.test.js index 2d8c71da3..3a28d5f4a 100644 --- a/packages/server/test/dev/browser/reload-worker.test.js +++ b/packages/server/test/dev/browser/reload-worker.test.js @@ -10,7 +10,7 @@ * runs in a real browser. The relay is driven with a fake EventSource + fake * MessagePorts so it needs no live SSE server. */ -import { startReloadWorker, RELOAD_QUIET_MS, RELOAD_MAX_HOLD_MS, RECONNECT_BASE_MS, RECONNECT_MAX_MS, parseVerdict, parseHello, pageIsStale } from '../../../src/dev-reload-worker.js'; +import { startReloadWorker, RELOAD_QUIET_IN_PLACE_MS, frameInPlace, RELOAD_QUIET_MS, RELOAD_MAX_HOLD_MS, RECONNECT_BASE_MS, RECONNECT_MAX_MS, parseVerdict, parseHello, pageIsStale } from '../../../src/dev-reload-worker.js'; import { assert } from '../../../../../test/browser-assert.js'; @@ -673,3 +673,44 @@ suite('dev reload stream pauses when hidden and reconnects with backoff (#1507)' assert.deepEqual(parseHello('b1'), { boot: 'b1', seq: null }); }); }); + +// #1575: a server that reloads in place (Bun's one long-lived dev server) +// marks its frames, and nothing restarts behind them, so the relay need not +// sit out the 2000ms restart window that was most of the save-to-paint time. +suite('dev reload settle for an in-place server (#1575)', () => { + test('a batch of in-place frames reloads after the short window', () => { + const { scope, tick } = fakeClock(); + startReloadWorker(scope, FakeEventSource, '/__webjs/events'); + const a = fakePort(); + scope.onconnect({ ports: [a.port] }); + FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 1, inPlace: true })); + FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 2, inPlace: true })); + tick(RELOAD_QUIET_IN_PLACE_MS - 1); + assert.deepEqual(a.received, [], 'still folding the burst'); + tick(1); + assert.deepEqual(a.received, [{ type: 'reload', verdict: 'page' }], 'one reload, right after the burst'); + }); + + test('a restart signal in the batch keeps the full window', () => { + const { scope, tick } = fakeClock(); + startReloadWorker(scope, FakeEventSource, '/__webjs/events'); + const a = fakePort(); + scope.onconnect({ ports: [a.port] }); + FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 1 })); + FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 2, inPlace: true })); + tick(RELOAD_QUIET_IN_PLACE_MS); + assert.deepEqual(a.received, [], 'a frame from a restarting server waits'); + tick(RELOAD_QUIET_MS); + assert.deepEqual(a.received, [{ type: 'reload', verdict: 'page' }]); + // The next batch starts short again. + FakeEventSource.last.fire('reload', JSON.stringify({ v: 'page', seq: 3, inPlace: true })); + tick(RELOAD_QUIET_IN_PLACE_MS); + assert.equal(a.received.length, 2); + }); + + test('frameInPlace reads only an explicit true', () => { + assert.equal(frameInPlace('{"v":"page","inPlace":true}'), true); + assert.equal(frameInPlace('{"v":"page"}'), false); + assert.equal(frameInPlace('not json'), false); + }); +}); diff --git a/packages/server/test/dev/hot-reload-robustness.test.js b/packages/server/test/dev/hot-reload-robustness.test.js index c7ae781eb..26ed3cb00 100644 --- a/packages/server/test/dev/hot-reload-robustness.test.js +++ b/packages/server/test/dev/hot-reload-robustness.test.js @@ -123,3 +123,10 @@ test('devImport never re-imports a specifier that failed, and recovers when the rmSync(dir, { recursive: true, force: true }); } }); + +test('an in-place server marks its reload frames; a restarting one does not (#1575)', async () => { + const { SseHub } = await import('../../src/listener-core.js'); + const frames = (hub) => { const out = []; hub.add({ send: (s) => out.push(s), close() {} }); hub.reload({ v: 'page' }); return out[0]; }; + assert.match(frames(new SseHub({ inPlace: true })), /"inPlace":true/); + assert.doesNotMatch(frames(new SseHub()), /inPlace/); +});