@webjsdev/cli 0.10.60 → 0.10.62
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 +31 -7
- package/lib/check-target.js +66 -1
- package/lib/create.js +28 -4
- package/lib/dev-reload.js +34 -10
- package/lib/port.js +32 -0
- package/lib/runtime-rewrite.js +16 -1
- package/lib/watch-recursive.js +147 -0
- package/package.json +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +5 -3
- package/templates/.agents/skills/webjs/references/components.md +2 -2
- package/templates/.agents/skills/webjs/references/runtime.md +4 -2
package/bin/webjs.js
CHANGED
|
@@ -6,10 +6,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { resolveBin } from '../lib/resolve-bin.js';
|
|
7
7
|
import { dbGenerateTtyHint } from '../lib/db-hints.js';
|
|
8
8
|
import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
|
|
9
|
-
import { loadAppEnv, resolvePort } from '../lib/port.js';
|
|
9
|
+
import { loadAppEnv, resolvePort, failFastOnPortInUse } from '../lib/port.js';
|
|
10
10
|
import { planDevSupervisor } from '../lib/dev-supervisor.js';
|
|
11
11
|
import { checkAppName, appNameErrorMessage } from '../lib/app-name.js';
|
|
12
|
-
import { findCheckTarget, notAnAppMessage, notAnAppJson } from '../lib/check-target.js';
|
|
12
|
+
import { findCheckTarget, notAnAppMessage, notAnAppJson, findServeTarget, notAnAppServeMessage } from '../lib/check-target.js';
|
|
13
13
|
|
|
14
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
@@ -147,7 +147,7 @@ const USAGE = `webjs commands:
|
|
|
147
147
|
const HELP = {
|
|
148
148
|
dev: {
|
|
149
149
|
usage: 'webjs dev [--port <n>] [--no-hot]',
|
|
150
|
-
summary: 'Start the dev server with live reload (source is the runtime, no build step).',
|
|
150
|
+
summary: 'Start the dev server with live reload (source is the runtime, no build step). Run it in the app directory (the one holding app/); anywhere else it exits 1 naming the app to start.',
|
|
151
151
|
options: [
|
|
152
152
|
{ flag: '--port <n>', description: 'Port to listen on (else PORT, else 8080).' },
|
|
153
153
|
{ flag: '--no-hot', description: 'Run in-process, without the hot-reload supervisor.' },
|
|
@@ -156,7 +156,7 @@ const HELP = {
|
|
|
156
156
|
},
|
|
157
157
|
start: {
|
|
158
158
|
usage: 'webjs start [--port <n>]',
|
|
159
|
-
summary: 'Start the production server (serves source directly, plain HTTP/1.1).',
|
|
159
|
+
summary: 'Start the production server (serves source directly, plain HTTP/1.1). Exits 1 outside an app directory.',
|
|
160
160
|
options: [
|
|
161
161
|
{ flag: '--port <n>', description: 'Port to listen on (else PORT, else 8080).' },
|
|
162
162
|
],
|
|
@@ -442,6 +442,19 @@ async function startDevParallelTasks(commands, cwd) {
|
|
|
442
442
|
});
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Exit 1 with a clear message when `webjs dev` / `webjs start` runs where there
|
|
447
|
+
* is no `app/` directory (#1526), naming the app to start instead.
|
|
448
|
+
*
|
|
449
|
+
* @param {'dev' | 'start'} command
|
|
450
|
+
*/
|
|
451
|
+
async function refuseOutsideApp(command) {
|
|
452
|
+
const target = await findServeTarget(process.cwd());
|
|
453
|
+
if (target.isApp) return;
|
|
454
|
+
console.error(notAnAppServeMessage(command, process.cwd(), target));
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
|
|
445
458
|
async function main() {
|
|
446
459
|
// `--version` / `-v` (top level): print the installed CLI version and exit.
|
|
447
460
|
if (cmd === '--version' || cmd === '-v') {
|
|
@@ -480,6 +493,15 @@ async function main() {
|
|
|
480
493
|
// `ERR_MODULE_NOT_FOUND: Cannot find package '@webjsdev/core'`. Probe up front
|
|
481
494
|
// and surface the cause + remedy instead. No-op (a cheap resolve) when the
|
|
482
495
|
// framework resolves, so the happy-path boot is untouched.
|
|
496
|
+
// #1526: refuse to serve a directory that is not an app, FIRST: before the
|
|
497
|
+
// resolve probe (whose "run npm install" advice is wrong at a workspace
|
|
498
|
+
// root), any before-step (a `webjs db migrate` in the wrong directory), or a
|
|
499
|
+
// spawn. Started from a workspace root, the server used to boot, say it was
|
|
500
|
+
// ready, and answer 404 for every route. The dev child skips it: its parent
|
|
501
|
+
// checked the same cwd it spawned the child in.
|
|
502
|
+
if ((cmd === 'dev' || cmd === 'start') && process.env.__WEBJS_DEV_CHILD !== '1') {
|
|
503
|
+
await refuseOutsideApp(cmd);
|
|
504
|
+
}
|
|
483
505
|
if (cmd === 'dev' || cmd === 'start') {
|
|
484
506
|
const { checkFrameworkResolves } = await import('../lib/doctor.js');
|
|
485
507
|
const probe = checkFrameworkResolves(process.cwd());
|
|
@@ -508,7 +530,7 @@ async function main() {
|
|
|
508
530
|
process.channel?.unref?.();
|
|
509
531
|
process.on('disconnect', () => process.exit(0));
|
|
510
532
|
}
|
|
511
|
-
await startServer({ appDir: process.cwd(), port, dev: true });
|
|
533
|
+
await failFastOnPortInUse(startServer({ appDir: process.cwd(), port, dev: true }));
|
|
512
534
|
break;
|
|
513
535
|
}
|
|
514
536
|
|
|
@@ -546,7 +568,9 @@ async function main() {
|
|
|
546
568
|
const { startServer } = await import('@webjsdev/server');
|
|
547
569
|
loadAppEnv(process.cwd());
|
|
548
570
|
const port = resolvePort(flag(rest, '--port'));
|
|
549
|
-
await startServer({ appDir: process.cwd(), port, dev: true })
|
|
571
|
+
await failFastOnPortInUse(startServer({ appDir: process.cwd(), port, dev: true }), {
|
|
572
|
+
exit: (code) => { killTasks(); process.exit(code); },
|
|
573
|
+
});
|
|
550
574
|
killTasks();
|
|
551
575
|
break;
|
|
552
576
|
}
|
|
@@ -571,7 +595,7 @@ async function main() {
|
|
|
571
595
|
const { readAppTasks } = await import('../lib/app-tasks.js');
|
|
572
596
|
await runPhaseBeforeSteps('start', readAppTasks(process.cwd()).start.before, process.cwd());
|
|
573
597
|
const port = resolvePort(flag(rest, '--port'));
|
|
574
|
-
await startServer({ appDir: process.cwd(), port, dev: false });
|
|
598
|
+
await failFastOnPortInUse(startServer({ appDir: process.cwd(), port, dev: false }));
|
|
575
599
|
break;
|
|
576
600
|
}
|
|
577
601
|
case 'db': {
|
package/lib/check-target.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { statSync } from 'node:fs';
|
|
26
26
|
import { readFile, glob } from 'node:fs/promises';
|
|
27
|
-
import { join } from 'node:path';
|
|
27
|
+
import { join, dirname, relative } from 'node:path';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* @typedef {{ isApp: boolean, workspaceApps: string[] }} CheckTarget
|
|
@@ -96,6 +96,71 @@ export async function workspaceApps(cwd) {
|
|
|
96
96
|
return [...apps].sort();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The nearest STRICT ancestor of `cwd` that holds an `app/` directory, or
|
|
101
|
+
* `null`. Started from inside an app (its `app/` or `components/` dir), this is
|
|
102
|
+
* the app the user meant.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} cwd
|
|
105
|
+
* @returns {string | null}
|
|
106
|
+
*/
|
|
107
|
+
export function findAncestorApp(cwd) {
|
|
108
|
+
let dir = dirname(cwd);
|
|
109
|
+
for (;;) {
|
|
110
|
+
if (hasAppDir(dir)) return dir;
|
|
111
|
+
const up = dirname(dir);
|
|
112
|
+
if (up === dir) return null;
|
|
113
|
+
dir = up;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Where a server command (`webjs dev` / `webjs start`) that was started outside
|
|
119
|
+
* an app should have been started (#1526): the workspace's member apps when
|
|
120
|
+
* `cwd` is a workspace root, else the nearest ancestor app, else nothing.
|
|
121
|
+
* `isApp` is the same `app/` predicate `webjs check` refuses on (#1301).
|
|
122
|
+
*
|
|
123
|
+
* @param {string} cwd
|
|
124
|
+
* @returns {Promise<{ isApp: boolean, workspaceApps: string[], ancestorApp: string | null }>}
|
|
125
|
+
*/
|
|
126
|
+
export async function findServeTarget(cwd) {
|
|
127
|
+
const target = await findCheckTarget(cwd);
|
|
128
|
+
if (target.isApp) return { ...target, ancestorApp: null };
|
|
129
|
+
return { ...target, ancestorApp: target.workspaceApps.length ? null : findAncestorApp(cwd) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The refusal `webjs dev` / `webjs start` print when started where there is no
|
|
134
|
+
* app (#1526). Without it the server boots, says it is ready, and answers 404
|
|
135
|
+
* for every route, which is how a launch from a workspace root used to look.
|
|
136
|
+
*
|
|
137
|
+
* @param {'dev' | 'start'} command
|
|
138
|
+
* @param {string} cwd
|
|
139
|
+
* @param {{ workspaceApps: string[], ancestorApp: string | null }} target
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
export function notAnAppServeMessage(command, cwd, { workspaceApps, ancestorApp }) {
|
|
143
|
+
const lines = [
|
|
144
|
+
`webjs ${command}: this directory is not a WebJs app, so there is nothing to serve.`,
|
|
145
|
+
'',
|
|
146
|
+
` ${cwd}`,
|
|
147
|
+
'',
|
|
148
|
+
'There is no `app/` directory here. Started anyway, the server would answer',
|
|
149
|
+
'404 for every route.',
|
|
150
|
+
'',
|
|
151
|
+
];
|
|
152
|
+
if (workspaceApps.length) {
|
|
153
|
+
lines.push('This is a workspace root. Start the server inside the app:', '');
|
|
154
|
+
for (const app of workspaceApps) lines.push(` cd ${app} && webjs ${command}`);
|
|
155
|
+
} else if (ancestorApp) {
|
|
156
|
+
lines.push('This directory is inside an app. Start the server from the app root:', '');
|
|
157
|
+
lines.push(` cd ${relative(cwd, ancestorApp) || '.'} && webjs ${command}`);
|
|
158
|
+
} else {
|
|
159
|
+
lines.push('Change into your app directory (the one holding `app/`) and re-run.');
|
|
160
|
+
}
|
|
161
|
+
return lines.join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
99
164
|
/**
|
|
100
165
|
* The human refusal, for stderr.
|
|
101
166
|
*
|
package/lib/create.js
CHANGED
|
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
19
|
import { spawnSync } from 'node:child_process';
|
|
20
|
-
import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
|
|
20
|
+
import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi, bunifyEnvExample } from './runtime-rewrite.js';
|
|
21
21
|
import { postgresCompose, postgresCi } from './db-rewrite.js';
|
|
22
22
|
import { assertValidAppName, toDatabaseName } from './app-name.js';
|
|
23
23
|
import { isGalleryAppShellFile } from './gallery-shell-files.js';
|
|
@@ -745,6 +745,9 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
745
745
|
'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md',
|
|
746
746
|
'.agents/rules/workflow.md',
|
|
747
747
|
'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts',
|
|
748
|
+
// Comments that name a command (#1527): `npm run ci` in the hook, the
|
|
749
|
+
// Tailwind build note in the ignore file.
|
|
750
|
+
'.hooks/pre-commit', 'gitignore',
|
|
748
751
|
]);
|
|
749
752
|
// compose.yaml builds from the (pure oven/bun) Dockerfile and inherits its
|
|
750
753
|
// `bun --bun run start` CMD; only its healthcheck needs switching off node
|
|
@@ -753,6 +756,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
753
756
|
'Dockerfile': bunifyDockerfile,
|
|
754
757
|
'compose.yaml': bunifyCompose,
|
|
755
758
|
'.github/workflows/ci.yml': bunifyCi,
|
|
759
|
+
'.env.example': bunifyEnvExample,
|
|
756
760
|
};
|
|
757
761
|
// Database axis (#1490): the compose + CI templates are the SQLite shape, so
|
|
758
762
|
// a --db postgres app derives its variant (a Postgres service, DATABASE_URL
|
|
@@ -1240,7 +1244,13 @@ export type ActionResult<T> =
|
|
|
1240
1244
|
const clearScriptSrc = join(TEMPLATES, 'scripts', 'clear-gallery.mjs');
|
|
1241
1245
|
if (existsSync(clearScriptSrc)) {
|
|
1242
1246
|
await mkdir(join(appDir, 'scripts'), { recursive: true });
|
|
1243
|
-
|
|
1247
|
+
// A Bun app gets the Bun spelling of the ui-kit command the script
|
|
1248
|
+
// prints and writes into the reset layout (#1527): `bunx`, never `npx`.
|
|
1249
|
+
const clearScript = await readFile(clearScriptSrc, 'utf8');
|
|
1250
|
+
await writeFile(
|
|
1251
|
+
join(appDir, 'scripts', 'clear-gallery.mjs'),
|
|
1252
|
+
isBun ? clearScript.replaceAll('npx webjsdev ', 'bunx webjsdev ') : clearScript,
|
|
1253
|
+
);
|
|
1244
1254
|
}
|
|
1245
1255
|
|
|
1246
1256
|
// Fail loudly if the @webjsdev/ui registry sources aren't on disk.
|
|
@@ -1293,6 +1303,18 @@ ${uiThemeRaw}
|
|
|
1293
1303
|
--text-h1: clamp(2rem, 1.5rem + 1.6vw, 2.85rem);
|
|
1294
1304
|
--text-h2: clamp(1.35rem, 1.15rem + 0.7vw, 1.7rem);
|
|
1295
1305
|
--text-lede: clamp(1.05rem, 0.95rem + 0.3vw, 1.2rem);
|
|
1306
|
+
/* Each size carries its line height (and, for the large steps, its
|
|
1307
|
+
tracking). Tailwind v4's text-* utility sets
|
|
1308
|
+
line-height: var(--tw-leading, var(--text-<name>--line-height)), so a
|
|
1309
|
+
size with no companion leaves an invalid value and the heading falls
|
|
1310
|
+
back to the body's 1.6, opening huge gaps between wrapped lines.
|
|
1311
|
+
A leading-* / tracking-* class on the element still wins. */
|
|
1312
|
+
--text-display--line-height: 1.04;
|
|
1313
|
+
--text-display--letter-spacing: -0.025em;
|
|
1314
|
+
--text-h1--line-height: 1.1;
|
|
1315
|
+
--text-h1--letter-spacing: -0.02em;
|
|
1316
|
+
--text-h2--line-height: 1.2;
|
|
1317
|
+
--text-lede--line-height: 1.55;
|
|
1296
1318
|
--duration-fast: 140ms;
|
|
1297
1319
|
--duration-slow: 380ms;
|
|
1298
1320
|
}
|
|
@@ -1746,10 +1768,12 @@ ThemeToggle.register('theme-toggle');
|
|
|
1746
1768
|
// single-bin fallback resolves it to the `webjs` binary, so behaviour
|
|
1747
1769
|
// matches `@webjsdev/cli` exactly while keeping the command short
|
|
1748
1770
|
// and unambiguous.
|
|
1771
|
+
// A Bun app runs one-off binaries with `bunx` (#1527).
|
|
1772
|
+
const x = isBun ? 'bunx' : 'npx';
|
|
1749
1773
|
const uiNote = isApi
|
|
1750
1774
|
? `# If you later add a UI to this API project:
|
|
1751
|
-
#
|
|
1752
|
-
:
|
|
1775
|
+
# ${x} webjsdev ui init && ${x} webjsdev ui add button card dialog`
|
|
1776
|
+
: `${x} webjsdev ui add <name> # add more ui-* components later`;
|
|
1753
1777
|
console.log(`
|
|
1754
1778
|
Next steps:
|
|
1755
1779
|
${runCommand}
|
package/lib/dev-reload.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
22
|
import { watch, statSync, readFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
|
+
import { watchRecursive } from './watch-recursive.js';
|
|
25
|
+
import { PORT_IN_USE_EXIT_CODE } from './port.js';
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Quiet window between a file event and the restart. `node --watch` used
|
|
@@ -127,10 +129,14 @@ export function watchRestartPaths(cwd, { dirs, files, ignore, onChange, onError,
|
|
|
127
129
|
const watchDir = (name) => {
|
|
128
130
|
if (closed || watchers.has(name) || !isDir(name)) return;
|
|
129
131
|
try {
|
|
130
|
-
|
|
132
|
+
// `watchRecursive` (#1529): on Linux under Node a per-directory walker,
|
|
133
|
+
// since Node 24's recursive watcher goes deaf to a file once it is
|
|
134
|
+
// replaced (`sed -i`, an editor's atomic save), so a second edit to the
|
|
135
|
+
// same file never restarted the server.
|
|
136
|
+
watchers.set(name, guard(watchRecursive(join(cwd, name), (_type, filename) => {
|
|
131
137
|
const rel = filename ? join(name, String(filename)) : name;
|
|
132
138
|
if (!ignore(rel)) onChange(rel);
|
|
133
|
-
})));
|
|
139
|
+
}, { ignore: (rel) => ignore(join(name, rel)), watchFn })));
|
|
134
140
|
} catch (err) {
|
|
135
141
|
onError(/** @type {NodeJS.ErrnoException} */ (err));
|
|
136
142
|
}
|
|
@@ -197,6 +203,8 @@ export function watchRestartPaths(cwd, { dirs, files, ignore, onChange, onError,
|
|
|
197
203
|
* backoffMs?: number[],
|
|
198
204
|
* stableMs?: number,
|
|
199
205
|
* killTimeoutMs?: number,
|
|
206
|
+
* finalExitCodes?: number[],
|
|
207
|
+
* onFinal?: (code: number) => void,
|
|
200
208
|
* }} opts
|
|
201
209
|
*/
|
|
202
210
|
export function createSupervisor({
|
|
@@ -209,6 +217,10 @@ export function createSupervisor({
|
|
|
209
217
|
backoffMs = CRASH_BACKOFF_MS,
|
|
210
218
|
stableMs = STABLE_MS,
|
|
211
219
|
killTimeoutMs = KILL_TIMEOUT_MS,
|
|
220
|
+
// Exit codes a restart cannot fix (a taken port, #1527): the supervisor
|
|
221
|
+
// stops and reports instead of retrying on its backoff forever.
|
|
222
|
+
finalExitCodes = [PORT_IN_USE_EXIT_CODE],
|
|
223
|
+
onFinal = () => {},
|
|
212
224
|
}) {
|
|
213
225
|
/** @type {ChildLike | null} */
|
|
214
226
|
let child = null;
|
|
@@ -260,6 +272,12 @@ export function createSupervisor({
|
|
|
260
272
|
return;
|
|
261
273
|
}
|
|
262
274
|
// Exited on its own: a crash, a fatal boot error, or an outside kill.
|
|
275
|
+
if (!signal && code !== null && finalExitCodes.includes(code)) {
|
|
276
|
+
stopping = true;
|
|
277
|
+
debounceTimer = clear(debounceTimer);
|
|
278
|
+
onFinal(code);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
263
281
|
if (now() - startedAt >= stableMs) crashes = 0;
|
|
264
282
|
const delay = backoffMs[Math.min(crashes, backoffMs.length - 1)];
|
|
265
283
|
crashes++;
|
|
@@ -326,9 +344,22 @@ export function superviseDevServer({ cwd, plan, env, onExit }) {
|
|
|
326
344
|
// watcher keeps running either way.
|
|
327
345
|
const ignoreWatchError = () => {};
|
|
328
346
|
|
|
347
|
+
let exiting = false;
|
|
348
|
+
/** @type {() => void} */
|
|
349
|
+
let closeWatch = () => {};
|
|
350
|
+
const shutdown = (code) => {
|
|
351
|
+
if (exiting) return;
|
|
352
|
+
exiting = true;
|
|
353
|
+
closeWatch();
|
|
354
|
+
sup.stop().then(() => onExit(code));
|
|
355
|
+
};
|
|
356
|
+
|
|
329
357
|
const sup = createSupervisor({
|
|
330
358
|
restartOnChange: plan.restartOnChange,
|
|
331
359
|
log,
|
|
360
|
+
// The child already printed why (the port and its holder); stop with its
|
|
361
|
+
// code rather than restarting a server that can never bind.
|
|
362
|
+
onFinal: (code) => shutdown(code),
|
|
332
363
|
spawnChild: () => {
|
|
333
364
|
const c = spawn(process.execPath, plan.args, {
|
|
334
365
|
// The IPC channel lets the child notice this process is gone and exit,
|
|
@@ -348,7 +379,7 @@ export function superviseDevServer({ cwd, plan, env, onExit }) {
|
|
|
348
379
|
});
|
|
349
380
|
|
|
350
381
|
const outputs = readRegenerateOutputs(cwd);
|
|
351
|
-
|
|
382
|
+
closeWatch = watchRestartPaths(cwd, {
|
|
352
383
|
dirs: plan.watchDirs,
|
|
353
384
|
files: plan.watchFiles,
|
|
354
385
|
ignore: (rel) => shouldIgnoreRestartPath(rel) || outputs.has(rel.replace(/\\/g, '/')),
|
|
@@ -356,13 +387,6 @@ export function superviseDevServer({ cwd, plan, env, onExit }) {
|
|
|
356
387
|
onError: ignoreWatchError,
|
|
357
388
|
});
|
|
358
389
|
|
|
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
390
|
process.on('SIGINT', () => shutdown(0));
|
|
367
391
|
process.on('SIGTERM', () => shutdown(0));
|
|
368
392
|
process.on('SIGHUP', () => shutdown(0));
|
package/lib/port.js
CHANGED
|
@@ -58,3 +58,35 @@ export function resolvePort(portFlag, env = process.env) {
|
|
|
58
58
|
if (env.PORT) return Number(env.PORT);
|
|
59
59
|
return 8080;
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The exit code of a server that could not bind because its port is taken
|
|
64
|
+
* (#1527): Linux's EADDRINUSE errno. The dev supervisor treats it as final and
|
|
65
|
+
* stops instead of restarting a child that can never bind.
|
|
66
|
+
*/
|
|
67
|
+
export const PORT_IN_USE_EXIT_CODE = 98;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Await a server start; on a taken port print the server's message (it names
|
|
71
|
+
* the process holding the port) and exit with `PORT_IN_USE_EXIT_CODE`. Any
|
|
72
|
+
* other error propagates unchanged.
|
|
73
|
+
*
|
|
74
|
+
* @template T
|
|
75
|
+
* @param {Promise<T>} started
|
|
76
|
+
* @param {{ error?: (line: string) => void, exit?: (code: number) => never }} [io]
|
|
77
|
+
* @returns {Promise<T>}
|
|
78
|
+
*/
|
|
79
|
+
export async function failFastOnPortInUse(started, io = {}) {
|
|
80
|
+
const error = io.error || ((line) => console.error(line));
|
|
81
|
+
const exit = io.exit || ((code) => process.exit(code));
|
|
82
|
+
try {
|
|
83
|
+
return await started;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
const err = /** @type {any} */ (e);
|
|
86
|
+
if (err && err.code === 'EADDRINUSE') {
|
|
87
|
+
error(`[webjs] ${err.message || 'the port is already in use'}`);
|
|
88
|
+
return exit(PORT_IN_USE_EXIT_CODE);
|
|
89
|
+
}
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
}
|
package/lib/runtime-rewrite.js
CHANGED
|
@@ -161,7 +161,22 @@ export function bunifyDockerfile(s) {
|
|
|
161
161
|
* @returns {string}
|
|
162
162
|
*/
|
|
163
163
|
export function bunifyCompose(s) {
|
|
164
|
-
return s
|
|
164
|
+
return s
|
|
165
|
+
.replace('test: ["CMD", "node", "-e", "fetch(', 'test: ["CMD", "bun", "-e", "fetch(')
|
|
166
|
+
// The secret-generation hint (#1527): a Bun app may have no node at all.
|
|
167
|
+
.replaceAll('# Generate: node -e ', '# Generate: bun -e ');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Rewrite `.env.example` for Bun (#1527): its secret-generation hint runs
|
|
172
|
+
* `node -e`, which a Bun-only machine (the oven/bun image, a Bun-only dev box)
|
|
173
|
+
* does not have. `bun -e` runs the same one-liner.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} s
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
export function bunifyEnvExample(s) {
|
|
179
|
+
return s.replaceAll('# Generate: node -e ', '# Generate: bun -e ');
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
/**
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A recursive directory watcher that keeps hearing a file after it is
|
|
3
|
+
* replaced (#1529).
|
|
4
|
+
*
|
|
5
|
+
* KEEP IN SYNC: `packages/cli/lib/watch-recursive.js` and
|
|
6
|
+
* `packages/server/src/dev/watch-recursive.js` are byte-identical copies (the
|
|
7
|
+
* CLI does not depend on server internals); `packages/cli/test/dev-supervisor/
|
|
8
|
+
* watch-recursive.test.js` fails when they drift.
|
|
9
|
+
*
|
|
10
|
+
* Node 24's `fs.watch(dir, { recursive: true })` on Linux is implemented in JS
|
|
11
|
+
* (`internal/fs/recursive_watch`) with one inotify watch per FILE plus a map of
|
|
12
|
+
* the files it knows. `sed -i`, an editor's save through a temp file, and every
|
|
13
|
+
* atomic write REPLACE the file (a new inode renamed over the old name). The
|
|
14
|
+
* per-file watch dies with the old inode, and the new file is never watched
|
|
15
|
+
* again because its name is already in the map, so every later edit to that
|
|
16
|
+
* file is silent: no dev restart, no live reload. Node 26 fixed it, but WebJs
|
|
17
|
+
* supports Node 24.
|
|
18
|
+
*
|
|
19
|
+
* A plain `fs.watch` on a DIRECTORY reports a replaced child by name on every
|
|
20
|
+
* Node version, so on Linux under Node this watches each directory
|
|
21
|
+
* non-recursively (one inotify watch per directory, fewer than one per file),
|
|
22
|
+
* adds a watcher when a subdirectory appears, and drops one when it goes away.
|
|
23
|
+
* Everywhere else the native recursive watcher is used unchanged: macOS and
|
|
24
|
+
* Windows have a kernel-level recursive watch, and Bun's `bun --hot` reloads
|
|
25
|
+
* edits itself.
|
|
26
|
+
*
|
|
27
|
+
* @module watch-recursive
|
|
28
|
+
*/
|
|
29
|
+
import { watch, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
30
|
+
import { EventEmitter } from 'node:events';
|
|
31
|
+
import { join, sep } from 'node:path';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether this runtime needs the per-directory walker rather than the native
|
|
35
|
+
* recursive watcher.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ platform?: string, isBun?: boolean }} [env]
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export function needsDirWalker({ platform = process.platform, isBun = !!process.versions.bun } = {}) {
|
|
41
|
+
return platform === 'linux' && !isBun;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Watch `dir` and everything under it. The listener gets `(eventType,
|
|
46
|
+
* filename)` with `filename` relative to `dir`, like the native recursive
|
|
47
|
+
* watcher. The returned watcher emits `'error'` for any watch or scan failure
|
|
48
|
+
* (never throws for one) and has `close()`.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} dir
|
|
51
|
+
* @param {(eventType: string, filename: string | null) => void} listener
|
|
52
|
+
* @param {{
|
|
53
|
+
* ignore?: (relPath: string) => boolean,
|
|
54
|
+
* watchFn?: typeof watch,
|
|
55
|
+
* walker?: boolean,
|
|
56
|
+
* }} [opts] `ignore` skips a subdirectory (never walked, never watched) and
|
|
57
|
+
* its events; `watchFn` is injectable for tests; `walker` forces the mode.
|
|
58
|
+
* @returns {import('node:events').EventEmitter & { close: () => void }}
|
|
59
|
+
*/
|
|
60
|
+
export function watchRecursive(dir, listener, opts = {}) {
|
|
61
|
+
const { ignore = () => false, watchFn = watch, walker = needsDirWalker() } = opts;
|
|
62
|
+
if (!walker) return /** @type {any} */ (watchFn(dir, { recursive: true }, listener));
|
|
63
|
+
|
|
64
|
+
const out = /** @type {EventEmitter & { close: () => void }} */ (new EventEmitter());
|
|
65
|
+
/** @type {Map<string, import('node:fs').FSWatcher>} rel dir -> watcher */
|
|
66
|
+
const watchers = new Map();
|
|
67
|
+
/** @type {Map<string, string>} real path -> rel dir (a symlink loop guard) */
|
|
68
|
+
const reals = new Map();
|
|
69
|
+
let closed = false;
|
|
70
|
+
// Errors from the initial walk happen before the caller can attach an
|
|
71
|
+
// 'error' listener, so they are held and emitted on the next tick.
|
|
72
|
+
/** @type {unknown[] | null} */
|
|
73
|
+
let early = [];
|
|
74
|
+
|
|
75
|
+
const fail = (err) => {
|
|
76
|
+
if (closed) return;
|
|
77
|
+
if (early) early.push(err);
|
|
78
|
+
else if (out.listenerCount('error') > 0) out.emit('error', err);
|
|
79
|
+
};
|
|
80
|
+
const abs = (rel) => (rel ? join(dir, rel) : dir);
|
|
81
|
+
const isDir = (rel) => {
|
|
82
|
+
try { return statSync(abs(rel)).isDirectory(); } catch { return false; }
|
|
83
|
+
};
|
|
84
|
+
const within = (rel, root) => root === '' || rel === root || rel.startsWith(root + sep);
|
|
85
|
+
|
|
86
|
+
const drop = (root) => {
|
|
87
|
+
for (const [rel, w] of [...watchers]) {
|
|
88
|
+
if (!within(rel, root)) continue;
|
|
89
|
+
try { w.close(); } catch {}
|
|
90
|
+
watchers.delete(rel);
|
|
91
|
+
}
|
|
92
|
+
for (const [real, rel] of [...reals]) if (within(rel, root)) reals.delete(real);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const add = (rel) => {
|
|
96
|
+
if (closed || watchers.has(rel) || (rel && ignore(rel))) return;
|
|
97
|
+
let real;
|
|
98
|
+
try { real = realpathSync(abs(rel)); } catch (err) { if (!rel) fail(err); return; }
|
|
99
|
+
if (reals.has(real)) return;
|
|
100
|
+
/** @type {import('node:fs').FSWatcher} */
|
|
101
|
+
let w;
|
|
102
|
+
try {
|
|
103
|
+
w = watchFn(abs(rel), (type, name) => onEvent(rel, type, name));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
fail(err);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
w.on('error', (err) => {
|
|
109
|
+
// A directory removed while watched errors on some kernels; that is the
|
|
110
|
+
// removal, already reported through the parent, not a failure.
|
|
111
|
+
const gone = !isDir(rel);
|
|
112
|
+
drop(rel);
|
|
113
|
+
if (!gone) fail(err);
|
|
114
|
+
});
|
|
115
|
+
watchers.set(rel, w);
|
|
116
|
+
reals.set(real, rel);
|
|
117
|
+
let entries = [];
|
|
118
|
+
try { entries = readdirSync(abs(rel), { withFileTypes: true }); } catch (err) { fail(err); }
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
const child = rel ? join(rel, e.name) : e.name;
|
|
121
|
+
if (e.isDirectory() || (e.isSymbolicLink() && isDir(child))) add(child);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const onEvent = (rel, type, name) => {
|
|
126
|
+
if (closed) return;
|
|
127
|
+
if (name == null) { listener(type, rel || null); return; }
|
|
128
|
+
const child = rel ? join(rel, String(name)) : String(name);
|
|
129
|
+
if (ignore(child)) return;
|
|
130
|
+
// A subdirectory appeared (created, or renamed into place) or went away.
|
|
131
|
+
if (isDir(child)) add(child);
|
|
132
|
+
else if (watchers.has(child)) drop(child);
|
|
133
|
+
listener(type, child);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
out.close = () => {
|
|
137
|
+
closed = true;
|
|
138
|
+
drop('');
|
|
139
|
+
};
|
|
140
|
+
add('');
|
|
141
|
+
process.nextTick(() => {
|
|
142
|
+
const held = early || [];
|
|
143
|
+
early = null;
|
|
144
|
+
for (const err of held) fail(err);
|
|
145
|
+
});
|
|
146
|
+
return out;
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.62",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@webjsdev/mcp": "^0.1.0",
|
|
21
|
-
"@webjsdev/server": "^0.8.
|
|
21
|
+
"@webjsdev/server": "^0.8.73",
|
|
22
22
|
"@webjsdev/ui": "^0.3.15"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
@@ -21,7 +21,8 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
|
|
|
21
21
|
|---|---|
|
|
22
22
|
| `REDIS_URL` | When set, sessions, rate limit, and cache use Redis instead of memory |
|
|
23
23
|
| `SESSION_SECRET` / `AUTH_SECRET` | Session and auth signing (see `auth-and-sessions.md`) |
|
|
24
|
-
| `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080` |
|
|
24
|
+
| `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080`. A taken port fails fast: `webjs dev` / `webjs start` exit 98 with `port 8080 is already in use by PID <n> (<command>)`, on Node and Bun alike |
|
|
25
|
+
| `WEBJS_REUSE_PORT` | `1` lets several `webjs dev` / `webjs start` processes share one port (`SO_REUSEPORT`, Linux; the kernel balances connections across them). Off by default, so a second server on a taken port fails instead of silently taking a share of the requests |
|
|
25
26
|
| `WEBJS_SOURCE_LOCATIONS` | `webjs dev` only. `1` stamps `data-webjs-src="<app-relative-file>:<line>"` on the elements of the app's `html` templates (see below); `0` turns off a config default. Same as `webjs.dev.sourceLocations: true`. Ignored by `webjs start` |
|
|
26
27
|
| `WEBJS_EMBED_ORIGINS` | `webjs dev` only. Comma-separated parent origins (`https://builder.dev,http://localhost:8080`) allowed to frame the dev server and receive the embed bridge's messages (see below). Replaces `webjs.dev.embedOrigins` when set. Ignored by `webjs start` |
|
|
27
28
|
| `WEBJS_DEV_RELOAD_IDLE` | `webjs dev` only. Seconds of no edit and no interaction after which the live-reload stream closes so an idle host can sleep (`webjs.dev.reloadIdle`; this wins). Off by default; see `runtime.md` |
|
|
@@ -37,10 +38,11 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
|
|
|
37
38
|
{ source: 'webjs-embed', type: 'error', message, stack, file, line, column } // window error + unhandledrejection
|
|
38
39
|
{ source: 'webjs-embed', type: 'network', method, url, status, error? } // fetch/XHR status >= 500, or status 0 on failure
|
|
39
40
|
{ source: 'webjs-embed', type: 'server-error', kind, message, file, line, path } // the dev error overlay went up
|
|
40
|
-
{ source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height } } // a click in inspect mode
|
|
41
|
+
{ source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height }, shiftKey, metaKey, altKey, ctrlKey } // a click in inspect mode (modifiers for multi-select, #1532)
|
|
42
|
+
{ source: 'webjs-embed', type: 'hold', enabled } // acknowledges a host hold command (#1532)
|
|
41
43
|
```
|
|
42
44
|
|
|
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.
|
|
45
|
+
and accepts, only from `window.parent` on a listed origin: `{ source: 'webjs-embed-host', type: 'navigate', path }` (a local path; a soft navigation when the client router is on), `{ source: 'webjs-embed-host', type: 'reload' }`, `{ source: 'webjs-embed-host', type: 'resume' }` (reopens a live-reload stream closed by `webjs.dev.reloadIdle`; every host command does this too), and `{ source: 'webjs-embed-host', type: 'inspect', enabled: true | false }` (hover highlight plus click capture that posts `select`; `src` is the nearest `data-webjs-src`, so pair it with `WEBJS_SOURCE_LOCATIONS=1` for click-to-edit), and `{ source: 'webjs-embed-host', type: 'hold', enabled: true | false }` (#1532: while held, live-reload signals are collected instead of applied, so a builder's agent can edit many files without the preview flickering through each save; releasing applies ONE reload at the strongest verdict collected, or none when nothing changed; the hold is kept in `sessionStorage`, so a new document in the same tab starts held, and only the host's release ends it). A full dev reload or a host `reload` keeps the scroll position of the path it reloads (#1532). Paths are app paths (`/api/x`, `/components/x.ts`). Unset adds zero bytes; `webjs start` never injects it nor relaxes a header.
|
|
44
46
|
|
|
45
47
|
Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
|
|
46
48
|
|
|
@@ -185,9 +185,9 @@ For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type`
|
|
|
185
185
|
|
|
186
186
|
**A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
|
|
187
187
|
|
|
188
|
-
**An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value. The two readers also see the same attribute SET, not merely the same fallback: a `state: true` prop
|
|
188
|
+
**An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value. The two readers also see the same attribute SET, not merely the same fallback: a `state: true` prop and an attribute matching no declared property are ignored by both, and a camelCase attribute name reaches its prop on both through the lowercased alias, and both are handed a value whose HTML character references are already decoded.
|
|
189
189
|
|
|
190
|
-
**Writing attributes in markup.** Names are case-insensitive and the browser lowercases them while parsing, so
|
|
190
|
+
**Writing attributes in markup.** Names are case-insensitive and the browser lowercases them while parsing, so a camelCase prop answers to BOTH its kebab-case name (`user-name`, the one reflection writes) and its lowercased name (`username`, lit's default), which is what a camelCase attribute in markup (`userName="…"`) arrives as; all three spellings reach `userName` on both sides. Prefer kebab-case. A prop that renames its attribute answers to the new name ONLY, so `open: prop(Boolean, { attribute: 'is-open' })` is written `<my-el is-open>` and `<my-el open>` reaches nothing. Character references are decoded before the value is coerced, so `cfg="{"a":1}"` parses as the object it spells and `label="Tom & Jerry"` is `Tom & Jerry`; the legacy semicolon-less forms decode exactly where a browser decodes them (` ` at the end of a value is a non-breaking space, ` x` and ` =x` stay literal), and writing the semicolon avoids the question. A `state: true` prop takes an SSR value only through a `.prop=${value}` binding from the parent template, never from an attribute.
|
|
191
191
|
|
|
192
192
|
**Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
|
|
193
193
|
|
|
@@ -42,9 +42,11 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
|
|
|
42
42
|
|
|
43
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.
|
|
44
44
|
|
|
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.
|
|
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. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
|
|
46
46
|
|
|
47
|
-
**`webjs dev`
|
|
47
|
+
**`webjs dev` and `webjs start` serve the directory they are started in, and refuse anywhere else (#1526).** Run them in the app directory, the one holding `app/`. In a workspace (`apps/web` under a root `package.json` with `workspaces`) that is the member, even when the CLI is hoisted to the root `node_modules`: the hoisted bin and the Bun `--hot` child both keep the directory they were started in. Started where there is no `app/` (the workspace root, or a subdirectory such as `app/` itself), both exit 1 before any `before` step runs, naming the app to start (`cd apps/web && webjs dev`), instead of booting a server that answers 404 for every route.
|
|
48
|
+
|
|
49
|
+
**`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor does only the crash recovery, since `bun --hot` reloads edits in place. Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
|
|
48
50
|
|
|
49
51
|
The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
|
|
50
52
|
|