@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/README.md +1 -1
- package/bin/webjs.js +116 -11
- package/lib/audit.js +218 -0
- package/lib/browser-test-files.js +125 -0
- package/lib/create.js +57 -24
- package/lib/db-rewrite.js +137 -0
- package/lib/dev-reload.js +382 -0
- package/lib/dev-supervisor.js +43 -42
- package/lib/doctor/codes.js +1 -0
- package/lib/doctor/probes/workspace-overrides.js +79 -0
- package/lib/doctor/runner.js +2 -0
- package/lib/package-manager.js +93 -0
- package/package.json +2 -2
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +11 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +38 -0
- package/templates/.agents/skills/webjs/references/data-and-actions.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +1 -0
- package/templates/.agents/skills/webjs/references/runtime.md +7 -3
- package/templates/.agents/skills/webjs/references/testing.md +2 -0
- package/templates/.dockerignore +1 -1
- package/templates/Dockerfile +3 -3
- package/templates/compose.yaml +3 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
+
}
|