Skip to content

Commit 21dfc1d

Browse files
committed
fix: correct the inverted warm comparison and the shared bun port base
Final whole-diff review found three things. The rationale for RELOAD_QUIET_MS said 2000ms is "under the 1900ms analysis warm", which is backwards: 2000 is above 1900. The number carries the argument for picking this value, so an inverted relation is not a stray adjective. The behaviour it describes is real, the debounce lands just past the point where the restarted process has finished warming, so it now says that. Corrected in the module and in the server AGENTS.md row that repeats it. The new bun script took 9500 + pid % 240, byte-identical to dev-reload-retry. Every other dev-server bun script takes a disjoint base on purpose, so that two FILES can never contend whatever their pids, leaving the modulus to separate concurrent runs of one file. Both ship a test wrapper the node runner can schedule concurrently, so the only thing preventing a bind collision was the two pids not being congruent mod 240. The every-request sweep missed the middleware sample on the backend-only docs page, which was the last surviving instance of the claim.
1 parent c82f2b7 commit 21dfc1d

4 files changed

Lines changed: 13 additions & 6 deletions

File tree

packages/server/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` /
3030

3131
| File | What it owns |
3232
|---|---|
33-
| `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. **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 (Node's `node --watch` briefly kills 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 `node --watch` 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 under 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. **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 |
33+
| `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. **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 (Node's `node --watch` briefly kills 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 `node --watch` 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. **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 |
3434
| `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) |
3535
| `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 |
3636
| `ssr.js` | SSR pipeline: nested layouts, metadata → `<head>`, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the 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. **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 |

packages/server/src/dev-reload-worker.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@
2828
*
2929
* 2000ms is above the measured 1071ms inter-save gap so a realistic burst
3030
* collapses to one reload, and deliberately not NEAR it, since a window close
31-
* to that gap fires just as the next restart begins. It is also under the
32-
* 1900ms analysis warm measured on the website app, so on a real app the wait
33-
* overlaps work the reload request would have blocked on anyway.
31+
* to that gap fires just as the next restart begins. It also lands just past
32+
* the 1900ms analysis warm measured on the website app, which is the useful
33+
* place for it: the restarted process kicks off `warmup()` as soon as it
34+
* listens, so the wait overlaps work the reload request would have blocked on
35+
* anyway and the reload arrives at a server that has finished warming.
3436
*/
3537
export const RELOAD_QUIET_MS = 2000;
3638

0 commit comments

Comments
 (0)