@webjsdev/cli 0.10.58 → 0.10.60

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/lib/create.js CHANGED
@@ -18,24 +18,10 @@ import { existsSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
21
+ import { postgresCompose, postgresCi } from './db-rewrite.js';
21
22
  import { assertValidAppName, toDatabaseName } from './app-name.js';
22
23
  import { isGalleryAppShellFile } from './gallery-shell-files.js';
23
-
24
- /**
25
- * Detect which package manager invoked us. Reads `npm_config_user_agent`,
26
- * which npm / pnpm / yarn / bun all set when running scripts or `npx`.
27
- * Falls back to `npm` when nothing is detected (matches what most users
28
- * actually have installed).
29
- *
30
- * @returns {'npm'|'pnpm'|'yarn'|'bun'}
31
- */
32
- function detectPackageManager() {
33
- const ua = process.env.npm_config_user_agent || '';
34
- if (ua.startsWith('pnpm/')) return 'pnpm';
35
- if (ua.startsWith('yarn/')) return 'yarn';
36
- if (ua.startsWith('bun/')) return 'bun';
37
- return 'npm';
38
- }
24
+ import { detectPackageManager } from './package-manager.js';
39
25
 
40
26
  /**
41
27
  * Run `<pm> install` inside the scaffolded app. Returns true on success.
@@ -327,7 +313,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
327
313
  // with the explicit flag winning over detection. A bun-flavored app SERVES on
328
314
  // Bun (its dev/start scripts force `--bun`), commits `bun.lock`, sets
329
315
  // `trustedDependencies`, and ships a bun Dockerfile / CI / agent docs.
330
- const runtime = opts.runtime || (detectPackageManager() === 'bun' ? 'bun' : 'node');
316
+ // Runtime detection reads ONLY the invoking tool (no lockfile walk): a
317
+ // global `webjs create` run inside someone else's Bun workspace should not
318
+ // silently flip the new app to serving on Bun.
319
+ const runtime = opts.runtime || (detectPackageManager({ cwd: null, prefer: 'agent' }) === 'bun' ? 'bun' : 'node');
331
320
  const VALID_RUNTIMES = ['node', 'bun'];
332
321
  if (!VALID_RUNTIMES.includes(runtime)) {
333
322
  throw new Error(`Unknown --runtime '${runtime}'. Only ${VALID_RUNTIMES.join(' / ')} are supported.`);
@@ -475,7 +464,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
475
464
  // The Tailwind v4 CLI that css:build runs to compile public/input.css into
476
465
  // the static public/tailwind.css the layout links. UI templates only (the
477
466
  // api template has no CSS). Build tooling, never shipped to the runtime.
478
- ...(isApi ? {} : { '@tailwindcss/cli': '^4.1.0' }),
467
+ // `tailwindcss` itself is declared too (#1493): public/input.css starts
468
+ // with `@import "tailwindcss"`, so the app imports that package directly.
469
+ // Leaving it transitive (via @tailwindcss/cli) breaks under bun's isolated
470
+ // linker and pnpm, which link only declared packages into the app's
471
+ // node_modules, so the compile fails with `Can't resolve 'tailwindcss'`.
472
+ ...(isApi ? {} : { '@tailwindcss/cli': '^4.1.0', tailwindcss: '^4.1.0' }),
479
473
  // tsserver plugin, wired into tsconfig below. Gives the language
480
474
  // INTELLIGENCE (go-to-def, completions, diagnostics, hover inside html``
481
475
  // templates) in any tsserver editor with NO editor plugin installed,
@@ -523,8 +517,18 @@ export async function scaffoldApp(name, cwd, opts = {}) {
523
517
  // `npm audit fix --force` proposes: its @web/test-runner-chrome@1 still
524
518
  // declares puppeteer-core ^24, so the same vulnerable chain resolves and
525
519
  // the audit stays red after a breaking major.
520
+ //
521
+ // basic-ftp (#1492) is the same kind of floor for the same runner: it is
522
+ // reached through puppeteer-core's proxy-agent chain and 6.2.2 is its fixed
523
+ // release (GHSA-c475-qrg2-pj4r), so an install that still resolves an older
524
+ // chain cannot land below it.
525
+ //
526
+ // Overrides are honoured ONLY at a workspace root. When this app is a
527
+ // member of an npm or bun workspace, move this block into the root
528
+ // package.json (`webjs doctor` warns with WORKSPACE_OVERRIDES until then).
526
529
  overrides: {
527
530
  'puppeteer-core': '^25.7.0',
531
+ 'basic-ftp': '^6.2.2',
528
532
  },
529
533
  // Dev + start task orchestration (#550). `webjs dev` / `webjs start` read
530
534
  // `before` and run it in-process, so `npm run dev` / `start` (thin aliases
@@ -576,6 +580,23 @@ export async function scaffoldApp(name, cwd, opts = {}) {
576
580
  // everything else keeps its default warn. Add a code with "off" to
577
581
  // silence it, or "error" to make it fatal too.
578
582
  doctor: { gate: { UNMARKED_ASSET_LINKS: 'error' } },
583
+ // The dependency audit's allowlist (#1492), the ONE place an accepted
584
+ // advisory is listed, each with the reason it is safe. `webjs audit`
585
+ // (the CI step below) fails on every other advisory at `level` or above,
586
+ // and prints an entry as stale once the audit stops reporting it, so
587
+ // remove it then. Only accept an advisory with NO patched release that
588
+ // the app's users cannot reach; anything with a fix gets upgraded.
589
+ audit: {
590
+ level: 'high',
591
+ ignore: [
592
+ {
593
+ id: 'GHSA-vfj7-8cjw-p6xm',
594
+ reason: 'braces <=3.0.3 (no patched release) is reached only through dev tooling: the Tailwind '
595
+ + 'CLI file watcher and the test runner globber, on glob patterns this repo writes. Nothing '
596
+ + 'in the served app expands a pattern from request input.',
597
+ },
598
+ ],
599
+ },
579
600
  // Local CI (#1471), the Rails `bin/ci` posture. `npm run ci` runs this
580
601
  // list on a developer machine and the generated GitHub workflow runs the
581
602
  // SAME list through the same command, so the two cannot drift. Bare
@@ -593,10 +614,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
593
614
  { title: 'Conventions', run: 'webjs check' },
594
615
  { title: 'Health', run: 'webjs doctor' },
595
616
  { title: 'Types', run: 'webjs typecheck' },
596
- {
597
- title: 'Security: dependency audit',
598
- run: isBun ? 'bun audit --audit-level=high' : 'npm audit --audit-level=high',
599
- },
617
+ // `webjs audit` (#1492) runs `npm audit` or `bun audit` (by the
618
+ // nearest lockfile) and fails at webjs.audit.level, minus the
619
+ // advisories webjs.audit.ignore accepts below.
620
+ { title: 'Security: dependency audit', run: 'webjs audit' },
600
621
  {
601
622
  title: 'Tests',
602
623
  steps: [
@@ -733,6 +754,14 @@ export async function scaffoldApp(name, cwd, opts = {}) {
733
754
  'compose.yaml': bunifyCompose,
734
755
  '.github/workflows/ci.yml': bunifyCi,
735
756
  };
757
+ // Database axis (#1490): the compose + CI templates are the SQLite shape, so
758
+ // a --db postgres app derives its variant (a Postgres service, DATABASE_URL
759
+ // pointed at it) by a pure transform, like the Bun rewrite above. Applied
760
+ // FIRST; the two touch disjoint lines, so they compose. SQLite copies as is.
761
+ const DB_REWRITE = dialect === 'postgres' ? {
762
+ 'compose.yaml': (c) => postgresCompose(c, toDatabaseName(name)),
763
+ '.github/workflows/ci.yml': (c) => postgresCi(c, toDatabaseName(name)),
764
+ } : {};
736
765
  for (const f of templateFiles) {
737
766
  // `--skip-ci` drops only the workflow; the PR template still ships.
738
767
  if (skipCi && f === '.github/workflows/ci.yml') continue;
@@ -754,6 +783,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
754
783
  const playbook = await readFile(join(TEMPLATES, 'partials', playbookFile), 'utf8');
755
784
  content = content.replace('{{PLAYBOOK}}', () => playbook.trimEnd());
756
785
  }
786
+ if (DB_REWRITE[f]) content = DB_REWRITE[f](content);
757
787
  if (isBun) {
758
788
  if (PROSE_REWRITE.has(f)) content = bunifyProse(content);
759
789
  else if (FILE_REWRITE[f]) content = FILE_REWRITE[f](content);
@@ -1663,8 +1693,11 @@ ThemeToggle.register('theme-toggle');
1663
1693
  // that exercise the scaffold without paying the install cost.
1664
1694
  // In bun mode, install with bun regardless of the invoking PM, so the app
1665
1695
  // commits `bun.lock` (text JSONC, git-diffable) instead of `package-lock.json`
1666
- // (#541). Otherwise honour the invoking PM (npm / pnpm / yarn / bun).
1667
- const pm = isBun ? 'bun' : detectPackageManager();
1696
+ // (#541). Otherwise honour the invoking PM (npm / pnpm / yarn / bun), and when
1697
+ // nothing invoked us through a package manager (a global `webjs` bin), the
1698
+ // lockfile of the enclosing project or workspace (#1494), so an app created
1699
+ // inside a bun workspace does not get a stray package-lock.json.
1700
+ const pm = isBun ? 'bun' : detectPackageManager({ cwd: dirname(appDir), prefer: 'agent' });
1668
1701
  let installed = false;
1669
1702
  let generatedMigration = false;
1670
1703
  if (shouldInstall) {
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Database-dialect rewrites for the deploy files (#1490).
3
+ *
4
+ * The canonical `compose.yaml` and `.github/workflows/ci.yml` templates are the
5
+ * SQLite shape (a `file:` DATABASE_URL, a named volume for the db file). A
6
+ * `--db postgres` app needs a real Postgres in both places, so these pure
7
+ * transforms DERIVE the Postgres variant from the canonical template, the same
8
+ * way `runtime-rewrite.js` derives the Bun variant. There is no parallel
9
+ * Postgres template to drift, and SQLite output stays byte-identical because
10
+ * nothing here runs for it.
11
+ *
12
+ * Order: create.js applies these BEFORE the Bun rewrites. They only touch the
13
+ * DATABASE_URL lines, the volume, and add a database service, none of which
14
+ * the Bun rewrites match (those swap `node -e` healthchecks, `npm` commands and
15
+ * the setup-node block), so the two axes compose in either order. Every anchor
16
+ * is asserted, so a template edit that moves one fails loudly in the scaffold
17
+ * tests instead of shipping a half-rewritten file.
18
+ *
19
+ * The credentials are local-only (a throwaway compose volume, an ephemeral CI
20
+ * service container), never a production value; production points
21
+ * DATABASE_URL at its own managed Postgres.
22
+ *
23
+ * @module db-rewrite
24
+ */
25
+
26
+ /** Local Postgres user + password for compose and CI (never production). */
27
+ export const LOCAL_PG_USER = 'webjs';
28
+ export const LOCAL_PG_PASSWORD = 'webjs';
29
+ /** The Postgres image both files run. */
30
+ export const PG_IMAGE = 'postgres:17-alpine';
31
+
32
+ /**
33
+ * @param {string} s
34
+ * @param {string} from
35
+ * @param {string} to
36
+ * @param {string} file
37
+ */
38
+ function replaceOnce(s, from, to, file) {
39
+ if (!s.includes(from)) {
40
+ throw new Error(`db-rewrite: ${file} template no longer contains the anchor ${JSON.stringify(from.slice(0, 60))}`);
41
+ }
42
+ return s.replace(from, () => to);
43
+ }
44
+
45
+ /**
46
+ * Rewrite compose.yaml for a Postgres app: a sibling `db` service with a
47
+ * `pg_isready` healthcheck and its own named volume, the app's DATABASE_URL
48
+ * pointed at it, and `depends_on` with `service_healthy` so the app's boot-time
49
+ * `webjs db migrate` never races the database's startup.
50
+ *
51
+ * @param {string} s
52
+ * @param {string} dbName fold-stable database name (toDatabaseName(appName))
53
+ * @returns {string}
54
+ */
55
+ export function postgresCompose(s, dbName) {
56
+ const url = `postgres://${LOCAL_PG_USER}:${LOCAL_PG_PASSWORD}@db:5432/${dbName}`;
57
+ let out = s;
58
+ out = replaceOnce(out, 'using the same Dockerfile, one service.',
59
+ 'using the same Dockerfile, plus a Postgres service.', 'compose.yaml');
60
+ out = replaceOnce(out,
61
+ "# In production your host provides DATABASE_URL + AUTH_SECRET. Locally this\n" +
62
+ "# uses the scaffold's SQLite file on a named volume so data survives\n" +
63
+ '# `compose down`.',
64
+ '# In production your host provides DATABASE_URL + AUTH_SECRET. Locally this\n' +
65
+ '# runs Postgres in the `db` service on a named volume so data survives\n' +
66
+ '# `compose down`.',
67
+ 'compose.yaml');
68
+ out = replaceOnce(out,
69
+ ' # SQLite on a volume for local dev. For production, scaffold with\n' +
70
+ ' # --db postgres (or swap db/columns.server.ts + db/connection.server.ts\n' +
71
+ ' # for the pg variant) and point DATABASE_URL at your managed Postgres.\n' +
72
+ ' DATABASE_URL: file:/data/dev.db\n',
73
+ ' # The `db` service below. In production, point DATABASE_URL at your\n' +
74
+ ' # managed Postgres instead.\n' +
75
+ ` DATABASE_URL: ${url}\n`,
76
+ 'compose.yaml');
77
+ // The app no longer owns a db file, so it needs no volume; it waits for a
78
+ // healthy database instead, because `webjs start` migrates before serving.
79
+ out = replaceOnce(out,
80
+ ' volumes:\n - app-data:/data\n',
81
+ ' depends_on:\n db:\n condition: service_healthy\n',
82
+ 'compose.yaml');
83
+ out = replaceOnce(out,
84
+ '\nvolumes:\n app-data:\n',
85
+ '\n' +
86
+ ' db:\n' +
87
+ ` image: ${PG_IMAGE}\n` +
88
+ ' environment:\n' +
89
+ ` POSTGRES_USER: ${LOCAL_PG_USER}\n` +
90
+ ` POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD}\n` +
91
+ ` POSTGRES_DB: ${dbName}\n` +
92
+ ' volumes:\n' +
93
+ ' - db-data:/var/lib/postgresql/data\n' +
94
+ ' healthcheck:\n' +
95
+ ` test: ["CMD-SHELL", "pg_isready -U ${LOCAL_PG_USER} -d ${dbName}"]\n` +
96
+ ' interval: 5s\n' +
97
+ ' timeout: 3s\n' +
98
+ ' retries: 10\n' +
99
+ '\n' +
100
+ 'volumes:\n' +
101
+ ' db-data:\n',
102
+ 'compose.yaml');
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Rewrite the GitHub Actions CI workflow for a Postgres app: a `postgres`
108
+ * service container with a health check (the job waits until it is healthy
109
+ * before the first step) and DATABASE_URL pointed at it on localhost.
110
+ *
111
+ * @param {string} s
112
+ * @param {string} dbName
113
+ * @returns {string}
114
+ */
115
+ export function postgresCi(s, dbName) {
116
+ return replaceOnce(s,
117
+ ' env:\n DATABASE_URL: file:./ci.db\n',
118
+ ' env:\n' +
119
+ ` DATABASE_URL: postgres://${LOCAL_PG_USER}:${LOCAL_PG_PASSWORD}@localhost:5432/${dbName}\n` +
120
+ ' # The app is scaffolded with --db postgres, so CI runs against a real\n' +
121
+ ' # Postgres. The job waits for the health check before the first step.\n' +
122
+ ' services:\n' +
123
+ ' postgres:\n' +
124
+ ` image: ${PG_IMAGE}\n` +
125
+ ' env:\n' +
126
+ ` POSTGRES_USER: ${LOCAL_PG_USER}\n` +
127
+ ` POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD}\n` +
128
+ ` POSTGRES_DB: ${dbName}\n` +
129
+ ' ports:\n' +
130
+ ' - 5432:5432\n' +
131
+ ' options: >-\n' +
132
+ ` --health-cmd "pg_isready -U ${LOCAL_PG_USER} -d ${dbName}"\n` +
133
+ ' --health-interval 5s\n' +
134
+ ' --health-timeout 5s\n' +
135
+ ' --health-retries 10\n',
136
+ '.github/workflows/ci.yml');
137
+ }
@@ -0,0 +1,382 @@
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
+
25
+ /**
26
+ * Quiet window between a file event and the restart. `node --watch` used
27
+ * 200ms; one editor save or `sed -i` lands its events within a few ms of each
28
+ * other, so 50ms still coalesces a save while starting the restart 150ms
29
+ * sooner.
30
+ */
31
+ export const RESTART_DEBOUNCE_MS = 50;
32
+
33
+ /**
34
+ * Delays before restarting a child that exited on its own (a crash), indexed
35
+ * by consecutive crash count and capped at the last entry. A file change
36
+ * restarts it at once regardless, so the backoff only matters when nothing is
37
+ * being edited: the preview comes back within seconds, and a child that fails
38
+ * deterministically at boot is retried every 10s instead of in a tight loop.
39
+ */
40
+ export const CRASH_BACKOFF_MS = [500, 1000, 2000, 5000, 10000];
41
+
42
+ /** A child that stayed up this long resets the crash backoff. */
43
+ export const STABLE_MS = 10_000;
44
+
45
+ /**
46
+ * How long a restarting child gets to exit after SIGTERM before SIGKILL. The
47
+ * server's own drain allows 10s, which is right for a deploy and far too long
48
+ * to hold an edit back in dev.
49
+ */
50
+ export const KILL_TIMEOUT_MS = 2000;
51
+
52
+ /**
53
+ * Paths inside a watched dir whose changes never restart the server: the same
54
+ * noise the server's in-process watcher ignores (`shouldIgnoreWatchPath` in
55
+ * `@webjsdev/server`), restated here so the CLI does not depend on a server
56
+ * internal.
57
+ *
58
+ * @param {string} rel path relative to the app root
59
+ * @returns {boolean}
60
+ */
61
+ export function shouldIgnoreRestartPath(rel) {
62
+ return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(rel || '');
63
+ }
64
+
65
+ /**
66
+ * Whether an error came from a file watcher. Node marks every `fs.watch`
67
+ * failure with `syscall: 'watch'`.
68
+ *
69
+ * @param {unknown} err
70
+ * @returns {boolean}
71
+ */
72
+ export function isWatchError(err) {
73
+ return !!err && typeof err === 'object' && /** @type {any} */ (err).syscall === 'watch';
74
+ }
75
+
76
+ /**
77
+ * The `webjs.dev.regenerate[].output` paths (#967), normalized to `/` with no
78
+ * leading `./`. The server writes these on request, so one that lives under a
79
+ * watched dir must not restart the server, or a request would restart the
80
+ * process that is serving it. Unreadable config yields no outputs.
81
+ *
82
+ * @param {string} cwd
83
+ * @returns {Set<string>}
84
+ */
85
+ export function readRegenerateOutputs(cwd) {
86
+ const out = new Set();
87
+ try {
88
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
89
+ const rules = pkg && pkg.webjs && pkg.webjs.dev && pkg.webjs.dev.regenerate;
90
+ if (Array.isArray(rules)) {
91
+ for (const r of rules) {
92
+ if (r && typeof r.output === 'string') out.add(r.output.replace(/\\/g, '/').replace(/^\.\//, ''));
93
+ }
94
+ }
95
+ } catch {}
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * Watch the restart paths of an app. Each dir in `dirs` is watched
101
+ * recursively; the app root is watched non-recursively, which catches an edit
102
+ * to a root file in `files` and a dir in `dirs` appearing or disappearing.
103
+ * Every watcher error goes to `onError` and never throws.
104
+ *
105
+ * @param {string} cwd the app root
106
+ * @param {{
107
+ * dirs: string[],
108
+ * files: string[],
109
+ * ignore: (rel: string) => boolean,
110
+ * onChange: (rel: string) => void,
111
+ * onError: (err: NodeJS.ErrnoException) => void,
112
+ * watchFn?: typeof watch,
113
+ * }} opts
114
+ * @returns {() => void} closes every watcher
115
+ */
116
+ export function watchRestartPaths(cwd, { dirs, files, ignore, onChange, onError, watchFn = watch }) {
117
+ /** @type {Map<string, import('node:fs').FSWatcher>} */
118
+ const watchers = new Map();
119
+ let closed = false;
120
+ const isDir = (name) => {
121
+ try { return statSync(join(cwd, name)).isDirectory(); } catch { return false; }
122
+ };
123
+ const guard = (w) => {
124
+ w.on('error', (err) => onError(err));
125
+ return w;
126
+ };
127
+ const watchDir = (name) => {
128
+ if (closed || watchers.has(name) || !isDir(name)) return;
129
+ try {
130
+ watchers.set(name, guard(watchFn(join(cwd, name), { recursive: true }, (_type, filename) => {
131
+ const rel = filename ? join(name, String(filename)) : name;
132
+ if (!ignore(rel)) onChange(rel);
133
+ })));
134
+ } catch (err) {
135
+ onError(/** @type {NodeJS.ErrnoException} */ (err));
136
+ }
137
+ };
138
+ const unwatchDir = (name) => {
139
+ const w = watchers.get(name);
140
+ if (!w) return;
141
+ try { w.close(); } catch {}
142
+ watchers.delete(name);
143
+ };
144
+
145
+ /** @type {import('node:fs').FSWatcher | null} */
146
+ let root = null;
147
+ try {
148
+ root = guard(watchFn(cwd, (_type, filename) => {
149
+ const name = filename ? String(filename) : '';
150
+ if (dirs.includes(name)) {
151
+ // A watched dir was created, replaced, or removed.
152
+ if (isDir(name)) watchDir(name); else unwatchDir(name);
153
+ onChange(name);
154
+ } else if (files.includes(name)) {
155
+ onChange(name);
156
+ }
157
+ }));
158
+ } catch (err) {
159
+ onError(/** @type {NodeJS.ErrnoException} */ (err));
160
+ }
161
+ for (const d of dirs) watchDir(d);
162
+
163
+ return () => {
164
+ closed = true;
165
+ try { root?.close(); } catch {}
166
+ for (const name of [...watchers.keys()]) unwatchDir(name);
167
+ };
168
+ }
169
+
170
+ /**
171
+ * @typedef {{
172
+ * pid?: number,
173
+ * kill: (signal?: NodeJS.Signals) => boolean | void,
174
+ * once: (event: 'exit', fn: (code: number | null, signal: NodeJS.Signals | null) => void) => unknown,
175
+ * }} ChildLike
176
+ */
177
+
178
+ /**
179
+ * The restart state machine.
180
+ *
181
+ * - `change(path)` (debounced): restart a running child when `restartOnChange`
182
+ * (Node), or start a child that is not running (either runtime, after a
183
+ * crash).
184
+ * - A restart sends SIGTERM, escalates to SIGKILL after `killTimeoutMs`, and
185
+ * spawns the replacement the moment the old child exits, never on a poll.
186
+ * - A child that exits on its own is restarted after the crash backoff, or at
187
+ * once on the next change.
188
+ * - `stop()` stops everything and resolves once no child is left.
189
+ *
190
+ * @param {{
191
+ * spawnChild: () => ChildLike,
192
+ * restartOnChange: boolean,
193
+ * log?: (line: string) => void,
194
+ * timers?: { setTimeout: typeof setTimeout, clearTimeout: typeof clearTimeout },
195
+ * now?: () => number,
196
+ * debounceMs?: number,
197
+ * backoffMs?: number[],
198
+ * stableMs?: number,
199
+ * killTimeoutMs?: number,
200
+ * }} opts
201
+ */
202
+ export function createSupervisor({
203
+ spawnChild,
204
+ restartOnChange,
205
+ log = () => {},
206
+ timers = globalThis,
207
+ now = Date.now,
208
+ debounceMs = RESTART_DEBOUNCE_MS,
209
+ backoffMs = CRASH_BACKOFF_MS,
210
+ stableMs = STABLE_MS,
211
+ killTimeoutMs = KILL_TIMEOUT_MS,
212
+ }) {
213
+ /** @type {ChildLike | null} */
214
+ let child = null;
215
+ let startedAt = 0;
216
+ let restarting = false;
217
+ let stopping = false;
218
+ let crashes = 0;
219
+ /** @type {string | null} */
220
+ let pendingPath = null;
221
+ /** @type {any} */ let debounceTimer = null;
222
+ /** @type {any} */ let backoffTimer = null;
223
+ /** @type {any} */ let killTimer = null;
224
+ /** @type {Array<() => void>} */
225
+ const stopWaiters = [];
226
+
227
+ const clear = (t) => { if (t !== null) timers.clearTimeout(t); return null; };
228
+
229
+ const launch = () => {
230
+ backoffTimer = clear(backoffTimer);
231
+ if (stopping || child) return;
232
+ const c = spawnChild();
233
+ child = c;
234
+ startedAt = now();
235
+ c.once('exit', (code, signal) => onExit(c, code, signal));
236
+ };
237
+
238
+ const terminate = (c) => {
239
+ try { c.kill('SIGTERM'); } catch {}
240
+ killTimer = clear(killTimer);
241
+ killTimer = timers.setTimeout(() => {
242
+ killTimer = null;
243
+ if (child === c) { try { c.kill('SIGKILL'); } catch {} }
244
+ }, killTimeoutMs);
245
+ };
246
+
247
+ const onExit = (c, code, signal) => {
248
+ if (child !== c) return;
249
+ child = null;
250
+ killTimer = clear(killTimer);
251
+ if (stopping) {
252
+ for (const w of stopWaiters.splice(0)) w();
253
+ return;
254
+ }
255
+ if (restarting) {
256
+ // The restart we asked for: start the replacement right away.
257
+ restarting = false;
258
+ crashes = 0;
259
+ launch();
260
+ return;
261
+ }
262
+ // Exited on its own: a crash, a fatal boot error, or an outside kill.
263
+ if (now() - startedAt >= stableMs) crashes = 0;
264
+ const delay = backoffMs[Math.min(crashes, backoffMs.length - 1)];
265
+ crashes++;
266
+ const why = signal ? `signal ${signal}` : `code ${code}`;
267
+ log(`dev server exited (${why}); restarting in ${delay < 1000 ? `${delay}ms` : `${delay / 1000}s`}, or on the next file change`);
268
+ backoffTimer = timers.setTimeout(launch, delay);
269
+ };
270
+
271
+ const flush = () => {
272
+ debounceTimer = null;
273
+ const path = pendingPath;
274
+ pendingPath = null;
275
+ if (stopping) return;
276
+ if (!child) {
277
+ // Not running (crashed, or waiting out a backoff): a change is the cue.
278
+ if (path) log(`${path} changed, starting the dev server`);
279
+ launch();
280
+ return;
281
+ }
282
+ if (!restartOnChange || restarting) return;
283
+ restarting = true;
284
+ if (path) log(`${path} changed, restarting the dev server`);
285
+ terminate(child);
286
+ };
287
+
288
+ return {
289
+ start: launch,
290
+ /** @param {string} path */
291
+ change(path) {
292
+ if (stopping) return;
293
+ if (pendingPath === null) pendingPath = path;
294
+ debounceTimer = clear(debounceTimer);
295
+ debounceTimer = timers.setTimeout(flush, debounceMs);
296
+ },
297
+ /** @returns {Promise<void>} */
298
+ stop() {
299
+ stopping = true;
300
+ debounceTimer = clear(debounceTimer);
301
+ backoffTimer = clear(backoffTimer);
302
+ if (!child) return Promise.resolve();
303
+ const done = new Promise((r) => stopWaiters.push(() => r(undefined)));
304
+ terminate(child);
305
+ return done;
306
+ },
307
+ get running() { return child !== null; },
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Run the dev server under the supervisor until a signal stops it.
313
+ *
314
+ * @param {{
315
+ * cwd: string,
316
+ * plan: { args: string[], restartOnChange: boolean, watchDirs: string[], watchFiles: string[] },
317
+ * env: NodeJS.ProcessEnv,
318
+ * onExit: (code: number) => void,
319
+ * }} opts
320
+ */
321
+ export function superviseDevServer({ cwd, plan, env, onExit }) {
322
+ const log = (line) => console.log(`[webjs] ${line}`);
323
+ // A watcher error here is NOT logged: the server child watches the whole app
324
+ // tree (a superset of these paths) and prints one warning per unwatchable
325
+ // path itself, so logging it here too would print every warning twice. The
326
+ // watcher keeps running either way.
327
+ const ignoreWatchError = () => {};
328
+
329
+ const sup = createSupervisor({
330
+ restartOnChange: plan.restartOnChange,
331
+ log,
332
+ spawnChild: () => {
333
+ const c = spawn(process.execPath, plan.args, {
334
+ // The IPC channel lets the child notice this process is gone and exit,
335
+ // so a killed supervisor never leaves an orphan holding the port.
336
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
337
+ cwd,
338
+ env,
339
+ });
340
+ // A failed spawn emits 'error' and may never emit 'exit'; report it as
341
+ // an exit so the backoff retries it (a repeated exit is ignored).
342
+ c.on('error', (err) => {
343
+ console.error(`[webjs] could not start the dev server: ${err.message}`);
344
+ c.emit('exit', 1, null);
345
+ });
346
+ return c;
347
+ },
348
+ });
349
+
350
+ const outputs = readRegenerateOutputs(cwd);
351
+ const closeWatch = watchRestartPaths(cwd, {
352
+ dirs: plan.watchDirs,
353
+ files: plan.watchFiles,
354
+ ignore: (rel) => shouldIgnoreRestartPath(rel) || outputs.has(rel.replace(/\\/g, '/')),
355
+ onChange: (rel) => sup.change(rel),
356
+ onError: ignoreWatchError,
357
+ });
358
+
359
+ let exiting = false;
360
+ const shutdown = (code) => {
361
+ if (exiting) return;
362
+ exiting = true;
363
+ closeWatch();
364
+ sup.stop().then(() => onExit(code));
365
+ };
366
+ process.on('SIGINT', () => shutdown(0));
367
+ process.on('SIGTERM', () => shutdown(0));
368
+ process.on('SIGHUP', () => shutdown(0));
369
+ // Last resort: a watcher error that escaped every listener (a runtime that
370
+ // emits it somewhere else) is never fatal, and the server child reports the
371
+ // same path itself. Anything else is a real
372
+ // supervisor bug, so it is reported and the process exits non-zero after
373
+ // stopping the child.
374
+ process.on('uncaughtException', (err) => {
375
+ if (isWatchError(err)) return;
376
+ console.error(err && err.stack ? err.stack : err);
377
+ shutdown(1);
378
+ });
379
+
380
+ sup.start();
381
+ return sup;
382
+ }