@webjsdev/cli 0.10.59 → 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/bin/webjs.js +19 -11
- package/lib/dev-reload.js +382 -0
- package/lib/dev-supervisor.js +43 -42
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/references/built-ins.md +6 -5
- 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/bin/webjs.js
CHANGED
|
@@ -500,6 +500,14 @@ async function main() {
|
|
|
500
500
|
// too, but that runs too late to affect the port the CLI computes.
|
|
501
501
|
loadAppEnv(process.cwd());
|
|
502
502
|
const port = resolvePort(flag(rest, '--port'));
|
|
503
|
+
// Exit with the supervisor (#1521): when its IPC channel closes, the
|
|
504
|
+
// parent is gone (killed outright, or crashed), and a child left
|
|
505
|
+
// running would hold the port against the next `webjs dev`. Unref'd
|
|
506
|
+
// so the channel itself never keeps this process alive.
|
|
507
|
+
if (process.connected) {
|
|
508
|
+
process.channel?.unref?.();
|
|
509
|
+
process.on('disconnect', () => process.exit(0));
|
|
510
|
+
}
|
|
503
511
|
await startServer({ appDir: process.cwd(), port, dev: true });
|
|
504
512
|
break;
|
|
505
513
|
}
|
|
@@ -520,22 +528,21 @@ async function main() {
|
|
|
520
528
|
loadAppEnv(process.cwd());
|
|
521
529
|
await runPhaseBeforeSteps('dev', devTasks.dev.before, process.cwd());
|
|
522
530
|
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
531
|
|
|
526
|
-
// Decide how to run: in-process (`--no-hot`), or
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
532
|
+
// Decide how to run: in-process (`--no-hot`), or in a child under WebJs's
|
|
533
|
+
// reload supervisor (#1521), which restarts it on a change on Node and
|
|
534
|
+
// runs it under `bun --hot` on Bun (#514), and brings a crashed child
|
|
535
|
+
// back on either. The branch logic lives in the pure `planDevSupervisor`
|
|
536
|
+
// so it is unit-testable without spawning a process.
|
|
531
537
|
const plan = planDevSupervisor({
|
|
532
538
|
isBun: !!process.versions.bun,
|
|
533
539
|
argv: process.argv.slice(1),
|
|
534
540
|
noHot: rest.includes('--no-hot'),
|
|
535
|
-
exists: (p) => existsSync(p),
|
|
536
541
|
});
|
|
537
542
|
|
|
538
543
|
if (plan.mode === 'inline') {
|
|
544
|
+
process.on('SIGINT', () => { killTasks(); process.exit(0); });
|
|
545
|
+
process.on('SIGTERM', () => { killTasks(); process.exit(0); });
|
|
539
546
|
const { startServer } = await import('@webjsdev/server');
|
|
540
547
|
loadAppEnv(process.cwd());
|
|
541
548
|
const port = resolvePort(flag(rest, '--port'));
|
|
@@ -544,12 +551,13 @@ async function main() {
|
|
|
544
551
|
break;
|
|
545
552
|
}
|
|
546
553
|
|
|
547
|
-
const
|
|
548
|
-
|
|
554
|
+
const { superviseDevServer } = await import('../lib/dev-reload.js');
|
|
555
|
+
superviseDevServer({
|
|
549
556
|
cwd: process.cwd(),
|
|
557
|
+
plan,
|
|
550
558
|
env: { ...process.env, __WEBJS_DEV_CHILD: '1' },
|
|
559
|
+
onExit: (code) => { killTasks(); process.exit(code); },
|
|
551
560
|
});
|
|
552
|
-
child.on('exit', (code) => { killTasks(); process.exit(code ?? 0); });
|
|
553
561
|
break;
|
|
554
562
|
}
|
|
555
563
|
case 'start': {
|
|
@@ -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
|
+
}
|
package/lib/dev-supervisor.js
CHANGED
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dev-server reload supervisor planning for `webjs dev` (
|
|
2
|
+
* Dev-server reload supervisor planning for `webjs dev` (issues #514, #1521).
|
|
3
3
|
*
|
|
4
|
-
* `webjs dev`
|
|
5
|
-
* an edit to a transitively-imported module (an action, query,
|
|
6
|
-
* takes effect without a manual restart. Both runtimes cache
|
|
7
|
-
* resolved URL with no public invalidation API, so the dev
|
|
8
|
-
* `@webjsdev/server`'s `dev.js` relies on
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
* @
|
|
35
|
-
*
|
|
36
|
-
* `
|
|
37
|
-
*
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.60",
|
|
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": {
|
|
@@ -22,12 +22,13 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
|
|
|
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
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` |
|
|
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); `0` turns off a config default. Same as `webjs.dev.sourceLocations: true`. 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). Replaces `webjs.dev.embedOrigins` when set. Ignored by `webjs start` |
|
|
27
|
+
| `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
28
|
|
|
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.
|
|
29
|
+
**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
30
|
|
|
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`
|
|
31
|
+
**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
32
|
|
|
32
33
|
```js
|
|
33
34
|
{ source: 'webjs-embed', type: 'ready', path, title } // document parsed
|
|
@@ -39,7 +40,7 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
|
|
|
39
40
|
{ source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height } } // a click in inspect mode
|
|
40
41
|
```
|
|
41
42
|
|
|
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.
|
|
43
|
+
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). Paths are app paths (`/api/x`, `/components/x.ts`). Unset adds zero bytes; `webjs start` never injects it nor relaxes a header.
|
|
43
44
|
|
|
44
45
|
Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
|
|
45
46
|
|
|
@@ -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,19 @@ 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 | `
|
|
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
|
|
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
|
|
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` 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. 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
48
|
|
|
45
49
|
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
50
|
|