@webjsdev/server 0.8.71 → 0.8.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.71",
3
+ "version": "0.8.73",
4
4
  "type": "module",
5
5
  "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
@@ -360,6 +360,10 @@ const RELOAD_WORKER_SRC = readFileSync(new URL('../dev-reload-worker.js', import
360
360
  const DEV_STYLES_SRC = readFileSync(new URL('../dev-styles.js', import.meta.url), 'utf8')
361
361
  .replace(/^export /gm, '');
362
362
 
363
+ // The embed host's reload hold (#1532), same shared-source rule.
364
+ const DEV_RELOAD_HOLD_SRC = readFileSync(new URL('../dev-reload-hold.js', import.meta.url), 'utf8')
365
+ .replace(/^export /gm, '');
366
+
363
367
  /**
364
368
  * The dev live-reload client. The `EventSource` URL is a framework-emitted
365
369
  * same-origin path, so it must carry the base path under a sub-path deploy
@@ -419,6 +423,12 @@ export function reloadClientJs(bp) {
419
423
  ${DEV_OVERLAY_SRC}
420
424
  ${RELOAD_WORKER_SRC}
421
425
  ${DEV_STYLES_SRC}
426
+ ${DEV_RELOAD_HOLD_SRC}
427
+ // A framing host holds reloads while its agent builds (#1532): the embed bridge
428
+ // sets __webjsEmbedHold, every signal below is kept (strongest verdict wins),
429
+ // and releasing the hold applies that one reload, or none if nothing changed.
430
+ var __webjsHold = createReloadHold(function (v) { __webjsApplyReload(v); }, function () { return globalThis.__webjsEmbedHold === true; });
431
+ globalThis.__webjsDevReleaseHold = function () { __webjsHold.release(); };
422
432
  function __webjsApplyError(data) {
423
433
  let f; try { f = JSON.parse(data); } catch (_) { return; }
424
434
  renderDevOverlay(f);
@@ -453,6 +463,7 @@ function __webjsWhenReady(then) {
453
463
  // survive. Anything else, including every signal the server could not classify,
454
464
  // is the full reload this always was.
455
465
  function __webjsApplyReload(verdict) {
466
+ if (__webjsHold.offer(verdict)) return;
456
467
  __webjsWhenReady(function () {
457
468
  // Runtime feature detection, never an assumption. The refresh entry is
458
469
  // published by enableClientRouter and removed by disableClientRouter, so its
package/src/dev/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { strongerVerdict } from '../dev-classify.js';
3
- import { watch as fsWatch } from 'node:fs/promises';
3
+ import { watchTree } from './watch-tree.js';
4
4
  import { relative } from 'node:path';
5
5
  import { createServer as createHttp1Server } from 'node:http';
6
6
  import { createRequestHandler } from './handler.js';
@@ -13,6 +13,7 @@ import { stripBasePath } from '../base-path.js';
13
13
  import { basePath } from '../importmap.js';
14
14
  import { attachWebSocket } from '../websocket.js';
15
15
  import { toWebRequest, sendWebResponse } from './helpers.js';
16
+ import { isAddrInUse, portInUseError, reusePortRequested } from '../port-in-use.js';
16
17
 
17
18
  function debounce(fn, ms) {
18
19
  let t;
@@ -80,7 +81,7 @@ function makeHttpServer(handler) {
80
81
  * @param {import('../listener-types.js').ListenerContext} ctx
81
82
  * @returns {{ server: import('node:http').Server, close: () => Promise<void> }}
82
83
  */
83
- function startNodeListener(ctx) {
84
+ async function startNodeListener(ctx) {
84
85
  const { app, dev, compress, logger, hub, port, basePathStr, timeouts, watcherAbort } = ctx;
85
86
 
86
87
  const server = makeHttpServer(async (req, res) => {
@@ -171,14 +172,25 @@ function startNodeListener(ctx) {
171
172
  // WebSocket endpoint at its URL.
172
173
  attachWebSocket(server, () => app.getRouteTable(), { dev, logger });
173
174
 
174
- server.listen(port, () => {
175
- logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
176
- // The server is now accepting connections; warm the first-request analysis
177
- // in the background so a real first request finds it memoized. Fire-and-
178
- // forget: listening (and thus readiness probes / load-balancer health) does
179
- // not wait on it, and a failure here does not bring the process down.
180
- app.warmup();
175
+ // Listening is awaited, so a bind failure rejects startServer instead of
176
+ // surfacing as an unhandled 'error' event. A taken port fails fast with the
177
+ // holder named (#1527); WEBJS_REUSE_PORT=1 shares the port on purpose
178
+ // (SO_REUSEPORT, honoured on Linux by Node 22.12+ / 23.1+).
179
+ await new Promise((resolve, reject) => {
180
+ const onError = (/** @type {NodeJS.ErrnoException} */ e) => reject(isAddrInUse(e) ? portInUseError(port, e) : e);
181
+ server.once('error', onError);
182
+ const options = reusePortRequested() ? { port, reusePort: true } : { port };
183
+ server.listen(options, () => {
184
+ server.off('error', onError);
185
+ resolve(undefined);
186
+ });
181
187
  });
188
+ logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
189
+ // The server is now accepting connections; warm the first-request analysis
190
+ // in the background so a real first request finds it memoized. Fire-and-
191
+ // forget: listening (and thus readiness probes / load-balancer health) does
192
+ // not wait on it, and a failure here does not bring the process down.
193
+ app.warmup();
182
194
 
183
195
  const closeServer = () => new Promise((resolve, reject) => {
184
196
  server.close((err) => (err ? reject(err) : resolve(undefined)));
@@ -190,7 +202,7 @@ function startNodeListener(ctx) {
190
202
  // Catch-all process handlers: log, but don't tear the process down on a
191
203
  // single mishandled promise. Uncaught exceptions are different: state may be
192
204
  // corrupted, so log + start an orderly shutdown rather than continuing.
193
- installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
205
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }), { dev });
194
206
 
195
207
  return {
196
208
  server,
@@ -252,11 +264,12 @@ export async function startServer(opts) {
252
264
  /** @type {AbortController | null} */
253
265
  let watcherAbort = null;
254
266
  if (dev) {
255
- // Watch the app root recursively via Node's built-in
256
- // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
257
- // Node 24. No external dep needed.
267
+ // Watch the app tree with Node's built-in `fs.watch` (no external dep):
268
+ // `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
269
+ // so `node_modules` is never walked, and handles a per-path watch error
270
+ // (EACCES, ENOENT) with a warning instead of an uncaughtException.
258
271
  //
259
- // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
272
+ // Paths are reported relative to the watched root. `shouldIgnoreWatchPath`
260
273
  // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
261
274
  // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
262
275
  // Live-reload classification (#1398). Several files can change inside one
@@ -273,26 +286,32 @@ export async function startServer(opts) {
273
286
  app.rebuild(v || undefined);
274
287
  }, 80);
275
288
  watcherAbort = new AbortController();
276
- const watchRoot = async (root) => {
277
- try {
278
- const events = fsWatch(root, { recursive: true, signal: watcherAbort.signal });
279
- for await (const event of events) {
280
- const filename = event.filename || '';
281
- if (shouldIgnoreWatchPath(filename)) continue;
289
+ // One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
290
+ // file must never stop the dev server, and a burst of the same temp file
291
+ // must not flood the log either.
292
+ const warned = new Set();
293
+ const onWatchError = (err) => {
294
+ const key = `${err && err.code}:${err && err.path}`;
295
+ if (warned.has(key) || warned.size > 200) return;
296
+ warned.add(key);
297
+ logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
298
+ };
299
+ const watchRoot = (root) => {
300
+ const close = watchTree(root, {
301
+ ignore: shouldIgnoreWatchPath,
302
+ onEvent: (filename) => {
282
303
  // A regenerate output (#967) is a build product the server itself
283
304
  // writes on request; ignoring it stops a spurious rebuild + reload
284
305
  // (and a reload -> refetch -> recompile -> reload cycle), same as the
285
306
  // db/dev.db carve-out above. `app` exposes the check because `state`
286
307
  // lives in createRequestHandler's scope, not here.
287
- if (app.isRegenerateOutput(filename)) continue;
308
+ if (app.isRegenerateOutput(filename)) return;
288
309
  pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
289
310
  rebuild();
290
- }
291
- } catch (err) {
292
- if (err && /** @type any */(err).name !== 'AbortError') {
293
- logger.warn({ err }, `file watcher exited (${root})`);
294
- }
295
- }
311
+ },
312
+ onError: onWatchError,
313
+ });
314
+ watcherAbort.signal.addEventListener('abort', close, { once: true });
296
315
  };
297
316
  watchRoot(app.appDir);
298
317
  // Extra roots the app reads from OUTSIDE its appDir (#894): repo-root content
@@ -321,9 +340,18 @@ export async function startServer(opts) {
321
340
  // skips the node:http bridge (toWebRequest/sendWebResponse) for ~1.9x more
322
341
  // req/s on the listening path (#511); the Bun shell is dynamically imported so
323
342
  // the `Bun.*` global is never referenced on Node.
324
- if (serverRuntime() === 'bun') {
325
- const { startBunListener } = await import('../listener-bun.js');
326
- return startBunListener(ctx);
343
+ try {
344
+ if (serverRuntime() === 'bun') {
345
+ const { startBunListener } = await import('../listener-bun.js');
346
+ return startBunListener(ctx);
347
+ }
348
+ return await startNodeListener(ctx);
349
+ } catch (e) {
350
+ // The listener never came up (a taken port, #1527): stop what was started
351
+ // for it, so an embedding caller that catches this is not left with a
352
+ // live file watcher keeping the process alive.
353
+ if (watcherAbort) watcherAbort.abort();
354
+ hub.closeAll();
355
+ throw e;
327
356
  }
328
- return startNodeListener(ctx);
329
357
  }
@@ -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
+ }
@@ -0,0 +1,108 @@
1
+ import { watch, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { watchRecursive } from './watch-recursive.js';
4
+
5
+ /**
6
+ * The dev live-reload watcher over one root (#1521).
7
+ *
8
+ * Two problems with the plain `fs.promises.watch(root, { recursive: true })`
9
+ * this replaces, both on Node 24's JS-implemented recursive watcher on Linux
10
+ * (`internal/fs/recursive_watch`), which opens one `fs.watch` per FILE:
11
+ *
12
+ * 1. A per-file failure (EACCES on a `sed -i` temp file another user created
13
+ * with mode 0600, ENOENT on a file that vanished between the directory event
14
+ * and the watch call) is emitted as `'error'` on an internal watcher the
15
+ * promise API never listens to, so it surfaces as an uncaughtException and
16
+ * the dev server shuts down. The callback form takes an `'error'` listener,
17
+ * and the watcher keeps delivering events for every other file after it.
18
+ * 2. Watching the app ROOT walks `node_modules` too: about 10k per-file watches
19
+ * and roughly 275ms of synchronous work on every boot of a scaffolded app,
20
+ * all for events the ignore filter then drops. So the root is watched
21
+ * non-recursively and each top-level directory that is not ignored gets its
22
+ * own recursive watcher.
23
+ *
24
+ * A top-level directory created or removed after start is picked up from the
25
+ * root watcher's events, so `mkdir lib` mid-session is watched like it was
26
+ * there at boot.
27
+ *
28
+ * @param {string} root absolute directory to watch
29
+ * @param {{
30
+ * ignore: (relPath: string) => boolean,
31
+ * onEvent: (relPath: string) => void,
32
+ * onError: (err: NodeJS.ErrnoException) => void,
33
+ * }} opts `relPath` is relative to `root`, with OS separators.
34
+ * @returns {() => void} closes every watcher
35
+ */
36
+ export function watchTree(root, { ignore, onEvent, onError }) {
37
+ /** @type {Map<string, import('node:fs').FSWatcher>} */
38
+ const dirs = new Map();
39
+ let closed = false;
40
+
41
+ const guard = (/** @type {import('node:fs').FSWatcher} */ w) => {
42
+ w.on('error', (err) => onError(/** @type {NodeJS.ErrnoException} */ (err)));
43
+ return w;
44
+ };
45
+
46
+ const isDir = (name) => {
47
+ try { return statSync(join(root, name)).isDirectory(); } catch { return false; }
48
+ };
49
+
50
+ const watchDir = (name) => {
51
+ if (closed || dirs.has(name) || ignore(name)) return;
52
+ try {
53
+ // `watchRecursive` (#1529): on Linux under Node a per-directory walker,
54
+ // since Node 24's recursive watcher goes deaf to a file once it is
55
+ // replaced (`sed -i`, an editor's atomic save).
56
+ const w = guard(watchRecursive(join(root, name), (_type, filename) => {
57
+ const rel = filename ? join(name, String(filename)) : name;
58
+ if (!ignore(rel)) onEvent(rel);
59
+ }, { ignore: (rel) => ignore(join(name, rel)) }));
60
+ dirs.set(name, w);
61
+ } catch (err) {
62
+ onError(/** @type {NodeJS.ErrnoException} */ (err));
63
+ }
64
+ };
65
+
66
+ /** @type {import('node:fs').FSWatcher | null} */
67
+ let top = null;
68
+ try {
69
+ top = guard(watch(root, (_type, filename) => {
70
+ if (!filename) return;
71
+ const name = String(filename);
72
+ if (ignore(name)) return;
73
+ // A top-level entry changed: start or stop following it as a directory.
74
+ if (isDir(name)) watchDir(name);
75
+ else if (dirs.has(name)) { try { dirs.get(name)?.close(); } catch {} dirs.delete(name); }
76
+ onEvent(name);
77
+ }));
78
+ } catch (err) {
79
+ onError(/** @type {NodeJS.ErrnoException} */ (err));
80
+ }
81
+
82
+ let entries = [];
83
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch {}
84
+ for (const e of entries) {
85
+ if (e.isDirectory() || (e.isSymbolicLink() && isDir(e.name))) watchDir(e.name);
86
+ }
87
+
88
+ return () => {
89
+ closed = true;
90
+ try { top?.close(); } catch {}
91
+ for (const w of dirs.values()) { try { w.close(); } catch {} }
92
+ dirs.clear();
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Whether an error came from a file watcher (#1521). Node marks every
98
+ * `fs.watch` failure with `syscall: 'watch'` (EACCES, EPERM, ENOENT, ELOOP, and
99
+ * ENOSPC when the inotify limit is reached). One unwatchable path is never a
100
+ * reason to stop a dev server, so the dev process handlers log these and keep
101
+ * serving.
102
+ *
103
+ * @param {unknown} err
104
+ * @returns {boolean}
105
+ */
106
+ export function isWatchError(err) {
107
+ return !!err && typeof err === 'object' && /** @type {any} */ (err).syscall === 'watch';
108
+ }
@@ -266,6 +266,11 @@ export function installEmbedBridge(origins, opts) {
266
266
  tag: picked.localName,
267
267
  text: (picked.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
268
268
  rect: rectOf(picked),
269
+ // Modifier keys (#1532), so a host can multi-select (shift/cmd-click).
270
+ shiftKey: !!e.shiftKey,
271
+ metaKey: !!e.metaKey,
272
+ altKey: !!e.altKey,
273
+ ctrlKey: !!e.ctrlKey,
269
274
  });
270
275
  }
271
276
  function setInspect(on) {
@@ -291,6 +296,57 @@ export function installEmbedBridge(origins, opts) {
291
296
  }
292
297
  cleanups.push(function () { setInspect(false); });
293
298
 
299
+ // Reload hold (#1532). While the host holds, the reload client keeps every
300
+ // live-reload signal instead of applying it (dev-reload-hold.js), and the
301
+ // release applies the one strongest reload, or none. The switch is a page
302
+ // global because the reload client is a separate script, and it is kept in
303
+ // sessionStorage so a new document in this tab (a full navigation or a host
304
+ // `reload` mid-build) starts held too.
305
+ var HOLD_KEY = 'webjs-embed-hold';
306
+ function store() {
307
+ try { return win.sessionStorage || null; } catch (_) { return null; }
308
+ }
309
+ function setHold(on) {
310
+ win.__webjsEmbedHold = on;
311
+ var s = store();
312
+ try { if (s) { if (on) s.setItem(HOLD_KEY, '1'); else s.removeItem(HOLD_KEY); } } catch (_) { /* storage blocked */ }
313
+ if (!on && typeof win.__webjsDevReleaseHold === 'function') {
314
+ try { win.__webjsDevReleaseHold(); } catch (_) { /* no reload client */ }
315
+ }
316
+ post('hold', { enabled: on });
317
+ }
318
+ try { if (store() && store().getItem(HOLD_KEY) === '1') win.__webjsEmbedHold = true; } catch (_) { /* storage blocked */ }
319
+ cleanups.push(function () { win.__webjsEmbedHold = false; });
320
+
321
+ // Scroll restore across a reload (#1532). A full dev reload, or the host's
322
+ // `reload`, would otherwise land at the top. The position is saved per path
323
+ // on pagehide and put back when the next document is a RELOAD of the same
324
+ // path: at once, after parsing, and after load (late images move layout),
325
+ // unless the user scrolls first.
326
+ var SCROLL_KEY = 'webjs-embed-scroll';
327
+ listen(win, 'pagehide', function () {
328
+ var s = store();
329
+ try { if (s) s.setItem(SCROLL_KEY, JSON.stringify({ path: here(), x: win.scrollX, y: win.scrollY })); } catch (_) { /* storage blocked */ }
330
+ });
331
+ (function restoreScroll() {
332
+ var s = store();
333
+ var saved = null;
334
+ try {
335
+ if (s) { saved = JSON.parse(s.getItem(SCROLL_KEY) || 'null'); s.removeItem(SCROLL_KEY); }
336
+ } catch (_) { saved = null; }
337
+ if (!saved || saved.path !== here() || typeof saved.y !== 'number') return;
338
+ var nav = null;
339
+ try { nav = win.performance.getEntriesByType('navigation')[0]; } catch (_) { /* no timing api */ }
340
+ if (!nav || nav.type !== 'reload') return;
341
+ var userMoved = false;
342
+ function moved() { userMoved = true; }
343
+ function put() { if (!userMoved) win.scrollTo(saved.x || 0, saved.y); }
344
+ ['wheel', 'touchstart', 'keydown', 'pointerdown'].forEach(function (t) { listen(win, t, moved, true); });
345
+ put();
346
+ if (doc.readyState === 'loading') listen(doc, 'DOMContentLoaded', put);
347
+ if (doc.readyState !== 'complete') listen(win, 'load', put);
348
+ })();
349
+
294
350
  // Host commands. Only from the parent window, only from an allowed origin.
295
351
  listen(win, 'message', function (e) {
296
352
  if (!e || e.source !== parent || origins.indexOf(e.origin) === -1) return;
@@ -322,6 +378,8 @@ export function installEmbedBridge(origins, opts) {
322
378
  a.remove();
323
379
  } else if (d.type === 'inspect') {
324
380
  setInspect(d.enabled === true);
381
+ } else if (d.type === 'hold') {
382
+ setHold(d.enabled === true);
325
383
  }
326
384
  // { type: 'resume' } needs nothing beyond the activity call above.
327
385
  });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The dev live-reload HOLD (#1532), shared source.
3
+ *
4
+ * A tool that frames a WebJs app (an AI app builder) pauses live reload while
5
+ * its agent is mid-build, so the preview does not flicker through every
6
+ * intermediate save, and releases it when the build is done. The embed bridge
7
+ * (`dev-embed-client.js`) owns the switch, the `__webjsEmbedHold` page global,
8
+ * set from the host's `{ type: 'hold', enabled }` command. The reload client
9
+ * routes every reload signal through this module: while held it keeps only the
10
+ * STRONGEST verdict seen, and on release it applies that one reload, or nothing
11
+ * when nothing changed.
12
+ *
13
+ * Import-free and inlined verbatim (`export` stripped) into the served reload
14
+ * client by `reloadClientJs` in `dev/helpers.js`, the same shared-source rule as
15
+ * `dev-styles.js`, so the unit test drives the code that ships.
16
+ */
17
+
18
+ /**
19
+ * Verdict strength, strongest first. Restated rather than imported from
20
+ * `dev-reload-worker.js` because this file is inlined on its own.
21
+ */
22
+ var HOLD_STRENGTH = ['reload', 'shell', 'page'];
23
+
24
+ /**
25
+ * The stronger of two verdicts. Anything that is not a known verdict is a full
26
+ * `reload`, the fail-safe every other reload path uses.
27
+ *
28
+ * @param {string | null} a the verdict held so far (null = none)
29
+ * @param {unknown} b the incoming verdict
30
+ * @returns {string}
31
+ */
32
+ export function strongerHeldVerdict(a, b) {
33
+ var ib = HOLD_STRENGTH.indexOf(/** @type {string} */ (b));
34
+ if (ib === -1) ib = 0;
35
+ if (a === null) return HOLD_STRENGTH[ib];
36
+ var ia = HOLD_STRENGTH.indexOf(a);
37
+ if (ia === -1) ia = 0;
38
+ return HOLD_STRENGTH[Math.min(ia, ib)];
39
+ }
40
+
41
+ /**
42
+ * Build the hold gate for one page.
43
+ *
44
+ * @param {(verdict: string) => void} apply applies a reload (the client's own apply)
45
+ * @param {() => boolean} isHeld whether the host currently holds reloads
46
+ * @returns {{ offer: (verdict: unknown) => boolean, release: () => void, pending: () => string | null }}
47
+ * `offer` returns true when it kept the signal (held), false when the caller
48
+ * should apply it now; `release` applies the one pending reload once the hold
49
+ * is off.
50
+ */
51
+ export function createReloadHold(apply, isHeld) {
52
+ /** @type {string | null} */
53
+ var held = null;
54
+ return {
55
+ offer: function (verdict) {
56
+ if (!isHeld()) return false;
57
+ held = strongerHeldVerdict(held, verdict);
58
+ return true;
59
+ },
60
+ release: function () {
61
+ if (isHeld() || held === null) return;
62
+ var v = held;
63
+ held = null;
64
+ apply(v);
65
+ },
66
+ pending: function () { return held; },
67
+ };
68
+ }
@@ -48,6 +48,7 @@ import { matchApi } from './router.js';
48
48
  import { registerClient } from './broadcast.js';
49
49
  import { setTrustedRemoteIp, propagateTrustedRemoteIp } from './rate-limit.js';
50
50
  import { applyForwarded } from './forwarded.js';
51
+ import { isAddrInUse, portInUseError, reusePortRequested } from './port-in-use.js';
51
52
  import {
52
53
  isCompressible,
53
54
  isEventsPath,
@@ -72,8 +73,16 @@ import {
72
73
  export function startBunListener(ctx) {
73
74
  const { app, dev, compress, logger, hub, port, basePathStr, timeouts, watcherAbort } = ctx;
74
75
 
75
- const server = Bun.serve({
76
+ /** @type {any} */
77
+ let server;
78
+ try {
79
+ server = Bun.serve({
76
80
  port,
81
+ // Explicit, never Bun's default (#1527): with `development: false` Bun
82
+ // turns SO_REUSEPORT on, so a second server on a taken port bound silently
83
+ // and the kernel split the requests between the two. Off unless the
84
+ // operator asks for a shared port with WEBJS_REUSE_PORT=1.
85
+ reusePort: reusePortRequested(),
77
86
  idleTimeout: bunIdleTimeout(timeouts),
78
87
  // WebJs owns its dev error overlay (over SSE) and handles thrown errors in
79
88
  // `fetch`, so Bun's own development error page must never interfere; keep it
@@ -145,6 +154,12 @@ export function startBunListener(ctx) {
145
154
  },
146
155
  },
147
156
  });
157
+ } catch (e) {
158
+ // A taken port fails fast with the holder named (#1527); the CLI prints
159
+ // it and exits instead of retrying.
160
+ if (isAddrInUse(e)) throw portInUseError(port, e);
161
+ throw e;
162
+ }
148
163
 
149
164
  logger.info(
150
165
  `webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${server.port} (Bun ${process.versions.bun})`,
@@ -157,7 +172,7 @@ export function startBunListener(ctx) {
157
172
  const shutdown = makeShutdown({ closeServer, hub, logger });
158
173
  process.once('SIGINT', () => shutdown('SIGINT'));
159
174
  process.once('SIGTERM', () => shutdown('SIGTERM'));
160
- installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
175
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }), { dev });
161
176
 
162
177
  return {
163
178
  server,
@@ -23,6 +23,7 @@ import {
23
23
  } from 'node:zlib';
24
24
  import { stripBasePath } from './base-path.js';
25
25
  import { randomUUID } from 'node:crypto';
26
+ import { isWatchError } from './dev/watch-tree.js';
26
27
 
27
28
  /** The dev live-reload SSE path (matched after base-path stripping). */
28
29
  export const EVENTS_PATH = '/__webjs/events';
@@ -416,10 +417,18 @@ export class SseHub {
416
417
  /**
417
418
  * Install once-only process error handlers. Idempotent across multiple
418
419
  * `startServer` calls in the same process. Runtime-neutral (plain `process.on`).
420
+ *
421
+ * In dev, an uncaught file-watcher error (`syscall: 'watch'`, #1521) is logged
422
+ * as a warning and NOT fatal: one unreadable or vanished file in a watched dir
423
+ * says nothing about the server's state, and shutting down over it is how a
424
+ * `sed -i` from another user took a whole dev preview down. Prod keeps the
425
+ * strict contract, every uncaught exception is fatal.
426
+ *
419
427
  * @param {import('./logger.js').Logger} logger
420
428
  * @param {() => void} onFatal Starts the FATAL shutdown, which exits non-zero.
429
+ * @param {{ dev?: boolean }} [opts]
421
430
  */
422
- export function installProcessHandlers(logger, onFatal) {
431
+ export function installProcessHandlers(logger, onFatal, { dev = false } = {}) {
423
432
  if (/** @type any */ (globalThis).__webjsProcHandlers) return;
424
433
  /** @type any */ (globalThis).__webjsProcHandlers = true;
425
434
  process.on('unhandledRejection', (reason) => {
@@ -428,6 +437,10 @@ export function installProcessHandlers(logger, onFatal) {
428
437
  });
429
438
  });
430
439
  process.on('uncaughtException', (err) => {
440
+ if (dev && isWatchError(err)) {
441
+ logger.warn(`file watcher skipped ${/** @type any */ (err).path || 'a path'} (${/** @type any */ (err).code || err.message})`);
442
+ return;
443
+ }
431
444
  logger.error('uncaughtException', { err: err.stack || err.message });
432
445
  // Begin orderly shutdown; process state may be corrupt.
433
446
  try { onFatal(); } catch {}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * A taken port fails the server fast, with the process that holds it (#1527).
3
+ *
4
+ * Two runtimes, two failure shapes for the same mistake (a second `webjs dev`
5
+ * on a port another server already listens on):
6
+ *
7
+ * - **Bun** turns `SO_REUSEPORT` on by default for `Bun.serve({ development:
8
+ * false })`, which the Bun listener passes. The second server then binds
9
+ * SILENTLY and the kernel splits connections between the two processes, so a
10
+ * stale server keeps answering a share of the requests with old code. The
11
+ * listener now passes `reusePort` explicitly (`reusePortRequested()`), so the
12
+ * bind fails with `EADDRINUSE` like it does on Node.
13
+ * - **Node** emitted `EADDRINUSE` as an unhandled `'error'` on the server,
14
+ * which crashed the process with a raw stack, and the dev supervisor then
15
+ * restarted it on its backoff forever.
16
+ *
17
+ * Both listeners now throw `portInUseError()`: an `EADDRINUSE` error whose
18
+ * message names the holder. The CLI prints it and exits with
19
+ * `PORT_IN_USE_EXIT_CODE`, which the dev supervisor treats as final.
20
+ *
21
+ * Sharing a port is still possible on purpose: `WEBJS_REUSE_PORT=1` sets
22
+ * `SO_REUSEPORT` on both runtimes (several processes behind the kernel's
23
+ * balancer).
24
+ */
25
+ import { readFileSync, readdirSync, readlinkSync } from 'node:fs';
26
+
27
+ /**
28
+ * Whether the operator asked for a shared port (`WEBJS_REUSE_PORT=1`).
29
+ *
30
+ * @param {NodeJS.ProcessEnv} [env]
31
+ * @returns {boolean}
32
+ */
33
+ export function reusePortRequested(env = process.env) {
34
+ const v = String(env.WEBJS_REUSE_PORT || '').toLowerCase();
35
+ return v === '1' || v === 'true';
36
+ }
37
+
38
+ /**
39
+ * Whether an error is a bind failure on a taken port. Node sets `code`; Bun's
40
+ * `Bun.serve` sets `code` too, and older Bun releases only said so in the
41
+ * message.
42
+ *
43
+ * @param {unknown} err
44
+ * @returns {boolean}
45
+ */
46
+ export function isAddrInUse(err) {
47
+ if (!err || typeof err !== 'object') return false;
48
+ const e = /** @type {any} */ (err);
49
+ return e.code === 'EADDRINUSE' || /EADDRINUSE|address already in use|Is port \d+ in use/i.test(String(e.message || ''));
50
+ }
51
+
52
+ /**
53
+ * The socket inodes LISTENing on `port`, from the text of `/proc/net/tcp` or
54
+ * `/proc/net/tcp6`. Columns: `sl local_address rem_address st ... inode`; the
55
+ * port is the hex after the colon of `local_address`, and state `0A` is LISTEN.
56
+ *
57
+ * @param {string} table
58
+ * @param {number} port
59
+ * @returns {string[]}
60
+ */
61
+ export function listenInodes(table, port) {
62
+ const out = [];
63
+ for (const line of table.split('\n').slice(1)) {
64
+ const cols = line.trim().split(/\s+/);
65
+ if (cols.length < 10) continue;
66
+ const local = cols[1];
67
+ const hexPort = local.slice(local.lastIndexOf(':') + 1);
68
+ if (parseInt(hexPort, 16) !== port || cols[3] !== '0A') continue;
69
+ if (cols[9] && cols[9] !== '0') out.push(cols[9]);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Best effort: the process listening on `port`, found through `/proc` (Linux
76
+ * only). Only processes this user may inspect are visible, so a server run by
77
+ * another user yields null. Never throws.
78
+ *
79
+ * @param {number} port
80
+ * @param {{ readFile?: (p: string) => string, readDir?: (p: string) => string[], readLink?: (p: string) => string, selfPid?: number }} [io]
81
+ * @returns {{ pid: number, command: string } | null}
82
+ */
83
+ export function findPortHolder(port, io = {}) {
84
+ const readFile = io.readFile || ((p) => readFileSync(p, 'utf8'));
85
+ const readDir = io.readDir || ((p) => readdirSync(p));
86
+ const readLink = io.readLink || ((p) => readlinkSync(p));
87
+ const selfPid = io.selfPid ?? process.pid;
88
+ try {
89
+ const inodes = new Set();
90
+ for (const t of ['/proc/net/tcp', '/proc/net/tcp6']) {
91
+ let text = '';
92
+ try { text = readFile(t); } catch { continue; }
93
+ for (const i of listenInodes(text, port)) inodes.add(`socket:[${i}]`);
94
+ }
95
+ if (inodes.size === 0) return null;
96
+ for (const name of readDir('/proc')) {
97
+ if (!/^\d+$/.test(name) || Number(name) === selfPid) continue;
98
+ let fds = [];
99
+ try { fds = readDir(`/proc/${name}/fd`); } catch { continue; }
100
+ for (const fd of fds) {
101
+ let target = '';
102
+ try { target = readLink(`/proc/${name}/fd/${fd}`); } catch { continue; }
103
+ if (!inodes.has(target)) continue;
104
+ let command = '';
105
+ try { command = readFile(`/proc/${name}/cmdline`).split('\0').filter(Boolean).join(' '); } catch {}
106
+ return { pid: Number(name), command: command.length > 120 ? `${command.slice(0, 119)}…` : command };
107
+ }
108
+ }
109
+ } catch {}
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * The message for a taken port.
115
+ *
116
+ * @param {number} port
117
+ * @param {{ pid: number, command: string } | null} holder
118
+ * @returns {string}
119
+ */
120
+ export function portInUseMessage(port, holder) {
121
+ const who = holder
122
+ ? `by PID ${holder.pid}${holder.command ? ` (${holder.command})` : ''}`
123
+ : 'by another process';
124
+ return `port ${port} is already in use ${who}. Stop that server, or start this one on another port (PORT=<n> or --port <n>).`;
125
+ }
126
+
127
+ /**
128
+ * The error both listeners throw on a taken port: code `EADDRINUSE`, a
129
+ * message naming the holder, the original error as `cause`.
130
+ *
131
+ * @param {number} port
132
+ * @param {unknown} cause
133
+ * @returns {Error & { code: 'EADDRINUSE', port: number, holder: { pid: number, command: string } | null }}
134
+ */
135
+ export function portInUseError(port, cause) {
136
+ const holder = findPortHolder(port);
137
+ const err = /** @type {any} */ (new Error(portInUseMessage(port, holder), { cause }));
138
+ err.code = 'EADDRINUSE';
139
+ err.port = port;
140
+ err.holder = holder;
141
+ return err;
142
+ }