diff --git a/.agents/skills/webjs/references/runtime.md b/.agents/skills/webjs/references/runtime.md index 2511ff1ec..c0b752b88 100644 --- a/.agents/skills/webjs/references/runtime.md +++ b/.agents/skills/webjs/references/runtime.md @@ -32,21 +32,21 @@ Three seams pick a runtime-specific implementation, all inside the framework, no | Listener | `node:http` shell | native `Bun.serve` (faster on the listening path only, not end-to-end, because SSR render dominates a real page) | | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) | | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` | -| Hot reload | restart on change (the `webjs dev` supervisor, #1521) | `bun --hot`, plus a restart for a `*.server.*` edit and, with source locations on, any app module edit (#1550) | +| Hot reload | restart on change (the `webjs dev` supervisor, #1521) | in place in one long-lived process (`bun --hot` resets the module registry, #1575); only an `instrumentation.*` / `env.*` edit restarts it | | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter | | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) | -| Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398), unless source locations are on | +| Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398) | | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same | **`webjs dev` lets an idle host sleep (#1507).** The live-reload stream sends nothing between edits (no keepalive) and is held open only while a tab showing the app is visible, so a sandbox or preview host that suspends on network quiet can suspend with a backgrounded dev tab open. Showing the tab reconnects, and an edit made meanwhile reloads the page on return. A host that counts an OPEN request as activity (a sandbox that suspends on idle) also needs `"webjs": { "dev": { "reloadIdle": 20 } }` (or `WEBJS_DEV_RELOAD_IDLE=20`): after that many seconds with no edit and no interaction the stream closes, and the next interaction, a tab showing, or an embed-bridge host command (`{ source: 'webjs-embed-host', type: 'resume' }`) reopens it. Off by default. Every reconnect also compares the server's state with the state the page on screen was rendered at, so an edit whose reload signal was lost while the stream was being replaced (a host that closes a held stream when it wakes) still reloads the page. **The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying. -Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. It cannot reload a module a `Bun.plugin` serves, though (#1550): every `'use server'` `*.server.*` module (the action-seeding plugin) and, when dev source locations are on (`WEBJS_SOURCE_LOCATIONS=1`), every app module (the source-locations plugin). The supervisor restarts the server for an edit to one of those, so on Bun with source locations on every app edit is a full reload, as on Node. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload. +Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload. **`webjs dev` and `webjs start` serve the directory they are started in, and refuse anywhere else (#1526).** Run them in the app directory, the one holding `app/`. In a workspace (`apps/web` under a root `package.json` with `workspaces`) that is the member, even when the CLI is hoisted to the root `node_modules`: the hoisted bin and the Bun `--hot` child both keep the directory they were started in. Started where there is no `app/` (the workspace root, or a subdirectory such as `app/` itself), both exit 1 before any `before` step runs, naming the app to start (`cd apps/web && webjs dev`), instead of booting a server that answers 404 for every route. -**`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an edit `bun --hot` cannot reload in place (a `*.server.*` module, or with source locations on any app module, #1550), and otherwise does only the crash recovery. Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port. +**`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an `instrumentation.*` / `env.*` edit (#1575), and otherwise does only the crash recovery. An agent writing files the way an AI editor does (bursts, partial writes, syntax errors then fixes, renames, deletes, atomic writes) is exercised by `scripts/dev-reload-stress.mjs` (`node scripts/dev-reload-stress.mjs ` against any running app). Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port. The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes. diff --git a/AGENTS.md b/AGENTS.md index 752a2f9a4..bde35860a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -568,7 +568,7 @@ Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a ## CLI reference ```sh -webjs dev [--port N] [--no-hot] # dev server with live reload (a restart-on-change supervisor on Node, bun --hot on Bun plus a restart for a module a Bun plugin serves, #1550; a watcher error never stops it and a crashed server is restarted, #1521). --no-hot runs in-process. Runs webjs.dev.before + webjs.dev.parallel (#550). A page or layout edit REFRESHES IN PLACE where the server process survives it (#1398); a component edit always reloads +webjs dev [--port N] [--no-hot] # dev server with live reload (a restart-on-change supervisor on Node; on Bun one long-lived server that `bun --hot` re-runs only to reset the module registry, restarted only for an instrumentation.*/env.* edit, #1575; a watcher error never stops it and a crashed server is restarted, #1521). --no-hot runs in-process. Runs webjs.dev.before + webjs.dev.parallel (#550). A page or layout edit REFRESHES IN PLACE where the server process survives it (#1398); a component edit always reloads webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550). dev AND start exit 1 when the cwd has no app/ (a workspace root, an app subdir), naming the app to start, instead of serving 404s (#1526) webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c6c2a2d0a..1484084db 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -70,10 +70,12 @@ lib/ `supervise` with `restartOnChange: true` on Node (restart the child on a change under `WATCH_DIRS` / the root `WATCH_FILES`), `supervise` with `bun --hot` and a - `restartFor` filter on Bun (`bun --hot` reloads in - place but never a module a `Bun.plugin` serves, so a - `*.server.*` edit, and with source locations on any - app module edit, restarts the child), or `inline` + `restartFor` filter on Bun (a `*.server.*` edit, with + source locations on any app module edit, and the root + boot hooks `instrumentation.*` / `env.*`), narrowed to + `inPlaceRestartFor` (the boot hooks only) once the + child announces over IPC that it reloads in place + (#1575, `inPlaceRestarts` in `dev-reload.js`), or `inline` (run in-process) for `--no-hot`. Node no longer runs under `node --watch`, which crashed on an unhandled watcher error and never came back. @@ -222,7 +224,7 @@ README.md npm-facing package readme. | Command | Implementation | |---|---| -| `webjs dev` | Runs `startServer({ dev: true })` in a child under WebJs's own reload supervisor (#514, #1521, `lib/dev-supervisor.js` plans, `lib/dev-reload.js` runs): on Node it restarts the child on a change (fresh ESM cache, plus the dev re-import's `?t=` query); on Bun the child runs under `bun --hot` (in-place module invalidation, since Bun keys its cache by path and ignores `?t=`). On both, a watcher error (EACCES / ENOENT on one path) is a warning, never a crash, and a crashed child is restarted on the next change or after a backoff. `--no-hot` opts out and runs the server in-process on either runtime. In the parent (pre-spawn) it runs the configured dev orchestration (#550, `lib/run-tasks.js`): the `webjs.dev.before` steps (one-shot) to completion, then the `webjs.dev.parallel` watchers (e.g. the Tailwind CLI) alongside the server, torn down on exit. So a bare `webjs dev` runs the same before-steps and watchers as `npm run dev`. The scaffold ships `webjs db migrate` as both a `dev.before` and a `start.before` step (#725), so a `db:generate`'d migration is applied on the next boot in dev and prod alike with no manual `db:migrate`; `.env` is loaded before the dev before-steps (same as start), so a Postgres dev migrate sees `DATABASE_URL`. Local binaries (`drizzle-kit`, `tailwindcss`) resolve because the spawn PATH is prepended with the ancestor `node_modules/.bin` dirs, npm-style. **Before dispatching either dev or start**, a directory-relative resolve probe (`checkFrameworkResolves` from `lib/doctor.js`) checks that `@webjsdev/core` resolves from `process.cwd()`; if not (the fresh-git-worktree-without-node_modules trap, #954), it prints the cause + remedy and exits 1 instead of letting a raw `ERR_MODULE_NOT_FOUND` bubble from deep in SSR. A no-op single resolve on the happy path. | +| `webjs dev` | Runs `startServer({ dev: true })` in a child under WebJs's own reload supervisor (#514, #1521, `lib/dev-supervisor.js` plans, `lib/dev-reload.js` runs): on Node it restarts the child on a change (fresh ESM cache, plus the dev re-import's `?t=` query); on Bun the child runs under `bun --hot`, whose re-run of this CLI is handed to the first run's server (`lib/dev-hot-rerun.js`, #1575) so one server lives for the whole session, and only an `instrumentation.*` / `env.*` edit restarts it. On both, a watcher error (EACCES / ENOENT on one path) is a warning, never a crash, and a crashed child is restarted on the next change or after a backoff. `--no-hot` opts out and runs the server in-process on either runtime. In the parent (pre-spawn) it runs the configured dev orchestration (#550, `lib/run-tasks.js`): the `webjs.dev.before` steps (one-shot) to completion, then the `webjs.dev.parallel` watchers (e.g. the Tailwind CLI) alongside the server, torn down on exit. So a bare `webjs dev` runs the same before-steps and watchers as `npm run dev`. The scaffold ships `webjs db migrate` as both a `dev.before` and a `start.before` step (#725), so a `db:generate`'d migration is applied on the next boot in dev and prod alike with no manual `db:migrate`; `.env` is loaded before the dev before-steps (same as start), so a Postgres dev migrate sees `DATABASE_URL`. Local binaries (`drizzle-kit`, `tailwindcss`) resolve because the spawn PATH is prepended with the ancestor `node_modules/.bin` dirs, npm-style. **Before dispatching either dev or start**, a directory-relative resolve probe (`checkFrameworkResolves` from `lib/doctor.js`) checks that `@webjsdev/core` resolves from `process.cwd()`; if not (the fresh-git-worktree-without-node_modules trap, #954), it prints the cause + remedy and exits 1 instead of letting a raw `ERR_MODULE_NOT_FOUND` bubble from deep in SSR. A no-op single resolve on the happy path. | | `webjs start` | `startServer({ dev: false })`, plain HTTP/1.1 (front a reverse proxy for TLS + HTTP/2). Shares the dev framework-resolve preflight above (#954). Both `dev` and `start` first refuse with exit 1 when the cwd has no `app/` (#1526, `refuseOutsideApp` in the bin, `findServeTarget` / `notAnAppServeMessage` in `lib/check-target.js`, the same predicate as `webjs check`'s #1301 guard): at a workspace root the message names the member apps, inside an app's subdirectory the app root. It runs before the resolve probe and every before-step, and the dev child skips it (its parent checked the same cwd). Without it a launch from a workspace root booted, said it was ready, and answered 404 for every route. Tests: `test/cli/check-target.test.mjs`, `test/e2e/dev-workspace-member.test.mjs` (hoisted bin under Node and Bun). | | `webjs test [--server\|--browser]` | Runtime-native test runner (#570): server tests run under `node --test` on Node and `bun test` on Bun (`bun --test` is invalid), dispatched on `process.versions.bun`; browser tests run the app's resolved `@web/test-runner` (`wtr`) bin via `process.execPath` (no `npx`). | | `webjs ci [-f\|--fail-fast] [--only ]... [--json] [--signoff]` | Local CI (#1471), the Rails `bin/ci` posture: runs the step list `package.json` declares under `webjs.ci.steps` (`readCiConfig` in `lib/ci-config.js`, `runCi` in `lib/ci-runner.js`). Each step is timed with a `✅` / `❌` result line, the run ends with a failure list and one total line, and the exit is 1 on any failure (130 on interrupt). A group with `parallel: N` runs N steps at once with each step's output captured and replayed whole; a group nested in it takes one slot and runs sequentially. Every child gets `CI=true`, `node_modules/.bin` on PATH, and the step's `env`; `.env` is loaded first like dev / start. The predicate is the CONFIG, not an `app/` directory, so a workspace root (this monorepo) is a legitimate target; no block is exit 1 naming the workspace members that declare one (`--json`: `{ error: { code: 'NO_CI_CONFIG' } }`), a malformed block is `INVALID_CI_CONFIG` with every problem's JSON path, an unknown `--only` title is `UNKNOWN_ONLY`. `--json` puts one document on stdout (`{ ok, seconds, interrupted, steps[] }`, failed steps carrying their captured output) and the human report on stderr. Under `GITHUB_ACTIONS` each step is a `::group::` and a failure an `::error::` annotation, and a `$GITHUB_STEP_SUMMARY` table is appended, so one cloud job running the whole list still names the layer that broke. `--signoff` runs `gh signoff` after a green run (a red run prints the do-not-merge heading). Tests: `test/cli/ci.test.mjs`, `test/bun/ci-runner.mjs` (both runtimes), `test/ci-config/`, `test/ci-runner/` | diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 67d9a33a5..9f0fa8358 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -456,6 +456,12 @@ async function refuseOutsideApp(command) { } async function main() { + // A `bun --hot` re-run of the dev server child (#1575): the first run's + // server owns the process, so hand it the reload and stop here. + if (cmd === 'dev' && process.env.__WEBJS_DEV_CHILD === '1' && process.versions.bun) { + const { rerunHotDevServer } = await import('../lib/dev-hot-rerun.js'); + if (await rerunHotDevServer()) return; + } // `--version` / `-v` (top level): print the installed CLI version and exit. if (cmd === '--version' || cmd === '-v') { console.log(readCliVersion()); @@ -526,7 +532,11 @@ async function main() { // parent is gone (killed outright, or crashed), and a child left // running would hold the port against the next `webjs dev`. Unref'd // so the channel itself never keeps this process alive. - if (process.connected) { + // Once per process: `bun --hot` re-runs this file on every edit + // (#1575), and each run used to add another listener. + const g = /** @type {any} */ (globalThis); + if (process.connected && !g.__webjsDevDisconnectHooked) { + g.__webjsDevDisconnectHooked = true; process.channel?.unref?.(); process.on('disconnect', () => process.exit(0)); } diff --git a/packages/cli/lib/dev-hot-rerun.js b/packages/cli/lib/dev-hot-rerun.js new file mode 100644 index 000000000..72bf17ae9 --- /dev/null +++ b/packages/cli/lib/dev-hot-rerun.js @@ -0,0 +1,54 @@ +/** + * The fast path for a `bun --hot` re-run of the `webjs dev` server child + * (#1575). + * + * `bun --hot` re-evaluates this CLI on every reload. The first run's server + * owns the process (`dev/hot-host.js` in `@webjsdev/server`) and only needs to + * hear that the module registry was reset, so a re-run calls the host directly + * instead of importing the whole server again just to reach `startServer`, + * which re-evaluated every framework module on every edit for nothing. + * + * The one thing a re-run must still notice is the framework itself changing + * under it (an upgrade while `webjs dev` runs): the first run's code cannot + * load the new copy in place, so the child exits and the supervisor starts a + * fresh process. + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HOSTS = Symbol.for('webjs.dev.hotHosts'); + +/** The installed `@webjsdev/server` version, or '' when it cannot be read. */ +function installedServerVersion() { + try { + const entry = fileURLToPath(import.meta.resolve('@webjsdev/server')); + return JSON.parse(readFileSync(join(dirname(entry), 'package.json'), 'utf8')).version || ''; + } catch { + return ''; + } +} + +/** + * Hand a re-run to the live dev server. Returns false when there is none (the + * first run), so the caller starts the server as usual. + * + * @param {{ exit?: (code: number) => void, log?: (line: string) => void }} [io] + * @returns {Promise<boolean>} + */ +export async function rerunHotDevServer(io = {}) { + const exit = io.exit || ((code) => process.exit(code)); + const log = io.log || ((line) => console.log(line)); + const hosts = /** @type {any} */ (globalThis)[HOSTS]; + if (!(hosts instanceof Map) || hosts.size === 0) return false; + const version = installedServerVersion(); + for (const host of hosts.values()) { + if (version && host.version && host.version !== version) { + log(`[webjs] @webjsdev/server changed (${host.version} -> ${version}), restarting the dev server`); + exit(0); + return true; + } + } + for (const host of hosts.values()) await host.rerun(); + return true; +} diff --git a/packages/cli/lib/dev-reload.js b/packages/cli/lib/dev-reload.js index da6b3d0e7..d13efa315 100644 --- a/packages/cli/lib/dev-reload.js +++ b/packages/cli/lib/dev-reload.js @@ -32,6 +32,13 @@ import { PORT_IN_USE_EXIT_CODE } from './port.js'; */ export const RESTART_DEBOUNCE_MS = 50; +/** + * The IPC message a dev server child sends when it reloads every module in + * place under `bun --hot`, plugin-served ones included (#1575). Mirrors + * `HOT_IN_PLACE_MESSAGE` in `@webjsdev/server`'s `dev/hot-host.js`. + */ +export const HOT_IN_PLACE_MESSAGE = 'hot-reload-in-place'; + /** * Delays before restarting a child that exited on its own (a crash), indexed * by consecutive crash count and capped at the last entry. A file change @@ -338,12 +345,35 @@ export function createSupervisor({ }; } +/** + * Which changed paths restart the live child. A Bun child that reloads + * plugin-served modules in place announces it over IPC once it is up (#1575); + * from then on only `plan.inPlaceRestartFor` (the boot hooks) restarts it. + * Per child: `reset()` on every spawn, so a child that never announces (an + * older `@webjsdev/server`) keeps the restarts it needs. + * + * @param {{ restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean }} plan + */ +export function inPlaceRestarts(plan) { + let inPlace = false; + const base = plan.restartFor || (() => true); + return { + /** @param {string} p */ + restartFor: (p) => (inPlace && plan.inPlaceRestartFor ? plan.inPlaceRestartFor(p) : base(p)), + /** @param {unknown} msg */ + onMessage: (msg) => { + if (msg && typeof msg === 'object' && /** @type {any} */ (msg).webjs === HOT_IN_PLACE_MESSAGE) inPlace = true; + }, + reset: () => { inPlace = false; }, + }; +} + /** * Run the dev server under the supervisor until a signal stops it. * * @param {{ * cwd: string, - * plan: { args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }, + * plan: { args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }, * env: NodeJS.ProcessEnv, * onExit: (code: number) => void, * }} opts @@ -366,14 +396,16 @@ export function superviseDevServer({ cwd, plan, env, onExit }) { sup.stop().then(() => onExit(code)); }; + const restarts = inPlaceRestarts(plan); const sup = createSupervisor({ restartOnChange: plan.restartOnChange, - ...(plan.restartFor ? { restartFor: plan.restartFor } : {}), + ...(plan.restartFor ? { restartFor: restarts.restartFor } : {}), log, // The child already printed why (the port and its holder); stop with its // code rather than restarting a server that can never bind. onFinal: (code) => shutdown(code), spawnChild: () => { + restarts.reset(); const c = spawn(process.execPath, plan.args, { // The IPC channel lets the child notice this process is gone and exit, // so a killed supervisor never leaves an orphan holding the port. @@ -381,6 +413,7 @@ export function superviseDevServer({ cwd, plan, env, onExit }) { cwd, env, }); + c.on('message', restarts.onMessage); // A failed spawn emits 'error' and may never emit 'exit'; report it as // an exit so the backoff retries it (a repeated exit is ignored). c.on('error', (err) => { diff --git a/packages/cli/lib/dev-supervisor.js b/packages/cli/lib/dev-supervisor.js index 2fb7448bf..c2fbe950b 100644 --- a/packages/cli/lib/dev-supervisor.js +++ b/packages/cli/lib/dev-supervisor.js @@ -46,7 +46,29 @@ export const WATCH_DIRS = ['app', 'components', 'modules', 'lib', 'actions']; * never restarts the dev server when edited, which is the quiet half of the * bug where a `middleware.ts` was loaded by neither. */ -export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs']; +export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs', ...bootFiles()]; + +/** + * The app-root boot hooks the server runs ONCE per process (#1575): + * `instrumentation.*` (its `register()`) and `env.*` (the env validation). An + * edit to one restarts the dev server on either runtime, because no in-place + * reload re-runs them. + * @returns {string[]} + */ +function bootFiles() { + return ['instrumentation', 'env'].flatMap((n) => ['ts', 'js', 'mts', 'mjs'].map((x) => `${n}.${x}`)); +} + +const BOOT_FILE = /^(?:instrumentation|env)\.m?[jt]s$/; + +/** + * Whether a changed (app-relative) path is a boot hook (see `bootFiles`). + * @param {string} path + * @returns {boolean} + */ +export function isBootFile(path) { + return BOOT_FILE.test(path); +} /** * Plan how `webjs dev` runs its server. @@ -56,7 +78,7 @@ export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', * @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim. * @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely). * @param {boolean} [opts.sourceLocations] Whether dev source locations are on (`WEBJS_SOURCE_LOCATIONS` / `webjs.dev.sourceLocations`), which puts every app module behind a Bun plugin. - * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }} + * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }} * `inline` runs the server in this process (no reload watcher); `supervise` * spawns `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1` under the * supervisor, which watches `watchDirs` (recursively) and `watchFiles` (at @@ -71,7 +93,20 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false if (noHot) return { mode: 'inline' }; const watch = { watchDirs: [...WATCH_DIRS], watchFiles: [...WATCH_FILES] }; - if (isBun) return { mode: 'supervise', args: ['--hot', ...argv], restartOnChange: true, restartFor: bunPluginServed(sourceLocations), ...watch }; + if (isBun) { + // `restartFor` covers a server that cannot reload a plugin-served module in + // place. A server that can says so over IPC once it is up (#1575), and the + // supervisor narrows to `inPlaceRestartFor`: only the boot hooks restart. + const plugin = bunPluginServed(sourceLocations); + return { + mode: 'supervise', + args: ['--hot', ...argv], + restartOnChange: true, + restartFor: (p) => plugin(p) || isBootFile(p), + inPlaceRestartFor: isBootFile, + ...watch, + }; + } return { mode: 'supervise', args: [...argv], restartOnChange: true, ...watch }; } diff --git a/packages/cli/test/dev-supervisor/dev-supervisor.test.js b/packages/cli/test/dev-supervisor/dev-supervisor.test.js index 506e6e547..86d8cf2d6 100644 --- a/packages/cli/test/dev-supervisor/dev-supervisor.test.js +++ b/packages/cli/test/dev-supervisor/dev-supervisor.test.js @@ -29,7 +29,12 @@ import { planDevSupervisor, sourceLocationsOn } from '../../lib/dev-supervisor.j const ARGV = ['/path/to/webjs.js', 'dev', '--port', '8080']; const WATCH = { watchDirs: ['app', 'components', 'modules', 'lib', 'actions'], - watchFiles: ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'], + watchFiles: [ + 'middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs', + // The boot hooks run once per process, so an edit restarts it (#1575). + 'instrumentation.ts', 'instrumentation.js', 'instrumentation.mts', 'instrumentation.mjs', + 'env.ts', 'env.js', 'env.mts', 'env.mjs', + ], }; test('Bun runs the child under `bun --hot` and restarts it for plugin-served paths (#1550)', () => { @@ -43,6 +48,20 @@ test('Bun runs the child under `bun --hot` and restarts it for plugin-served pat assert.equal(plan.restartFor('modules/todos/actions/add.server.ts'), true); assert.equal(plan.restartFor('app/page.ts'), false); assert.equal(plan.restartFor('components/card.js'), false); + // The boot hooks never reload in place. + assert.equal(plan.restartFor('instrumentation.ts'), true); + assert.equal(plan.restartFor('env.ts'), true); +}); + +test('a Bun child that reloads in place narrows the restarts to the boot hooks (#1575)', () => { + const plan = planDevSupervisor({ isBun: true, argv: ARGV, noHot: false, sourceLocations: true }); + assert.ok(plan.mode === 'supervise' && plan.inPlaceRestartFor); + for (const p of ['modules/todos/actions/add.server.ts', 'app/page.ts', 'components/card.js']) { + assert.equal(plan.inPlaceRestartFor(p), false, p); + } + for (const p of ['instrumentation.ts', 'instrumentation.mjs', 'env.ts', 'env.js']) assert.equal(plan.inPlaceRestartFor(p), true, p); + // Only at the app root: a nested file of the same name is an ordinary module. + assert.equal(plan.inPlaceRestartFor('lib/env.ts'), false); }); test('with source locations on, every app module restarts the Bun child (#1550)', () => { @@ -125,3 +144,27 @@ test('the watched middleware extensions match the ones the server loads', () => 'every extension the server loads must also be watched in dev', ); }); + +test('the supervisor narrows to the in-place restarts only after the child announces it (#1575)', async () => { + const { inPlaceRestarts, HOT_IN_PLACE_MESSAGE } = await import('../../lib/dev-reload.js'); + const plan = planDevSupervisor({ isBun: true, argv: ARGV, noHot: false }); + assert.ok(plan.mode === 'supervise'); + const r = inPlaceRestarts(plan); + const action = 'modules/todos/actions/add.server.ts'; + // An older server never announces, so it keeps the restart it needs. + assert.equal(r.restartFor(action), true); + r.onMessage({ webjs: 'something-else' }); + assert.equal(r.restartFor(action), true); + r.onMessage({ webjs: HOT_IN_PLACE_MESSAGE }); + assert.equal(r.restartFor(action), false); + assert.equal(r.restartFor('instrumentation.ts'), true); + // A new child starts un-announced again. + r.reset(); + assert.equal(r.restartFor(action), true); +}); + +test('the in-place IPC message matches the one @webjsdev/server sends (#1575)', async () => { + const cli = await import('../../lib/dev-reload.js'); + const server = await import('../../../server/src/dev/hot-host.js'); + assert.equal(cli.HOT_IN_PLACE_MESSAGE, server.HOT_IN_PLACE_MESSAGE); +}); diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index f6721ee7e..1216c466b 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -75,7 +75,8 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `env-schema.js` | Boot-time env-var validation (#236). `validateEnv(schema, env)` is the PURE validator: it checks an env object against a schema (an object of `name -> type-name | options`, supporting `string`/`number`/`boolean`/`url`/`enum`, `required`/`optional`/`default`, `minLength`/`pattern`), collecting ALL errors at once and returning the coerced + defaulted values to write back. A schema may instead be a FUNCTION `(env) => void` (the escape hatch for zod etc.), whose throw becomes the single error. `loadEnvSchema(appDir)` reads the optional app-root `env.{js,ts}` (null when absent, so opt-in); `applyEnvValidation(appDir)` is the side-effecting boot wrapper called from `createRequestHandler` right after the `.env` auto-load: it validates `process.env`, applies coerced values back, and THROWS a clear aggregated Error on failure (CLI exits non-zero, embedded host rejects), consistent with the Node-version preflight. `formatEnvErrors` composes the aggregated message. | | `webjs-config-validate.js` | Boot-time validation of the app's `webjs` package.json block (#1300). `validateWebjsBlock(schema, value)` is the PURE structural validator (top-level key membership under `additionalProperties: false`, `enum` membership, `boolean` / `integer` leaves), promoted out of `test/config/webjs-config-schema.test.js` so the boot path could use it too; that test now imports it and its assertions run byte-identically, which is what proves the move was faithful. `validateAppWebjsConfig(pkg)` loads the packaged `webjs-config.schema.json` (memoized) and returns one message per problem, `[]` for an absent or non-object block. `dev.js`'s `warnOnInvalidWebjsConfig(appDir, logger)` calls it once from `createRequestHandler`, so dev, prod, and an embedded host share one call site, and emits ONE aggregated `logger.warn`. It WARNS and never throws: a typo costs one feature its default, a boot failure costs the whole app. Deliberately NOT a replacement for the CLI's `readDoctorPolicy` gate validation, which runs where the server may not resolve (#954), checks `DOCTOR_CODES` membership the schema cannot express, and fails CLOSED. Adds NO `webjs.*` key, so the three-surface lockstep below and the `KNOWN_KEYS` drift test are untouched. Tests: `test/config/webjs-config-validate.test.js`, `test/bun/webjs-config-validate.mjs` | | `dev-source-locations.js` | Dev source locations (#1499). `annotateSourceLocations(source, file)` lexes a JS / TS module (comments, strings, regex vs division, nested template literals) and inserts ` data-webjs-src="<file>:<line>"` right after each element opening tag name in the STATIC text of `html` tagged templates, never adding a newline. Skips closing tags, comments, attribute values, raw-text bodies, `svg` / `math` descendants, document and head elements, and `css` / `svg` templates. Fails open (returns the source unchanged). `isSourceLocationCandidate` excludes `node_modules`, `*.server.*` and files outside the app. Applied to served app modules by `dev/serve.js` when the handler was built with `WEBJS_SOURCE_LOCATIONS=1` under dev. | -| `dev-import.js` | `devImportSpecifier(file, dev)`: the specifier every dev re-import uses (#1550; pages, layouts, route handlers, action modules, middleware, metadata routes, env schema, instrumentation, readiness, WS routes). In dev it carries a fresh `?t=` query: on the file URL under Node, on the PLAIN absolute path under Bun, which drops a `file://` specifier's query (so a URL-plus-query cache-bust was a silent no-op there). A path holding `?` or `#` falls back to the file URL. Outside dev it is the plain file URL. Tests: `test/dev/dev-import.test.js` (runs on both runtimes, including a real re-import round trip). | +| `dev-import.js` | `devImportSpecifier(file, dev)`: the specifier every dev re-import uses (#1550; pages, layouts, route handlers, action modules, middleware, metadata routes, env schema, instrumentation, readiness, WS routes). In dev it carries a `?t=` query naming the file's CONTENT (#1575; a random one per call made every request a new, never-freed module instance): on the file URL under Node, on the PLAIN absolute path under Bun, which drops a `file://` specifier's query (so a URL-plus-query cache-bust was a silent no-op there). A path holding `?` or `#` falls back to the file URL. Outside dev it is the plain file URL. `devImport(file, dev)` imports through it and never re-imports a specifier that failed (on Bun under `--hot` that never settles). Tests: `test/dev/dev-import.test.js` (runs on both runtimes, including a real re-import round trip). | +| `dev/hot-host.js`, `dev/bun-app-source.js`, `dev/bun-alias-resolve.js` | One dev server per process under `bun --hot` (#1575). `startServer` registers a host on `globalThis` (`Symbol.for('webjs.dev.hotHosts')`); a re-run (the CLI's `lib/dev-hot-rerun.js`, or a second `startServer`) calls `host.rerun()`, which re-derives the analysis without a reload frame, and exits the child when `@webjsdev/server` itself changed. App modules load through a fresh-reading `Bun.plugin` (`bun-app-source.js`; Bun keeps a replaced file's old source) and `#` imports resolve through the app's map (`bun-alias-resolve.js`; Bun's directory cache goes stale), so the watcher pokes a temp-dir sentinel for a registry reset when an edited module is imported by another (`needsRegistryReset` on the handler), new, or `*.server.*`. Tests: `test/dev/hot-reload-robustness.test.js`, end to end `test/bun/dev-reload-stress.mjs` (both runtimes) over `scripts/dev-reload-stress.mjs`. | | `dev-source-locations-hook.js` | The SSR half of #1499: `registerSourceLocationHook(appDir)` installs a process-global load hook scoped to registered app roots (`module.registerHooks` calling `nextLoad` first, so `.ts` keeps `module-typescript` and Node still strips; on Bun a `Bun.plugin` whose filter excludes `node_modules` and `*.server.*` by lookahead, so the `'use server'` seed plugin keeps every server module whatever the registration order). Installed by `createRequestHandler` in dev only. | | `dev-embed.js` | The dev embed bridge, server half (#1498). `parseEmbedOrigins` normalizes `WEBJS_EMBED_ORIGINS` (http(s) origins only; a wildcard, bare host or other scheme is dropped with a warning). `setEmbedOrigins` is the module-level switch `createRequestHandler` sets at construction (the parsed value in dev, `[]` otherwise), read by `embedScriptTag`, which `ssr/head.js`'s `wrapHead` emits right after the public-env shim when `opts.dev` and origins are set (with the request's CSP nonce; `''` otherwise, so the off case is byte-identical). `allowEmbedFraming` drops `X-Frame-Options` and widens a CSP `frame-ancestors`; `dev/handler.js` applies it last in the header merge, in dev only. | | `dev-embed-client.js` | The dev embed bridge, browser half (#1498). `installEmbedBridge(origins)` posts `ready` / `navigate` / `console` / `error` / `network` / `server-error` / `select` messages to an allowed parent and handles `navigate` / `reload` / `inspect` / `hold` host commands. `select` carries the click's modifier keys, `hold` (#1532) sets the `__webjsEmbedHold` page global (persisted in `sessionStorage`) and on release calls the reload client's `__webjsDevReleaseHold`, and a reload of the same path restores the scroll saved at `pagehide`. Import-free and comment-light by contract: `dev-embed.js` reads this file, strips its comments and `export`, and inlines it, so the browser test drives the shipping code. `server-error` relays the `webjs:dev-overlay` event `dev-overlay.js`'s `renderDevOverlay` dispatches once an overlay is actually on screen. | diff --git a/packages/server/src/action-seed.js b/packages/server/src/action-seed.js index e0c973514..3a0750b27 100644 --- a/packages/server/src/action-seed.js +++ b/packages/server/src/action-seed.js @@ -650,6 +650,14 @@ export async function registerActionHooks(opts = {}) { _registered = true; if (serverRuntime() === 'bun') { + // Once per PROCESS, not per module instance (#1575): `bun --hot` re-runs + // the server with fresh module state, and a `Bun.plugin` cannot be + // unregistered, so a module-level guard alone stacked one more plugin per + // edit (the first one registered keeps answering anyway). + const g = /** @type {any} */ (globalThis); + const installed = Symbol.for('webjs.actionSeed.bunPlugin'); + if (g[installed]) { _hookInstalled = true; return; } + g[installed] = true; // Bun has no module.registerHooks; install the same facade via Bun.plugin. const { installBunSeedPlugin } = await import('./action-seed-bun.js'); installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE }); diff --git a/packages/server/src/actions.js b/packages/server/src/actions.js index eff88e6b6..f2e138e4e 100644 --- a/packages/server/src/actions.js +++ b/packages/server/src/actions.js @@ -20,7 +20,7 @@ import { getBodyLimits } from './context.js'; import { basePath } from './importmap.js'; import { withBasePath } from './base-path.js'; import { FORM_ACTION_ID_KEY } from '@webjsdev/core'; -import { devImportSpecifier } from './dev-import.js'; +import { devImport } from './dev-import.js'; /** * The JSON / RPC body cap in effect for the current request: the per-request @@ -638,5 +638,5 @@ async function actionErrorResponse(err, dev) { * @param {boolean} dev */ async function loadModule(file, dev) { - return import(devImportSpecifier(file, dev)); + return devImport(file, dev); } diff --git a/packages/server/src/api.js b/packages/server/src/api.js index d1a4f7226..07578a07b 100644 --- a/packages/server/src/api.js +++ b/packages/server/src/api.js @@ -1,5 +1,5 @@ import { makeThenable } from './thenable-params.js'; -import { devImportSpecifier } from './dev-import.js'; +import { devImport } from './dev-import.js'; /** * Dispatch an incoming request to a matched API route. @@ -14,7 +14,7 @@ import { devImportSpecifier } from './dev-import.js'; * @returns {Promise<Response>} */ export async function handleApi(route, params, webRequest, dev) { - const mod = await import(devImportSpecifier(route.file, dev)); + const mod = await devImport(route.file, dev); const method = webRequest.method.toUpperCase(); const handler = mod[method]; if (!handler) { diff --git a/packages/server/src/component-elision.js b/packages/server/src/component-elision.js index b3b5b45ad..758a8c941 100644 --- a/packages/server/src/component-elision.js +++ b/packages/server/src/component-elision.js @@ -1157,6 +1157,84 @@ export async function computeElidableComponents(components, moduleGraph, readFil return elidableComponents; } +/** + * Per-file scan results, keyed by path and validated on the exact source and + * the two facts about the file the scan depends on (#1575). The scan reads one + * file and nothing else, so an unchanged file never needs it again; on a large + * app it was most of the elision pass (about 350 ms of Crisp's 450 ms rebuild), + * repeated in full after every edit. + * @type {Map<string, { src: string, isRoute: boolean, isComponent: boolean, result: ReturnType<typeof scanFileUncached> }>} + */ +const SCAN_RESULT_CACHE = new Map(); + +/** + * The per-file half of `analyzeElision`: every signal it reads off ONE file. + * @param {string} file + * @param {string} src the raw source as read + * @param {{ isRoute: boolean, isComponent: boolean, appDir: string, scanStripper: any }} ctx + */ +function scanFileForElision(file, src, ctx) { + const hit = SCAN_RESULT_CACHE.get(file); + if (hit && hit.src === src && hit.isRoute === ctx.isRoute && hit.isComponent === ctx.isComponent) return hit.result; + const result = scanFileUncached(file, src, ctx); + SCAN_RESULT_CACHE.set(file, { src, isRoute: ctx.isRoute, isComponent: ctx.isComponent, result }); + return result; +} + +/** + * @param {string} file + * @param {string} src + * @param {{ isRoute: boolean, isComponent: boolean, appDir: string, scanStripper: any }} ctx + */ +function scanFileUncached(file, src, { isRoute, isComponent, appDir, scanStripper }) { + // Erase type syntax first, so every scan below reads the code that RUNS + // (#1423). This is the single point where the analyser reads a file, so + // doing it here covers the module-scope, template, import, and component + // scans at once. + src = eraseTypesForScan(scanStripper, file, src); + // Mask comments once for every signal scan below (#179): a `<tag>`, an + // `@event`, a browser global, an `import`, or a `whenDefined` written in a + // comment must not register as a real signal. String and template content + // is kept, so a real rendered tag, a real `@click=${}` in an html template, + // and a real `whenDefined('tag')` (the tag rides a string) still match. + // (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` / + // `analyzeComponentSource` also redact strings/templates internally; running + // them on the comment-masked source just additionally drops comment prose.) + const masked = maskComments(src); + const tags = extractRenderedTags(masked); + const { redacted, literals } = redactToPlaceholders(src); + // A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is + // SSR output, not module client work: an inline `<script>`'s browser + // globals run from the rendered HTML, and a page-template `@event` is + // dropped at SSR. For a route module, scan those two template-borne signals + // on the template-redacted source so they do not pin the module, while a + // genuine module-scope `document.x` OUTSIDE any template still flags (#623). + // The import-based checks stay on `redacted`: a real `import 'pkg'` side + // effect DOES run when a page/layout module loads in the browser, and + // `importsSideEffectNonCorePackage` itself skips local `#`-alias imports. + const templateScan = isRoute ? redacted : masked; + const clientGlobalOrBare = EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) || + importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(templateScan) || + hasModuleScopeSideEffect(redacted, literals); + let interactive = false; + /** @type {string | null} */ + let interactiveReason = null; + if (isComponent) { + const v = analyzeComponentSource(masked); + if (v.interactive) { interactive = true; interactiveReason = v.reason ?? null; } + } + return { + tags, + reactive: importsReactivePrimitive(redacted, literals), + clientRouter: importsClientRouter(redacted, literals), + clientGlobalOrBare, + interactive, + interactiveReason, + observedTags: [...[...masked.matchAll(WHEN_DEFINED_RE)].map((m) => m[1]), ...[...masked.matchAll(TAG_DEFINED_RE)].map((m) => m[1])], + observedClasses: [...masked.matchAll(INSTANCEOF_RE)].map((m) => m[1]), + }; +} + /** * Full elision analysis: which display-only COMPONENT modules can be elided, * AND which page/layout ROUTE modules are inert (do no client work) and can @@ -1257,53 +1335,28 @@ export async function analyzeElision(components, routeModules, moduleGraph, read // (#1423). This is the single point where the analyser reads a file, so // doing it here covers the module-scope, template, import, and component // scans at once. - src = eraseTypesForScan(scanStripper, file, src); - // Mask comments once for every signal scan below (#179): a `<tag>`, an - // `@event`, a browser global, an `import`, or a `whenDefined` written in a - // comment must not register as a real signal. String and template content - // is kept, so a real rendered tag, a real `@click=${}` in an html template, - // and a real `whenDefined('tag')` (the tag rides a string) still match. - // (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` / - // `analyzeComponentSource` also redact strings/templates internally; running - // them on the comment-masked source just additionally drops comment prose.) - const masked = maskComments(src); - fileTags.set(file, extractRenderedTags(masked)); - const { redacted, literals } = redactToPlaceholders(src); - if (importsReactivePrimitive(redacted, literals)) reactiveFiles.add(file); - if (importsClientRouter(redacted, literals)) clientRouterFiles.add(file); - // A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is - // SSR output, not module client work: an inline `<script>`'s browser - // globals run from the rendered HTML, and a page-template `@event` is - // dropped at SSR. For a route module, scan those two template-borne signals - // on the template-redacted source so they do not pin the module, while a - // genuine module-scope `document.x` OUTSIDE any template still flags (#623). - // The import-based checks stay on `redacted`: a real `import 'pkg'` side - // effect DOES run when a page/layout module loads in the browser, and - // `importsSideEffectNonCorePackage` itself skips local `#`-alias imports. - const templateScan = routeModuleSet.has(file) ? redacted : masked; - if (EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) || - importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(templateScan) || - hasModuleScopeSideEffect(redacted, literals)) { - clientGlobalOrBareFiles.add(file); - } - if (componentFiles.has(file)) { - const v = analyzeComponentSource(masked); - if (v.interactive) { mustShip.add(file); noteShip(file, 'own', null, v.reason); } - } + const scan = scanFileForElision(file, src, { + isRoute: routeModuleSet.has(file), + isComponent: componentFiles.has(file), + appDir, + scanStripper, + }); + fileTags.set(file, scan.tags); + if (scan.reactive) reactiveFiles.add(file); + if (scan.clientRouter) clientRouterFiles.add(file); + if (scan.clientGlobalOrBare) clientGlobalOrBareFiles.add(file); + if (scan.interactive) { mustShip.add(file); noteShip(file, 'own', null, scan.interactiveReason); } // Cross-module registration observation (#169): if THIS module observes // another component's tag, that component must register client-side, so // it cannot be elided. Map each observed tag/class back to its component - // file. Resolution against tagToFile / classToFile happens after the loop - // (all components are known up front, but we collect here while we hold - // each source). Verdict-safe: only ever forces MORE components to ship. - for (const m of masked.matchAll(WHEN_DEFINED_RE)) { - const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); } - } - for (const m of masked.matchAll(TAG_DEFINED_RE)) { - const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); } + // file. Resolution against tagToFile / classToFile happens here, outside + // the per-file cache, because the component set is not a property of this + // file. Verdict-safe: only ever forces MORE components to ship. + for (const tag of scan.observedTags) { + const f = tagToFile.get(tag); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); } } - for (const m of masked.matchAll(INSTANCEOF_RE)) { - const f = classToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); } + for (const cls of scan.observedClasses) { + const f = classToFile.get(cls); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); } } } diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index 0ab8ddc31..a6114524a 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -165,6 +165,44 @@ export async function primeComponentRegistry(appDir, components) { return { count: components.length }; } +/** + * Per-file results of the orphan scan, validated on the exact source (#1575): + * the dev server runs `findOrphanComponents` after every rebuild, and on a + * large app re-scanning every unchanged file cost about 100 ms an edit. + * @type {Map<string, { src: string, declared: Set<string>, registered: string[] }>} + */ +const ORPHAN_SCAN_CACHE = new Map(); + +/** + * The classes one file declares as WebComponents and the ones it registers. + * @param {string} file + * @param {string} src + * @returns {{ declared: Set<string>, registered: string[] }} + */ +function orphanScan(file, src) { + const hit = ORPHAN_SCAN_CACHE.get(file); + if (hit && hit.src === src) return hit; + const { redacted } = redactToPlaceholders(src); + // Find every class that extends WebComponent (exact name: we trust + // the framework convention). + const classRe = /\b(?:export\s+)?(?:default\s+)?class\s+([A-Z][A-Za-z0-9_$]*)\s+extends\s+WebComponent\b/g; + const declared = new Set(); + let m; + while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]); + + // "Registered" is whatever `extractComponents` accepts, reused rather than + // re-expressed. The two scans MUST agree on what a literal tag is: when + // this used its own looser pattern, a redacted interpolated template + // (`Class.register(\`${p}__STR_1__\`)`) satisfied the orphan scan but not + // `extractComponents`, so a computed template-literal tag was neither a + // component NOR an orphan and vanished from every surface, which is the + // exact shape the orphan report exists to catch. + const registered = extractComponents(src).map((c) => c.className); + const entry = { src, declared, registered }; + ORPHAN_SCAN_CACHE.set(file, entry); + return entry; +} + /** * Find ORPHAN components: a `class X extends WebComponent` that NOTHING in the * app registers with a LITERAL tag, via either `X.register('tag')` or @@ -242,22 +280,8 @@ export async function findOrphanComponents(appDir) { // blank-strings argument: the default form keeps plain-string bodies and // single-line untagged templates verbatim, so a sample written either way // would match again and the false orphan would be back. - const { redacted } = redactToPlaceholders(src); - // Find every class that extends WebComponent (exact name: we trust - // the framework convention). - const classRe = /\b(?:export\s+)?(?:default\s+)?class\s+([A-Z][A-Za-z0-9_$]*)\s+extends\s+WebComponent\b/g; - const declared = new Set(); - let m; - while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]); - - // "Registered" is whatever `extractComponents` accepts, reused rather than - // re-expressed. The two scans MUST agree on what a literal tag is: when - // this used its own looser pattern, a redacted interpolated template - // (`Class.register(\`${p}__STR_1__\`)`) satisfied the orphan scan but not - // `extractComponents`, so a computed template-literal tag was neither a - // component NOR an orphan and vanished from every surface, which is the - // exact shape the orphan report exists to catch. - for (const c of extractComponents(src)) registeredAnywhere.add(c.className); + const { declared, registered } = orphanScan(file, src); + for (const cls of registered) registeredAnywhere.add(cls); if (declared.size) declaredPerFile.push({ file, declared }); } diff --git a/packages/server/src/dev-import.js b/packages/server/src/dev-import.js index 34dd73aec..477a0b255 100644 --- a/packages/server/src/dev-import.js +++ b/packages/server/src/dev-import.js @@ -2,13 +2,21 @@ * The import specifier for a module the dev server re-imports on every request * (#1550): a page, layout, route handler, action module, middleware, and so on. * - * In dev the specifier carries a fresh `?t=` query, so each request gets a new - * module instance and a just-edited top-level file is served without waiting - * for a restart. Node keys its ESM cache by the full file URL, query included. - * Bun keys its cache by path and DROPS the query of a `file://` specifier, so - * the same URL-plus-query is a silent no-op there: on Bun the query must ride a - * plain absolute path, which Bun loads fresh (and hands to a `Bun.plugin` - * `onLoad` with the query intact). + * In dev the specifier carries a `?t=` query naming the file's CONTENT (#1575), + * so a just-edited top-level file is served without waiting for a restart. Node + * keys its ESM cache by the full file URL, query included. Bun keys its cache by + * path and DROPS the query of a `file://` specifier, so the same URL-plus-query + * is a silent no-op there: on Bun the query must ride a plain absolute path, + * which Bun loads fresh (and hands to a `Bun.plugin` `onLoad` with the query + * intact). + * + * The query used to be random per call, which made every request a NEW module + * instance that neither runtime ever frees: about 60 KB per page view, so a dev + * server left open on a page grew without bound. Keyed by content, an unchanged + * file maps to the module already loaded. Content rather than mtime, because + * Linux stamps mtime from a coarse clock, so two quick writes of the same + * length can share one and the second would never be seen. A file that cannot + * be read gets a unique query, the old behaviour. * * A path that already contains `?` or `#` cannot carry a query unambiguously as * a plain path, so on Bun it falls back to the file URL (no fresh import; the @@ -17,18 +25,67 @@ * Outside dev the specifier is the plain file URL, exactly as before. */ import { pathToFileURL } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; /** * @param {string} file absolute file path * @param {boolean} dev whether to cache-bust - * @param {{ bun?: boolean, now?: () => number }} [opts] test seams: the runtime (default: detected) and the clock + * @param {{ bun?: boolean, version?: (file: string) => string }} [opts] test + * seams: the runtime (default: detected) and the content version * @returns {string} */ export function devImportSpecifier(file, dev, opts = {}) { const url = pathToFileURL(file).toString(); if (!dev) return url; const bun = opts.bun ?? Boolean(typeof process !== 'undefined' && process.versions && process.versions.bun); - const bust = `?t=${(opts.now ?? Date.now)()}-${Math.random().toString(36).slice(2)}`; + const bust = `?t=${(opts.version ?? contentVersion)(file)}`; if (bun && !/[?#]/.test(file)) return file + bust; return url + bust; } + +/** + * A short hash of the file's bytes, or a unique token when it cannot be read. + * @param {string} file + * @returns {string} + */ +export function contentVersion(file) { + try { + return createHash('sha1').update(readFileSync(file)).digest('hex').slice(0, 16); + } catch { + return `u${Date.now()}-${Math.random().toString(36).slice(2)}`; + } +} + +/** + * Specifiers whose import rejected, mapped to the key to use instead (#1575). + * Under `bun --hot`, importing a module that failed to parse a SECOND time + * through the same specifier never settles, and a later reload can then crash + * the process (Bun 1.3.14). A content-keyed specifier repeats for as long as + * the bytes do, so a broken file would hang every request after the first. A + * failed key is therefore never imported again: the next attempt gets a fresh + * one, which sticks once it loads. + * @type {Map<string, string>} + */ +const RETRY_KEYS = new Map(); +let retrySeq = 0; + +/** + * `import()` a module the dev server re-imports on every request, through + * `devImportSpecifier`. Use this rather than `import(devImportSpecifier(...))`. + * + * @param {string} file absolute file path + * @param {boolean} dev + * @returns {Promise<any>} + */ +export async function devImport(file, dev) { + const base = devImportSpecifier(file, dev); + if (!dev) return import(base); + const spec = RETRY_KEYS.get(base) || base; + try { + return await import(spec); + } catch (e) { + RETRY_KEYS.set(base, `${base}-r${++retrySeq}`); + throw e; + } +} diff --git a/packages/server/src/dev-source-locations-hook.js b/packages/server/src/dev-source-locations-hook.js index 6e6942b9c..a08bb0b65 100644 --- a/packages/server/src/dev-source-locations-hook.js +++ b/packages/server/src/dev-source-locations-hook.js @@ -105,6 +105,13 @@ export function registerSourceLocationHook(appDir) { if (_roots.has(appDir)) return _installed; _roots.add(appDir); if (serverRuntime() === 'bun') { + // Once per root per PROCESS (#1575): `bun --hot` re-runs this module with + // an empty `_roots`, and a `Bun.plugin` cannot be unregistered. + const g = /** @type {any} */ (globalThis); + const key = Symbol.for('webjs.sourceLocations.bunRoots'); + if (!(g[key] instanceof Set)) g[key] = new Set(); + if (g[key].has(appDir)) { _installed = true; return _installed; } + g[key].add(appDir); // One plugin per root, each with a filter that can never match a // `node_modules` or `*.server.*` path (see the module comment). const filter = new RegExp( diff --git a/packages/server/src/dev/bun-alias-resolve.js b/packages/server/src/dev/bun-alias-resolve.js new file mode 100644 index 000000000..80ee523fa --- /dev/null +++ b/packages/server/src/dev/bun-alias-resolve.js @@ -0,0 +1,71 @@ +/** + * Resolve the app's `#` subpath imports ourselves under `webjs dev` on Bun + * (#1575). + * + * Bun caches a directory's entries when it resolves through it and, under + * `bun --hot`, refreshes that cache only for a directory it watches. It does + * not watch a directory that holds only modules a `Bun.plugin` served (an + * `actions/` folder of `*.server.ts` files), and a `package.json "imports"` + * specifier (`#modules/x/new.ts`) resolves through that stale listing, while a + * relative one does not. So a file an agent added next to an action was + * "Cannot find module" for the rest of the process, through every reload. This + * `onResolve` expands the alias through the same map the module graph uses + * (`expandImportAlias`) and hands Bun an absolute path that exists, which Bun + * then loads normally (the seed plugin's `onLoad` still applies by path). A + * specifier it cannot place falls through to Bun's own resolver, so an error + * reads exactly as it would without it. + * + * Dev only (production never reloads) and Bun only (Node's resolver reads the + * file system on every resolve). + * + * @module dev/bun-alias-resolve + */ +import { existsSync, statSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; +import { expandImportAlias } from '../module-graph.js'; + +const ROOTS = Symbol.for('webjs.dev.bunAliasRoots'); + +/** + * Resolve `spec` imported by `importer` against the app's `#` alias map, or + * null when it is not an app alias or the target is not a file. + * + * @param {string} spec + * @param {string} importer absolute path, possibly with a query + * @param {string} appDir absolute + * @returns {string | null} + */ +export function resolveAppAlias(spec, importer, appDir) { + if (!spec.startsWith('#')) return null; + const from = String(importer || '').split('?')[0]; + if (!from.startsWith(appDir + sep) || from.includes(`${sep}node_modules${sep}`)) return null; + const rel = expandImportAlias(spec, appDir); + if (!rel) return null; + const abs = resolve(appDir, rel); + try { return existsSync(abs) && statSync(abs).isFile() ? abs : null; } catch { return null; } +} + +/** + * Install the resolver for `appDir`, once per process. + * @param {string} appDir absolute + */ +export function registerBunAliasResolver(appDir) { + const g = /** @type {any} */ (globalThis); + if (!g.Bun || typeof g.Bun.plugin !== 'function') return; + if (!(g[ROOTS] instanceof Set)) g[ROOTS] = new Set(); + if (g[ROOTS].has(appDir)) return; + g[ROOTS].add(appDir); + g.Bun.plugin({ + name: 'webjs-dev-alias', + setup(build) { + build.onResolve({ filter: /^#/ }, (args) => { + try { + const path = resolveAppAlias(args.path, args.importer, appDir); + return path ? { path } : undefined; + } catch { + return undefined; + } + }); + }, + }); +} diff --git a/packages/server/src/dev/bun-app-source.js b/packages/server/src/dev/bun-app-source.js new file mode 100644 index 000000000..4c8a8287d --- /dev/null +++ b/packages/server/src/dev/bun-app-source.js @@ -0,0 +1,66 @@ +/** + * Load the app's own modules through a `Bun.plugin` under `webjs dev` on Bun + * (#1575), so every load reads the file as it is on disk now. + * + * Under `bun --hot`, Bun keeps a per-path copy of a module's source and + * refreshes it from its file watcher. Once a file is replaced rather than + * rewritten (an editor's atomic save, `sed -i`, a `git checkout`), that watch + * is gone and the copy is never refreshed: every later import of that path, a + * fresh `?t=` query included, evaluates the old source for the rest of the + * process. Measured on the scaffold: one atomic write to a page, and the page + * served its previous version through every later edit until a restart. A + * plugin's `onLoad` is not served from that copy, so it is always fresh. + * + * The trade is that `bun --hot` does not watch a module a plugin served + * (#1550), so the dev server's own watcher asks for the registry reset itself + * (`pokeHotSentinel` in `dev/hot-host.js`) after an edit to any app module. + * + * The filter matches app modules only: never `node_modules`, and never a + * `*.server.*` module, which the `'use server'` seed plugin serves. When dev + * source locations are on, their plugin (same filter, registered first) wins + * and serves annotated source instead, which is just as fresh. + * + * @module dev/bun-app-source + */ +import { readFileSync } from 'node:fs'; + +const ROOTS = Symbol.for('webjs.dev.bunAppSourceRoots'); + +/** @param {string} s */ +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * The `onLoad` filter for one app root: its JS/TS modules outside + * `node_modules`, `*.server.*` excluded, with an optional query. + * @param {string} appDir absolute + * @returns {RegExp} + */ +export function appSourceFilter(appDir) { + return new RegExp('^' + escapeRe(appDir) + '/(?:(?!node_modules/)(?!.*\\.server\\.).)*\\.m?[jt]s(\\?.*)?$'); +} + +/** + * Install the loader for `appDir`, once per process. + * @param {string} appDir absolute + */ +export function registerBunAppSource(appDir) { + const g = /** @type {any} */ (globalThis); + if (!g.Bun || typeof g.Bun.plugin !== 'function') return; + if (!(g[ROOTS] instanceof Set)) g[ROOTS] = new Set(); + if (g[ROOTS].has(appDir)) return; + g[ROOTS].add(appDir); + const filter = appSourceFilter(appDir); + g.Bun.plugin({ + name: 'webjs-dev-app-source', + setup(build) { + build.onLoad({ filter }, (args) => { + const abs = args.path.split('?')[0]; + // A read failure (the file is gone) propagates, which Bun reports as + // the load error it would have raised itself. + return { contents: readFileSync(abs, 'utf8'), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' }; + }); + }, + }); +} diff --git a/packages/server/src/dev/handler.js b/packages/server/src/dev/handler.js index 2e2e3d2b4..e250dd616 100644 --- a/packages/server/src/dev/handler.js +++ b/packages/server/src/dev/handler.js @@ -1,7 +1,10 @@ import { readFile } from 'node:fs/promises'; import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { registerBunAliasResolver } from './bun-alias-resolve.js'; +import { registerBunAppSource } from './bun-app-source.js'; +import { serverRuntime } from '../listener-core.js'; import { createHash } from 'node:crypto'; -import { join, resolve, sep } from 'node:path'; +import { join, relative, resolve, sep } from 'node:path'; // Server-side `.ts` imports are handled natively by Node 24+'s default // type-stripping (`process.features.typescript === 'strip'`) or by Bun. The @@ -30,7 +33,7 @@ import { mintNonce, buildCspHeader, cspHeaderName } from '../csp.js'; import { propagateTrustedRemoteIp } from '../rate-limit.js'; import { reachedBareImports, resolveVendorImports, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from '../vendor.js'; import { browserEntryFiles } from '../browser-entries.js'; -import { buildModuleGraph, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport } from '../module-graph.js'; +import { buildModuleGraph, dynamicEdges, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport } from '../module-graph.js'; import { primeComponentRegistry, findOrphanComponents, scanComponents } from '../component-scanner.js'; import { analyzeElision, lazyComponentFiles, bindingImportedLazyFiles } from '../component-elision.js'; @@ -72,7 +75,7 @@ import { import { handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset, } from './serve.js'; -import { devImportSpecifier } from '../dev-import.js'; +import { devImport } from '../dev-import.js'; /** * A short content digest of a single file's bytes for the app-source deploy @@ -297,6 +300,12 @@ export async function createRequestHandler(opts) { // what a bound `<form action=${action}>` resolves through, so gating the hook // on seeding would mean `webjs.seed: false` silently broke every no-JS form. await registerActionHooks({ seed: await readSeedEnabled(appDir), dev }); + // Bun's resolver keeps a stale directory listing for a `#` import under + // `bun --hot` (#1575); resolve the app's aliases ourselves in dev. + if (dev && serverRuntime() === 'bun') { + registerBunAliasResolver(appDir); + try { const real = realpathSync(appDir); if (real !== appDir) registerBunAliasResolver(real); } catch { /* appDir only */ } + } // Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or // `webjs.dev.sourceLocations: true`) under `webjs dev` @@ -312,6 +321,13 @@ export async function createRequestHandler(opts) { // Module URLs are realpaths, so a symlinked app dir registers both forms. try { const real = realpathSync(appDir); if (real !== appDir) registerSourceLocationHook(real); } catch { /* keep appDir only */ } } + // Bun under `bun --hot` serves a replaced file's old source forever (#1575): + // load app modules through a plugin that reads them fresh. After the + // source-locations plugin, so that one wins when it is on. + if (dev && serverRuntime() === 'bun') { + registerBunAppSource(appDir); + try { const real = realpathSync(appDir); if (real !== appDir) registerBunAppSource(real); } catch { /* appDir only */ } + } // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a // deterministic vendor map that is cheap to read (one file, no analysis, no @@ -370,6 +386,7 @@ export async function createRequestHandler(opts) { // developer remembering to run `webjs types`. Best-effort and fire-and- // forget: a failure logs and never blocks boot. Re-emitted after each route // rebuild (see doRebuild) so adding/removing a route refreshes the types. + let routeTypesTmpSeq = 0; /** @returns {Promise<void>} */ async function emitRouteTypes() { try { @@ -382,7 +399,9 @@ export async function createRequestHandler(opts) { // is atomic within the same dir. Both paths sit under the watcher-ignored // .webjs/, so neither the temp write nor the rename re-triggers a rebuild. const dest = join(outDir, 'routes.d.ts'); - const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`); + // pid AND a counter (#1575): two rebuilds in one process used to share + // one temp name, so the second rename failed with ENOENT. + const tmp = join(outDir, `routes.d.ts.${process.pid}.${++routeTypesTmpSeq}.tmp`); await writeFile(tmp, text); await rename(tmp, dest); } catch (e) { @@ -858,7 +877,7 @@ export async function createRequestHandler(opts) { } if (!file) { readinessFn = null; return null; } try { - const mod = await import(devImportSpecifier(file, dev)); + const mod = await devImport(file, dev); readinessFn = typeof mod.default === 'function' ? mod.default : null; } catch (e) { logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) }); @@ -878,9 +897,11 @@ export async function createRequestHandler(opts) { * classification of the change that triggered this rebuild (#1398). Absent * (an embedded host calling `rebuild()`, or any caller with no filename to * classify) means a full reload, which is the fail-safe default. + * @param {{ notify?: boolean }} [rebuildOpts] `notify: false` re-derives + * without a reload frame (a `bun --hot` registry reset, #1575). */ - async function rebuild(verdict) { - rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict)).catch((e) => { + async function rebuild(verdict, { notify = true } = {}) { + rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify)).catch((e) => { logger.error?.(`[webjs] rebuild failed:`, e); // Push the failure to the open tab so the overlay appears live after a // breaking edit, not only on the next manual navigation (#264). @@ -889,8 +910,13 @@ export async function createRequestHandler(opts) { return rebuildInFlight; } - /** @param {import('../dev-classify.js').ReloadVerdict} [verdict] */ - async function doRebuild(verdict) { + /** + * @param {import('../dev-classify.js').ReloadVerdict} [verdict] + * @param {boolean} [notify] tell the open tabs (`onReload`). False for the + * re-derive a `bun --hot` re-run asks for (#1575), whose edit the watcher + * already announced. + */ + async function doRebuild(verdict, notify = true) { // The route table is the only eager artifact (cheap directory scan); rebuild // it so routing reflects added/removed route files immediately. state.routeTable = await buildRouteTable(appDir); @@ -934,7 +960,7 @@ export async function createRequestHandler(opts) { // reloads every open tab, and if the underlying error is still present the // reloaded request re-pushes a fresh frame (a brief dismiss-then-reappear // flicker on an unrelated edit, self-correcting to the right end state). - state.lastDevError = null; + if (notify) state.lastDevError = null; // The verdict was computed against the PREVIOUS build's graph, because this // rebuild only INVALIDATES the lazy analysis (`analysisDone = false` above) // and the fresh graph is not built until the next request. Awaiting @@ -945,7 +971,25 @@ export async function createRequestHandler(opts) { // an otherwise morphable page, is closed on the CLIENT: `addNewHeadElements` // runs on both boundary tiers and swaps in the changed boot script, which // executes and registers the new component. - opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' }); + if (notify) opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' }); + } + + /** A routing-convention module under `app/`, which only the router loads. */ + const ROUTE_MODULE_FILE = /^app\/(?:.*\/)?(?:page|layout|route|loading|error|not-found|forbidden|unauthorized|global-error|global-not-found|middleware)\.m?[jt]s$/; + /** @type {WeakMap<object, Set<string>>} every file some app module imports, per graph */ + const importedFilesCache = new WeakMap(); + /** + * @param {Map<string, Set<string>>} graph + * @returns {Set<string>} + */ + function importedFilesOf(graph) { + let set = importedFilesCache.get(graph); + if (set) return set; + set = new Set(); + for (const deps of graph.values()) for (const d of deps) set.add(d); + for (const deps of dynamicEdges(graph).values()) for (const d of deps) set.add(d); + importedFilesCache.set(graph, set); + return set; } /** @param {Request} req */ @@ -1410,6 +1454,27 @@ export async function createRequestHandler(opts) { * @param {string} [root] the watched root, defaulting to the app dir * @returns {import('../dev-classify.js').ReloadVerdict} */ + /** + * Whether an edit to this path needs the runtime's module registry reset + * (#1575), the `bun --hot` re-run `startServer` pokes for. A module no + * other app module imports is only ever loaded through `devImport`, whose + * specifier names the file's content, so its edit is a new specifier and + * needs nothing reset. Anything imported by another module, a file the last + * analysis never saw (a new file may be the target of an import that + * failed), or any edit before the first analysis does. + * + * @param {string} filename relative to `root` + * @param {string} [root] the watched root, defaulting to the app dir + * @returns {boolean} + */ + needsRegistryReset: (filename, root) => { + const abs = resolve(root || appDir, filename); + if (!analysisEverDone) return true; + // A new routing file (a page, layout, route handler, boundary) is only + // ever loaded by the router, and nothing can have tried to import it yet. + if (!state.graphFiles.has(abs)) return !ROUTE_MODULE_FILE.test(relative(appDir, abs).split(sep).join('/')); + return importedFilesOf(state.moduleGraph).has(abs); + }, classifyWatchPath: (filename, root) => classifyChangedPath(resolve(root || appDir, filename), { appDir, shippedFiles: state.shippedFiles, diff --git a/packages/server/src/dev/hot-host.js b/packages/server/src/dev/hot-host.js new file mode 100644 index 000000000..c2720bcf0 --- /dev/null +++ b/packages/server/src/dev/hot-host.js @@ -0,0 +1,176 @@ +/** + * One dev server per process under `bun --hot` (#1575). + * + * `bun --hot` re-evaluates the entry module whenever a file it loaded changes, + * with a fresh module registry and `globalThis` preserved. For `webjs dev` the + * entry is the CLI, so every page, layout or component edit ran `startServer` + * again: a second request handler, a second set of file watchers, a second SSE + * hub, another `instrumentation.register()`, and both `Bun.plugin` hooks + * registered again. Nothing from the previous run was disposed, because nothing + * could reach it. Measured on the scaffold: about 25 MB more per edit, 5.8 GB + * after 225 edits, after which `bun --hot` stopped reloading at all and a + * component edit was never served again until a restart. A second instance also + * built its route table before its watchers existed, so a file added or renamed + * while it booted was invisible to the instance that ended up serving (a 404 + * for a route that exists, an unknown action). + * + * So the first run owns the process: its listener, hub, watchers and request + * handler stay, and a later run only tells it the module registry was reset + * (`rerun`). The handler then re-derives its analysis on the next request, + * which re-imports the app's modules from the fresh registry. The framework's + * own code stays the first run's, so its caches stay warm. Mixing that code + * with app modules from a later registry is safe by design: core's type checks + * are structural (`_$webjs`) and its shared state is keyed by `Symbol.for`. + * + * The state lives on `globalThis` under a `Symbol.for` key because a re-run + * evaluates a NEW copy of this module, whose own module variables start empty. + * + * @module dev/hot-host + */ +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; + +const HOSTS = Symbol.for('webjs.dev.hotHosts'); + +/** + * Sent to the `webjs dev` supervisor over IPC once the hot host is up, so it + * stops restarting the process for plugin-served edits this server now reloads + * in place. Mirrors `HOT_IN_PLACE_MESSAGE` in `@webjsdev/cli`'s `dev-reload.js`. + */ +export const HOT_IN_PLACE_MESSAGE = 'hot-reload-in-place'; + +/** Tell the supervisor (when there is one) that edits reload in place. */ +export function announceHotInPlace() { + try { + if (typeof process.send === 'function' && process.connected) process.send({ webjs: HOT_IN_PLACE_MESSAGE }); + } catch { /* no supervisor, or it is gone */ } +} + +/** + * Whether this process re-runs its entry on a file change: Bun under `--hot`. + * + * @param {{ isBun?: boolean, execArgv?: string[] }} [env] + * @returns {boolean} + */ +export function hotRerunCapable({ isBun = !!process.versions.bun, execArgv = process.execArgv } = {}) { + return isBun && Array.isArray(execArgv) && execArgv.includes('--hot'); +} + +/** + * @typedef {{ + * version: string, + * rerun: () => Promise<unknown>, + * poke: () => void, + * }} HotHost + */ + +/** @returns {Map<string, HotHost>} */ +function hosts() { + const g = /** @type {any} */ (globalThis); + if (!(g[HOSTS] instanceof Map)) g[HOSTS] = new Map(); + return g[HOSTS]; +} + +/** + * The host key: one dev server per app directory and port. + * @param {string} appDir absolute + * @param {number} port + */ +export function hotHostKey(appDir, port) { + return `${appDir}\0${port}`; +} + +/** @param {string} key @returns {HotHost | undefined} */ +export function getHotHost(key) { + return hosts().get(key); +} + +/** @param {string} key @param {HotHost} host */ +export function setHotHost(key, host) { + hosts().set(key, host); +} + +/** @param {string} key */ +export function deleteHotHost(key) { + hosts().delete(key); +} + +/** + * The sentinel module that lets the dev server ask `bun --hot` for a re-run. + * `bun --hot` does not watch a module a `Bun.plugin` served (#1550), and in dev + * every app module is one (`*.server.*` through the seed plugin, the rest + * through `dev/bun-app-source.js`). A `*.server.*` edit used to restart the + * whole process, which dropped requests for 0.3 to 2 seconds. Writing this file + * instead re-runs the entry within milliseconds, which resets the registry. + * + * It lives in the OS temp dir, per process: inside the app it would match the + * app-source plugin's filter (and so not be watched) and the dev watcher. + * + * @param {string} appDir + * @returns {string} absolute path + */ +export function hotSentinelPath(appDir) { + const tag = createHash('sha1').update(appDir).digest('hex').slice(0, 12); + return join(tmpdir(), 'webjs-dev-hot', `${process.pid}-${tag}.mjs`); +} + +/** + * Create (if needed) and import the sentinel so `bun --hot` watches it. A + * re-run's fresh registry imports (and so watches) it again. + * + * @param {string} appDir + * @returns {Promise<void>} + */ +export async function armHotSentinel(appDir) { + const file = hotSentinelPath(appDir); + try { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, 'export default 0;\n', { flag: 'wx' }); + } catch { /* already there */ } + try { await import(pathToFileURL(file).href); } catch { /* best effort: without it a poke is a no-op */ } +} + +/** Remove the sentinel (the server is closing). @param {string} appDir */ +export function removeHotSentinel(appDir) { + try { rmSync(hotSentinelPath(appDir), { force: true }); } catch { /* best effort */ } +} + +/** + * Ask `bun --hot` for a re-run by changing the sentinel. + * @param {string} appDir + */ +export function pokeHotSentinel(appDir) { + try { writeFileSync(hotSentinelPath(appDir), `export default ${Date.now()};\n`); } catch { /* best effort */ } +} + +const APP_MODULE = /\.m?[jt]s$/; +/** Files no module imports at runtime: an edit to one never needs a registry reset. */ +const INERT_ASSET = /\.(?:css|md|mdx|txt|log|map|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf)$/i; + +/** + * Whether a changed path could leave a stale module in the registry, so the + * dev server has to ask `bun --hot` for a reset. In dev on Bun every JS/TS + * module of the app is served by a `Bun.plugin` (`*.server.*` through the seed + * plugin, the rest through `dev/bun-app-source.js` or the source-locations + * plugin), which `bun --hot` never watches. + * + * `isRouteOnly` answers whether a module path is one only the router loads + * (nothing imports it; see `needsRegistryReset` on the request handler): its + * dev re-import is keyed by content, so it needs no reset. Anything the + * dev server cannot place, such as the extensionless temp file `sed -i` + * renames over the real one, gets the reset, because the event may name the + * temp file rather than the module it replaced. + * + * @param {string} path relative to the watched root + * @param {(path: string) => boolean} isRouteOnly + * @returns {boolean} + */ +export function needsHotReset(path, isRouteOnly) { + if (/(?:^|[\\/])node_modules[\\/]/.test(path)) return false; + if (INERT_ASSET.test(path)) return false; + if (APP_MODULE.test(path)) return !isRouteOnly(path); + return true; +} diff --git a/packages/server/src/dev/serve.js b/packages/server/src/dev/serve.js index 053e5a37c..2ca087dc6 100644 --- a/packages/server/src/dev/serve.js +++ b/packages/server/src/dev/serve.js @@ -34,7 +34,7 @@ import { versionModuleImports, withAssetHash } from '../asset-hash.js'; import { toUrlPath } from '../ssr/preloads.js'; import { BUFFERED_MARKER } from '../conditional-get.js'; import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js'; -import { devImportSpecifier } from '../dev-import.js'; +import { devImport } from '../dev-import.js'; import { runMetadataRoute } from '../metadata-routes.js'; /** @@ -573,7 +573,7 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) { const handlers = []; for (const f of files) { try { - const mod = await import(devImportSpecifier(f, dev)); + const mod = await devImport(f, dev); if (typeof mod.default === 'function') handlers.push(mod.default); } catch { // Bad middleware file: skip; top-level error handler will catch real problems. @@ -617,7 +617,7 @@ export async function loadMiddleware(appDir, dev, logger) { } if (!file) return null; try { - const mod = await import(devImportSpecifier(file, dev)); + const mod = await devImport(file, dev); return typeof mod.default === 'function' ? mod.default : null; } catch (e) { logger.error('failed to load root middleware', { file, err: String(e) }); diff --git a/packages/server/src/dev/server.js b/packages/server/src/dev/server.js index 8837da18d..da380b56a 100644 --- a/packages/server/src/dev/server.js +++ b/packages/server/src/dev/server.js @@ -1,7 +1,11 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { strongerVerdict } from '../dev-classify.js'; import { watchTree } from './watch-tree.js'; -import { relative } from 'node:path'; +import { relative, resolve } from 'node:path'; +import { + hotRerunCapable, hotHostKey, getHotHost, setHotHost, deleteHotHost, + armHotSentinel, pokeHotSentinel, removeHotSentinel, needsHotReset, announceHotInPlace, +} from './hot-host.js'; import { createServer as createHttp1Server } from 'node:http'; import { createRequestHandler } from './handler.js'; import { readServerTimeoutsFromApp, readDevWatchPathsFromApp } from './config.js'; @@ -15,6 +19,18 @@ import { attachWebSocket } from '../websocket.js'; import { toWebRequest, sendWebResponse } from './helpers.js'; import { isAddrInUse, portInUseError, reusePortRequested } from '../port-in-use.js'; +/** This copy of `@webjsdev/server`'s version, so a hot re-run can tell the framework itself changed. */ +const SERVER_VERSION = (() => { + try { return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || ''; } catch { return ''; } +})(); + +/** + * How long a poked re-run (#1575) may take before the dev server stops waiting + * for it and rebuilds anyway, so an edit is never lost to a sentinel `bun --hot` + * did not see. + */ +const POKE_FALLBACK_MS = 1500; + function debounce(fn, ms) { let t; return (...args) => { @@ -237,6 +253,22 @@ export async function startServer(opts) { const compress = opts.compress ?? !dev; const logger = opts.logger || defaultLogger({ dev }); + // `bun --hot` re-runs the CLI, and so this function, on every edit to a + // module it loaded (#1575). The first run owns the process; a later one only + // tells it the module registry was reset. See dev/hot-host.js. + const hotKey = dev && hotRerunCapable() ? hotHostKey(resolve(opts.appDir), port) : null; + const host = hotKey ? getHotHost(hotKey) : undefined; + if (host) { + if (host.version !== SERVER_VERSION) { + // The framework itself changed under a running dev server (an upgrade). + // Its first-run code cannot load the new copy in place, so exit and let + // the `webjs dev` supervisor start a fresh process on the new version. + logger.warn(`[webjs] @webjsdev/server changed (${host.version || '?'} -> ${SERVER_VERSION || '?'}), restarting the dev server`); + if (process.env.__WEBJS_DEV_CHILD === '1') process.exit(0); + } + return /** @type {any} */ (await host.rerun()); + } + // Runtime-neutral SSE registry + fanout (shared by the node:http and Bun.serve // shells via listener-core.js, so live-reload + the dev error overlay behave // identically on both). Built before the handler so its onReload / onDevError @@ -263,6 +295,10 @@ export async function startServer(opts) { /** @type {AbortController | null} */ let watcherAbort = null; + /** The strongest verdict of the edits a poked re-run has yet to apply. */ + /** @type {import('../dev-classify.js').ReloadVerdict | null} */ + let pokedVerdict = null; + /** @type {any} */ let pokeFallback = null; if (dev) { // Watch the app tree with Node's built-in `fs.watch` (no external dep): // `watchTree` (#1521) watches the root plus each non-ignored top-level dir, @@ -273,18 +309,35 @@ export async function startServer(opts) { // (module-level, exported for tests) skips node_modules, .git, .webjs/, and // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops. // Live-reload classification (#1398). Several files can change inside one - // 80ms debounce window, so hold the STRONGEST verdict of the window and + // 50ms debounce window (80ms before #1575), so hold the STRONGEST verdict of the window and // hand it to the rebuild. Same rule as the browser relay's cross-batch // accumulation, for the same reason: a window mixing a page edit and a // component edit is a component edit, and taking the last one would morph // fresh markup onto the old component class. /** @type {import('../dev-classify.js').ReloadVerdict | null} */ let pendingVerdict = null; + // Under `bun --hot`, a window that changed a module a `Bun.plugin` serves + // pokes the re-run sentinel instead of rebuilding (#1575): the rebuild has + // to wait for the registry reset, or the next request would still import + // the stale module. The re-run (`hotRerun` below) rebuilds with the verdict. + let pendingPoke = false; const rebuild = debounce(() => { const v = pendingVerdict; pendingVerdict = null; + if (pendingPoke) { + pendingPoke = false; + pokedVerdict = strongerVerdict(pokedVerdict, v); + pokeHotSentinel(app.appDir); + clearTimeout(pokeFallback); + pokeFallback = setTimeout(() => { + const pv = pokedVerdict; + pokedVerdict = null; + if (pv) app.rebuild(pv); + }, POKE_FALLBACK_MS); + return; + } app.rebuild(v || undefined); - }, 80); + }, 50); watcherAbort = new AbortController(); // One warning per unwatchable path (#1521): an EACCES / ENOENT on a single // file must never stop the dev server, and a burst of the same temp file @@ -307,6 +360,7 @@ export async function startServer(opts) { // lives in createRequestHandler's scope, not here. if (app.isRegenerateOutput(filename)) return; pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root)); + if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true; rebuild(); }, onError: onWatchError, @@ -341,11 +395,43 @@ export async function startServer(opts) { // req/s on the listening path (#511); the Bun shell is dynamically imported so // the `Bun.*` global is never referenced on Node. try { + /** @type {{ server: any, close: () => Promise<void> }} */ + let handle; if (serverRuntime() === 'bun') { const { startBunListener } = await import('../listener-bun.js'); - return startBunListener(ctx); + handle = startBunListener(ctx); + } else { + handle = await startNodeListener(ctx); + } + if (hotKey) { + // Own the process for every later `bun --hot` re-run (#1575). + await armHotSentinel(app.appDir); + const close = handle.close; + handle = { ...handle, close: () => { deleteHotHost(hotKey); removeHotSentinel(app.appDir); return close(); } }; + const owned = handle; + setHotHost(hotKey, { + version: SERVER_VERSION, + /** + * The registry was reset: re-watch the sentinel from the fresh registry, + * then re-derive the analysis so it re-imports the app's modules (the + * middleware, the components it primes) from that registry. A re-run a + * poke asked for carries the verdict of the edits behind it and tells the + * browser; any other is silent, since the watcher already sent that + * edit's own reload frame. + */ + rerun: async () => { + await armHotSentinel(app.appDir); + const pv = pokedVerdict; + pokedVerdict = null; + clearTimeout(pokeFallback); + await app.rebuild(pv || undefined, { notify: !!pv }); + return owned; + }, + poke: () => pokeHotSentinel(app.appDir), + }); + announceHotInPlace(); } - return await startNodeListener(ctx); + return handle; } catch (e) { // The listener never came up (a taken port, #1527): stop what was started // for it, so an embedding caller that catches this is not left with a diff --git a/packages/server/src/env-schema.js b/packages/server/src/env-schema.js index 4ed60a86e..7cb57e015 100644 --- a/packages/server/src/env-schema.js +++ b/packages/server/src/env-schema.js @@ -33,7 +33,7 @@ */ import { join } from 'node:path'; import { stat } from 'node:fs/promises'; -import { devImportSpecifier } from './dev-import.js'; +import { devImport } from './dev-import.js'; /** Field type names a schema may declare. */ const KNOWN_TYPES = new Set(['string', 'number', 'boolean', 'url', 'enum']); @@ -223,7 +223,7 @@ export async function loadEnvSchema(appDir, opts = {}) { } } if (!file) return null; - const mod = await import(devImportSpecifier(file, Boolean(opts.dev))); + const mod = await devImport(file, Boolean(opts.dev)); return mod.default ?? null; } diff --git a/packages/server/src/form-action-identity.js b/packages/server/src/form-action-identity.js index a42826a38..c355aefc5 100644 --- a/packages/server/src/form-action-identity.js +++ b/packages/server/src/form-action-identity.js @@ -16,7 +16,7 @@ import { actionIdentitiesOf, identityHookInstalled } from './action-seed.js'; import { pathToFileURL } from 'node:url'; -import { devImportSpecifier } from './dev-import.js'; +import { devImport } from './dev-import.js'; /** * Resolved identities for functions the hook never registered. Only ever @@ -283,7 +283,7 @@ export async function lookupActionIdentity(idx, id) { if (!file) return { ok: false, reason: 'skew' }; let module; try { - module = await import(devImportSpecifier(file, Boolean(idx.dev))); + module = await devImport(file, Boolean(idx.dev)); } catch (error) { return { ok: false, reason: 'load-failed', error }; } diff --git a/packages/server/src/instrumentation.js b/packages/server/src/instrumentation.js index ac1e2eb29..4c6c77f8f 100644 --- a/packages/server/src/instrumentation.js +++ b/packages/server/src/instrumentation.js @@ -18,7 +18,7 @@ import { join } from 'node:path'; import { stat } from 'node:fs/promises'; import { AsyncLocalStorage } from 'node:async_hooks'; -import { devImportSpecifier } from './dev-import.js'; +import { devImport } from './dev-import.js'; /** * Per-boot handoff channel for a `register()`-registered error sink. An @@ -86,7 +86,7 @@ export async function runInstrumentation(appDir, opts = {}) { const store = { onError: null }; await _als.run(store, async () => { try { - const mod = await import(devImportSpecifier(file, dev)); + const mod = await devImport(file, dev); const register = typeof mod.default === 'function' ? mod.default : (typeof mod.register === 'function' ? mod.register : null); diff --git a/packages/server/src/listener-core.js b/packages/server/src/listener-core.js index dd8919846..39bccaba0 100644 --- a/packages/server/src/listener-core.js +++ b/packages/server/src/listener-core.js @@ -23,7 +23,7 @@ import { import { stripBasePath } from './base-path.js'; import { randomUUID } from 'node:crypto'; import { isWatchError } from './dev/watch-tree.js'; -import { devImportSpecifier } from './dev-import.js'; +import { devImport } from './dev-import.js'; /** The dev live-reload SSE path (matched after base-path stripping). */ export const EVENTS_PATH = '/__webjs/events'; @@ -329,7 +329,7 @@ export async function readBufferedOrStream(web, maxBytes) { * @param {boolean} dev */ export function loadWsModule(file, dev) { - return import(devImportSpecifier(file, dev)); + return devImport(file, dev); } /** diff --git a/packages/server/src/metadata-routes.js b/packages/server/src/metadata-routes.js index fb0a1d35a..d4a2240ce 100644 --- a/packages/server/src/metadata-routes.js +++ b/packages/server/src/metadata-routes.js @@ -20,7 +20,7 @@ */ import { sitemap as sitemapXml, robots as robotsTxt } from './sitemap.js'; import { isUrlSegment } from './router.js'; -import { devImportSpecifier } from './dev-import.js'; +import { devImport } from './dev-import.js'; import { siteUrlFromEnv } from './ssr/seo.js'; import { basePath } from './importmap.js'; import { withBasePath } from './base-path.js'; @@ -42,7 +42,7 @@ async function isNoindex(files, dev) { let robots; for (const file of files) { try { - const mod = await import(devImportSpecifier(file, dev)); + const mod = await devImport(file, dev); if (mod.metadata && typeof mod.metadata === 'object' && mod.metadata.robots !== undefined) { robots = mod.metadata.robots; } @@ -120,7 +120,7 @@ export async function listPages(routeTable, { siteUrl, dev }) { } let mod; try { - mod = await import(devImportSpecifier(page.file, dev)); + mod = await devImport(page.file, dev); } catch { continue; } @@ -167,7 +167,7 @@ function absoluteEntries(entries, siteUrl) { * @returns {Promise<Response | null>} null when the module has no default export */ export async function runMetadataRoute(meta, { req, url, path, dev, routeTable }) { - const mod = await import(devImportSpecifier(meta.file, dev)); + const mod = await devImport(meta.file, dev); if (typeof mod.default !== 'function') return null; const siteUrl = siteUrlFromEnv() || url.origin; const ctx = { diff --git a/packages/server/src/module-graph.js b/packages/server/src/module-graph.js index b0126858c..4b8f13a79 100644 --- a/packages/server/src/module-graph.js +++ b/packages/server/src/module-graph.js @@ -508,7 +508,7 @@ async function walk(dir, appDir, graph, seen, dynamic, bare, bareDynamic) { * rebuilds incremental for large apps without restructuring the caller. * Keyed by mtime AND size: a same-tick edit that also changes the file length * is caught even on coarse-resolution filesystems where mtime alone could miss. - * @type {Map<string, { mtimeMs: number, size: number, deps: Set<string> }>} + * @type {Map<string, { mtimeMs: number, size: number, specs: string[], dynSpecs: string[], bareDeps: Set<string>, bareDynDeps: Set<string> }>} */ const PARSE_CACHE = new Map(); @@ -530,10 +530,7 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic) seen?.add(file); // mark live (both cache-hit and miss paths) for cache eviction const cached = PARSE_CACHE.get(file); if (cached && cached.mtimeMs === mtimeMs && cached.size === size) { - if (cached.deps.size) graph.set(file, cached.deps); - if (cached.dynDeps && cached.dynDeps.size && dynamic) dynamic.set(file, cached.dynDeps); - if (cached.bareDeps && cached.bareDeps.size && bare) bare.set(file, cached.bareDeps); - if (cached.bareDynDeps && cached.bareDynDeps.size && bareDynamic) bareDynamic.set(file, cached.bareDynDeps); + addResolved(file, appDir, cached, graph, dynamic, bare, bareDynamic); return; } @@ -574,7 +571,8 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic) // string (blanked here), so it is read back from raw `src` via the match's // group indices (the `d` flag). const masked = redactStringsAndTemplates(src, true); - const deps = new Set(); + /** @type {string[]} local (relative / aliased) static specifiers */ + const specs = []; /** @type {Set<string>} bare npm vendor specifiers imported by this file (#754) */ const bareDeps = new Set(); for (const re of [IMPORT_RE, EXPORT_FROM_RE]) { @@ -612,8 +610,7 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic) if (isVendorSpecifier(spec)) bareDeps.add(spec); continue; } - const resolved = resolveImport(spec, file, appDir); - if (resolved) deps.add(resolved); + specs.push(spec); } } // Dynamic `import('…')` with a string-literal specifier (#751): a separate @@ -636,7 +633,8 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic) // computed `import(expr)` still falls out (its char after the quote is not // `,`/`)`), preserving the documented limitation. const { redacted: holeAware, literals } = redactToPlaceholders(src); - const dynDeps = new Set(); + /** @type {string[]} local dynamic-import specifiers */ + const dynSpecs = []; const bareDynDeps = new Set(); for (const m of holeAware.matchAll(DYNAMIC_IMPORT_RE)) { const token = m[1]; @@ -651,14 +649,43 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic) if (isVendorSpecifier(spec)) bareDynDeps.add(spec); continue; } + dynSpecs.push(spec); + } + const parsed = { mtimeMs, size, specs, dynSpecs, bareDeps, bareDynDeps }; + PARSE_CACHE.set(file, parsed); + addResolved(file, appDir, parsed, graph, dynamic, bare, bareDynamic); +} + +/** + * Resolve a parsed file's specifiers and add its edges to the graph maps. + * + * The cache holds SPECIFIERS, not resolved paths (#1575): resolution depends on + * which files exist, not on the importer's own bytes, so a cached resolution + * outlives the file system it was made against. An importer written before the + * module it imports (the order an agent often writes them in) resolved to + * nothing, was cached that way, and kept no edge after the target appeared, + * because the importer's mtime never changed again; the gate then 404'd the new + * module. Resolving on every build costs a few `existsSync` calls per edge. + * + * @param {string} file + * @param {string} appDir + * @param {{ specs: string[], dynSpecs: string[], bareDeps: Set<string>, bareDynDeps: Set<string> }} parsed + */ +function addResolved(file, appDir, parsed, graph, dynamic, bare, bareDynamic) { + const deps = new Set(); + for (const spec of parsed.specs) { + const resolved = resolveImport(spec, file, appDir); + if (resolved) deps.add(resolved); + } + const dynDeps = new Set(); + for (const spec of parsed.dynSpecs) { const resolved = resolveImport(spec, file, appDir); if (resolved && !deps.has(resolved)) dynDeps.add(resolved); } - PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps, bareDynDeps }); if (deps.size) graph.set(file, deps); if (dynDeps.size && dynamic) dynamic.set(file, dynDeps); - if (bareDeps.size && bare) bare.set(file, bareDeps); - if (bareDynDeps.size && bareDynamic) bareDynamic.set(file, bareDynDeps); + if (parsed.bareDeps.size && bare) bare.set(file, parsed.bareDeps); + if (parsed.bareDynDeps.size && bareDynamic) bareDynamic.set(file, parsed.bareDynDeps); } /** diff --git a/packages/server/src/ssr/render.js b/packages/server/src/ssr/render.js index 275646e2b..189004f08 100644 --- a/packages/server/src/ssr/render.js +++ b/packages/server/src/ssr/render.js @@ -17,7 +17,7 @@ import { escapeHtml } from './escape.js'; import { cachedHtmlResponse, getNonce, htmlResponse, streamingHtmlResponse, } from './responses.js'; -import { devImportSpecifier } from '../dev-import.js'; +import { devImport } from '../dev-import.js'; import { applySeoDefaults } from './seo.js'; @@ -108,7 +108,7 @@ export function privateFragment(res) { * @param {boolean} dev */ async function loadModule(file, dev) { - return import(devImportSpecifier(file, dev)); + return devImport(file, dev); } /** diff --git a/packages/server/test/dev/dev-import.test.js b/packages/server/test/dev/dev-import.test.js index 8488db73f..076ddcdf0 100644 --- a/packages/server/test/dev/dev-import.test.js +++ b/packages/server/test/dev/dev-import.test.js @@ -22,13 +22,13 @@ test('outside dev the specifier is the file URL, with no query', () => { }); test('in dev on Node the query rides the file URL', () => { - const s = devImportSpecifier(FILE, true, { bun: false, now: () => 42 }); - assert.match(s, /^file:\/\/\/srv\/app\/app\/page\.ts\?t=42-[a-z0-9]+$/); + const s = devImportSpecifier(FILE, true, { bun: false, version: () => 'v42' }); + assert.equal(s, 'file:///srv/app/app/page.ts?t=v42'); }); test('in dev on Bun the query rides the plain path', () => { - const s = devImportSpecifier(FILE, true, { bun: true, now: () => 42 }); - assert.match(s, /^\/srv\/app\/app\/page\.ts\?t=42-[a-z0-9]+$/); + const s = devImportSpecifier(FILE, true, { bun: true, version: () => 'v42' }); + assert.equal(s, '/srv/app/app/page.ts?t=v42'); }); test('on Bun a path that already holds ? or # falls back to the file URL', () => { @@ -37,8 +37,24 @@ test('on Bun a path that already holds ? or # falls back to the file URL', () => } }); -test('two specifiers for the same file differ', () => { - assert.notEqual(devImportSpecifier(FILE, true, { bun: true, now: () => 1 }), devImportSpecifier(FILE, true, { bun: true, now: () => 1 })); +test('the query names the content: same bytes, same specifier; new bytes, a new one (#1575)', () => { + const dir = mkdtempSync(join(tmpdir(), 'webjs-dev-import-')); + try { + const file = join(dir, 'm.mjs'); + writeFileSync(file, 'export default "a1";\n'); + const first = devImportSpecifier(file, true); + // A random query per call made every request a new, never-freed module. + assert.equal(devImportSpecifier(file, true), first); + // Same length, written immediately: a coarse mtime cannot tell these apart. + writeFileSync(file, 'export default "a2";\n'); + assert.notEqual(devImportSpecifier(file, true), first); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('an unreadable file gets a unique specifier each call', () => { + assert.notEqual(devImportSpecifier('/nope/x.ts', true, { bun: true }), devImportSpecifier('/nope/x.ts', true, { bun: true })); }); test('a re-import through the helper sees an edit on this runtime', async () => { diff --git a/packages/server/test/dev/hot-reload-robustness.test.js b/packages/server/test/dev/hot-reload-robustness.test.js new file mode 100644 index 000000000..c7ae781eb --- /dev/null +++ b/packages/server/test/dev/hot-reload-robustness.test.js @@ -0,0 +1,125 @@ +/** + * The pieces of the `bun --hot` robustness fix (#1575) that hold without a + * running server. The end-to-end proof (a real `webjs dev` taking agent-style + * edits) is `test/bun/dev-reload-stress.mjs`. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { hotRerunCapable, needsHotReset, hotSentinelPath, hotHostKey, getHotHost, setHotHost, deleteHotHost } from '../../src/dev/hot-host.js'; +import { resolveAppAlias } from '../../src/dev/bun-alias-resolve.js'; +import { appSourceFilter } from '../../src/dev/bun-app-source.js'; +import { buildModuleGraph } from '../../src/module-graph.js'; +import { devImport } from '../../src/dev-import.js'; + +function tempApp() { + const dir = mkdtempSync(join(tmpdir(), 'webjs-hot-robust-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module', imports: { '#*': './*' } })); + return dir; +} + +test('only Bun under --hot re-runs the entry', () => { + assert.equal(hotRerunCapable({ isBun: true, execArgv: ['--hot'] }), true); + assert.equal(hotRerunCapable({ isBun: true, execArgv: [] }), false); + assert.equal(hotRerunCapable({ isBun: false, execArgv: ['--hot'] }), false); +}); + +test('the hot host lives on globalThis, so a re-run (a fresh module copy) finds it', async () => { + const key = hotHostKey('/srv/app', 4321); + const host = { version: 'x', rerun: async () => 'rerun', poke: () => {} }; + setHotHost(key, host); + try { + // A query makes a second, independent instance of the module: exactly what + // a `bun --hot` re-run evaluates. + const fresh = await import(`../../src/dev/hot-host.js?copy=${Date.now()}`); + assert.equal(fresh.getHotHost(key), host); + } finally { + deleteHotHost(key); + } + assert.equal(getHotHost(key), undefined); +}); + +test('which edits need a registry reset on Bun', () => { + const routeOnly = (p) => p === 'app/page.ts'; + // An imported module, a server module, a new module: reset. + for (const p of ['components/x.js', 'modules/a/actions/b.server.ts', 'lib/y.mts']) assert.equal(needsHotReset(p, routeOnly), true, p); + // A module only the router loads is re-imported by content: no reset. + assert.equal(needsHotReset('app/page.ts', routeOnly), false); + // `sed -i` renames an extensionless temp file over the module: the event may + // name only the temp file, so an unplaceable path resets. + assert.equal(needsHotReset('lib/sedAb12Cd', routeOnly), true); + for (const p of ['public/app.css', 'content/post.md', 'node_modules/pkg/index.js']) assert.equal(needsHotReset(p, routeOnly), false, p); +}); + +test('the sentinel lives outside the app, so neither the app-source plugin nor the watcher sees it', () => { + const p = hotSentinelPath('/srv/app'); + assert.ok(!p.startsWith('/srv/app/'), p); + assert.ok(p.endsWith('.mjs')); + assert.equal(hotSentinelPath('/srv/app'), p, 'stable for one app in one process'); + assert.notEqual(hotSentinelPath('/srv/other'), p); +}); + +test('the app-source filter takes app modules and leaves node_modules and *.server.* alone', () => { + const f = appSourceFilter('/srv/app'); + for (const p of ['/srv/app/app/page.ts', '/srv/app/components/c.js?t=abc', '/srv/app/lib/x.mts']) assert.ok(f.test(p), p); + for (const p of ['/srv/app/node_modules/x/i.js', '/srv/app/modules/a.server.ts', '/srv/other/app/page.ts', '/srv/app/app/style.css']) assert.ok(!f.test(p), p); +}); + +test('a # alias resolves to the file on disk, only for an importer inside the app', () => { + const dir = tempApp(); + try { + mkdirSync(join(dir, 'modules/u'), { recursive: true }); + const importer = join(dir, 'modules/a.server.ts'); + assert.equal(resolveAppAlias('#modules/u/new.ts', importer + '?t=1', dir), null, 'not there yet'); + writeFileSync(join(dir, 'modules/u/new.ts'), 'export const v = 1;\n'); + assert.equal(resolveAppAlias('#modules/u/new.ts', importer + '?t=1', dir), join(dir, 'modules/u/new.ts')); + assert.equal(resolveAppAlias('#modules/u/new.ts', join(dir, 'node_modules/p/i.js'), dir), null, 'a package keeps its own map'); + assert.equal(resolveAppAlias('./u/new.ts', importer, dir), null, 'not an alias'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('an importer written before its import target gains the edge once the target exists', async () => { + const dir = tempApp(); + try { + mkdirSync(join(dir, 'app'), { recursive: true }); + mkdirSync(join(dir, 'components'), { recursive: true }); + const page = join(dir, 'app/page.ts'); + // Two resolutions that depend on what exists: an extensionless specifier, + // and a `.js` specifier naming a `.ts` file (the TypeScript convention). + writeFileSync(page, "import './local';\nimport '#components/widget.js';\nexport default () => '';\n"); + let g = await buildModuleGraph(dir); + assert.equal(g.get(page)?.has(join(dir, 'app/local.ts')) ?? false, false); + // The page is untouched, so its parse comes from the cache. A cached + // RESOLUTION kept the old guess forever; a cached specifier re-resolves. + writeFileSync(join(dir, 'app/local.ts'), 'export {};\n'); + writeFileSync(join(dir, 'components/widget.ts'), 'export {};\n'); + g = await buildModuleGraph(dir); + assert.equal(g.get(page)?.has(join(dir, 'app/local.ts')), true); + assert.equal(g.get(page)?.has(join(dir, 'components/widget.ts')), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('devImport never re-imports a specifier that failed, and recovers when the file is fixed', async () => { + const dir = tempApp(); + try { + const f = join(dir, 'm.mjs'); + writeFileSync(f, 'export default (((;\n'); + await assert.rejects(devImport(f, true)); + // Same bytes: a fresh key, so it fails again rather than reusing (and on + // Bun under --hot, hanging on) the failed module record. + await assert.rejects(devImport(f, true)); + writeFileSync(f, 'export default "fixed";\n'); + assert.equal((await devImport(f, true)).default, 'fixed'); + // Unchanged content reuses the loaded module (no new instance per call). + assert.equal(await devImport(f, true), await devImport(f, true)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/dev-reload-stress.mjs b/scripts/dev-reload-stress.mjs new file mode 100644 index 000000000..834dd088c --- /dev/null +++ b/scripts/dev-reload-stress.mjs @@ -0,0 +1,383 @@ +/** + * The dev-server reload stress (#1575): edit a running `webjs dev` app the way + * an AI agent does and check that, once each sequence of edits settles, the + * server serves exactly the current files. + * + * Sequences: new / edited / burst-written pages, layouts, components, `'use + * server'` actions and the modules they import, route handlers, new exports, + * renamed actions and route folders, deleted routes, files written partially + * then completed, syntax errors then fixes (page, action, imported component), + * atomic (temp + rename) writes, a whole feature written at once, and a + * git-checkout-like churn. Each check records how long after the write the new + * version was served, then holds it for a moment to catch one that flaps back. + * + * Everything it writes lives under `zzstress` names and is removed at the end. + * It never touches the app's own files. + * + * node scripts/dev-reload-stress.mjs <appDir> <baseUrl> [--only=a,b] [--soak=N] + * + * `--soak=N` then makes N more edits and prints the server's RSS (Linux; the + * pid listening on the port), the memory-growth check. + * + * Used by `test/bun/dev-reload-stress.mjs` against a staged fixture app. + */ +import { mkdirSync, writeFileSync, rmSync, renameSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const NS = 'zzstress'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * @typedef {{ name: string, ok: boolean, ms: number, why?: string }} StressResult + */ + +/** + * Run the stress sequences against a running dev server. + * + * @param {{ + * appDir: string, + * base: string, + * only?: string[], + * timeoutMs?: number, + * tolerateRestarts?: boolean, + * log?: (line: string) => void, + * }} opts `tolerateRestarts`: a refused connection is "not yet" rather than a + * flap, for a runtime whose dev server restarts the process on an edit (Node). + * @returns {Promise<StressResult[]>} + */ +export async function runDevReloadStress({ appDir, base, only = [], timeoutMs = 8000, tolerateRestarts = false, log = () => {} }) { + const TIMEOUT = timeoutMs; + const P = (rel) => join(appDir, rel); + const write = (rel, s) => { mkdirSync(dirname(P(rel)), { recursive: true }); writeFileSync(P(rel), s); }; + const atomicWrite = (rel, s) => { mkdirSync(dirname(P(rel)), { recursive: true }); const t = P(rel) + '.tmp' + process.pid; writeFileSync(t, s); renameSync(t, P(rel)); }; + const rm = (rel) => rmSync(P(rel), { recursive: true, force: true }); + const hash = (rel) => createHash('sha256').update(P(rel)).digest('hex').slice(0, 10); + const origin = new URL(base).origin; + + async function get(path) { + try { + const r = await fetch(base + path, { headers: { accept: 'text/html' }, redirect: 'manual' }); + return { status: r.status, body: await r.text() }; + } catch (e) { return { status: 0, body: String(e) }; } + } + async function call(rel, fn) { + try { + const r = await fetch(`${base}/__webjs/action/${hash(rel)}/${fn}`, { + method: 'POST', + headers: { origin, 'content-type': 'application/vnd.webjs+json', accept: 'application/vnd.webjs+json' }, + body: '[]', + }); + return { status: r.status, body: await r.text() }; + } catch (e) { return { status: 0, body: String(e) }; } + } + + /** Poll until `check` passes (ms from `t0`), then confirm it holds for `hold` ms. */ + async function settle(name, probe, check, t0, hold = 600) { + let last; + while (performance.now() - t0 < TIMEOUT) { + last = await probe(); + if (check(last)) { + const ms = Math.round(performance.now() - t0); + const until = performance.now() + hold; + while (performance.now() < until) { + const again = await probe(); + if (!check(again) && !(tolerateRestarts && again.status === 0)) { + return { name, ok: false, ms, why: `flapped back: ${again.status} ${again.body.slice(0, 160)}` }; + } + await sleep(25); + } + return { name, ok: true, ms }; + } + await sleep(10); + } + const mk = String(last?.body).match(/(?:marker|layout|comp|act|route):[A-Za-z0-9:]+/g); + return { name, ok: false, ms: TIMEOUT, why: `last: ${last?.status} ${mk ? mk.join(',') : String(last?.body).replace(/\s+/g, ' ').slice(0, 300)}` }; + } + const has = (m) => (r) => r.status === 200 && r.body.includes(m); + const status = (s) => (r) => r.status === s; + + const results = []; + const scenarios = {}; + const S = (name, fn) => { scenarios[name] = fn; }; + + const page = (m, extra = '') => `import { html } from '@webjsdev/core';\n${extra}\nexport default function P() {\n return html\`<main><p>marker:${m}</p></main>\`;\n}\n`; + const pageWith = (imports, body) => `import { html } from '@webjsdev/core';\n${imports}\nexport default async function P() {\n ${body}\n}\n`; + const layout = (m) => `import { html } from '@webjsdev/core';\nexport default function L({ children }) {\n return html\`<section data-l="${m}">layout:${m} \${children}</section>\`;\n}\n`; + const action = (fn, m, imp = '', expr = `'${m}'`) => `'use server';\n${imp}\nexport async function ${fn}() {\n return 'act:' + ${expr};\n}\n`; + const comp = (tag, m) => `import { WebComponent, html } from '@webjsdev/core';\nexport class C extends WebComponent() {\n static tag = '${tag}';\n n = 0;\n render() { return html\`<button @click=\${() => this.n++}>comp:${m}</button>\`; }\n}\nC.register('${tag}');\n`; + const route = (m) => `export function GET() {\n return new Response('route:${m}');\n}\n`; + + + S('new-page', async () => { + const t0 = performance.now(); write(`app/${NS}/new/page.ts`, page('np1')); + return settle('new-page', () => get(`/${NS}/new`), has('marker:np1'), t0); + }); + S('page-burst', async () => { + write(`app/${NS}/burst/page.ts`, page('b0')); + await settle('pre', () => get(`/${NS}/burst`), has('marker:b0'), performance.now()); + for (let i = 1; i <= 25; i++) { write(`app/${NS}/burst/page.ts`, page('b' + i)); await sleep(4); } + const t0 = performance.now(); + return settle('page-burst', () => get(`/${NS}/burst`), has('marker:b25'), t0); + }); + S('page-edit-spaced', async () => { + const out = []; + for (let i = 1; i <= 6; i++) { + const t0 = performance.now(); write(`app/${NS}/burst/page.ts`, page('s' + i)); + out.push(await settle(`page-edit-${i}`, () => get(`/${NS}/burst`), has('marker:s' + i), t0, 100)); + await sleep(150 * i); + } + return out; + }); + S('layout', async () => { + let t0 = performance.now(); write(`app/${NS}/lay/layout.ts`, layout('L1')); write(`app/${NS}/lay/page.ts`, page('lp')); + const a = await settle('layout-new', () => get(`/${NS}/lay`), (r) => has('layout:L1')(r) && r.body.includes('marker:lp'), t0); + t0 = performance.now(); write(`app/${NS}/lay/layout.ts`, layout('L2')); + const b = await settle('layout-edit', () => get(`/${NS}/lay`), has('layout:L2'), t0); + return [a, b]; + }); + S('component', async () => { + let t0 = performance.now(); + write(`components/${NS}-badge.ts`, comp(`${NS}-badge`, 'C1')); + write(`app/${NS}/comp/page.ts`, pageWith(`import '#components/${NS}-badge.ts';`, `return html\`<p>cpage</p><${NS}-badge></${NS}-badge>\`;`)); + const a = await settle('component-new', () => get(`/${NS}/comp`), has('comp:C1'), t0); + t0 = performance.now(); write(`components/${NS}-badge.ts`, comp(`${NS}-badge`, 'C2')); + const b = await settle('component-edit', () => get(`/${NS}/comp`), has('comp:C2'), t0); + return [a, b]; + }); + const ACT = `modules/${NS}/actions/ping.server.ts`; + S('action', async () => { + let t0 = performance.now(); write(ACT, action('ping', 'A1')); + const a = await settle('action-new', () => call(ACT, 'ping'), has('act:A1'), t0); + t0 = performance.now(); write(ACT, action('ping', 'A2')); + const b = await settle('action-edit', () => call(ACT, 'ping'), has('act:A2'), t0); + // transitive util + write(`modules/${NS}/utils/val.server.ts`, `export const VAL = 'U1';\n`); + t0 = performance.now(); write(ACT, action('ping', '', `import { VAL } from '#modules/${NS}/utils/val.server.ts';`, 'VAL')); + const c = await settle('action-uses-util', () => call(ACT, 'ping'), has('act:U1'), t0); + t0 = performance.now(); write(`modules/${NS}/utils/val.server.ts`, `export const VAL = 'U2';\n`); + const d = await settle('action-util-edit', () => call(ACT, 'ping'), has('act:U2'), t0); + t0 = performance.now(); write(`modules/${NS}/utils/val.ts`, `export const VAL2 = 'V1';\n`); + write(ACT, action('ping', '', `import { VAL2 } from '#modules/${NS}/utils/val.ts';`, 'VAL2')); + const e = await settle('action-uses-plain-util', () => call(ACT, 'ping'), has('act:V1'), t0); + t0 = performance.now(); write(`modules/${NS}/utils/val.ts`, `export const VAL2 = 'V2';\n`); + const f = await settle('action-plain-util-edit', () => call(ACT, 'ping'), has('act:V2'), t0); + return [a, b, c, d, e, f]; + }); + S('action-new-fn', async () => { + const t0 = performance.now(); write(ACT, action('ping', 'A3') + action('pong', 'P1').replace("'use server';\n", '')); + return settle('action-new-export', () => call(ACT, 'pong'), has('act:P1'), t0); + }); + S('action-rename', async () => { + write(ACT, action('ping', 'R0')); + await settle('pre', () => call(ACT, 'ping'), has('act:R0'), performance.now()); + const NEW = `modules/${NS}/actions/renamed.server.ts`; + const t0 = performance.now(); renameSync(P(ACT), P(NEW)); + const [a, b] = await Promise.all([ + settle('action-rename-new', () => call(NEW, 'ping'), has('act:R0'), t0), + settle('action-rename-old-gone', () => call(ACT, 'ping'), status(404), t0)]); + renameSync(P(NEW), P(ACT)); + return [a, b]; + }); + S('route-handler', async () => { + let t0 = performance.now(); write(`app/api/${NS}/route.ts`, route('R1')); + const a = await settle('route-new', () => get(`/api/${NS}`), has('route:R1'), t0); + t0 = performance.now(); write(`app/api/${NS}/route.ts`, route('R2')); + const b = await settle('route-edit', () => get(`/api/${NS}`), has('route:R2'), t0); + return [a, b]; + }); + S('delete', async () => { + write(`app/${NS}/del/page.ts`, page('d1')); + await settle('pre', () => get(`/${NS}/del`), has('marker:d1'), performance.now()); + const t0 = performance.now(); rm(`app/${NS}/del`); + return settle('delete-page', () => get(`/${NS}/del`), status(404), t0); + }); + S('rename-dir', async () => { + write(`app/${NS}/ra/page.ts`, page('ra')); + await settle('pre', () => get(`/${NS}/ra`), has('marker:ra'), performance.now()); + const t0 = performance.now(); rm(`app/${NS}/rb`); renameSync(P(`app/${NS}/ra`), P(`app/${NS}/rb`)); + const [a, b] = await Promise.all([ + settle('rename-dir-new', () => get(`/${NS}/rb`), has('marker:ra'), t0), + settle('rename-dir-old', () => get(`/${NS}/ra`), status(404), t0)]); + return [a, b]; + }); + S('partial-write', async () => { + const full = page('pw1'); + const t0 = performance.now(); + write(`app/${NS}/partial/page.ts`, full.slice(0, 60)); + await sleep(30); + write(`app/${NS}/partial/page.ts`, full); + return settle('partial-then-complete', () => get(`/${NS}/partial`), has('marker:pw1'), t0); + }); + S('partial-action', async () => { + const full = action('ping', 'PA1'); + const t0 = performance.now(); + write(ACT, full.slice(0, 30)); + await sleep(30); + write(ACT, full); + return settle('partial-action', () => call(ACT, 'ping'), has('act:PA1'), t0); + }); + S('syntax-error', async () => { + write(`app/${NS}/syn/page.ts`, page('se0')); + await settle('pre', () => get(`/${NS}/syn`), has('marker:se0'), performance.now()); + let t0 = performance.now(); write(`app/${NS}/syn/page.ts`, page('se1').replace('return html', 'return html((')); + const a = await settle('syntax-error-shows-500', () => get(`/${NS}/syn`), (r) => r.status >= 500, t0, 300); + t0 = performance.now(); write(`app/${NS}/syn/page.ts`, page('se2')); + const b = await settle('syntax-error-recovers', () => get(`/${NS}/syn`), has('marker:se2'), t0); + t0 = performance.now(); write(ACT, action('ping', 'X').replace('return', 'return (((')); + const c = await settle('action-syntax-error', () => call(ACT, 'ping'), (r) => r.status >= 500 || r.status === 0, t0, 300); + t0 = performance.now(); write(ACT, action('ping', 'SE3')); + const d = await settle('action-syntax-recovers', () => call(ACT, 'ping'), has('act:SE3'), t0); + return [a, b, c, d]; + }); + S('component-syntax-error', async () => { + write(`components/${NS}-badge.ts`, comp(`${NS}-badge`, 'K0')); + write(`app/${NS}/comp/page.ts`, pageWith(`import '#components/${NS}-badge.ts';`, `return html\`<p>cpage</p><${NS}-badge></${NS}-badge>\`;`)); + await settle('pre', () => get(`/${NS}/comp`), has('comp:K0'), performance.now()); + let t0 = performance.now(); write(`components/${NS}-badge.ts`, comp(`${NS}-badge`, 'K1').replace('render() {', 'render() {((')); + const a = await settle('component-syntax-error-500', () => get(`/${NS}/comp`), (r) => r.status >= 500, t0, 300); + t0 = performance.now(); write(`components/${NS}-badge.ts`, comp(`${NS}-badge`, 'K2')); + const b = await settle('component-syntax-recovers', () => get(`/${NS}/comp`), has('comp:K2'), t0); + return [a, b]; + }); + S('atomic-write', async () => { + let t0 = performance.now(); atomicWrite(`app/${NS}/burst/page.ts`, page('aw1')); + const a = await settle('atomic-page', () => get(`/${NS}/burst`), has('marker:aw1'), t0); + t0 = performance.now(); atomicWrite(ACT, action('ping', 'AW2')); + const b = await settle('atomic-action', () => call(ACT, 'ping'), has('act:AW2'), t0); + t0 = performance.now(); atomicWrite(`app/${NS}/burst/page.ts`, page('aw3')); + const c = await settle('atomic-page-again', () => get(`/${NS}/burst`), has('marker:aw3'), t0); + return [a, b, c]; + }); + S('new-feature-at-once', async () => { + // mkdir + page + action + component all at once, page calls action during SSR + const f = `modules/${NS}f/actions/feat.server.ts`; + const t0 = performance.now(); + write(f, action('feat', 'F1')); + write(`app/${NS}/feat/page.ts`, pageWith(`import { feat } from '#modules/${NS}f/actions/feat.server.ts';`, `const v = await feat();\n return html\`<p>marker:\${v}</p>\`;`)); + const [a, b] = await Promise.all([ + settle('feature-page', () => get(`/${NS}/feat`), has('marker:act:F1'), t0), + settle('feature-action', () => call(f, 'feat'), has('act:F1'), t0)]); + return [a, b]; + }); + S('mass-churn', async () => { + // a git-checkout-like burst: delete and recreate many files + for (let i = 0; i < 12; i++) write(`app/${NS}/m${i}/page.ts`, page('m' + i)); + await sleep(50); + rm(`app/${NS}`); + await sleep(20); + const t0 = performance.now(); + for (let i = 0; i < 12; i++) write(`app/${NS}/m${i}/page.ts`, page('mm' + i)); + write(ACT, action('ping', 'MC')); + return Promise.all([ + settle('mass-page-0', () => get(`/${NS}/m0`), has('marker:mm0'), t0, 200), + settle('mass-page-11', () => get(`/${NS}/m11`), has('marker:mm11'), t0, 200), + settle('mass-action', () => call(ACT, 'ping'), has('act:MC'), t0, 200)]); + }); + S('stable', async () => { + const t0 = performance.now(); + return settle('home-still-200', () => get('/'), status(200), t0, 100); + }); + + + try { + for (const n of only.length ? only : Object.keys(scenarios)) { + if (!scenarios[n]) throw new Error(`unknown scenario ${n}`); + const r = await scenarios[n](); + for (const x of [r].flat()) { + if (x.name === 'pre') continue; + results.push(x); + log(`${x.ok ? (x.ms <= 200 ? 'PASS' : 'SLOW') : 'FAIL'} ${x.name.padEnd(28)} ${String(x.ms).padStart(5)}ms ${x.why || ''}`); + } + } + } finally { + rm(`app/${NS}`); rm(`app/api/${NS}`); rm(`modules/${NS}`); rm(`modules/${NS}f`); rm(`components/${NS}-badge.ts`); + } + return results; +} + +/** One-line summary of a stress run. @param {StressResult[]} results */ +export function summarizeStress(results) { + const fail = results.filter((r) => !r.ok).length; + const slow = results.filter((r) => r.ok && r.ms > 200).length; + const okMs = results.filter((r) => r.ok).map((r) => r.ms).sort((a, b) => a - b); + return `${results.length} checks: ${results.length - fail - slow} pass, ${slow} slow (>200ms), ${fail} fail; p50 ${okMs[Math.floor(okMs.length / 2)] ?? '-'}ms max ${okMs.at(-1) ?? '-'}ms`; +} + +/** + * Edit one page `edits` times (every fifth edit an action instead), waiting + * for each to be served, and sample the server's RSS. + * + * @param {{ appDir: string, base: string, edits: number, rssMb?: () => number, log?: (line: string) => void }} opts + * @returns {Promise<{ fails: number, worstMs: number, rss: number[] }>} + */ +export async function runDevReloadSoak({ appDir, base, edits, rssMb = () => -1, log = () => {} }) { + const pg = join(appDir, `app/${NS}soak/page.ts`); + const act = join(appDir, `modules/${NS}soak/a.server.ts`); + mkdirSync(dirname(pg), { recursive: true }); + mkdirSync(dirname(act), { recursive: true }); + const h = createHash('sha256').update(act).digest('hex').slice(0, 10); + const origin = new URL(base).origin; + let worstMs = 0, fails = 0; + const rss = []; + try { + for (let i = 0; i < edits; i++) { + const m = 'k' + i + '_' + Date.now().toString(36); + const isAct = i % 5 === 4; + if (isAct) writeFileSync(act, `'use server';\nexport async function a() { return 'act:${m}'; }\n`); + else writeFileSync(pg, `import { html } from '@webjsdev/core';\nexport default function P() { return html\`<p>marker:${m}</p>\`; }\n`); + const s = performance.now(); + let ok = false; + while (performance.now() - s < 10000) { + try { + const r = isAct + ? await fetch(`${base}/__webjs/action/${h}/a`, { method: 'POST', headers: { origin, 'content-type': 'application/vnd.webjs+json' }, body: '[]' }) + : await fetch(`${base}/${NS}soak`); + const b = await r.text(); + if (r.status === 200 && b.includes(isAct ? 'act:' + m : 'marker:' + m)) { ok = true; break; } + } catch {} + await sleep(10); + } + const ms = Math.round(performance.now() - s); + worstMs = Math.max(worstMs, ms); + if (!ok) fails++; + if (i % 25 === 0 || i === edits - 1) { const mb = rssMb(); rss.push(mb); log(`edit ${i} rss ${mb}MB last ${ms}ms worst ${worstMs}ms fails ${fails}`); } + await sleep(20); + } + } finally { + rmSync(dirname(pg), { recursive: true, force: true }); + rmSync(dirname(act), { recursive: true, force: true }); + } + return { fails, worstMs, rss }; +} + +/** RSS in MB of the process listening on `port` (Linux), or -1. @param {number | string} port */ +export function listenerRssMb(port) { + try { + const pid = execSync(`ss -ltnpH 'sport = :${port}'`, { encoding: 'utf8' }).match(/pid=(\d+)/)?.[1]; + if (!pid) return -1; + return Math.round(Number(readFileSync(`/proc/${pid}/status`, 'utf8').match(/VmRSS:\s+(\d+)/)[1]) / 1024); + } catch { return -1; } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const [appDir, base] = process.argv.slice(2); + if (!appDir || !base) { + console.error('usage: node scripts/dev-reload-stress.mjs <appDir> <baseUrl> [--only=a,b] [--soak=N]'); + process.exit(2); + } + const only = (process.argv.find((a) => a.startsWith('--only=')) || '').slice(7).split(',').filter(Boolean); + const soak = Number((process.argv.find((a) => a.startsWith('--soak=')) || '').slice(7) || 0); + const results = await runDevReloadStress({ appDir, base, only, timeoutMs: Number(process.env.STRESS_TIMEOUT || 8000), log: console.log }); + console.log('\n' + summarizeStress(results)); + let failed = results.some((r) => !r.ok); + if (soak) { + const port = new URL(base).port || '80'; + const r = await runDevReloadSoak({ appDir, base, edits: soak, rssMb: () => listenerRssMb(port), log: console.log }); + console.log(`soak: ${soak} edits, ${r.fails} not served, worst ${r.worstMs}ms, rss ${r.rss.join(' -> ')}MB`); + failed ||= r.fails > 0; + } + process.exit(failed ? 1 : 0); +} diff --git a/test/bun/dev-reload-stress.mjs b/test/bun/dev-reload-stress.mjs new file mode 100644 index 000000000..0b3c29643 --- /dev/null +++ b/test/bun/dev-reload-stress.mjs @@ -0,0 +1,100 @@ +/** + * The dev-server reload stress (#1575), end to end on WHICHEVER runtime runs + * this file: + * + * node test/bun/dev-reload-stress.mjs + * bun test/bun/dev-reload-stress.mjs + * + * Stages a small app, starts the real `webjs dev` (the supervisor, and on Bun + * the `bun --hot` child), and runs `scripts/dev-reload-stress.mjs` against it: + * agent-style edit sequences (bursts, partial writes, syntax errors, renames, + * deletes, atomic writes, a whole feature at once, a checkout-like churn), each + * of which must end with the server serving exactly the current files. + * + * On Bun it also asserts what #1575 fixed: one server for the whole run (a + * re-run never starts a second one), no process restart for a `'use server'` + * edit, and flat memory over a soak of edits (it grew about 25 MB an edit + * before). On Node the server restarts the child on an edit by design, so a + * refused connection during a restart counts as "not yet", and only the end + * state is asserted. + * + * A plain assert script (not node:test) so the same file runs on both + * runtimes; `dev-reload-stress.test.mjs` runs it under the root suite. + */ +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'; +import { runDevReloadStress, runDevReloadSoak, summarizeStress, listenerRssMb } from '../../scripts/dev-reload-stress.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../..'); +const CLI = join(ROOT, 'packages/cli/bin/webjs.js'); +const isBun = !!process.versions.bun; +const runtime = isBun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`; +const PORT = 9450 + (process.pid % 250); +const BASE = `http://localhost:${PORT}`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const dir = mkdtempSync(join(tmpdir(), 'webjs-dev-stress-')); +const put = (rel, s) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), s); }; + +let child; +let log = ''; +try { + put('package.json', JSON.stringify({ name: 'dev-stress', private: true, type: 'module', imports: { '#*': './*' }, webjs: {} })); + put('app/layout.ts', "import { html } from '@webjsdev/core';\nexport default function L({ children }) { return html`<div id=root>${children}</div>`; }\n"); + put('app/page.ts', "import { html } from '@webjsdev/core';\nexport default function P() { return html`<p>home</p>`; }\n"); + put('components/.keep', ''); + put('modules/.keep', ''); + mkdirSync(join(dir, 'node_modules/@webjsdev'), { recursive: true }); + for (const p of ['core', 'server']) symlinkSync(join(ROOT, 'packages', p), join(dir, 'node_modules/@webjsdev', p)); + + child = spawn(process.execPath, [CLI, 'dev', '--port', String(PORT)], { + cwd: dir, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'development' }, + }); + child.stdout.on('data', (d) => { log += d; }); + child.stderr.on('data', (d) => { log += d; }); + + const deadline = Date.now() + 30_000; + for (;;) { + try { if ((await fetch(BASE + '/')).status === 200) break; } catch {} + if (Date.now() > deadline) assert.fail(`dev server never came up on ${runtime}\n--- server log ---\n${log}`); + await sleep(200); + } + + const results = await runDevReloadStress({ appDir: dir, base: BASE, timeoutMs: 10_000, tolerateRestarts: !isBun }); + const failed = results.filter((r) => !r.ok); + console.log(`${runtime}: ${summarizeStress(results)}`); + assert.deepEqual(failed, [], `the dev server did not settle on the current files on ${runtime}\n--- server log (tail) ---\n${log.slice(-6000)}`); + + if (isBun) { + const starts = log.match(/dev server ready on/g)?.length ?? 0; + assert.equal(starts, 1, `a bun --hot re-run must hand off to the first server, not start another (${starts} starts)`); + assert.ok(!/restarting the dev server/.test(log), 'no edit in the stress needs a process restart on Bun'); + if (process.platform === 'linux') { + const soak = await runDevReloadSoak({ appDir: dir, base: BASE, edits: 80, rssMb: () => listenerRssMb(PORT) }); + assert.equal(soak.fails, 0, 'every soak edit was served'); + const valid = soak.rss.filter((x) => x > 0); + if (valid.length >= 2) { + const growth = valid.at(-1) - valid[0]; + console.log(`${runtime}: soak of 80 edits, rss ${valid.join(' -> ')}MB`); + // Before #1575 every re-run kept the previous server alive: ~25 MB an edit. + assert.ok(growth < 200, `memory grew ${growth} MB over 80 edits`); + } + } + } + console.log(`OK webjs dev settles on the current files under agent-style edits on ${runtime} (#1575)`); +} 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-stress.test.mjs b/test/bun/dev-reload-stress.test.mjs new file mode 100644 index 000000000..69b912dfd --- /dev/null +++ b/test/bun/dev-reload-stress.test.mjs @@ -0,0 +1,12 @@ +/** + * Run the dev-server reload stress (#1575) under WHICHEVER runtime executes the + * suite: `npm test` covers Node's restart-based reload, and CI runs + * `bun test/bun/dev-reload-stress.mjs` for the `bun --hot` path the fix is + * about. The behaviour lives in the plain assert script so both runtimes run + * the same file. + */ +import { test } from 'node:test'; + +test('webjs dev settles on the current files under agent-style edits on this runtime (#1575)', { timeout: 240_000 }, async () => { + await import('./dev-reload-stress.mjs'); +}); diff --git a/website/app/docs/configuration/page.ts b/website/app/docs/configuration/page.ts index 9b155ea45..7d95aac1c 100644 --- a/website/app/docs/configuration/page.ts +++ b/website/app/docs/configuration/page.ts @@ -15,7 +15,7 @@ export default function Configuration() { <li>File watching via Node's built-in <code>fs.watch</code> (automatic)</li> <li>Live reload via SSE (<code>/__webjs/events</code>)</li> <li>TypeScript files transformed on the fly</li> - <li>No cache-busting needed, since module loads are busted per request</li> + <li>No cache-busting needed, since an edited module is re-imported on the next request</li> </ul> <p><strong>Source locations for tooling.</strong> Set <code>"webjs": { "dev": { "sourceLocations": true } }</code> in <code>package.json</code>, or run <code>WEBJS_SOURCE_LOCATIONS=1 webjs dev</code> (the variable wins, so <code>0</code> turns a config default off for one run), and every element opening tag written in one of the app's <code>html</code> templates carries <code>data-webjs-src="<app-relative-file>:<line>"</code>, for example <code>data-webjs-src="components/todo-list.ts:12"</code>, in the server-rendered markup and in client renders alike. A tool hosting the app (an inspector, click-to-edit in an app builder) reads it with <code>el.closest('[data-webjs-src]')</code>. It is a dev-only source transform of the app's own modules: <code>*.server.*</code> modules, <code>node_modules</code>, <code>css</code> and <code>svg</code> templates, document and head elements, raw-text elements and the inside of <code>svg</code> are left alone, line numbers stay exact, and <code>webjs start</code> ignores the variable.</p> <p><strong>A port that is already taken.</strong> <code>webjs dev</code> and <code>webjs start</code> stop at once with <code>port 8080 is already in use by PID 1234 (bun ... webjs dev)</code> and exit code 98 when another process listens on the port, on Node and on Bun, so a forgotten server never quietly takes a share of your requests. To run several processes on one port on purpose (the kernel balances connections across them, Linux), set <code>WEBJS_REUSE_PORT=1</code> for each.</p> diff --git a/website/app/docs/runtime/page.ts b/website/app/docs/runtime/page.ts index bd40bedb8..05f9b39d3 100644 --- a/website/app/docs/runtime/page.ts +++ b/website/app/docs/runtime/page.ts @@ -25,16 +25,16 @@ export default function Runtime() { <tr><td>Listener</td><td><code>node:http</code> shell</td><td>native <code>Bun.serve</code> (about 1.9x req/s on the listening path)</td><td>planned</td></tr> <tr><td>TypeScript stripping</td><td>built-in <code>module.stripTypeScriptTypes</code></td><td><code>amaro</code></td><td>planned</td></tr> <tr><td>SQLite driver</td><td>built-in <code>node:sqlite</code></td><td>built-in <code>bun:sqlite</code></td><td>planned</td></tr> - <tr><td>Hot reload</td><td>restart on change (the <code>webjs dev</code> supervisor)</td><td><code>bun --hot</code></td><td>planned</td></tr> + <tr><td>Hot reload</td><td>restart on change (the <code>webjs dev</code> supervisor)</td><td>in place, in one long-lived process</td><td>planned</td></tr> <tr><td>WebSocket</td><td>the <code>ws</code> library</td><td>native <code>Bun.serve</code> (bridged to the same API)</td><td>planned</td></tr> <tr><td>103 Early Hints</td><td>yes</td><td>no (<code>Bun.serve</code> has no informational-response API)</td><td>planned</td></tr> <tr><td>Dev edit to a page / layout</td><td>full reload (the dev restart replaces the process)</td><td>refreshes in place, no reload</td><td>planned</td></tr> </tbody> </table> <p>The in-place dev refresh needs the server process to <strong>survive</strong> the edit, which is the whole of that last row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping your scroll position and the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so it needs a process that is still alive to do the classifying.</p> - <p>Bun's <code>bun --hot</code> invalidates modules in place without restarting, so it gets the refresh. It cannot reload a module that a Bun plugin serves, though: every <code>'use server'</code> <code>*.server.*</code> module, and with dev source locations on (<code>WEBJS_SOURCE_LOCATIONS=1</code>) every app module. The supervisor restarts the server for an edit to one of those, so on Bun with source locations on every app edit is a full reload, as on Node. On Node, the <code>webjs dev</code> supervisor restarts the server process on a change under <code>app</code>, <code>components</code>, <code>modules</code>, <code>lib</code>, or <code>actions</code>, or to a root <code>middleware</code> file, and a fresh process holds no record of what changed, so those edits are a full reload. Two Node cases still refresh in place: an edit outside that watched set (<code>db/schema.server.ts</code>, a <code>webjs.dev.watch</code> content directory), and <code>npm run dev -- --no-hot</code>, which keeps the server in one process on either runtime. A component edit is a full reload everywhere by design, because <code>customElements.define</code> is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.</p> + <p>On Bun the dev server stays ONE process for the whole session, so it gets the refresh. <code>bun --hot</code> re-runs the CLI on a change, and the first server keeps running and only re-reads the app's modules, so nothing starts twice and memory stays flat however many edits you make. App modules are loaded fresh from disk on every reload (an editor's atomic save included), and an edit to a <code>'use server'</code> module no longer restarts the process. Only an edit to <code>instrumentation.*</code> or <code>env.*</code>, which run once per process, restarts the server. On Node, the <code>webjs dev</code> supervisor restarts the server process on a change under <code>app</code>, <code>components</code>, <code>modules</code>, <code>lib</code>, or <code>actions</code>, or to a root <code>middleware</code> file, and a fresh process holds no record of what changed, so those edits are a full reload. Two Node cases still refresh in place: an edit outside that watched set (<code>db/schema.server.ts</code>, a <code>webjs.dev.watch</code> content directory), and <code>npm run dev -- --no-hot</code>, which keeps the server in one process on either runtime. A component edit is a full reload everywhere by design, because <code>customElements.define</code> is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.</p> - <p>The supervisor is part of <code>webjs dev</code> itself (it replaced <code>node --watch</code>), and it is built so the preview does not stay down. A file in a watched directory that the dev server cannot read or watch (for example the temporary file <code>sed -i</code> creates when another user runs it, or a file deleted while it was being watched) logs one <code>file watcher skipped</code> warning, and the server keeps serving and reloading. If the server process crashes, the supervisor starts it again on the next file change, or by itself after a short wait (0.5s, then up to 10s for repeated crashes). On Bun the same supervisor brings a crashed server back and restarts it for the edits <code>bun --hot</code> cannot reload, and <code>bun --hot</code> handles the rest.</p> + <p>The supervisor is part of <code>webjs dev</code> itself (it replaced <code>node --watch</code>), and it is built so the preview does not stay down. A file in a watched directory that the dev server cannot read or watch (for example the temporary file <code>sed -i</code> creates when another user runs it, or a file deleted while it was being watched) logs one <code>file watcher skipped</code> warning, and the server keeps serving and reloading. If the server process crashes, the supervisor starts it again on the next file change, or by itself after a short wait (0.5s, then up to 10s for repeated crashes). On Bun the same supervisor brings a crashed server back and restarts it for an <code>instrumentation.*</code> or <code>env.*</code> edit; every other edit reloads in place.</p> <p>Either way the <code>.ts</code> stripping is position-preserving with no sourcemap, and the bytes the browser fetches are identical. The 103 Early Hints gap only costs a small first-load latency edge where your edge forwards 103, never correctness (the modulepreload hints still ship in the document head).</p> diff --git a/website/app/docs/ssr/page.ts b/website/app/docs/ssr/page.ts index db7f98f6f..a930c40ad 100644 --- a/website/app/docs/ssr/page.ts +++ b/website/app/docs/ssr/page.ts @@ -33,7 +33,7 @@ export default async function Home() { <li><strong>Route match</strong>: the router scans the <code>app/</code> directory tree (at startup) and matches the URL pathname against the file-based route table. Dynamic segments (<code>[slug]</code>), catch-all segments (<code>[...rest]</code>), and route groups (<code>(marketing)</code>) all use the file-based conventions documented under <a href="/docs/routing">Routing</a>.</li> <li><strong>103 Early Hints</strong>: before SSR begins, the server calls <code>res.writeEarlyHints()</code> with <code><link rel="modulepreload"></code> headers for every module URL that the page will need (the page file itself, its layout chain, and any web component modules discovered on a previous render). This lets the browser start fetching scripts while the server is still computing HTML.</li> <li><strong>Segment middleware</strong>: if any <code>middleware.ts</code> files exist on the path from root to the matched route, they execute outermost-first as a chain: <code>(req, next) => Response</code>.</li> - <li><strong>Load page module</strong>: the server dynamically imports the matched <code>page.ts</code> file. In dev mode, a cache-busting query string is appended to the import URL so edits take effect immediately without restarting.</li> + <li><strong>Load page module</strong>: the server dynamically imports the matched <code>page.ts</code> file. In dev mode, the import URL carries a query naming the file's content, so an edit takes effect immediately without restarting while an unchanged file reuses the module already loaded.</li> <li><strong>Load layout chain</strong>: layout files are loaded outermost-first (<code>app/layout.ts</code>, then <code>app/blog/layout.ts</code>, etc.). Each layout wraps the previous result via its <code>children</code> prop.</li> <li><strong>Collect metadata</strong>: each layout and the page can export a <code>metadata</code> object or a <code>generateMetadata(ctx)</code> async function. Metadata is merged page-wins so the innermost file's title takes precedence.</li> <li><strong>renderToString</strong>: the fully-nested template tree is rendered to an HTML string. Promises in template holes are awaited. Arrays and <code>repeat()</code> iterables are expanded.</li> diff --git a/website/app/docs/websockets/page.ts b/website/app/docs/websockets/page.ts index adea61b5b..1dc6188f1 100644 --- a/website/app/docs/websockets/page.ts +++ b/website/app/docs/websockets/page.ts @@ -175,7 +175,7 @@ conn.send({ type: 'subscribe', channel: 'updates' });</code-block> </ul> <h2>The globalThis Pattern for Shared State</h2> - <p>In dev mode, WebJs cache-busts module imports on every request so that edits take effect immediately. This means module-level variables (like a <code>Set</code> of connected clients) are reset every time the module reloads. To preserve shared state across dev reloads, attach it to <code>globalThis</code>:</p> + <p>In dev mode, WebJs re-imports a module after each edit so the edit takes effect immediately. This means module-level variables (like a <code>Set</code> of connected clients) are reset every time the module reloads. To preserve shared state across dev reloads, attach it to <code>globalThis</code>:</p> <code-block>// modules/chat/clients.ts import type { WebSocket } from 'ws';