@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 +1 -1
- package/src/dev/helpers.js +11 -0
- package/src/dev/server.js +59 -31
- package/src/dev/watch-recursive.js +147 -0
- package/src/dev/watch-tree.js +108 -0
- package/src/dev-embed-client.js +58 -0
- package/src/dev-reload-hold.js +68 -0
- package/src/listener-bun.js +17 -2
- package/src/listener-core.js +14 -1
- package/src/port-in-use.js +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
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",
|
package/src/dev/helpers.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
256
|
-
// `
|
|
257
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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))
|
|
308
|
+
if (app.isRegenerateOutput(filename)) return;
|
|
288
309
|
pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
|
|
289
310
|
rebuild();
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
+
}
|
package/src/dev-embed-client.js
CHANGED
|
@@ -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
|
+
}
|
package/src/listener-bun.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/listener-core.js
CHANGED
|
@@ -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
|
+
}
|