@webjsdev/cli 0.10.59 → 0.10.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/webjs.js CHANGED
@@ -6,10 +6,10 @@ import { fileURLToPath } from 'node:url';
6
6
  import { resolveBin } from '../lib/resolve-bin.js';
7
7
  import { dbGenerateTtyHint } from '../lib/db-hints.js';
8
8
  import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
9
- import { loadAppEnv, resolvePort } from '../lib/port.js';
9
+ import { loadAppEnv, resolvePort, failFastOnPortInUse } from '../lib/port.js';
10
10
  import { planDevSupervisor } from '../lib/dev-supervisor.js';
11
11
  import { checkAppName, appNameErrorMessage } from '../lib/app-name.js';
12
- import { findCheckTarget, notAnAppMessage, notAnAppJson } from '../lib/check-target.js';
12
+ import { findCheckTarget, notAnAppMessage, notAnAppJson, findServeTarget, notAnAppServeMessage } from '../lib/check-target.js';
13
13
 
14
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
15
  const [cmd, ...rest] = process.argv.slice(2);
@@ -147,7 +147,7 @@ const USAGE = `webjs commands:
147
147
  const HELP = {
148
148
  dev: {
149
149
  usage: 'webjs dev [--port <n>] [--no-hot]',
150
- summary: 'Start the dev server with live reload (source is the runtime, no build step).',
150
+ summary: 'Start the dev server with live reload (source is the runtime, no build step). Run it in the app directory (the one holding app/); anywhere else it exits 1 naming the app to start.',
151
151
  options: [
152
152
  { flag: '--port <n>', description: 'Port to listen on (else PORT, else 8080).' },
153
153
  { flag: '--no-hot', description: 'Run in-process, without the hot-reload supervisor.' },
@@ -156,7 +156,7 @@ const HELP = {
156
156
  },
157
157
  start: {
158
158
  usage: 'webjs start [--port <n>]',
159
- summary: 'Start the production server (serves source directly, plain HTTP/1.1).',
159
+ summary: 'Start the production server (serves source directly, plain HTTP/1.1). Exits 1 outside an app directory.',
160
160
  options: [
161
161
  { flag: '--port <n>', description: 'Port to listen on (else PORT, else 8080).' },
162
162
  ],
@@ -442,6 +442,19 @@ async function startDevParallelTasks(commands, cwd) {
442
442
  });
443
443
  }
444
444
 
445
+ /**
446
+ * Exit 1 with a clear message when `webjs dev` / `webjs start` runs where there
447
+ * is no `app/` directory (#1526), naming the app to start instead.
448
+ *
449
+ * @param {'dev' | 'start'} command
450
+ */
451
+ async function refuseOutsideApp(command) {
452
+ const target = await findServeTarget(process.cwd());
453
+ if (target.isApp) return;
454
+ console.error(notAnAppServeMessage(command, process.cwd(), target));
455
+ process.exit(1);
456
+ }
457
+
445
458
  async function main() {
446
459
  // `--version` / `-v` (top level): print the installed CLI version and exit.
447
460
  if (cmd === '--version' || cmd === '-v') {
@@ -480,6 +493,15 @@ async function main() {
480
493
  // `ERR_MODULE_NOT_FOUND: Cannot find package '@webjsdev/core'`. Probe up front
481
494
  // and surface the cause + remedy instead. No-op (a cheap resolve) when the
482
495
  // framework resolves, so the happy-path boot is untouched.
496
+ // #1526: refuse to serve a directory that is not an app, FIRST: before the
497
+ // resolve probe (whose "run npm install" advice is wrong at a workspace
498
+ // root), any before-step (a `webjs db migrate` in the wrong directory), or a
499
+ // spawn. Started from a workspace root, the server used to boot, say it was
500
+ // ready, and answer 404 for every route. The dev child skips it: its parent
501
+ // checked the same cwd it spawned the child in.
502
+ if ((cmd === 'dev' || cmd === 'start') && process.env.__WEBJS_DEV_CHILD !== '1') {
503
+ await refuseOutsideApp(cmd);
504
+ }
483
505
  if (cmd === 'dev' || cmd === 'start') {
484
506
  const { checkFrameworkResolves } = await import('../lib/doctor.js');
485
507
  const probe = checkFrameworkResolves(process.cwd());
@@ -500,7 +522,15 @@ async function main() {
500
522
  // too, but that runs too late to affect the port the CLI computes.
501
523
  loadAppEnv(process.cwd());
502
524
  const port = resolvePort(flag(rest, '--port'));
503
- await startServer({ appDir: process.cwd(), port, dev: true });
525
+ // Exit with the supervisor (#1521): when its IPC channel closes, the
526
+ // parent is gone (killed outright, or crashed), and a child left
527
+ // running would hold the port against the next `webjs dev`. Unref'd
528
+ // so the channel itself never keeps this process alive.
529
+ if (process.connected) {
530
+ process.channel?.unref?.();
531
+ process.on('disconnect', () => process.exit(0));
532
+ }
533
+ await failFastOnPortInUse(startServer({ appDir: process.cwd(), port, dev: true }));
504
534
  break;
505
535
  }
506
536
 
@@ -520,36 +550,38 @@ async function main() {
520
550
  loadAppEnv(process.cwd());
521
551
  await runPhaseBeforeSteps('dev', devTasks.dev.before, process.cwd());
522
552
  const killTasks = await startDevParallelTasks(devTasks.dev.parallel, process.cwd());
523
- process.on('SIGINT', () => { killTasks(); process.exit(0); });
524
- process.on('SIGTERM', () => { killTasks(); process.exit(0); });
525
553
 
526
- // Decide how to run: in-process (`--no-hot`), or re-exec'd under the host
527
- // runtime's hot-reload supervisor (`node --watch` on Node, `bun --hot` on
528
- // Bun, #514). The branch logic lives in the pure `planDevSupervisor` so it
529
- // is unit-testable without spawning a process.
530
- const { existsSync } = await import('node:fs');
554
+ // Decide how to run: in-process (`--no-hot`), or in a child under WebJs's
555
+ // reload supervisor (#1521), which restarts it on a change on Node and
556
+ // runs it under `bun --hot` on Bun (#514), and brings a crashed child
557
+ // back on either. The branch logic lives in the pure `planDevSupervisor`
558
+ // so it is unit-testable without spawning a process.
531
559
  const plan = planDevSupervisor({
532
560
  isBun: !!process.versions.bun,
533
561
  argv: process.argv.slice(1),
534
562
  noHot: rest.includes('--no-hot'),
535
- exists: (p) => existsSync(p),
536
563
  });
537
564
 
538
565
  if (plan.mode === 'inline') {
566
+ process.on('SIGINT', () => { killTasks(); process.exit(0); });
567
+ process.on('SIGTERM', () => { killTasks(); process.exit(0); });
539
568
  const { startServer } = await import('@webjsdev/server');
540
569
  loadAppEnv(process.cwd());
541
570
  const port = resolvePort(flag(rest, '--port'));
542
- await startServer({ appDir: process.cwd(), port, dev: true });
571
+ await failFastOnPortInUse(startServer({ appDir: process.cwd(), port, dev: true }), {
572
+ exit: (code) => { killTasks(); process.exit(code); },
573
+ });
543
574
  killTasks();
544
575
  break;
545
576
  }
546
577
 
547
- const child = spawn(process.execPath, plan.args, {
548
- stdio: 'inherit',
578
+ const { superviseDevServer } = await import('../lib/dev-reload.js');
579
+ superviseDevServer({
549
580
  cwd: process.cwd(),
581
+ plan,
550
582
  env: { ...process.env, __WEBJS_DEV_CHILD: '1' },
583
+ onExit: (code) => { killTasks(); process.exit(code); },
551
584
  });
552
- child.on('exit', (code) => { killTasks(); process.exit(code ?? 0); });
553
585
  break;
554
586
  }
555
587
  case 'start': {
@@ -563,7 +595,7 @@ async function main() {
563
595
  const { readAppTasks } = await import('../lib/app-tasks.js');
564
596
  await runPhaseBeforeSteps('start', readAppTasks(process.cwd()).start.before, process.cwd());
565
597
  const port = resolvePort(flag(rest, '--port'));
566
- await startServer({ appDir: process.cwd(), port, dev: false });
598
+ await failFastOnPortInUse(startServer({ appDir: process.cwd(), port, dev: false }));
567
599
  break;
568
600
  }
569
601
  case 'db': {
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { statSync } from 'node:fs';
26
26
  import { readFile, glob } from 'node:fs/promises';
27
- import { join } from 'node:path';
27
+ import { join, dirname, relative } from 'node:path';
28
28
 
29
29
  /**
30
30
  * @typedef {{ isApp: boolean, workspaceApps: string[] }} CheckTarget
@@ -96,6 +96,71 @@ export async function workspaceApps(cwd) {
96
96
  return [...apps].sort();
97
97
  }
98
98
 
99
+ /**
100
+ * The nearest STRICT ancestor of `cwd` that holds an `app/` directory, or
101
+ * `null`. Started from inside an app (its `app/` or `components/` dir), this is
102
+ * the app the user meant.
103
+ *
104
+ * @param {string} cwd
105
+ * @returns {string | null}
106
+ */
107
+ export function findAncestorApp(cwd) {
108
+ let dir = dirname(cwd);
109
+ for (;;) {
110
+ if (hasAppDir(dir)) return dir;
111
+ const up = dirname(dir);
112
+ if (up === dir) return null;
113
+ dir = up;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Where a server command (`webjs dev` / `webjs start`) that was started outside
119
+ * an app should have been started (#1526): the workspace's member apps when
120
+ * `cwd` is a workspace root, else the nearest ancestor app, else nothing.
121
+ * `isApp` is the same `app/` predicate `webjs check` refuses on (#1301).
122
+ *
123
+ * @param {string} cwd
124
+ * @returns {Promise<{ isApp: boolean, workspaceApps: string[], ancestorApp: string | null }>}
125
+ */
126
+ export async function findServeTarget(cwd) {
127
+ const target = await findCheckTarget(cwd);
128
+ if (target.isApp) return { ...target, ancestorApp: null };
129
+ return { ...target, ancestorApp: target.workspaceApps.length ? null : findAncestorApp(cwd) };
130
+ }
131
+
132
+ /**
133
+ * The refusal `webjs dev` / `webjs start` print when started where there is no
134
+ * app (#1526). Without it the server boots, says it is ready, and answers 404
135
+ * for every route, which is how a launch from a workspace root used to look.
136
+ *
137
+ * @param {'dev' | 'start'} command
138
+ * @param {string} cwd
139
+ * @param {{ workspaceApps: string[], ancestorApp: string | null }} target
140
+ * @returns {string}
141
+ */
142
+ export function notAnAppServeMessage(command, cwd, { workspaceApps, ancestorApp }) {
143
+ const lines = [
144
+ `webjs ${command}: this directory is not a WebJs app, so there is nothing to serve.`,
145
+ '',
146
+ ` ${cwd}`,
147
+ '',
148
+ 'There is no `app/` directory here. Started anyway, the server would answer',
149
+ '404 for every route.',
150
+ '',
151
+ ];
152
+ if (workspaceApps.length) {
153
+ lines.push('This is a workspace root. Start the server inside the app:', '');
154
+ for (const app of workspaceApps) lines.push(` cd ${app} && webjs ${command}`);
155
+ } else if (ancestorApp) {
156
+ lines.push('This directory is inside an app. Start the server from the app root:', '');
157
+ lines.push(` cd ${relative(cwd, ancestorApp) || '.'} && webjs ${command}`);
158
+ } else {
159
+ lines.push('Change into your app directory (the one holding `app/`) and re-run.');
160
+ }
161
+ return lines.join('\n');
162
+ }
163
+
99
164
  /**
100
165
  * The human refusal, for stderr.
101
166
  *
package/lib/create.js CHANGED
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url';
17
17
  import { existsSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { spawnSync } from 'node:child_process';
20
- import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
20
+ import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi, bunifyEnvExample } from './runtime-rewrite.js';
21
21
  import { postgresCompose, postgresCi } from './db-rewrite.js';
22
22
  import { assertValidAppName, toDatabaseName } from './app-name.js';
23
23
  import { isGalleryAppShellFile } from './gallery-shell-files.js';
@@ -745,6 +745,9 @@ export async function scaffoldApp(name, cwd, opts = {}) {
745
745
  'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md',
746
746
  '.agents/rules/workflow.md',
747
747
  'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts',
748
+ // Comments that name a command (#1527): `npm run ci` in the hook, the
749
+ // Tailwind build note in the ignore file.
750
+ '.hooks/pre-commit', 'gitignore',
748
751
  ]);
749
752
  // compose.yaml builds from the (pure oven/bun) Dockerfile and inherits its
750
753
  // `bun --bun run start` CMD; only its healthcheck needs switching off node
@@ -753,6 +756,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
753
756
  'Dockerfile': bunifyDockerfile,
754
757
  'compose.yaml': bunifyCompose,
755
758
  '.github/workflows/ci.yml': bunifyCi,
759
+ '.env.example': bunifyEnvExample,
756
760
  };
757
761
  // Database axis (#1490): the compose + CI templates are the SQLite shape, so
758
762
  // a --db postgres app derives its variant (a Postgres service, DATABASE_URL
@@ -1240,7 +1244,13 @@ export type ActionResult<T> =
1240
1244
  const clearScriptSrc = join(TEMPLATES, 'scripts', 'clear-gallery.mjs');
1241
1245
  if (existsSync(clearScriptSrc)) {
1242
1246
  await mkdir(join(appDir, 'scripts'), { recursive: true });
1243
- await cp(clearScriptSrc, join(appDir, 'scripts', 'clear-gallery.mjs'));
1247
+ // A Bun app gets the Bun spelling of the ui-kit command the script
1248
+ // prints and writes into the reset layout (#1527): `bunx`, never `npx`.
1249
+ const clearScript = await readFile(clearScriptSrc, 'utf8');
1250
+ await writeFile(
1251
+ join(appDir, 'scripts', 'clear-gallery.mjs'),
1252
+ isBun ? clearScript.replaceAll('npx webjsdev ', 'bunx webjsdev ') : clearScript,
1253
+ );
1244
1254
  }
1245
1255
 
1246
1256
  // Fail loudly if the @webjsdev/ui registry sources aren't on disk.
@@ -1746,10 +1756,12 @@ ThemeToggle.register('theme-toggle');
1746
1756
  // single-bin fallback resolves it to the `webjs` binary, so behaviour
1747
1757
  // matches `@webjsdev/cli` exactly while keeping the command short
1748
1758
  // and unambiguous.
1759
+ // A Bun app runs one-off binaries with `bunx` (#1527).
1760
+ const x = isBun ? 'bunx' : 'npx';
1749
1761
  const uiNote = isApi
1750
1762
  ? `# If you later add a UI to this API project:
