diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index afe0944f..cdd0532c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -133,6 +133,15 @@ jobs:
# stale. The same script proves no Node regression under `npm test`.
- name: webjs dev hot reload on Bun
run: bun test/bun/dev-hot-reload.mjs
+ # Dev live-reload of a webjs.dev.watch dir OUTSIDE the appDir (#894) and
+ # the SSE retry hint on the Bun.serve shell (#893). Plain scripts (no
+ # per-test timeout) since each boots the real CLI + polls for readiness;
+ # the node:test integration versions are node:http-listener-specific and
+ # denylisted in run-bun-tests.js.
+ - name: webjs dev extra-watch + reload-retry on Bun
+ run: |
+ bun test/bun/dev-extra-watch.mjs
+ bun test/bun/dev-reload-retry.mjs
# SSR action-result seeding on Bun (#529): seeding rode Node's
# module.registerHooks, which Bun lacks; it now installs via a Bun.plugin
# onLoad, so a shipping async component seeds during SSR (the __webjs-seeds
diff --git a/agent-docs/configuration.md b/agent-docs/configuration.md
index 0e7153a2..85b2e44c 100644
--- a/agent-docs/configuration.md
+++ b/agent-docs/configuration.md
@@ -276,7 +276,8 @@ identically. This replaces the old `predev` / `prestart` npm hooks +
"webjs": {
"dev": {
"before": ["webjs db migrate"], // one-shot, runs to completion first
- "parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"] // long-lived, runs alongside the server
+ "parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"], // long-lived, runs alongside the server
+ "watch": ["../blog"] // extra dirs to live-reload on, OUTSIDE the appDir
},
"start": {
"before": ["webjs db migrate"] // one-shot, before serving
@@ -286,6 +287,7 @@ identically. This replaces the old `predev` / `prestart` npm hooks +
- **`before`** (dev and start): commands run sequentially to completion BEFORE the server boots (the old `predev` / `prestart`: `webjs db migrate`, a registry copy). A non-zero exit ABORTS the boot with a clear message, so a failed migration never serves stale code/schema.
- **`parallel`** (dev only): long-lived child processes that run ALONGSIDE the server (the old `concurrently` watchers: the Tailwind CLI `--watch`). They are spawned once in the parent (not on every hot-reload restart) and TORN DOWN on exit (SIGINT / SIGTERM / server exit), so a watcher cannot leak past the dev server.
+- **`watch`** (dev only, #894): extra directories the dev live-reload watcher follows IN ADDITION to the appDir. The dev server watches its appDir recursively, but an app that reads content from OUTSIDE its tree (the in-repo `website` renders posts from a repo-root `blog/` dir, a sibling of the app) sees no reload when that outside content changes. Each entry is resolved relative to the app root and MAY escape it (`"../blog"`); a change under one runs the same rebuild + browser reload as an in-tree edit. Opt-in: a missing/empty `watch` means the appDir is the only watched root, unchanged. A configured path that does not exist is skipped, an entry that overlaps the appDir (an ancestor or descendant) is dropped as redundant, and the usual `node_modules` / `.git` / `.webjs` / `db` noise is ignored under each extra root too.
- Each command runs through a shell, so a normal command line works. An empty / absent block means `webjs dev` / `start` run unchanged, so a plain app with no Tailwind/DB needs no config.
- The scaffold uses the Tailwind browser runtime (no CSS build step), so it ships only `dev.before` / `start.before` (the Drizzle migration apply); an app that adds the Tailwind CLI puts its `--watch` under `webjs.dev.parallel`. The in-repo apps (`examples/blog`, `website`, `docs`, ui-website) show the Tailwind `parallel` watcher pattern.
- **Prod note:** `before` runs at boot, so `webjs start` runs `webjs db migrate` in-process to apply pending migrations. Drizzle has no client-codegen step (the schema IS the types, inferred at compile time), so there is nothing to run at image-BUILD time. Authoring a new migration from a schema change is a dev-time `webjs db generate`, committed to source control, not a boot step. So `CMD ["npm", "start"]` and `CMD ["webjs", "start"]` are equivalent.
diff --git a/packages/core/src/webjs-config.d.ts b/packages/core/src/webjs-config.d.ts
index ab6e6eb3..0b37b61b 100644
--- a/packages/core/src/webjs-config.d.ts
+++ b/packages/core/src/webjs-config.d.ts
@@ -95,6 +95,17 @@ export interface WebjsDevTasks {
* the parent and torn down on exit, so a watcher cannot leak past the server.
*/
parallel?: string[];
+ /**
+ * Extra directories the dev live-reload watcher follows IN ADDITION to the
+ * appDir (#894). The dev server watches its appDir recursively, but an app
+ * that reads content from OUTSIDE its tree (e.g. blog markdown in a repo-root
+ * `blog/` dir, a sibling of the app) sees no reload when that content changes.
+ * Each entry is resolved relative to the app root and MAY escape it
+ * (`"../blog"`); a change under one triggers the same rebuild + browser reload
+ * as an in-tree edit. Missing / overlapping (ancestor or descendant of the
+ * appDir) entries are skipped. Read by the server (`readDevWatchPathsFromApp`).
+ */
+ watch?: string[];
}
/** Start task orchestration in `webjs.start` (#550). Read by the CLI, not the server. */
diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md
index 7323e169..211517d1 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` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. Dev error overlay (#264): `reportDevError(error, info)` builds a frame (via `dev-error.js`) and pushes it to the open tab over the SSE channel as a `webjs-error` event, fed by three sources (the SSR render catch via `ssrOpts.onDevError`, the `tsResponse` strip-failure, and the `rebuild` catch); a successful rebuild clears `state.lastDevError`; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). Dev-only: `reportDevError` early-returns in prod and `/__webjs/reload.js` 404s. **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` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. Dev error overlay (#264): `reportDevError(error, info)` builds a frame (via `dev-error.js`) and pushes it to the open tab over the SSE channel as a `webjs-error` event, fed by three sources (the SSR render catch via `ssrOpts.onDevError`, the `tsResponse` strip-failure, and the `rebuild` catch); a successful rebuild clears `state.lastDevError`; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). **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. **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 |
| `route-types.js` | Route-types generator (#258). `generateRouteTypes(appDir)` reuses `buildRouteTable` to emit the `.d.ts` text that augments `@webjsdev/core` (the `WebjsRoutes` href union + `RouteParamMap` per-route params), backing `webjs types` and the dev-startup emit. Pages-only (a `route.{js,ts}` API path is not a navigable href); strips route groups, excludes `_private`; an optional catch-all `[[...x]]` emits both the without-segment and a normalized `[...x]` href key while keeping the doubled literal as the param-map key. Deterministic (sorted keys). Helpers `routeKeyFromDir` / `dynamicSegments` / `paramTypeForKey` / `webjsRoutesKeysForKey` are exported for unit tests |
| `ssr.js` | SSR pipeline: nested layouts, metadata → `
`, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the page-action re-render passes 422). Server HTML cache (#241): on a plain GET render it loads the page module once to read `export const revalidate`, serves a cache HIT via `cachedHtmlResponse` (re-minting the build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the page-action re-render and partial-nav (`X-Webjs-Have`) requests. **Frame subtree render (#253):** after `renderChain`, when the request carries `x-webjs-frame: ` (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. **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 |
diff --git a/packages/server/src/dev-reload-worker.js b/packages/server/src/dev-reload-worker.js
index 5eb4b2b1..b6a5c5c9 100644
--- a/packages/server/src/dev-reload-worker.js
+++ b/packages/server/src/dev-reload-worker.js
@@ -21,6 +21,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
const ports = new Set();
/** @type {string | null} the last error frame, cached for late-joining tabs */
let lastError = null;
+ /** @type {string | null} the last-seen per-process boot id (#893) */
+ let lastBoot = null;
// A MessagePort has no reliable close event, so prune a port when a post to it
// throws (a closed tab). Some browsers silently no-op instead of throwing,
@@ -33,6 +35,22 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
}
const es = new EventSourceCtor(eventsUrl);
+
+ // The `hello` frame fires on every (re)connect and carries the server's
+ // per-process boot id (#893). A full server restart (Node's `node --watch`)
+ // drops this connection, and if the in-process rebuild's `reload` frame was
+ // killed with the old process no reload was delivered, so the edit would need
+ // a MANUAL refresh. The browser auto-reconnects to the FRESH process, whose
+ // boot id differs, so a CHANGED id is the edit signal: broadcast a reload (the
+ // tab still gates it on a readiness probe). A transient reconnect (sleep/wake,
+ // a network blip, a tab evicted at the HTTP/1.1 cap) reconnects to the SAME
+ // process with the SAME id, so it never reloads. The first `hello` only
+ // records the baseline.
+ es.addEventListener('hello', (e) => {
+ if (lastBoot !== null && e.data !== lastBoot) fanout({ type: 'reload' });
+ lastBoot = e.data;
+ });
+
es.addEventListener('reload', () => { lastError = null; fanout({ type: 'reload' }); });
es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js
index 87980c3a..4e86330c 100644
--- a/packages/server/src/dev.js
+++ b/packages/server/src/dev.js
@@ -44,6 +44,7 @@ import {
negotiateEncoding,
createCompressor,
varyWithAcceptEncoding,
+ DEV_BOOT_ID,
} from './listener-core.js';
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport, appImportsMap } from './module-graph.js';
@@ -421,6 +422,50 @@ export async function readBodyLimitsFromApp(appDir) {
return readBodyLimits(pkg);
}
+/**
+ * Extra dev watch roots from the app's package.json `webjs.dev.watch` (#894).
+ * These are directories the app READS from but that live OUTSIDE its appDir, so
+ * the recursive `fs.watch(appDir)` never sees them (e.g. the website renders
+ * posts from a repo-root `blog/` dir, a sibling of the app, so editing a post
+ * would not live-reload). Each entry is resolved relative to the appDir and MAY
+ * escape it (`"../blog"`); a change under one runs the same rebuild + reload as
+ * an in-tree edit. Opt-in: a missing/empty config yields `[]`, so a plain app
+ * watches only its appDir, unchanged.
+ *
+ * Returns absolute, de-duped paths, skipping any that overlap the appDir (the
+ * appDir is already watched recursively, so an ancestor or descendant root
+ * would just double-fire the rebuild). Existence is NOT checked here (the caller
+ * filters missing paths so it can log them); the reader stays a pure
+ * package.json read, matching the other `readXFromApp` helpers.
+ *
+ * @param {string} appDir
+ * @returns {Promise}
+ */
+export async function readDevWatchPathsFromApp(appDir) {
+ let pkg;
+ try {
+ pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
+ } catch {
+ return [];
+ }
+ const raw = pkg && pkg.webjs && pkg.webjs.dev && pkg.webjs.dev.watch;
+ if (!Array.isArray(raw)) return [];
+ /** @type {string[]} */
+ const out = [];
+ const seen = new Set();
+ for (const entry of raw) {
+ if (typeof entry !== 'string' || !entry.trim()) continue;
+ const abs = resolve(appDir, entry);
+ // Skip the appDir itself and any ancestor/descendant overlap: the appDir is
+ // already watched recursively, so an overlapping extra root double-fires.
+ if (abs === appDir || appDir.startsWith(abs + sep) || abs.startsWith(appDir + sep)) continue;
+ if (seen.has(abs)) continue;
+ seen.add(abs);
+ out.push(abs);
+ }
+ return out;
+}
+
/**
* Resolve the node:http server timeouts (issue #237) from the app's
* package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
@@ -1543,9 +1588,9 @@ export async function startServer(opts) {
// the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
const rebuild = debounce(() => app.rebuild(), 80);
watcherAbort = new AbortController();
- (async () => {
+ const watchRoot = async (root) => {
try {
- const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
+ const events = fsWatch(root, { recursive: true, signal: watcherAbort.signal });
for await (const event of events) {
const filename = event.filename || '';
if (shouldIgnoreWatchPath(filename)) continue;
@@ -1553,10 +1598,17 @@ export async function startServer(opts) {
}
} catch (err) {
if (err && /** @type any */(err).name !== 'AbortError') {
- logger.warn({ err }, 'file watcher exited');
+ logger.warn({ err }, `file watcher exited (${root})`);
}
}
- })();
+ };
+ watchRoot(app.appDir);
+ // Extra roots the app reads from OUTSIDE its appDir (#894): repo-root content
+ // dirs (blog markdown, data fixtures) the recursive appDir watch can't see.
+ // Opt-in via `webjs.dev.watch`; each funnels into the same debounced rebuild.
+ const extraWatch = (await readDevWatchPathsFromApp(app.appDir)).filter((p) => existsSync(p));
+ for (const root of extraWatch) watchRoot(root);
+ if (extraWatch.length) logger.info?.(`[webjs] also watching ${extraWatch.length} extra dev path(s): ${extraWatch.join(', ')}`);
}
// Inbound server timeouts (issue #237). On node these are the node:http
@@ -1611,7 +1663,12 @@ function startNodeListener(ctx) {
'cache-control': 'no-cache',
connection: 'keep-alive',
});
- res.write(`event: hello\ndata: webjs\n\n`);
+ // `retry: 300` shrinks the browser's EventSource reconnect backoff from
+ // its ~3s default so a reconnect after a `node --watch` restart happens
+ // promptly. The `hello` data is a per-process boot id (#893): the client
+ // reloads on a reconnect ONLY when it changes (a real restart), so a
+ // transient reconnect never triggers a spurious reload.
+ res.write(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`);
// Register a node client wrapper in the shared hub: the fanout + keepalive
// live in SseHub; only the transport write (res.write / res.end) is local.
const client = {
@@ -2663,15 +2720,43 @@ function reloadClientJs(bp) {
// its own `EventSource`, the original behaviour. Correct, just not shared.
const eventsUrl = JSON.stringify(withBasePath('/__webjs/events', bp));
const workerUrl = JSON.stringify(withBasePath('/__webjs/reload-worker.js', bp));
+ const versionUrl = JSON.stringify(withBasePath('/__webjs/version', bp));
return `// webjs dev reload client
${DEV_OVERLAY_SRC}
function __webjsApplyError(data) {
let f; try { f = JSON.parse(data); } catch (_) { return; }
renderDevOverlay(f);
}
+// Never reload INTO a server that is still restarting (Node's node --watch
+// briefly kills the process on an edit), which would paint a style-less,
+// half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
+// until it answers, THEN reload. Under an in-process reload the server is up,
+// so the first probe passes and the reload is instant; under a restart the
+// probes fail until the fresh server is listening, so the old page stays put
+// until the new one is ready. Bounded, so a genuinely-dead server still
+// reloads (and shows the browser's own error) rather than hanging forever.
+function __webjsReloadWhenReady() {
+ var tries = 0;
+ function attempt() {
+ fetch(${versionUrl}, { cache: 'no-store' }).then(function (r) {
+ if (r && r.ok) location.reload(); else again();
+ }).catch(again);
+ }
+ function again() { if (++tries > 100) location.reload(); else setTimeout(attempt, 100); }
+ attempt();
+}
function __webjsDirectEvents() {
+ // Same restart-reload as the SharedWorker relay (#893), for the per-tab
+ // fallback: the hello frame carries the server's per-process boot id, so a
+ // CHANGED id on reconnect means a real restart (reload), while a transient
+ // reconnect to the same process keeps the id (no spurious reload).
+ var lastBoot = null;
const es = new EventSource(${eventsUrl});
- es.addEventListener('reload', () => location.reload());
+ es.addEventListener('hello', (e) => {
+ if (lastBoot !== null && e.data !== lastBoot) __webjsReloadWhenReady();
+ lastBoot = e.data;
+ });
+ es.addEventListener('reload', () => __webjsReloadWhenReady());
es.addEventListener('webjs-error', (e) => __webjsApplyError(e.data));
}
try {
@@ -2679,7 +2764,7 @@ try {
const w = new SharedWorker(${workerUrl});
w.port.onmessage = (e) => {
const m = e.data || {};
- if (m.type === 'reload') location.reload();
+ if (m.type === 'reload') __webjsReloadWhenReady();
else if (m.type === 'webjs-error') __webjsApplyError(m.data);
};
w.port.start();
diff --git a/packages/server/src/listener-bun.js b/packages/server/src/listener-bun.js
index 65237ce7..2e28d6fb 100644
--- a/packages/server/src/listener-bun.js
+++ b/packages/server/src/listener-bun.js
@@ -58,6 +58,7 @@ import {
MAX_SYNC_COMPRESS_BYTES,
varyWithAcceptEncoding,
readBufferedOrStream,
+ DEV_BOOT_ID,
} from './listener-core.js';
/* global Bun */
@@ -275,7 +276,11 @@ function bunSseResponse(req, hub, app) {
send: (s) => { try { controller.enqueue(enc.encode(s)); } catch {} },
close: () => { try { controller.close(); } catch {} },
};
- controller.enqueue(enc.encode('event: hello\ndata: webjs\n\n'));
+ // `retry: 300` shrinks the EventSource reconnect backoff so a reconnect
+ // after a dev restart re-triggers a reload promptly (#893); the `hello`
+ // data is the per-process boot id so a reconnect reloads only on a real
+ // restart, matching the node:http shell.
+ controller.enqueue(enc.encode(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`));
hub.add(client);
// Replay an unresolved dev error (#264) so a tab connecting AFTER the
// breaking edit still shows the overlay.
diff --git a/packages/server/src/listener-core.js b/packages/server/src/listener-core.js
index 2bcbdc4c..8e8e2aea 100644
--- a/packages/server/src/listener-core.js
+++ b/packages/server/src/listener-core.js
@@ -22,10 +22,21 @@ import {
constants as zlibConstants,
} from 'node:zlib';
import { stripBasePath } from './base-path.js';
+import { randomUUID } from 'node:crypto';
/** The dev live-reload SSE path (matched after base-path stripping). */
export const EVENTS_PATH = '/__webjs/events';
+/**
+ * A per-PROCESS id sent on the dev SSE `hello` frame (#893). It is regenerated
+ * on every server start, so a `node --watch` RESTART yields a NEW id while a
+ * transient reconnect (laptop sleep/wake, a network blip, a tab evicted at the
+ * HTTP/1.1 connection cap) reconnects to the SAME process and sees the SAME id.
+ * The reload client reloads only when this id CHANGES, so a mere reconnect never
+ * triggers a spurious full reload that would discard in-page dev state.
+ */
+export const DEV_BOOT_ID = randomUUID();
+
/**
* Detect the host runtime so `startServer` can pick an adapter. Bun sets
* `process.versions.bun` (and also reports a `node` version via its compat
diff --git a/packages/server/test/dev/browser/reload-worker.test.js b/packages/server/test/dev/browser/reload-worker.test.js
index 0143398d..6c14705c 100644
--- a/packages/server/test/dev/browser/reload-worker.test.js
+++ b/packages/server/test/dev/browser/reload-worker.test.js
@@ -75,4 +75,29 @@ suite('dev reload SharedWorker relay (#887)', () => {
const { es } = startReloadWorker(scope, FakeEventSource, '/base/__webjs/events');
assert.equal(es.url, '/base/__webjs/events', 'the one connection uses the base-path-aware URL');
});
+
+ // #893: a `node --watch` restart drops the connection; if the in-process
+ // reload frame was killed with the old process, no reload was delivered, so
+ // the edit would need a manual refresh. The `hello` frame carries a
+ // per-process boot id, so a CHANGED id on reconnect is the reload signal.
+ test('a reconnect to a NEW process (changed boot id) fans a reload', () => {
+ const scope = {};
+ startReloadWorker(scope, FakeEventSource, '/__webjs/events');
+ const a = fakePort();
+ scope.onconnect({ ports: [a.port] });
+ FakeEventSource.last.fire('hello', 'BOOT_A'); // initial connect: baseline only
+ assert.deepEqual(a.received, [], 'the first hello does not reload');
+ FakeEventSource.last.fire('hello', 'BOOT_B'); // reconnected to a fresh process
+ assert.deepEqual(a.received, [{ type: 'reload' }], 'a new boot id reloads the tab');
+ });
+
+ test('a transient reconnect to the SAME process (same boot id) never reloads', () => {
+ const scope = {};
+ startReloadWorker(scope, FakeEventSource, '/__webjs/events');
+ const a = fakePort();
+ scope.onconnect({ ports: [a.port] });
+ FakeEventSource.last.fire('hello', 'BOOT_A'); // first connect
+ FakeEventSource.last.fire('hello', 'BOOT_A'); // sleep/wake or blip: same process
+ assert.deepEqual(a.received, [], 'a same-process reconnect is not an edit (no state loss)');
+ });
});
diff --git a/packages/server/test/dev/reload-retry-hint.test.js b/packages/server/test/dev/reload-retry-hint.test.js
new file mode 100644
index 00000000..39778be5
--- /dev/null
+++ b/packages/server/test/dev/reload-retry-hint.test.js
@@ -0,0 +1,51 @@
+/**
+ * #893: the dev SSE stream sends a short `retry:` hint so the browser's
+ * EventSource reconnects quickly after a `node --watch` restart (its default
+ * backoff is ~3s). That reconnect is what re-triggers a reload for an edit whose
+ * in-process reload frame was killed with the old process, so a slow reconnect
+ * would make an app edit feel like it needs a manual refresh. Booting the real
+ * server is the point: the hint is written by the listener shell, not the
+ * handler, so only a live connection to `/__webjs/events` proves it ships.
+ */
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
+import { get } from 'node:http';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { startServer } from '../../src/dev.js';
+
+function makeApp() {
+ const appDir = mkdtempSync(join(tmpdir(), 'webjs-retry-'));
+ mkdirSync(join(appDir, 'app'), { recursive: true });
+ writeFileSync(join(appDir, 'app', 'page.js'), "export default function P() { return 'ok'; }\n");
+ writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'x', type: 'module' }));
+ return appDir;
+}
+
+/** Read the first SSE chunk (the hello frame) from /__webjs/events. */
+function firstFrame(port, ms) {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = (v) => { if (!done) { done = true; try { req.destroy(); } catch {} resolve(v); } };
+ const req = get({ port, path: '/__webjs/events', headers: { accept: 'text/event-stream' } }, (res) => {
+ res.setEncoding('utf8');
+ res.on('data', (c) => finish(c));
+ });
+ req.on('error', () => finish(''));
+ setTimeout(() => finish(''), ms);
+ });
+}
+
+test('the dev SSE hello frame carries a short retry hint for fast reconnect (#893)', async () => {
+ const srv = await startServer({ appDir: makeApp(), port: 0, dev: true });
+ const port = srv.server.address().port;
+ try {
+ const frame = await firstFrame(port, 4000);
+ assert.match(frame, /(^|\n)retry: 300(\n|$)/, 'the stream sets a 300ms reconnect backoff');
+ assert.match(frame, /event: hello/, 'the hello frame still opens the stream');
+ } finally {
+ await srv.close();
+ }
+});
diff --git a/packages/server/test/dev/reload-shared-connection.test.js b/packages/server/test/dev/reload-shared-connection.test.js
index 13945beb..8a019b57 100644
--- a/packages/server/test/dev/reload-shared-connection.test.js
+++ b/packages/server/test/dev/reload-shared-connection.test.js
@@ -79,3 +79,33 @@ test('the worker events URL carries the base path under a sub-path deploy (#256)
assert.match(client, /reload-worker\.js/, 'client references the worker');
assert.match(client, /\/app\/__webjs\/reload-worker\.js/, 'worker URL is base-path prefixed in the client');
});
+
+// #893: a reload must never paint into a half-restarted server, and an app edit
+// must never need a manual refresh. The client gates every reload on the server
+// being healthy (probe /__webjs/version, then reload), and the direct-EventSource
+// fallback treats a reconnect after a drop (a `node --watch` restart) as an edit
+// signal so the reload fires even when the in-process reload frame was killed
+// with the old process.
+test('the reload client probes the server is up before reloading (no restart flash, #893)', async () => {
+ const appDir = makeApp();
+ const app = await createRequestHandler({ appDir, dev: true });
+ const clientSrc = await (await app.handle(new Request('http://x/__webjs/reload.js'))).text();
+
+ assert.match(clientSrc, /function __webjsReloadWhenReady/, 'reload is gated on a readiness probe');
+ assert.match(clientSrc, /fetch\(\"\/__webjs\/version\"/, 'the probe hits the lightweight version endpoint');
+ // Both the SharedWorker path and the direct fallback route through the gate,
+ // never a bare location.reload() on a reload signal.
+ assert.match(clientSrc, /if \(m\.type === 'reload'\) __webjsReloadWhenReady\(\)/, 'the SharedWorker path gates the reload');
+ assert.match(clientSrc, /addEventListener\('reload', \(\) => __webjsReloadWhenReady\(\)\)/, 'the direct fallback gates the reload');
+ // The direct fallback reloads on a reconnect only when the boot id changed (a
+ // real restart), never on a same-process transient reconnect.
+ assert.match(clientSrc, /addEventListener\('hello'/, 'the fallback tracks the boot id via the hello frame');
+ assert.match(clientSrc, /if \(lastBoot !== null && e\.data !== lastBoot\) __webjsReloadWhenReady\(\)/, 'only a changed boot id reloads');
+});
+
+test('the direct-fallback probe carries the base path under a sub-path deploy (#893 + #256)', async () => {
+ const appDir = makeApp({ basePath: '/app' });
+ const app = await createRequestHandler({ appDir, dev: true });
+ const clientSrc = await (await app.handle(new Request('http://x/app/__webjs/reload.js'))).text();
+ assert.match(clientSrc, /fetch\(\"\/app\/__webjs\/version\"/, 'the readiness probe is base-path prefixed');
+});
diff --git a/packages/server/test/dev/watch-extra-paths-live.test.js b/packages/server/test/dev/watch-extra-paths-live.test.js
new file mode 100644
index 00000000..90b2786f
--- /dev/null
+++ b/packages/server/test/dev/watch-extra-paths-live.test.js
@@ -0,0 +1,89 @@
+/**
+ * #894 end-to-end: a `webjs.dev.watch` entry pointing OUTSIDE the appDir makes
+ * an edit under that dir live-reload, exactly like an in-tree edit. Booting the
+ * real dev server (not just the handler) is the point: the extra watchers are
+ * wired in `startServer`, alongside the recursive appDir watcher, and prove
+ * themselves only by firing an actual SSE `reload` frame. The counterfactual
+ * (no config -> the same sibling edit fires NOTHING) proves the appDir watch
+ * alone never saw the outside dir, which is the bug.
+ */
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
+import { get } from 'node:http';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { startServer } from '../../src/dev.js';
+
+/** Build an app dir + a SIBLING content dir; return both. */
+function scaffold(webjs) {
+ const root = mkdtempSync(join(tmpdir(), 'webjs-extrawatch-'));
+ const appDir = join(root, 'site');
+ const contentDir = join(root, 'content');
+ mkdirSync(join(appDir, 'app'), { recursive: true });
+ mkdirSync(contentDir, { recursive: true });
+ writeFileSync(join(appDir, 'app', 'page.js'), "export default function P() { return 'ok'; }\n");
+ writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'site', type: 'module', ...(webjs ? { webjs } : {}) }));
+ writeFileSync(join(contentDir, 'post.md'), '# original\n');
+ return { appDir, contentDir };
+}
+
+/** Resolve on the FIRST `event: reload` seen on the SSE stream, or on timeout. */
+function waitForReload(port, ms) {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = (v) => { if (!done) { done = true; try { req.destroy(); } catch {} resolve(v); } };
+ const req = get({ port, path: '/__webjs/events', headers: { accept: 'text/event-stream' } }, (res) => {
+ res.setEncoding('utf8');
+ let buf = '';
+ res.on('data', (c) => { buf += c; if (/(^|\n)event: reload(\n|$)/.test(buf)) finish(true); });
+ });
+ req.on('error', () => finish(false));
+ setTimeout(() => finish(false), ms);
+ });
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+test('an edit under a webjs.dev.watch dir OUTSIDE the appDir live-reloads (#894)', async () => {
+ const { appDir, contentDir } = scaffold({ dev: { watch: ['../content'] } });
+ const srv = await startServer({ appDir, port: 0, dev: true });
+ const port = srv.server.address().port;
+ try {
+ const reloaded = waitForReload(port, 4000);
+ await sleep(150); // let the SSE stream connect before the edit
+ writeFileSync(join(contentDir, 'post.md'), '# edited\n');
+ assert.equal(await reloaded, true, 'editing the sibling content dir fired a reload');
+ } finally {
+ await srv.close();
+ }
+});
+
+test('WITHOUT the config, the same sibling edit fires nothing (proves it was unwatched)', async () => {
+ const { appDir, contentDir } = scaffold(undefined); // no webjs.dev.watch
+ const srv = await startServer({ appDir, port: 0, dev: true });
+ const port = srv.server.address().port;
+ try {
+ const reloaded = waitForReload(port, 1500);
+ await sleep(150);
+ writeFileSync(join(contentDir, 'post.md'), '# edited\n');
+ assert.equal(await reloaded, false, 'the sibling dir is not watched by default');
+ } finally {
+ await srv.close();
+ }
+});
+
+test('an in-tree app edit still reloads (extra watchers do not displace the appDir watcher)', async () => {
+ const { appDir } = scaffold({ dev: { watch: ['../content'] } });
+ const srv = await startServer({ appDir, port: 0, dev: true });
+ const port = srv.server.address().port;
+ try {
+ const reloaded = waitForReload(port, 4000);
+ await sleep(150);
+ writeFileSync(join(appDir, 'app', 'page.js'), "export default function P() { return 'edited'; }\n");
+ assert.equal(await reloaded, true, 'the recursive appDir watcher still fires');
+ } finally {
+ await srv.close();
+ }
+});
diff --git a/packages/server/test/dev/watch-extra-paths.test.js b/packages/server/test/dev/watch-extra-paths.test.js
new file mode 100644
index 00000000..a7be48b9
--- /dev/null
+++ b/packages/server/test/dev/watch-extra-paths.test.js
@@ -0,0 +1,61 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { readDevWatchPathsFromApp } from '../../src/dev.js';
+
+// #894: the recursive fs.watch on the appDir cannot see content the app reads
+// from OUTSIDE its tree (the website renders posts from a repo-root `blog/`
+// sibling of the app), so editing that content never live-reloads.
+// `webjs.dev.watch` declares extra roots the watcher also follows.
+
+async function appWith(webjs) {
+ const dir = await mkdtemp(join(tmpdir(), 'webjs-devwatch-'));
+ const pkg = webjs === undefined ? {} : { webjs };
+ await writeFile(join(dir, 'package.json'), JSON.stringify(pkg));
+ return dir;
+}
+
+test('reads webjs.dev.watch and resolves entries to absolute paths (escaping the appDir)', async () => {
+ const dir = await appWith({ dev: { watch: ['../blog', '../shared-data'] } });
+ const got = await readDevWatchPathsFromApp(dir);
+ assert.deepEqual(got, [resolve(dir, '../blog'), resolve(dir, '../shared-data')]);
+});
+
+test('an app with no config watches only its appDir (empty extra list)', async () => {
+ assert.deepEqual(await readDevWatchPathsFromApp(await appWith(undefined)), []);
+ assert.deepEqual(await readDevWatchPathsFromApp(await appWith({ dev: {} })), []);
+ assert.deepEqual(await readDevWatchPathsFromApp(await appWith({ dev: { watch: [] } })), []);
+});
+
+test('drops non-string / blank entries and de-dupes', async () => {
+ const dir = await appWith({ dev: { watch: ['../blog', '', ' ', 42, '../blog', null] } });
+ assert.deepEqual(await readDevWatchPathsFromApp(dir), [resolve(dir, '../blog')]);
+});
+
+test('skips the appDir itself and any ancestor/descendant overlap (already watched)', async () => {
+ const dir = await appWith({ dev: { watch: ['.', '..', './sub', '../blog'] } });
+ // '.' is the appDir, '..' is an ancestor, './sub' is a descendant: all elided.
+ // Only the sibling '../blog' survives.
+ assert.deepEqual(await readDevWatchPathsFromApp(dir), [resolve(dir, '../blog')]);
+});
+
+test('an unreadable / missing package.json yields no extra paths (never throws)', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'webjs-devwatch-nopkg-'));
+ assert.deepEqual(await readDevWatchPathsFromApp(dir), []);
+});
+
+test('a garbage webjs.dev.watch value is ignored, not crashed on', async () => {
+ const dir = await appWith({ dev: { watch: 'blog' } }); // string, not array
+ assert.deepEqual(await readDevWatchPathsFromApp(dir), []);
+});
+
+// Sanity that a real declared sibling that EXISTS is returned (the caller then
+// filters by existence; here we just prove the path resolution is real).
+test('a declared sibling dir that exists resolves correctly', async () => {
+ const dir = await appWith({ dev: { watch: ['../content'] } });
+ await mkdir(resolve(dir, '../content'), { recursive: true });
+ const got = await readDevWatchPathsFromApp(dir);
+ assert.deepEqual(got, [resolve(dir, '../content')]);
+});
diff --git a/packages/server/webjs-config.schema.json b/packages/server/webjs-config.schema.json
index 3b05165c..352514bb 100644
--- a/packages/server/webjs-config.schema.json
+++ b/packages/server/webjs-config.schema.json
@@ -173,6 +173,11 @@
"description": "Long-lived commands run as child processes alongside the dev server, torn down on exit so a watcher cannot leak past it.",
"type": "array",
"items": { "type": "string" }
+ },
+ "watch": {
+ "description": "Extra directories the dev live-reload watcher follows in addition to the appDir (#894), for content the app reads from outside its own tree (e.g. blog markdown in a repo-root blog/ dir). Each entry is resolved relative to the app root and may escape it (../blog). Read by the server (readDevWatchPathsFromApp).",
+ "type": "array",
+ "items": { "type": "string" }
}
}
},
diff --git a/scripts/run-bun-tests.js b/scripts/run-bun-tests.js
index 47e5cca8..1ca585f3 100644
--- a/scripts/run-bun-tests.js
+++ b/scripts/run-bun-tests.js
@@ -40,6 +40,8 @@ const DENYLIST = [
{ match: 'packages/server/test/api/dev-cache-bust.test.js', reason: 'asserts the bare server-level dev ?t= import cache-bust directly (no supervisor), which Bun ignores by keying its module cache on path. The USER-FACING hot reload is fixed for Bun at the CLI level via `bun --hot` (#514), proven cross-runtime by test/bun/dev-hot-reload.mjs; this unit test exercises the Node-only `?t=` mechanism. The rest of handleApi runs on Bun via api.test.js.' },
{ match: 'packages/server/test/body-limit/server-timeouts.test.js', reason: 'asserts node:http server.requestTimeout/headersTimeout/keepAliveTimeout; the Bun shell uses Bun.serve idleTimeout instead (#511). The runtime-agnostic 413 body-limit tests run on Bun via integration.test.js.' },
{ match: 'packages/server/test/dev/dev-handler.test.js', reason: 'node:http shell internals (toWebRequest / sendWebResponse / server.address, the node ServerResponse streaming path). The Bun shell is covered by test/bun/listener.mjs and test/bun/compression.mjs + listener/compression-parity.test.js (which now assert brotli on the Bun shell too, #517).' },
+ { match: 'packages/server/test/dev/watch-extra-paths-live.test.js', reason: 'boots startServer and reads the port via the node:http `server.address()` shape (#894), which the Bun.serve shell does not expose. The Bun behavior (an outside webjs.dev.watch dir live-reloads over SSE) is proven on Bun by test/bun/dev-extra-watch.mjs, which drives the real CLI + fetch. The readDevWatchPathsFromApp reader logic is runtime-agnostic and covered by watch-extra-paths.test.js.' },
+ { match: 'packages/server/test/dev/reload-retry-hint.test.js', reason: 'boots startServer and reads the port via the node:http `server.address()` shape (#893). The Bun behavior (the SSE hello carries the retry hint on the Bun.serve shell) is proven on Bun by test/bun/dev-reload-retry.mjs. The client-side reload protocol + boot-id relay are runtime-agnostic and covered by reload-shared-connection.test.js and dev/browser/reload-worker.test.js.' },
{ match: 'packages/server/test/ts-strip/ts-strip.test.js', reason: 'uses the node built-in stripper as the byte-identity reference (absent on Bun). The amaro path (Bun backend) is covered on Bun by test/bun/smoke.mjs + dev/dev-error-overlay.test.js, and a forced-amaro parity test runs on Node.' },
{ match: 'packages/server/test/importmap/importmap.test.js', reason: 'relies on node:test source-order for the shared importmap module singleton; Bun orders/isolates tests differently (the importmap functions themselves are runtime-agnostic).' },
{ match: 'packages/server/test/file-storage/disk-store.test.js', reason: "Bun's test runner mis-attributes the intentional mid-stream ReadableStream error across this file's tests. The FileStore streaming behavior (put/get round-trip AND the no-orphan-on-mid-stream-error invariant) is now proven on Bun by test/bun/file-storage.mjs (the #509 Readable.fromWeb->reader-loop fix)." },
diff --git a/test/bun/dev-extra-watch.mjs b/test/bun/dev-extra-watch.mjs
new file mode 100644
index 00000000..1f5c1e0e
--- /dev/null
+++ b/test/bun/dev-extra-watch.mjs
@@ -0,0 +1,107 @@
+/**
+ * Cross-runtime dev extra-watch test (#894): start `webjs dev` under WHICHEVER
+ * runtime runs this file, with a `webjs.dev.watch` entry pointing at a content
+ * dir OUTSIDE the appDir, then edit a file in that dir and assert the server
+ * fires a live-reload over SSE WITHOUT a manual refresh. Run under both:
+ *
+ * node test/bun/dev-extra-watch.mjs
+ * bun test/bun/dev-extra-watch.mjs
+ *
+ * The dev live-reload watcher is set up in `startServer`, which runs on BOTH
+ * the node:http and `Bun.serve` shells, so an app that reads content from
+ * outside its tree (blog markdown in a repo-root `blog/`, a sibling of the app)
+ * must reload identically on Node and Bun. A plain assert script (not
+ * node:test) so the SAME file runs on both runtimes; it exits non-zero on
+ * failure and spawns the real CLI via the current runtime's `process.execPath`.
+ */
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = resolve(__dirname, '../..');
+const CLI = join(ROOT, 'packages/cli/bin/webjs.js');
+const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
+const PORT = 9750 + (process.pid % 240);
+const BASE = `http://localhost:${PORT}`;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function until(fn, { timeoutMs, stepMs = 200 }) {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ try { if (await fn()) return true; } catch { /* keep polling */ }
+ if (Date.now() > deadline) return false;
+ await sleep(stepMs);
+ }
+}
+
+/** Resolve true on the first `event: reload` on the SSE stream, else on timeout. */
+async function reloadFired(timeoutMs) {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
+ try {
+ const res = await fetch(`${BASE}/__webjs/events`, { headers: { accept: 'text/event-stream' }, signal: ctrl.signal });
+ const reader = res.body.getReader();
+ const dec = new TextDecoder();
+ let buf = '';
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) return false;
+ buf += dec.decode(value, { stream: true });
+ if (/(^|\n)event: reload(\n|$)/.test(buf)) return true;
+ }
+ } catch {
+ return false;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+const root = mkdtempSync(join(tmpdir(), 'webjs-extra-watch-'));
+const dir = join(root, 'site');
+const contentDir = join(root, 'content');
+
+let child;
+try {
+ mkdirSync(join(dir, 'app'), { recursive: true });
+ mkdirSync(contentDir, { recursive: true });
+ writeFileSync(join(dir, 'app/page.ts'), "import { html } from '@webjsdev/core';\nexport default () => html`
ok
`;\n");
+ writeFileSync(join(contentDir, 'post.md'), '# original\n');
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({
+ name: 'extra-watch', type: 'module', imports: { '#*': './*' },
+ webjs: { dev: { watch: ['../content'] } },
+ }));
+ mkdirSync(join(dir, 'node_modules/@webjsdev'), { recursive: true });
+ symlinkSync(join(ROOT, 'packages/core'), join(dir, 'node_modules/@webjsdev/core'));
+ symlinkSync(join(ROOT, 'packages/server'), join(dir, 'node_modules/@webjsdev/server'));
+
+ child = spawn(process.execPath, [CLI, 'dev', '--port', String(PORT)], {
+ cwd: dir, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, NODE_ENV: 'development' },
+ });
+ let log = '';
+ child.stdout.on('data', (d) => { log += d; });
+ child.stderr.on('data', (d) => { log += d; });
+
+ const ready = await until(async () => (await fetch(`${BASE}/__webjs/version`)).ok, { timeoutMs: 30_000 });
+ assert.ok(ready, `dev server never came up on ${runtime}\n--- server log ---\n${log}`);
+
+ // Open the SSE stream, then edit the sibling content file and expect a reload.
+ const fired = reloadFired(10_000);
+ await sleep(300); // let the SSE stream connect before the edit
+ writeFileSync(join(contentDir, 'post.md'), '# edited\n');
+ assert.ok(await fired, `editing a webjs.dev.watch dir OUTSIDE the appDir did not live-reload on ${runtime}\n--- server log ---\n${log}`);
+
+ console.log(`OK webjs dev live-reloads an edit to an outside webjs.dev.watch dir on ${runtime} (#894)`);
+} finally {
+ if (child && child.pid) {
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch {} }
+ await sleep(500);
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
+ }
+ rmSync(root, { recursive: true, force: true });
+}
diff --git a/test/bun/dev-extra-watch.test.mjs b/test/bun/dev-extra-watch.test.mjs
new file mode 100644
index 00000000..b932f224
--- /dev/null
+++ b/test/bun/dev-extra-watch.test.mjs
@@ -0,0 +1,14 @@
+/**
+ * Run the cross-runtime dev extra-watch check (#894) under WHICHEVER runtime
+ * executes the suite. Picked up by the root `node --test` runner, so `npm test`
+ * exercises it on Node; CI runs `bun test/bun/dev-extra-watch.mjs` separately
+ * for the `Bun.serve` shell. The behaviour script is a plain assert file
+ * (`dev-extra-watch.mjs`, not `*.test.mjs`, so the runner does not double-run
+ * it); importing it spawns the real CLI, edits an outside dir, and throws on
+ * any failure.
+ */
+import { test } from 'node:test';
+
+test('webjs dev live-reloads an edit to an outside webjs.dev.watch dir on this runtime (#894)', async () => {
+ await import('./dev-extra-watch.mjs');
+});
diff --git a/test/bun/dev-reload-retry.mjs b/test/bun/dev-reload-retry.mjs
new file mode 100644
index 00000000..9b27d320
--- /dev/null
+++ b/test/bun/dev-reload-retry.mjs
@@ -0,0 +1,75 @@
+/**
+ * Cross-runtime dev reload-retry test (#893): the dev SSE `hello` frame must
+ * carry a short `retry:` hint on BOTH the node:http and `Bun.serve` listener
+ * shells, so the browser's EventSource reconnects quickly after a dev restart
+ * (its default backoff is ~3s) and an app edit whose in-process reload frame
+ * was killed with the old process re-triggers a reload promptly. The hint is
+ * written by each listener shell, so it must be proven on each runtime. Run:
+ *
+ * node test/bun/dev-reload-retry.mjs
+ * bun test/bun/dev-reload-retry.mjs
+ */
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = resolve(__dirname, '../..');
+const CLI = join(ROOT, 'packages/cli/bin/webjs.js');
+const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
+const PORT = 9500 + (process.pid % 240);
+const BASE = `http://localhost:${PORT}`;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function until(fn, { timeoutMs, stepMs = 200 }) {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ try { if (await fn()) return true; } catch { /* keep polling */ }
+ if (Date.now() > deadline) return false;
+ await sleep(stepMs);
+ }
+}
+
+const dir = mkdtempSync(join(tmpdir(), 'webjs-reload-retry-'));
+let child;
+try {
+ mkdirSync(join(dir, 'app'), { recursive: true });
+ writeFileSync(join(dir, 'app/page.ts'), "import { html } from '@webjsdev/core';\nexport default () => html`
ok
`;\n");
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'retry', type: 'module', imports: { '#*': './*' }, webjs: {} }));
+ mkdirSync(join(dir, 'node_modules/@webjsdev'), { recursive: true });
+ symlinkSync(join(ROOT, 'packages/core'), join(dir, 'node_modules/@webjsdev/core'));
+ symlinkSync(join(ROOT, 'packages/server'), join(dir, 'node_modules/@webjsdev/server'));
+
+ child = spawn(process.execPath, [CLI, 'dev', '--port', String(PORT)], {
+ cwd: dir, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, NODE_ENV: 'development' },
+ });
+ let log = '';
+ child.stdout.on('data', (d) => { log += d; });
+ child.stderr.on('data', (d) => { log += d; });
+
+ const ready = await until(async () => (await fetch(`${BASE}/__webjs/version`)).ok, { timeoutMs: 30_000 });
+ assert.ok(ready, `dev server never came up on ${runtime}\n--- server log ---\n${log}`);
+
+ // Read the first SSE chunk and assert the retry hint rides the hello frame.
+ const res = await fetch(`${BASE}/__webjs/events`, { headers: { accept: 'text/event-stream' } });
+ const reader = res.body.getReader();
+ const { value } = await reader.read();
+ try { await reader.cancel(); } catch {}
+ const frame = new TextDecoder().decode(value);
+ assert.match(frame, /(^|\n)retry: 300(\n|$)/, `SSE hello lacked the retry hint on ${runtime}: ${JSON.stringify(frame)}`);
+ assert.match(frame, /event: hello/, `SSE hello frame missing on ${runtime}: ${JSON.stringify(frame)}`);
+
+ console.log(`OK dev SSE carries the retry: 300 reconnect hint on ${runtime} (#893)`);
+} finally {
+ if (child && child.pid) {
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch {} }
+ await sleep(500);
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
+ }
+ rmSync(dir, { recursive: true, force: true });
+}
diff --git a/test/bun/dev-reload-retry.test.mjs b/test/bun/dev-reload-retry.test.mjs
new file mode 100644
index 00000000..7d49e2b7
--- /dev/null
+++ b/test/bun/dev-reload-retry.test.mjs
@@ -0,0 +1,13 @@
+/**
+ * Run the cross-runtime dev reload-retry check (#893) under WHICHEVER runtime
+ * runs the suite. `npm test` exercises the node:http shell; CI runs
+ * `bun test/bun/dev-reload-retry.mjs` for the `Bun.serve` shell. The behaviour
+ * script is a plain assert file (`dev-reload-retry.mjs`, not `*.test.mjs`, so
+ * the runner does not double-run it); importing it spawns the real CLI and
+ * throws on any failure.
+ */
+import { test } from 'node:test';
+
+test('dev SSE carries the retry reconnect hint on this runtime (#893)', async () => {
+ await import('./dev-reload-retry.mjs');
+});
diff --git a/website/package.json b/website/package.json
index 790c32b5..fce9be3c 100644
--- a/website/package.json
+++ b/website/package.json
@@ -14,7 +14,7 @@
"css:build": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify"
},
"webjs": {
- "dev": { "before": ["npm run css:build"], "parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"] },
+ "dev": { "before": ["npm run css:build"], "parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"], "watch": ["../blog"] },
"start": { "before": ["npm run css:build"] }
},
"dependencies": {