@webjsdev/server 0.8.71 → 0.8.72

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.72",
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",
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';
@@ -190,7 +190,7 @@ function startNodeListener(ctx) {
190
190
  // Catch-all process handlers: log, but don't tear the process down on a
191
191
  // single mishandled promise. Uncaught exceptions are different: state may be
192
192
  // corrupted, so log + start an orderly shutdown rather than continuing.
193
- installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
193
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }), { dev });
194
194
 
195
195
  return {
196
196
  server,
@@ -252,11 +252,12 @@ export async function startServer(opts) {
252
252
  /** @type {AbortController | null} */
253
253
  let watcherAbort = null;
254
254
  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.
255
+ // Watch the app tree with Node's built-in `fs.watch` (no external dep):
256
+ // `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
257
+ // so `node_modules` is never walked, and handles a per-path watch error
258
+ // (EACCES, ENOENT) with a warning instead of an uncaughtException.
258
259
  //
259
- // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
260
+ // Paths are reported relative to the watched root. `shouldIgnoreWatchPath`
260
261
  // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
261
262
  // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
262
263
  // Live-reload classification (#1398). Several files can change inside one
@@ -273,26 +274,32 @@ export async function startServer(opts) {
273
274
  app.rebuild(v || undefined);
274
275
  }, 80);
275
276
  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;
277
+ // One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
278
+ // file must never stop the dev server, and a burst of the same temp file
279
+ // must not flood the log either.
280
+ const warned = new Set();
281
+ const onWatchError = (err) => {
282
+ const key = `${err && err.code}:${err && err.path}`;
283
+ if (warned.has(key) || warned.size > 200) return;
284
+ warned.add(key);
285
+ logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
286
+ };
287
+ const watchRoot = (root) => {
288
+ const close = watchTree(root, {
289
+ ignore: shouldIgnoreWatchPath,
290
+ onEvent: (filename) => {
282
291
  // A regenerate output (#967) is a build product the server itself
283
292
  // writes on request; ignoring it stops a spurious rebuild + reload
284
293
  // (and a reload -> refetch -> recompile -> reload cycle), same as the
285
294
  // db/dev.db carve-out above. `app` exposes the check because `state`
286
295
  // lives in createRequestHandler's scope, not here.
287
- if (app.isRegenerateOutput(filename)) continue;
296
+ if (app.isRegenerateOutput(filename)) return;
288
297
  pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
289
298
  rebuild();
290
- }
291
- } catch (err) {
292
- if (err && /** @type any */(err).name !== 'AbortError') {
293
- logger.warn({ err }, `file watcher exited (${root})`);
294
- }
295
- }
299
+ },
300
+ onError: onWatchError,
301
+ });
302
+ watcherAbort.signal.addEventListener('abort', close, { once: true });
296
303
  };
297
304
  watchRoot(app.appDir);
298
305
  // Extra roots the app reads from OUTSIDE its appDir (#894): repo-root content