1751
- # npx webjsdev ui init && npx webjsdev ui add button card dialog`
1752
- : `npx webjsdev ui add <name> # add more ui-* components later`;
1763
+ # ${x} webjsdev ui init && ${x} webjsdev ui add button card dialog`
1764
+ : `${x} webjsdev ui add <name> # add more ui-* components later`;
1753
1765
  console.log(`
1754
1766
  Next steps:
1755
1767
  ${runCommand}
@@ -0,0 +1,406 @@
1
+ /**
2
+ * The `webjs dev` reload supervisor (#1521): runs the dev server in a child
3
+ * process, restarts it when a watched file changes, and brings it back when it
4
+ * crashes. Replaces `node --watch`, which crashed on the first watcher error it
5
+ * could not handle (an EACCES on another user's `sed -i` temp file, a file that
6
+ * vanished between the directory event and the watch call) and left the
7
+ * preview dead until someone restarted `webjs dev` by hand.
8
+ *
9
+ * Three pieces, each testable on its own:
10
+ * - `watchRestartPaths` watches the planned dirs recursively and the planned
11
+ * root files, handles every watcher error with a warning, and follows a
12
+ * watched dir that is created or removed after start.
13
+ * - `createSupervisor` is the restart state machine. It takes injected spawn
14
+ * and timer functions, so its behaviour is tested without a process.
15
+ * - `superviseDevServer` wires both to a real child, the signals, and a
16
+ * last-resort `uncaughtException` guard that swallows only watcher errors.
17
+ *
18
+ * The planning (which runtime, which paths, restart on change or only on a
19
+ * crash) stays in `dev-supervisor.js`.
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import { watch, statSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { watchRecursive } from './watch-recursive.js';
25
+ import { PORT_IN_USE_EXIT_CODE } from './port.js';
26
+
27
+ /**
28
+ * Quiet window between a file event and the restart. `node --watch` used
29
+ * 200ms; one editor save or `sed -i` lands its events within a few ms of each
30
+ * other, so 50ms still coalesces a save while starting the restart 150ms
31
+ * sooner.
32
+ */
33
+ export const RESTART_DEBOUNCE_MS = 50;
34
+
35
+ /**
36
+ * Delays before restarting a child that exited on its own (a crash), indexed
37
+ * by consecutive crash count and capped at the last entry. A file change
38
+ * restarts it at once regardless, so the backoff only matters when nothing is
39
+ * being edited: the preview comes back within seconds, and a child that fails
40
+ * deterministically at boot is retried every 10s instead of in a tight loop.
41
+ */
42
+ export const CRASH_BACKOFF_MS = [500, 1000, 2000, 5000, 10000];
43
+
44
+ /** A child that stayed up this long resets the crash backoff. */
45
+ export const STABLE_MS = 10_000;
46
+
47
+ /**
48
+ * How long a restarting child gets to exit after SIGTERM before SIGKILL. The
49
+ * server's own drain allows 10s, which is right for a deploy and far too long
50
+ * to hold an edit back in dev.
51
+ */
52
+ export const KILL_TIMEOUT_MS = 2000;
53
+
54
+ /**
55
+ * Paths inside a watched dir whose changes never restart the server: the same
56
+ * noise the server's in-process watcher ignores (`shouldIgnoreWatchPath` in
57
+ * `@webjsdev/server`), restated here so the CLI does not depend on a server
58
+ * internal.
59
+ *
60
+ * @param {string} rel path relative to the app root
61
+ * @returns {boolean}
62
+ */
63
+ export function shouldIgnoreRestartPath(rel) {
64
+ return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(rel || '');
65
+ }
66
+
67
+ /**
68
+ * Whether an error came from a file watcher. Node marks every `fs.watch`
69
+ * failure with `syscall: 'watch'`.
70
+ *
71
+ * @param {unknown} err
72
+ * @returns {boolean}
73
+ */
74
+ export function isWatchError(err) {
75
+ return !!err && typeof err === 'object' && /** @type {any} */ (err).syscall === 'watch';
76
+ }
77
+
78
+ /**
79
+ * The `webjs.dev.regenerate[].output` paths (#967), normalized to `/` with no
80
+ * leading `./`. The server writes these on request, so one that lives under a
81
+ * watched dir must not restart the server, or a request would restart the
82
+ * process that is serving it. Unreadable config yields no outputs.
83
+ *
84
+ * @param {string} cwd
85
+ * @returns {Set<string>}
86
+ */
87
+ export function readRegenerateOutputs(cwd) {
88
+ const out = new Set();
89
+ try {
90
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
91
+ const rules = pkg && pkg.webjs && pkg.webjs.dev && pkg.webjs.dev.regenerate;
92
+ if (Array.isArray(rules)) {
93
+ for (const r of rules) {
94
+ if (r && typeof r.output === 'string') out.add(r.output.replace(/\\/g, '/').replace(/^\.\//, ''));
95
+ }
96
+ }
97
+ } catch {}
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Watch the restart paths of an app. Each dir in `dirs` is watched
103
+ * recursively; the app root is watched non-recursively, which catches an edit
104
+ * to a root file in `files` and a dir in `dirs` appearing or disappearing.
105
+ * Every watcher error goes to `onError` and never throws.
106
+ *
107
+ * @param {string} cwd the app root
108
+ * @param {{
109
+ * dirs: string[],
110
+ * files: string[],
111
+ * ignore: (rel: string) => boolean,
112
+ * onChange: (rel: string) => void,
113
+ * onError: (err: NodeJS.ErrnoException) => void,
114
+ * watchFn?: typeof watch,
115
+ * }} opts
116
+ * @returns {() => void} closes every watcher
117
+ */
118
+ export function watchRestartPaths(cwd, { dirs, files, ignore, onChange, onError, watchFn = watch }) {
119
+ /** @type {Map<string, import('node:fs').FSWatcher>} */
120
+ const watchers = new Map();
121
+ let closed = false;
122
+ const isDir = (name) => {
123
+ try { return statSync(join(cwd, name)).isDirectory(); } catch { return false; }
124
+ };
125
+ const guard = (w) => {
126
+ w.on('error', (err) => onError(err));
127
+ return w;
128
+ };
129
+ const watchDir = (name) => {
130
+ if (closed || watchers.has(name) || !isDir(name)) return;
131
+ try {
132
+ // `watchRecursive` (#1529): on Linux under Node a per-directory walker,
133
+ // since Node 24's recursive watcher goes deaf to a file once it is
134
+ // replaced (`sed -i`, an editor's atomic save), so a second edit to the
135
+ // same file never restarted the server.
136
+ watchers.set(name, guard(watchRecursive(join(cwd, name), (_type, filename) => {
137
+ const rel = filename ? join(name, String(filename)) : name;
138
+ if (!ignore(rel)) onChange(rel);
139
+ }, { ignore: (rel) => ignore(join(name, rel)), watchFn })));
140
+ } catch (err) {
141
+ onError(/** @type {NodeJS.ErrnoException} */ (err));
142
+ }
143
+ };
144
+ const unwatchDir = (name) => {
145
+ const w = watchers.get(name);
146
+ if (!w) return;
147
+ try { w.close(); } catch {}
148
+ watchers.delete(name);
149
+ };
150
+
151
+ /** @type {import('node:fs').FSWatcher | null} */
152
+ let root = null;
153
+ try {
154
+ root = guard(watchFn(cwd, (_type, filename) => {
155
+ const name = filename ? String(filename) : '';
156
+ if (dirs.includes(name)) {
157
+ // A watched dir was created, replaced, or removed.
158
+ if (isDir(name)) watchDir(name); else unwatchDir(name);
159
+ onChange(name);
160
+ } else if (files.includes(name)) {
161
+ onChange(name);
162
+ }
163
+ }));
164
+ } catch (err) {
165
+ onError(/** @type {NodeJS.ErrnoException} */ (err));
166
+ }
167
+ for (const d of dirs) watchDir(d);
168
+
169
+ return () => {
170
+ closed = true;
171
+ try { root?.close(); } catch {}
172
+ for (const name of [...watchers.keys()]) unwatchDir(name);
173
+ };
174
+ }
175
+
176
+ /**
177
+ * @typedef {{
178
+ * pid?: number,
179
+ * kill: (signal?: NodeJS.Signals) => boolean | void,
180
+ * once: (event: 'exit', fn: (code: number | null, signal: NodeJS.Signals | null) => void) => unknown,
181
+ * }} ChildLike
182
+ */
183
+
184
+ /**
185
+ * The restart state machine.
186
+ *
187
+ * - `change(path)` (debounced): restart a running child when `restartOnChange`
188
+ * (Node), or start a child that is not running (either runtime, after a
189
+ * crash).
190
+ * - A restart sends SIGTERM, escalates to SIGKILL after `killTimeoutMs`, and
191
+ * spawns the replacement the moment the old child exits, never on a poll.
192
+ * - A child that exits on its own is restarted after the crash backoff, or at
193
+ * once on the next change.
194
+ * - `stop()` stops everything and resolves once no child is left.
195
+ *
196
+ * @param {{
197
+ * spawnChild: () => ChildLike,
198
+ * restartOnChange: boolean,
199
+ * log?: (line: string) => void,
200
+ * timers?: { setTimeout: typeof setTimeout, clearTimeout: typeof clearTimeout },
201
+ * now?: () => number,
202
+ * debounceMs?: number,
203
+ * backoffMs?: number[],
204
+ * stableMs?: number,
205
+ * killTimeoutMs?: number,
206
+ * finalExitCodes?: number[],
207
+ * onFinal?: (code: number) => void,
208
+ * }} opts
209
+ */
210
+ export function createSupervisor({
211
+ spawnChild,
212
+ restartOnChange,
213
+ log = () => {},
214
+ timers = globalThis,
215
+ now = Date.now,
216
+ debounceMs = RESTART_DEBOUNCE_MS,
217
+ backoffMs = CRASH_BACKOFF_MS,
218
+ stableMs = STABLE_MS,
219
+ killTimeoutMs = KILL_TIMEOUT_MS,
220
+ // Exit codes a restart cannot fix (a taken port, #1527): the supervisor
221
+ // stops and reports instead of retrying on its backoff forever.
222
+ finalExitCodes = [PORT_IN_USE_EXIT_CODE],
223
+ onFinal = () => {},
224
+ }) {
225
+ /** @type {ChildLike | null} */
226
+ let child = null;
227
+ let startedAt = 0;
228
+ let restarting = false;
229
+ let stopping = false;
230
+ let crashes = 0;
231
+ /** @type {string | null} */
232
+ let pendingPath = null;
233
+ /** @type {any} */ let debounceTimer = null;
234
+ /** @type {any} */ let backoffTimer = null;
235
+ /** @type {any} */ let killTimer = null;
236
+ /** @type {Array<() => void>} */
237
+ const stopWaiters = [];
238
+
239
+ const clear = (t) => { if (t !== null) timers.clearTimeout(t); return null; };
240
+
241
+ const launch = () => {
242
+ backoffTimer = clear(backoffTimer);
243
+ if (stopping || child) return;
244
+ const c = spawnChild();
245
+ child = c;
246
+ startedAt = now();
247
+ c.once('exit', (code, signal) => onExit(c, code, signal));
248
+ };
249
+
250
+ const terminate = (c) => {
251
+ try { c.kill('SIGTERM'); } catch {}
252
+ killTimer = clear(killTimer);
253
+ killTimer = timers.setTimeout(() => {
254
+ killTimer = null;
255
+ if (child === c) { try { c.kill('SIGKILL'); } catch {} }
256
+ }, killTimeoutMs);
257
+ };
258
+
259
+ const onExit = (c, code, signal) => {
260
+ if (child !== c) return;
261
+ child = null;
262
+ killTimer = clear(killTimer);
263
+ if (stopping) {
264
+ for (const w of stopWaiters.splice(0)) w();
265
+ return;
266
+ }
267
+ if (restarting) {
268
+ // The restart we asked for: start the replacement right away.
269
+ restarting = false;
270
+ crashes = 0;
271
+ launch();
272
+ return;
273
+ }
274
+ // Exited on its own: a crash, a fatal boot error, or an outside kill.
275
+ if (!signal && code !== null && finalExitCodes.includes(code)) {
276
+ stopping = true;
277
+ debounceTimer = clear(debounceTimer);
278
+ onFinal(code);
279
+ return;
280
+ }
281
+ if (now() - startedAt >= stableMs) crashes = 0;
282
+ const delay = backoffMs[Math.min(crashes, backoffMs.length - 1)];
283
+ crashes++;
284
+ const why = signal ? `signal ${signal}` : `code ${code}`;
285
+ log(`dev server exited (${why}); restarting in ${delay < 1000 ? `${delay}ms` : `${delay / 1000}s`}, or on the next file change`);
286
+ backoffTimer = timers.setTimeout(launch, delay);
287
+ };
288
+
289
+ const flush = () => {
290
+ debounceTimer = null;
291
+ const path = pendingPath;
292
+ pendingPath = null;
293
+ if (stopping) return;
294
+ if (!child) {
295
+ // Not running (crashed, or waiting out a backoff): a change is the cue.
296
+ if (path) log(`${path} changed, starting the dev server`);
297
+ launch();
298
+ return;
299
+ }
300
+ if (!restartOnChange || restarting) return;
301
+ restarting = true;
302
+ if (path) log(`${path} changed, restarting the dev server`);
303
+ terminate(child);
304
+ };
305
+
306
+ return {
307
+ start: launch,
308
+ /** @param {string} path */
309
+ change(path) {
310
+ if (stopping) return;
311
+ if (pendingPath === null) pendingPath = path;
312
+ debounceTimer = clear(debounceTimer);
313
+ debounceTimer = timers.setTimeout(flush, debounceMs);
314
+ },
315
+ /** @returns {Promise<void>} */
316
+ stop() {
317
+ stopping = true;
318
+ debounceTimer = clear(debounceTimer);
319
+ backoffTimer = clear(backoffTimer);
320
+ if (!child) return Promise.resolve();
321
+ const done = new Promise((r) => stopWaiters.push(() => r(undefined)));
322
+ terminate(child);
323
+ return done;
324
+ },
325
+ get running() { return child !== null; },
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Run the dev server under the supervisor until a signal stops it.
331
+ *
332
+ * @param {{
333
+ * cwd: string,
334
+ * plan: { args: string[], restartOnChange: boolean, watchDirs: string[], watchFiles: string[] },
335
+ * env: NodeJS.ProcessEnv,
336
+ * onExit: (code: number) => void,
337
+ * }} opts
338
+ */
339
+ export function superviseDevServer({ cwd, plan, env, onExit }) {
340
+ const log = (line) => console.log(`[webjs] ${line}`);
341
+ // A watcher error here is NOT logged: the server child watches the whole app
342
+ // tree (a superset of these paths) and prints one warning per unwatchable
343
+ // path itself, so logging it here too would print every warning twice. The
344
+ // watcher keeps running either way.
345
+ const ignoreWatchError = () => {};
346
+
347
+ let exiting = false;
348
+ /** @type {() => void} */
349
+ let closeWatch = () => {};
350
+ const shutdown = (code) => {
351
+ if (exiting) return;
352
+ exiting = true;
353
+ closeWatch();
354
+ sup.stop().then(() => onExit(code));
355
+ };
356
+
357
+ const sup = createSupervisor({
358
+ restartOnChange: plan.restartOnChange,
359
+ log,
360
+ // The child already printed why (the port and its holder); stop with its
361
+ // code rather than restarting a server that can never bind.
362
+ onFinal: (code) => shutdown(code),
363
+ spawnChild: () => {
364
+ const c = spawn(process.execPath, plan.args, {
365
+ // The IPC channel lets the child notice this process is gone and exit,
366
+ // so a killed supervisor never leaves an orphan holding the port.
367
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
368
+ cwd,
369
+ env,
370
+ });
371
+ // A failed spawn emits 'error' and may never emit 'exit'; report it as
372
+ // an exit so the backoff retries it (a repeated exit is ignored).
373
+ c.on('error', (err) => {
374
+ console.error(`[webjs] could not start the dev server: ${err.message}`);
375
+ c.emit('exit', 1, null);
376
+ });
377
+ return c;
378
+ },
379
+ });
380
+
381
+ const outputs = readRegenerateOutputs(cwd);
382
+ closeWatch = watchRestartPaths(cwd, {
383
+ dirs: plan.watchDirs,
384
+ files: plan.watchFiles,
385
+ ignore: (rel) => shouldIgnoreRestartPath(rel) || outputs.has(rel.replace(/\\/g, '/')),
386
+ onChange: (rel) => sup.change(rel),
387
+ onError: ignoreWatchError,
388
+ });
389
+
390
+ process.on('SIGINT', () => shutdown(0));
391
+ process.on('SIGTERM', () => shutdown(0));
392
+ process.on('SIGHUP', () => shutdown(0));
393
+ // Last resort: a watcher error that escaped every listener (a runtime that
394
+ // emits it somewhere else) is never fatal, and the server child reports the
395
+ // same path itself. Anything else is a real
396
+ // supervisor bug, so it is reported and the process exits non-zero after
397
+ // stopping the child.
398
+ process.on('uncaughtException', (err) => {
399
+ if (isWatchError(err)) return;
400
+ console.error(err && err.stack ? err.stack : err);
401
+ shutdown(1);
402
+ });
403
+
404
+ sup.start();
405
+ return sup;
406
+ }
@@ -1,29 +1,44 @@
1
1
  /**
2
- * Dev-server reload supervisor planning for `webjs dev` (issue #514).
2
+ * Dev-server reload supervisor planning for `webjs dev` (issues #514, #1521).
3
3
  *
4
- * `webjs dev` re-execs itself under the host runtime's hot-reload supervisor so
5
- * an edit to a transitively-imported module (an action, query, component, util)
6
- * takes effect without a manual restart. Both runtimes cache ES modules by
7
- * resolved URL with no public invalidation API, so the dev re-import in
8
- * `@webjsdev/server`'s `dev.js` relies on the runtime's own file-watching cache
9
- * invalidation:
4
+ * `webjs dev` runs its server in a CHILD process that a supervising parent
5
+ * restarts, so an edit to a transitively-imported module (an action, query,
6
+ * component, util) takes effect without a manual restart. Both runtimes cache
7
+ * ES modules by resolved URL with no public invalidation API, so the dev
8
+ * re-import in `@webjsdev/server`'s `dev.js` relies on a fresh process (Node)
9
+ * or the runtime's own cache invalidation (Bun):
10
10
  *
11
- * - **Node** has no in-place module-cache eviction, so it re-execs under
12
- * `node --watch`, which RESTARTS the process on a file change (a fresh ESM
13
- * cache each time). The dev re-import additionally appends a `?t=` cache-bust
14
- * query that Node honours between restarts.
15
- * - **Bun** keys its module cache by path and IGNORES that `?t=` query, so the
16
- * `node --watch` model does not transfer: without help a re-imported module
17
- * stays STALE on Bun (the #514 bug). Bun's `--hot` invalidates loaded modules
18
- * on a file change WITHOUT restarting the process, which is exactly what the
19
- * dev re-import needs; `Bun.serve` is reused across hot reloads, so the
20
- * listener is not duplicated. `--hot` auto-watches every loaded file, so the
21
- * node `--watch-path` flags do not apply (and are not Bun flags).
11
+ * - **Node** has no in-place module-cache eviction, so the parent RESTARTS the
12
+ * child on a change under the watched paths (a fresh ESM cache each time).
13
+ * This used to be `node --watch`, which dies on the first watcher error it
14
+ * cannot handle (an EACCES on a temp file another user created, a file that
15
+ * vanished mid-scan, #1521) and takes the preview down for good. WebJs's own
16
+ * supervisor (`lib/dev-reload.js`) watches the same paths with every watcher
17
+ * error handled, and restarts the child faster.
18
+ * - **Bun** keys its module cache by path and IGNORES the `?t=` cache-bust, so
19
+ * a restart-per-edit model is not needed: `bun --hot` invalidates loaded
20
+ * modules on a file change WITHOUT restarting the process, and `Bun.serve` is
21
+ * reused across hot reloads. The parent still supervises it, but only to
22
+ * bring a CRASHED child back (`restartOnChange: false`).
23
+ *
24
+ * On both runtimes a child that exits on its own (a crash) is restarted on the
25
+ * next file change, and after a short backoff even with no change.
22
26
  *
23
27
  * This pure planner returns the spawn decision so the bin stays a thin shell and
24
28
  * the branch logic is unit-testable without spawning a process.
25
29
  */
26
30
 
31
+ /** Project directories whose changes restart the dev server on Node. */
32
+ export const WATCH_DIRS = ['app', 'components', 'modules', 'lib', 'actions'];
33
+
34
+ /**
35
+ * Every extension the server's root-middleware lookup accepts, in the same
36
+ * order. If these two lists diverge, an app gets a middleware that loads but
37
+ * never restarts the dev server when edited, which is the quiet half of the
38
+ * bug where a `middleware.ts` was loaded by neither.
39
+ */
40
+ export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
41
+
27
42
  /**
28
43
  * Plan how `webjs dev` runs its server.
29
44
  *
@@ -31,35 +46,21 @@
31
46
  * @param {boolean} opts.isBun Whether the host runtime is Bun (`process.versions.bun`).
32
47
  * @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim.
33
48
  * @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely).
34
- * @param {(path: string) => boolean} opts.exists Existence check for the Node `--watch-path` targets (relative to cwd). Unused on Bun.
35
- * @returns {{ mode: 'inline' } | { mode: 'spawn', args: string[] }}
36
- * `inline` runs the server in this process (no reload watcher); `spawn`
37
- * re-execs `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1`.
49
+ * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, watchDirs: string[], watchFiles: string[] }}
50
+ * `inline` runs the server in this process (no reload watcher); `supervise`
51
+ * spawns `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1` under the
52
+ * supervisor, which watches `watchDirs` (recursively) and `watchFiles` (at
53
+ * the app root). The directories need not exist yet: one created later is
54
+ * picked up.
38
55
  */
39
- export function planDevSupervisor({ isBun, argv, noHot, exists }) {
56
+ export function planDevSupervisor({ isBun, argv, noHot }) {
40
57
  // `--no-hot` opts out of the reload supervisor on either runtime: run the dev
41
58
  // server in THIS process with no watcher. Degraded dev (a deep-import edit
42
59
  // needs a manual restart) but useful under an external process manager or a
43
60
  // debugger that wants a single, un-re-exec'd process.
44
61
  if (noHot) return { mode: 'inline' };
45
62
 
46
- if (isBun) return { mode: 'spawn', args: ['--hot', ...argv] };
47
-
48
- // Node: re-exec under `node --watch`, watching the project dirs/files that
49
- // exist. `--watch-preserve-output` keeps prior logs across a restart.
50
- const watchPaths = [];
51
- for (const dir of ['app', 'components', 'modules', 'lib', 'actions']) {
52
- if (exists(dir)) watchPaths.push('--watch-path', dir);
53
- }
54
- // Every extension the server's root-middleware lookup accepts, in the same
55
- // order. If these two lists diverge, an app gets a middleware that loads but
56
- // never restarts the dev server when edited, which is the quiet half of the
57
- // bug where a `middleware.ts` was loaded by neither.
58
- for (const f of ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs']) {
59
- if (exists(f)) watchPaths.push('--watch-path', f);
60
- }
61
- return {
62
- mode: 'spawn',
63
- args: ['--watch', '--watch-preserve-output', ...watchPaths, ...argv],
64
- };
63
+ const watch = { watchDirs: [...WATCH_DIRS], watchFiles: [...WATCH_FILES] };
64
+ if (isBun) return { mode: 'supervise', args: ['--hot', ...argv], restartOnChange: false, ...watch };
65
+ return { mode: 'supervise', args: [...argv], restartOnChange: true, ...watch };
65
66
  }
package/lib/port.js CHANGED
@@ -58,3 +58,35 @@ export function resolvePort(portFlag, env = process.env) {
58
58
  if (env.PORT) return Number(env.PORT);
59
59
  return 8080;
60
60
  }
61
+
62
+ /**
63
+ * The exit code of a server that could not bind because its port is taken
64
+ * (#1527): Linux's EADDRINUSE errno. The dev supervisor treats it as final and
65
+ * stops instead of restarting a child that can never bind.
66
+ */
67
+ export const PORT_IN_USE_EXIT_CODE = 98;
68
+
69
+ /**
70
+ * Await a server start; on a taken port print the server's message (it names
71
+ * the process holding the port) and exit with `PORT_IN_USE_EXIT_CODE`. Any
72
+ * other error propagates unchanged.
73
+ *
74
+ * @template T
75
+ * @param {Promise<T>} started
76
+ * @param {{ error?: (line: string) => void, exit?: (code: number) => never }} [io]
77
+ * @returns {Promise<T>}
78
+ */
79
+ export async function failFastOnPortInUse(started, io = {}) {
80
+ const error = io.error || ((line) => console.error(line));
81
+ const exit = io.exit || ((code) => process.exit(code));
82
+ try {
83
+ return await started;
84
+ } catch (e) {
85
+ const err = /** @type {any} */ (e);
86
+ if (err && err.code === 'EADDRINUSE') {
87
+ error(`[webjs] ${err.message || 'the port is already in use'}`);
88
+ return exit(PORT_IN_USE_EXIT_CODE);
89
+ }
90
+ throw e;
91
+ }
92
+ }
@@ -161,7 +161,22 @@ export function bunifyDockerfile(s) {
161
161
  * @returns {string}
162
162
  */
163
163
  export function bunifyCompose(s) {
164
- return s.replace('test: ["CMD", "node", "-e", "fetch(', 'test: ["CMD", "bun", "-e", "fetch(');
164
+ return s
165
+ .replace('test: ["CMD", "node", "-e", "fetch(', 'test: ["CMD", "bun", "-e", "fetch(')
166
+ // The secret-generation hint (#1527): a Bun app may have no node at all.
167
+ .replaceAll('# Generate: node -e ', '# Generate: bun -e ');
168
+ }
169
+
170
+ /**
171
+ * Rewrite `.env.example` for Bun (#1527): its secret-generation hint runs
172
+ * `node -e`, which a Bun-only machine (the oven/bun image, a Bun-only dev box)
173
+ * does not have. `bun -e` runs the same one-liner.
174
+ *
175
+ * @param {string} s
176
+ * @returns {string}
177
+ */
178
+ export function bunifyEnvExample(s) {
179
+ return s.replaceAll('# Generate: node -e ', '# Generate: bun -e ');
165
180
  }
166
181
 
167
182
  /**
@@ -0,0 +1,147 @@
1
+ /**
2
+ * A recursive directory watcher that keeps hearing a file after it is
3
+ * replaced (#1529).
4
+ *
5
+ * KEEP IN SYNC: `packages/cli/lib/watch-recursive.js` and
6
+ * `packages/server/src/dev/watch-recursive.js` are byte-identical copies (the
7
+ * CLI does not depend on server internals); `packages/cli/test/dev-supervisor/
8
+ * watch-recursive.test.js` fails when they drift.
9
+ *
10
+ * Node 24's `fs.watch(dir, { recursive: true })` on Linux is implemented in JS
11
+ * (`internal/fs/recursive_watch`) with one inotify watch per FILE plus a map of
12
+ * the files it knows. `sed -i`, an editor's save through a temp file, and every
13
+ * atomic write REPLACE the file (a new inode renamed over the old name). The
14
+ * per-file watch dies with the old inode, and the new file is never watched
15
+ * again because its name is already in the map, so every later edit to that
16
+ * file is silent: no dev restart, no live reload. Node 26 fixed it, but WebJs
17
+ * supports Node 24.
18
+ *
19
+ * A plain `fs.watch` on a DIRECTORY reports a replaced child by name on every
20
+ * Node version, so on Linux under Node this watches each directory
21
+ * non-recursively (one inotify watch per directory, fewer than one per file),
22
+ * adds a watcher when a subdirectory appears, and drops one when it goes away.
23
+ * Everywhere else the native recursive watcher is used unchanged: macOS and
24
+ * Windows have a kernel-level recursive watch, and Bun's `bun --hot` reloads
25
+ * edits itself.
26
+ *
27
+ * @module watch-recursive
28
+ */
29
+ import { watch, readdirSync, realpathSync, statSync } from 'node:fs';
30
+ import { EventEmitter } from 'node:events';
31
+ import { join, sep } from 'node:path';
32
+
33
+ /**
34
+ * Whether this runtime needs the per-directory walker rather than the native
35
+ * recursive watcher.
36
+ *
37
+ * @param {{ platform?: string, isBun?: boolean }} [env]
38
+ * @returns {boolean}
39
+ */
40
+ export function needsDirWalker({ platform = process.platform, isBun = !!process.versions.bun } = {}) {
41
+ return platform === 'linux' && !isBun;
42
+ }
43
+
44
+ /**
45
+ * Watch `dir` and everything under it. The listener gets `(eventType,
46
+ * filename)` with `filename` relative to `dir`, like the native recursive
47
+ * watcher. The returned watcher emits `'error'` for any watch or scan failure
48
+ * (never throws for one) and has `close()`.
49
+ *
50
+ * @param {string} dir
51
+ * @param {(eventType: string, filename: string | null) => void} listener
52
+ * @param {{
53
+ * ignore?: (relPath: string) => boolean,
54
+ * watchFn?: typeof watch,
55
+ * walker?: boolean,
56
+ * }} [opts] `ignore` skips a subdirectory (never walked, never watched) and
57
+ * its events; `watchFn` is injectable for tests; `walker` forces the mode.
58
+ * @returns {import('node:events').EventEmitter & { close: () => void }}
59
+ */
60
+ export function watchRecursive(dir, listener, opts = {}) {
61
+ const { ignore = () => false, watchFn = watch, walker = needsDirWalker() } = opts;
62
+ if (!walker) return /** @type {any} */ (watchFn(dir, { recursive: true }, listener));
63
+
64
+ const out = /** @type {EventEmitter & { close: () => void }} */ (new EventEmitter());
65
+ /** @type {Map<string, import('node:fs').FSWatcher>} rel dir -> watcher */
66
+ const watchers = new Map();
67
+ /** @type {Map<string, string>} real path -> rel dir (a symlink loop guard) */
68
+ const reals = new Map();
69
+ let closed = false;
70
+ // Errors from the initial walk happen before the caller can attach an
71
+ // 'error' listener, so they are held and emitted on the next tick.
72
+ /** @type {unknown[] | null} */
73
+ let early = [];
74
+
75
+ const fail = (err) => {
76
+ if (closed) return;
77
+ if (early) early.push(err);
78
+ else if (out.listenerCount('error') > 0) out.emit('error', err);
79
+ };
80
+ const abs = (rel) => (rel ? join(dir, rel) : dir);
81
+ const isDir = (rel) => {
82
+ try { return statSync(abs(rel)).isDirectory(); } catch { return false; }
83
+ };
84
+ const within = (rel, root) => root === '' || rel === root || rel.startsWith(root + sep);
85
+
86
+ const drop = (root) => {
87
+ for (const [rel, w] of [...watchers]) {
88
+ if (!within(rel, root)) continue;
89
+ try { w.close(); } catch {}
90
+ watchers.delete(rel);
91
+ }
92
+ for (const [real, rel] of [...reals]) if (within(rel, root)) reals.delete(real);
93
+ };
94
+
95
+ const add = (rel) => {
96
+ if (closed || watchers.has(rel) || (rel && ignore(rel))) return;
97
+ let real;
98
+ try { real = realpathSync(abs(rel)); } catch (err) { if (!rel) fail(err); return; }
99
+ if (reals.has(real)) return;
100
+ /** @type {import('node:fs').FSWatcher} */
101
+ let w;
102
+ try {
103
+ w = watchFn(abs(rel), (type, name) => onEvent(rel, type, name));
104
+ } catch (err) {
105
+ fail(err);
106
+ return;
107
+ }
108
+ w.on('error', (err) => {
109
+ // A directory removed while watched errors on some kernels; that is the
110
+ // removal, already reported through the parent, not a failure.
111
+ const gone = !isDir(rel);
112
+ drop(rel);
113
+ if (!gone) fail(err);
114
+ });
115
+ watchers.set(rel, w);
116
+ reals.set(real, rel);
117
+ let entries = [];
118
+ try { entries = readdirSync(abs(rel), { withFileTypes: true }); } catch (err) { fail(err); }
119
+ for (const e of entries) {
120
+ const child = rel ? join(rel, e.name) : e.name;
121
+ if (e.isDirectory() || (e.isSymbolicLink() && isDir(child))) add(child);
122
+ }
123
+ };
124
+
125
+ const onEvent = (rel, type, name) => {
126
+ if (closed) return;
127
+ if (name == null) { listener(type, rel || null); return; }
128
+ const child = rel ? join(rel, String(name)) : String(name);
129
+ if (ignore(child)) return;
130
+ // A subdirectory appeared (created, or renamed into place) or went away.
131
+ if (isDir(child)) add(child);
132
+ else if (watchers.has(child)) drop(child);
133
+ listener(type, child);
134
+ };
135
+
136
+ out.close = () => {
137
+ closed = true;
138
+ drop('');
139
+ };
140
+ add('');
141
+ process.nextTick(() => {
142
+ const held = early || [];
143
+ early = null;
144
+ for (const err of held) fail(err);
145
+ });
146
+ return out;
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.59",
3
+ "version": "0.10.61",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@webjsdev/mcp": "^0.1.0",
21
- "@webjsdev/server": "^0.8.68",
21
+ "@webjsdev/server": "^0.8.73",
22
22
  "@webjsdev/ui": "^0.3.15"
23
23
  },
24
24
  "publishConfig": {
@@ -21,13 +21,15 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
21
21
  |---|---|
22
22
  | `REDIS_URL` | When set, sessions, rate limit, and cache use Redis instead of memory |
23
23
  | `SESSION_SECRET` / `AUTH_SECRET` | Session and auth signing (see `auth-and-sessions.md`) |
24
- | `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080` |
25
- | `WEBJS_SOURCE_LOCATIONS` | `webjs dev` only. `1` stamps `data-webjs-src="<app-relative-file>:<line>"` on the elements of the app's `html` templates (see below). Ignored by `webjs start` |
26
- | `WEBJS_EMBED_ORIGINS` | `webjs dev` only. Comma-separated parent origins (`https://builder.dev,http://localhost:8080`) allowed to frame the dev server and receive the embed bridge's messages (see below). Ignored by `webjs start` |
24
+ | `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080`. A taken port fails fast: `webjs dev` / `webjs start` exit 98 with `port 8080 is already in use by PID <n> (<command>)`, on Node and Bun alike |
25
+ | `WEBJS_REUSE_PORT` | `1` lets several `webjs dev` / `webjs start` processes share one port (`SO_REUSEPORT`, Linux; the kernel balances connections across them). Off by default, so a second server on a taken port fails instead of silently taking a share of the requests |
26
+ | `WEBJS_SOURCE_LOCATIONS` | `webjs dev` only. `1` stamps `data-webjs-src="<app-relative-file>:<line>"` on the elements of the app's `html` templates (see below); `0` turns off a config default. Same as `webjs.dev.sourceLocations: true`. Ignored by `webjs start` |
27
+ | `WEBJS_EMBED_ORIGINS` | `webjs dev` only. Comma-separated parent origins (`https://builder.dev,http://localhost:8080`) allowed to frame the dev server and receive the embed bridge's messages (see below). Replaces `webjs.dev.embedOrigins` when set. Ignored by `webjs start` |
28
+ | `WEBJS_DEV_RELOAD_IDLE` | `webjs dev` only. Seconds of no edit and no interaction after which the live-reload stream closes so an idle host can sleep (`webjs.dev.reloadIdle`; this wins). Off by default; see `runtime.md` |
27
29
 
28
- **Source locations for tooling (`WEBJS_SOURCE_LOCATIONS=1`, dev only).** A tool that hosts the app (an inspector, click-to-edit in an embedding builder) can map a clicked element back to the line that wrote it. With the variable set, `webjs dev` adds `data-webjs-src="components/todo-list.ts:12"` to every element opening tag written in an `html` template inside the app, both in the SSR markup and in client renders (one source transform applied to the served module and to the module the server imports, so the two agree and hydration is unaffected). Read it with `el.closest('[data-webjs-src]')`. Not annotated: `*.server.*` modules, `node_modules`, `css` / `svg` tagged templates, `html` / `head` / `body` / head-only and raw-text elements, and the descendants of `svg` / `math`. Lines are exact; nothing reaches production. The importmap `<script>` in `<head>` carries an unrelated `data-webjs-src` (the app-source deploy id), so match the `file:line` value shape when querying the whole document.
30
+ **Source locations for tooling (`webjs.dev.sourceLocations: true` or `WEBJS_SOURCE_LOCATIONS=1`, dev only, off by default).** A tool that hosts the app (an inspector, click-to-edit in an embedding builder) can map a clicked element back to the line that wrote it. With the variable set, `webjs dev` adds `data-webjs-src="components/todo-list.ts:12"` to every element opening tag written in an `html` template inside the app, both in the SSR markup and in client renders (one source transform applied to the served module and to the module the server imports, so the two agree and hydration is unaffected). Read it with `el.closest('[data-webjs-src]')`. Not annotated: `*.server.*` modules, `node_modules`, `css` / `svg` tagged templates, `html` / `head` / `body` / head-only and raw-text elements, and the descendants of `svg` / `math`. Lines are exact; nothing reaches production. The importmap `<script>` in `<head>` carries an unrelated `data-webjs-src` (the app-source deploy id), so match the `file:line` value shape when querying the whole document.
29
31
 
30
- **Embed bridge for iframe previews (`WEBJS_EMBED_ORIGINS`, dev only).** A tool that previews the app inside an iframe (an app builder, a docs playground) sets `WEBJS_EMBED_ORIGINS` to its own origin(s). `webjs dev` then (1) drops `X-Frame-Options` and adds those origins to a CSP `frame-ancestors`, so the frame loads without the app stripping headers in `webjs.headers`, and (2) inlines a small nonce-signed script into every document that, when framed by a listed origin, posts to `window.parent` (with that exact target origin, never `*`):
32
+ **Embed bridge for iframe previews (`webjs.dev.embedOrigins` or `WEBJS_EMBED_ORIGINS`, dev only, off by default).** A tool that previews the app inside an iframe (an app builder, a docs playground) lists its own origin(s) in `package.json` (`"webjs": { "dev": { "embedOrigins": ["https://builder.example"] } }`) or sets `WEBJS_EMBED_ORIGINS` (comma-separated, replaces the config list for that run). `webjs dev` then (1) drops `X-Frame-Options` and adds those origins to a CSP `frame-ancestors`, so the frame loads without the app stripping headers in `webjs.headers`, and (2) inlines a small nonce-signed script into every document that, when framed by a listed origin, posts to `window.parent` (with that exact target origin, never `*`):
31
33
 
32
34
  ```js
33
35
  { source: 'webjs-embed', type: 'ready', path, title } // document parsed
@@ -36,10 +38,11 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
36
38
  { source: 'webjs-embed', type: 'error', message, stack, file, line, column } // window error + unhandledrejection
37
39
  { source: 'webjs-embed', type: 'network', method, url, status, error? } // fetch/XHR status >= 500, or status 0 on failure
38
40
  { source: 'webjs-embed', type: 'server-error', kind, message, file, line, path } // the dev error overlay went up
39
- { source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height } } // a click in inspect mode
41
+ { source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height }, shiftKey, metaKey, altKey, ctrlKey } // a click in inspect mode (modifiers for multi-select, #1532)
42
+ { source: 'webjs-embed', type: 'hold', enabled } // acknowledges a host hold command (#1532)
40
43
  ```
41
44
 
42
- and accepts, only from `window.parent` on a listed origin: `{ source: 'webjs-embed-host', type: 'navigate', path }` (a local path; a soft navigation when the client router is on), `{ source: 'webjs-embed-host', type: 'reload' }`, and `{ source: 'webjs-embed-host', type: 'inspect', enabled: true | false }` (hover highlight plus click capture that posts `select`; `src` is the nearest `data-webjs-src`, so pair it with `WEBJS_SOURCE_LOCATIONS=1` for click-to-edit). Paths are app paths (`/api/x`, `/components/x.ts`). Unset adds zero bytes; `webjs start` never injects it nor relaxes a header.
45
+ and accepts, only from `window.parent` on a listed origin: `{ source: 'webjs-embed-host', type: 'navigate', path }` (a local path; a soft navigation when the client router is on), `{ source: 'webjs-embed-host', type: 'reload' }`, `{ source: 'webjs-embed-host', type: 'resume' }` (reopens a live-reload stream closed by `webjs.dev.reloadIdle`; every host command does this too), and `{ source: 'webjs-embed-host', type: 'inspect', enabled: true | false }` (hover highlight plus click capture that posts `select`; `src` is the nearest `data-webjs-src`, so pair it with `WEBJS_SOURCE_LOCATIONS=1` for click-to-edit), and `{ source: 'webjs-embed-host', type: 'hold', enabled: true | false }` (#1532: while held, live-reload signals are collected instead of applied, so a builder's agent can edit many files without the preview flickering through each save; releasing applies ONE reload at the strongest verdict collected, or none when nothing changed; the hold is kept in `sessionStorage`, so a new document in the same tab starts held, and only the host's release ends it). A full dev reload or a host `reload` keeps the scroll position of the path it reloads (#1532). Paths are app paths (`/api/x`, `/components/x.ts`). Unset adds zero bytes; `webjs start` never injects it nor relaxes a header.
43
46
 
44
47
  Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
45
48
 
@@ -206,7 +206,7 @@ export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
206
206
 
207
207
  ### Cancellation with `actionSignal()`
208
208
 
209
- Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
209
+ Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Only an action called in the synchronous part of a component's own `render()` is tied to that render. One called from `connectedCallback`, an event handler, `firstUpdated()` / `updated()`, or a `Task` is never cancelled by a re-render, including a child element's `connectedCallback` that runs while its parent's template is being committed, so a child can start its first fetch there without the parent's next render cancelling it. Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
210
210
 
211
211
  ```ts
212
212
  'use server';
@@ -87,6 +87,7 @@ export default async function User({ params }: { params: { id: string } }) {
87
87
 
88
88
  - `[param]/page.ts` dynamic segment, read via `params.param`.
89
89
  - `[...rest]/page.ts` catch-all, `[[...rest]]/page.ts` optional catch-all.
90
+ - Overlapping routes resolve by positional specificity, for pages and `route.ts` handlers alike: segment by segment, a static segment beats `[param]`, which beats a catch-all, so `api/auth/callback/github/route.ts` answers before `api/auth/[...path]/route.ts` whatever the directory order.
90
91
  - `(group)/...` route group: the folder is NOT in the URL but still scopes layout / error.
91
92
  - `_private/...` private folder: ignored by the router.
92
93
 
@@ -32,15 +32,21 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
32
32
  | Listener | `node:http` shell | native `Bun.serve` (faster on the listening path only, not end-to-end, because SSR render dominates a real page) |
33
33
  | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
34
34
  | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
35
- | Hot reload | `node --watch` | `bun --hot` |
35
+ | Hot reload | restart on change (the `webjs dev` supervisor, #1521) | `bun --hot` |
36
36
  | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
37
  | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
- | Dev edit to a page / layout | full reload (the `node --watch` restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
38
+ | Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
39
39
  | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
40
40
 
41
+ **`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.
42
+
41
43
  **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.
42
44
 
43
- Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. Node's `bun --hot` equivalent is `node --watch`, which RESTARTS the 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. 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.
45
+ Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. 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. 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.
46
+
47
+ **`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.
48
+
49
+ **`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 <path> (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 does only the crash recovery, since `bun --hot` reloads edits in place. 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.
44
50
 
45
51
  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.
46
52