@webjsdev/server 0.8.72 → 0.8.74
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 +21 -4
- package/src/dev/server.js +33 -12
- package/src/dev/watch-recursive.js +147 -0
- package/src/dev/watch-tree.js +6 -2
- package/src/dev-embed-client.js +58 -0
- package/src/dev-reload-hold.js +68 -0
- package/src/dev-styles.js +67 -33
- package/src/listener-bun.js +16 -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.74",
|
|
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
|
|
@@ -481,10 +492,16 @@ function __webjsApplyReload(verdict) {
|
|
|
481
492
|
// overlay comes back describing the CURRENT error. Dismissing afterwards
|
|
482
493
|
// would race that push and wipe a legitimately new overlay.
|
|
483
494
|
dismissDevOverlay();
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
495
|
+
// Load the rebuilt stylesheets FIRST and swap the markup in only once
|
|
496
|
+
// they are in (#1535): a \`webjs.dev.regenerate\` compile takes 100ms to
|
|
497
|
+
// seconds, and markup swapped in before it lands paints a new class with
|
|
498
|
+
// no rule. The old sheets stay applied until the swap, then go.
|
|
499
|
+
preloadStyles().then(function (styles) {
|
|
500
|
+
return refresh(verdict).then(function (ok) {
|
|
501
|
+
if (!ok) { location.reload(); return; }
|
|
502
|
+
styles.commit();
|
|
503
|
+
});
|
|
504
|
+
}).then(null, function () { location.reload(); });
|
|
488
505
|
return;
|
|
489
506
|
}
|
|
490
507
|
location.reload();
|
package/src/dev/server.js
CHANGED
|
@@ -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)));
|
|
@@ -328,9 +340,18 @@ export async function startServer(opts) {
|
|
|
328
340
|
// skips the node:http bridge (toWebRequest/sendWebResponse) for ~1.9x more
|
|
329
341
|
// req/s on the listening path (#511); the Bun shell is dynamically imported so
|
|
330
342
|
// the `Bun.*` global is never referenced on Node.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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;
|
|
334
356
|
}
|
|
335
|
-
return startNodeListener(ctx);
|
|
336
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
|
+
}
|
package/src/dev/watch-tree.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { watch, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { watchRecursive } from './watch-recursive.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The dev live-reload watcher over one root (#1521).
|
|
@@ -49,10 +50,13 @@ export function watchTree(root, { ignore, onEvent, onError }) {
|
|
|
49
50
|
const watchDir = (name) => {
|
|
50
51
|
if (closed || dirs.has(name) || ignore(name)) return;
|
|
51
52
|
try {
|
|
52
|
-
|
|
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) => {
|
|
53
57
|
const rel = filename ? join(name, String(filename)) : name;
|
|
54
58
|
if (!ignore(rel)) onEvent(rel);
|
|
55
|
-
}));
|
|
59
|
+
}, { ignore: (rel) => ignore(join(name, rel)) }));
|
|
56
60
|
dirs.set(name, w);
|
|
57
61
|
} catch (err) {
|
|
58
62
|
onError(/** @type {NodeJS.ErrnoException} */ (err));
|
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/dev-styles.js
CHANGED
|
@@ -17,39 +17,42 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* Load the rebuilt stylesheets BEFORE the in-place refresh swaps the markup in
|
|
21
|
+
* (#1535), so a new class never paints without its rule.
|
|
21
22
|
*
|
|
22
|
-
* The
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* The re-request used to run AFTER the swap, and that order was the flash: the new
|
|
24
|
+
* markup is on screen while its stylesheet is still being rebuilt and fetched
|
|
25
|
+
* (a `webjs.dev.regenerate` compile is 100ms to seconds), so an element that
|
|
26
|
+
* gained a utility class shows with no rule for it until the sheet lands.
|
|
27
|
+
* Measured on a fixture with an 800ms compile: 56 animation frames (about
|
|
28
|
+
* 0.9s) painted the edited element unstyled. A full reload never had the
|
|
29
|
+
* problem, because a `<head>` stylesheet is render-blocking and the browser
|
|
30
|
+
* holds the old page until it loads.
|
|
25
31
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* one), so the head would otherwise gain a link on every refresh. Keying on the
|
|
36
|
-
* path alone would be wrong in the other direction: it would delete an author's
|
|
37
|
-
* second, legitimately distinct link to the same file, such as a `media="print"`
|
|
38
|
-
* sheet.
|
|
32
|
+
* So the cache-busted replacements go in first, BESIDE the current links
|
|
33
|
+
* (both apply meanwhile, the same rules twice), and the returned promise
|
|
34
|
+
* resolves once each has loaded or failed, or after `timeoutMs` for one that
|
|
35
|
+
* hangs. The caller swaps the markup in and then calls `commit()`, which drops
|
|
36
|
+
* each old link whose replacement loaded, plus any same-identity copy the head
|
|
37
|
+
* merge appended, keeping the replacement. A replacement that failed is
|
|
38
|
+
* removed and the old link kept (#936, #1400: never take the last working
|
|
39
|
+
* sheet down). One still pending at commit finishes the same way when it
|
|
40
|
+
* settles.
|
|
39
41
|
*
|
|
40
42
|
* @param {ParentNode} [root] where to scan, defaulting to the whole document.
|
|
41
|
-
* Injectable so a browser test can scope itself to its own container.
|
|
42
43
|
* @param {() => number} [now] cache-buster source, injectable for tests.
|
|
43
|
-
* @
|
|
44
|
+
* @param {number} [timeoutMs] the longest to hold the swap for a stylesheet.
|
|
45
|
+
* @returns {Promise<{ links: Element[], commit: () => void }>}
|
|
44
46
|
*/
|
|
45
|
-
export function
|
|
47
|
+
export function preloadStyles(root, now, timeoutMs) {
|
|
46
48
|
const scope = root || (typeof document !== 'undefined' ? document : null);
|
|
47
|
-
|
|
49
|
+
const limit = typeof timeoutMs === 'number' ? timeoutMs : 10000;
|
|
50
|
+
if (!scope) return Promise.resolve({ links: [], commit: function () {} });
|
|
48
51
|
const stamp = now || Date.now;
|
|
49
52
|
/** @type {Record<string, { el: Element, url: URL }>} */
|
|
50
53
|
const kept = Object.create(null);
|
|
51
|
-
const
|
|
52
|
-
for (const el of
|
|
54
|
+
const current = [].slice.call(scope.querySelectorAll('link[rel~="stylesheet"][href]'));
|
|
55
|
+
for (const el of current) {
|
|
53
56
|
let url;
|
|
54
57
|
try { url = new URL(el.getAttribute('href'), location.href); } catch (_) { continue; }
|
|
55
58
|
if (url.origin !== location.origin) continue;
|
|
@@ -57,7 +60,21 @@ export function refreshStyles(root, now) {
|
|
|
57
60
|
if (kept[key]) { if (el.parentNode) el.parentNode.removeChild(el); continue; }
|
|
58
61
|
kept[key] = { el, url };
|
|
59
62
|
}
|
|
60
|
-
|
|
63
|
+
let committed = false;
|
|
64
|
+
/** @type {Array<{ key: string, old: Element, next: Element, state: string }>} */
|
|
65
|
+
const pairs = [];
|
|
66
|
+
// Drop every other copy of this sheet (the old link, a copy the head merge
|
|
67
|
+
// re-appended) so exactly the replacement is left.
|
|
68
|
+
const settleLoaded = function (pair) {
|
|
69
|
+
const links = [].slice.call(scope.querySelectorAll('link[rel~="stylesheet"][href]'));
|
|
70
|
+
for (const el of links) {
|
|
71
|
+
if (el === pair.next) continue;
|
|
72
|
+
let url;
|
|
73
|
+
try { url = new URL(el.getAttribute('href'), location.href); } catch (_) { continue; }
|
|
74
|
+
if (url.origin === location.origin && identityKey(el, url) === pair.key && el.parentNode) el.parentNode.removeChild(el);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const waits = [];
|
|
61
78
|
for (const key of Object.keys(kept)) {
|
|
62
79
|
const old = kept[key].el;
|
|
63
80
|
const url = kept[key].url;
|
|
@@ -65,17 +82,34 @@ export function refreshStyles(root, now) {
|
|
|
65
82
|
url.searchParams.set('__webjs_dev', String(stamp()));
|
|
66
83
|
const next = /** @type {Element} */ (old.cloneNode(false));
|
|
67
84
|
next.setAttribute('href', url.pathname + url.search);
|
|
68
|
-
next
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
const pair = { key, old, next, state: 'pending' };
|
|
86
|
+
pairs.push(pair);
|
|
87
|
+
waits.push(new Promise(function (resolve) {
|
|
88
|
+
next.addEventListener('load', function () {
|
|
89
|
+
pair.state = 'load';
|
|
90
|
+
if (committed) settleLoaded(pair);
|
|
91
|
+
resolve(undefined);
|
|
92
|
+
});
|
|
93
|
+
next.addEventListener('error', function () {
|
|
94
|
+
pair.state = 'error';
|
|
95
|
+
if (next.parentNode) next.parentNode.removeChild(next);
|
|
96
|
+
resolve(undefined);
|
|
97
|
+
});
|
|
98
|
+
}));
|
|
75
99
|
old.parentNode.insertBefore(next, old.nextSibling);
|
|
76
|
-
added.push(next);
|
|
77
100
|
}
|
|
78
|
-
|
|
101
|
+
const commit = function () {
|
|
102
|
+
committed = true;
|
|
103
|
+
for (const pair of pairs) if (pair.state === 'load') settleLoaded(pair);
|
|
104
|
+
};
|
|
105
|
+
const links = pairs.map(function (p) { return p.next; });
|
|
106
|
+
if (!waits.length) return Promise.resolve({ links, commit });
|
|
107
|
+
return new Promise(function (resolve) {
|
|
108
|
+
let done = false;
|
|
109
|
+
const finish = function () { if (!done) { done = true; resolve({ links, commit }); } };
|
|
110
|
+
Promise.all(waits).then(finish);
|
|
111
|
+
setTimeout(finish, limit);
|
|
112
|
+
});
|
|
79
113
|
}
|
|
80
114
|
|
|
81
115
|
/**
|
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})`,
|
|
@@ -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
|
+
}
|