@@ -0,0 +1,104 @@
1
+ import { watch, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The dev live-reload watcher over one root (#1521).
6
+ *
7
+ * Two problems with the plain `fs.promises.watch(root, { recursive: true })`
8
+ * this replaces, both on Node 24's JS-implemented recursive watcher on Linux
9
+ * (`internal/fs/recursive_watch`), which opens one `fs.watch` per FILE:
10
+ *
11
+ * 1. A per-file failure (EACCES on a `sed -i` temp file another user created
12
+ * with mode 0600, ENOENT on a file that vanished between the directory event
13
+ * and the watch call) is emitted as `'error'` on an internal watcher the
14
+ * promise API never listens to, so it surfaces as an uncaughtException and
15
+ * the dev server shuts down. The callback form takes an `'error'` listener,
16
+ * and the watcher keeps delivering events for every other file after it.
17
+ * 2. Watching the app ROOT walks `node_modules` too: about 10k per-file watches
18
+ * and roughly 275ms of synchronous work on every boot of a scaffolded app,
19
+ * all for events the ignore filter then drops. So the root is watched
20
+ * non-recursively and each top-level directory that is not ignored gets its
21
+ * own recursive watcher.
22
+ *
23
+ * A top-level directory created or removed after start is picked up from the
24
+ * root watcher's events, so `mkdir lib` mid-session is watched like it was
25
+ * there at boot.
26
+ *
27
+ * @param {string} root absolute directory to watch
28
+ * @param {{
29
+ * ignore: (relPath: string) => boolean,
30
+ * onEvent: (relPath: string) => void,
31
+ * onError: (err: NodeJS.ErrnoException) => void,
32
+ * }} opts `relPath` is relative to `root`, with OS separators.
33
+ * @returns {() => void} closes every watcher
34
+ */
35
+ export function watchTree(root, { ignore, onEvent, onError }) {
36
+ /** @type {Map<string, import('node:fs').FSWatcher>} */
37
+ const dirs = new Map();
38
+ let closed = false;
39
+
40
+ const guard = (/** @type {import('node:fs').FSWatcher} */ w) => {
41
+ w.on('error', (err) => onError(/** @type {NodeJS.ErrnoException} */ (err)));
42
+ return w;
43
+ };
44
+
45
+ const isDir = (name) => {
46
+ try { return statSync(join(root, name)).isDirectory(); } catch { return false; }
47
+ };
48
+
49
+ const watchDir = (name) => {
50
+ if (closed || dirs.has(name) || ignore(name)) return;
51
+ try {
52
+ const w = guard(watch(join(root, name), { recursive: true }, (_type, filename) => {
53
+ const rel = filename ? join(name, String(filename)) : name;
54
+ if (!ignore(rel)) onEvent(rel);
55
+ }));
56
+ dirs.set(name, w);
57
+ } catch (err) {
58
+ onError(/** @type {NodeJS.ErrnoException} */ (err));
59
+ }
60
+ };
61
+
62
+ /** @type {import('node:fs').FSWatcher | null} */
63
+ let top = null;
64
+ try {
65
+ top = guard(watch(root, (_type, filename) => {
66
+ if (!filename) return;
67
+ const name = String(filename);
68
+ if (ignore(name)) return;
69
+ // A top-level entry changed: start or stop following it as a directory.
70
+ if (isDir(name)) watchDir(name);
71
+ else if (dirs.has(name)) { try { dirs.get(name)?.close(); } catch {} dirs.delete(name); }
72
+ onEvent(name);
73
+ }));
74
+ } catch (err) {
75
+ onError(/** @type {NodeJS.ErrnoException} */ (err));
76
+ }
77
+
78
+ let entries = [];
79
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch {}
80
+ for (const e of entries) {
81
+ if (e.isDirectory() || (e.isSymbolicLink() && isDir(e.name))) watchDir(e.name);
82
+ }
83
+
84
+ return () => {
85
+ closed = true;
86
+ try { top?.close(); } catch {}
87
+ for (const w of dirs.values()) { try { w.close(); } catch {} }
88
+ dirs.clear();
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Whether an error came from a file watcher (#1521). Node marks every
94
+ * `fs.watch` failure with `syscall: 'watch'` (EACCES, EPERM, ENOENT, ELOOP, and
95
+ * ENOSPC when the inotify limit is reached). One unwatchable path is never a
96
+ * reason to stop a dev server, so the dev process handlers log these and keep
97
+ * serving.
98
+ *
99
+ * @param {unknown} err
100
+ * @returns {boolean}
101
+ */
102
+ export function isWatchError(err) {
103
+ return !!err && typeof err === 'object' && /** @type {any} */ (err).syscall === 'watch';
104
+ }
@@ -157,7 +157,7 @@ export function startBunListener(ctx) {
157
157
  const shutdown = makeShutdown({ closeServer, hub, logger });
158
158
  process.once('SIGINT', () => shutdown('SIGINT'));
159
159
  process.once('SIGTERM', () => shutdown('SIGTERM'));
160
- installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
160
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }), { dev });
161
161
 
162
162
  return {
163
163
  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 {}