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": {