@webjsdev/server 0.8.70 → 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 +1 -1
- package/src/dev/handler.js +1 -1
- package/src/dev/helpers.js +16 -1
- package/src/dev/server.js +31 -20
- package/src/dev/watch-tree.js +104 -0
- package/src/dev-reload-state.js +31 -0
- package/src/dev-reload-worker.js +68 -4
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +14 -1
- package/src/ssr/head.js +6 -1
- package/src/vendor/manifest.js +27 -0
- package/src/vendor/pins.js +61 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
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/handler.js
CHANGED
|
@@ -816,7 +816,7 @@ export async function createRequestHandler(opts) {
|
|
|
816
816
|
// path skipped it. This runs on the first warm AND after every rebuild,
|
|
817
817
|
// so the pruned map is the single source of truth.
|
|
818
818
|
const reachable = await scan();
|
|
819
|
-
({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
|
|
819
|
+
({ imports, integrity } = prunePinToReachable(imports, integrity, reachable, { appDir }));
|
|
820
820
|
}
|
|
821
821
|
await setVendorEntries(imports, integrity);
|
|
822
822
|
return v.ok;
|
package/src/dev/helpers.js
CHANGED
|
@@ -531,12 +531,27 @@ function __webjsDirectEvents() {
|
|
|
531
531
|
// and its hello says whether an edit landed meanwhile. \`bye\` on pagehide
|
|
532
532
|
// drops the tab outright, and a bfcache restore reports in again.
|
|
533
533
|
function __webjsReportVisibility(send) {
|
|
534
|
+
// The state this page was rendered at (#1516). The relay compares it with
|
|
535
|
+
// the server's on connect and on every reconnect, so an edit whose reload
|
|
536
|
+
// frame was lost (a stream replaced at a host's wake, a fresh relay, a tab
|
|
537
|
+
// that connected after the fanout) still reloads this page.
|
|
538
|
+
// Sent again on a bfcache restore, since \`bye\` dropped this tab's entry.
|
|
539
|
+
function sendPage() {
|
|
540
|
+
try {
|
|
541
|
+
var metas = document.querySelectorAll('meta[name="webjs-dev-reload"]');
|
|
542
|
+
var meta = metas.length ? metas[metas.length - 1] : null;
|
|
543
|
+
if (!meta) return;
|
|
544
|
+
var page = JSON.parse(meta.getAttribute('content') || 'null');
|
|
545
|
+
if (page && typeof page.boot === 'string') send({ type: 'page', boot: page.boot, seq: typeof page.seq === 'number' ? page.seq : null });
|
|
546
|
+
} catch (_) { /* no usable state: the relay's own hello check still runs */ }
|
|
547
|
+
}
|
|
548
|
+
sendPage();
|
|
534
549
|
function report() {
|
|
535
550
|
try { send({ type: 'visibility', visible: document.visibilityState !== 'hidden' }); } catch (_) { /* relay gone */ }
|
|
536
551
|
}
|
|
537
552
|
document.addEventListener('visibilitychange', report);
|
|
538
553
|
addEventListener('pagehide', function () { try { send({ type: 'bye' }); } catch (_) { /* relay gone */ } });
|
|
539
|
-
addEventListener('pageshow', function (e) { if (e.persisted) report(); });
|
|
554
|
+
addEventListener('pageshow', function (e) { if (e.persisted) { sendPage(); report(); } });
|
|
540
555
|
report();
|
|
541
556
|
// Interaction keeps an idle-closing stream (webjs.dev.reloadIdle) open and
|
|
542
557
|
// reopens a closed one, at most once a second. The dev embed bridge calls
|
package/src/dev/server.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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';
|
|
7
7
|
import { readServerTimeoutsFromApp, readDevWatchPathsFromApp } from './config.js';
|
|
8
8
|
import { defaultLogger } from '../logger.js';
|
|
9
|
-
import { SseHub, makeShutdown, installProcessHandlers, serverRuntime } from '../listener-core.js';
|
|
9
|
+
import { SseHub, makeShutdown, installProcessHandlers, serverRuntime, DEV_BOOT_ID } from '../listener-core.js';
|
|
10
|
+
import { setDevReloadState } from '../dev-reload-state.js';
|
|
10
11
|
import { urlFromRequest } from '../forwarded.js';
|
|
11
12
|
import { stripBasePath } from '../base-path.js';
|
|
12
13
|
import { basePath } from '../importmap.js';
|
|
@@ -189,7 +190,7 @@ function startNodeListener(ctx) {
|
|
|
189
190
|
// Catch-all process handlers: log, but don't tear the process down on a
|
|
190
191
|
// single mishandled promise. Uncaught exceptions are different: state may be
|
|
191
192
|
// corrupted, so log + start an orderly shutdown rather than continuing.
|
|
192
|
-
installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
|
|
193
|
+
installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }), { dev });
|
|
193
194
|
|
|
194
195
|
return {
|
|
195
196
|
server,
|
|
@@ -229,6 +230,9 @@ export async function startServer(opts) {
|
|
|
229
230
|
// identically on both). Built before the handler so its onReload / onDevError
|
|
230
231
|
// callbacks can fan out through it.
|
|
231
232
|
const hub = new SseHub();
|
|
233
|
+
// Pages render the stream's state into a meta tag (#1516), so the relay can
|
|
234
|
+
// re-check a page against the server on every reconnect.
|
|
235
|
+
if (dev) setDevReloadState(() => ({ boot: DEV_BOOT_ID, seq: hub.seq }));
|
|
232
236
|
let app;
|
|
233
237
|
try {
|
|
234
238
|
app = await createRequestHandler({
|
|
@@ -248,11 +252,12 @@ export async function startServer(opts) {
|
|
|
248
252
|
/** @type {AbortController | null} */
|
|
249
253
|
let watcherAbort = null;
|
|
250
254
|
if (dev) {
|
|
251
|
-
// Watch the app
|
|
252
|
-
// `
|
|
253
|
-
//
|
|
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.
|
|
254
259
|
//
|
|
255
|
-
//
|
|
260
|
+
// Paths are reported relative to the watched root. `shouldIgnoreWatchPath`
|
|
256
261
|
// (module-level, exported for tests) skips node_modules, .git, .webjs/, and
|
|
257
262
|
// the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
|
|
258
263
|
// Live-reload classification (#1398). Several files can change inside one
|
|
@@ -269,26 +274,32 @@ export async function startServer(opts) {
|
|
|
269
274
|
app.rebuild(v || undefined);
|
|
270
275
|
}, 80);
|
|
271
276
|
watcherAbort = new AbortController();
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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) => {
|
|
278
291
|
// A regenerate output (#967) is a build product the server itself
|
|
279
292
|
// writes on request; ignoring it stops a spurious rebuild + reload
|
|
280
293
|
// (and a reload -> refetch -> recompile -> reload cycle), same as the
|
|
281
294
|
// db/dev.db carve-out above. `app` exposes the check because `state`
|
|
282
295
|
// lives in createRequestHandler's scope, not here.
|
|
283
|
-
if (app.isRegenerateOutput(filename))
|
|
296
|
+
if (app.isRegenerateOutput(filename)) return;
|
|
284
297
|
pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
|
|
285
298
|
rebuild();
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
}
|
|
299
|
+
},
|
|
300
|
+
onError: onWatchError,
|
|
301
|
+
});
|
|
302
|
+
watcherAbort.signal.addEventListener('abort', close, { once: true });
|
|
292
303
|
};
|
|
293
304
|
watchRoot(app.appDir);
|
|
294
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live-reload state a dev page was rendered at (#1516).
|
|
3
|
+
*
|
|
4
|
+
* `startServer` owns the reload stream's `SseHub`, which knows this process's
|
|
5
|
+
* boot id and how many reload frames it has sent. SSR renders that pair into a
|
|
6
|
+
* `webjs-dev-reload` meta tag, and the reload client hands it to the relay, so
|
|
7
|
+
* on every reconnect the relay can tell whether the page on screen is behind
|
|
8
|
+
* the server even when the reload frame that would have said so was lost.
|
|
9
|
+
*
|
|
10
|
+
* A module-level provider, the same shape as the base path and the embed
|
|
11
|
+
* origins, because the head is rendered far from the listener. An embedded
|
|
12
|
+
* `createRequestHandler` with no `startServer` has no reload stream, so no
|
|
13
|
+
* provider is set and no meta tag is rendered.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @type {(() => { boot: string, seq: number }) | null} */
|
|
17
|
+
let provider = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Set (or clear with null) the function that reports the current state.
|
|
21
|
+
* @param {(() => { boot: string, seq: number }) | null} fn
|
|
22
|
+
*/
|
|
23
|
+
export function setDevReloadState(fn) {
|
|
24
|
+
provider = typeof fn === 'function' ? fn : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The current `{ boot, seq }`, or null when there is no reload stream. */
|
|
28
|
+
export function devReloadState() {
|
|
29
|
+
if (!provider) return null;
|
|
30
|
+
try { return provider(); } catch { return null; }
|
|
31
|
+
}
|
package/src/dev-reload-worker.js
CHANGED
|
@@ -98,6 +98,23 @@ export function parseHello(data) {
|
|
|
98
98
|
return { boot: String(data), seq: null };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Whether a tab's page is behind the server (#1516). `page` is the
|
|
103
|
+
* `{ boot, seq }` the page was rendered at (the `webjs-dev-reload` meta tag),
|
|
104
|
+
* `boot` / `seq` what the server reports now. A different boot id is a
|
|
105
|
+
* restarted process; a different seq from the same process is an edit the page
|
|
106
|
+
* has not picked up. A missing seq on either side cannot say, so it is not
|
|
107
|
+
* stale (the boot id alone still decides).
|
|
108
|
+
* @param {{ boot: string, seq: number | null } | undefined} page
|
|
109
|
+
* @param {string | null} boot
|
|
110
|
+
* @param {number | null} seq
|
|
111
|
+
*/
|
|
112
|
+
export function pageIsStale(page, boot, seq) {
|
|
113
|
+
if (!page || boot === null) return false;
|
|
114
|
+
if (page.boot !== boot) return true;
|
|
115
|
+
return page.seq !== null && seq !== null && page.seq !== seq;
|
|
116
|
+
}
|
|
117
|
+
|
|
101
118
|
/** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
|
|
102
119
|
function reloadSeq(data) {
|
|
103
120
|
try {
|
|
@@ -118,6 +135,16 @@ function reloadSeq(data) {
|
|
|
118
135
|
* server's boot id and reload `seq`, and a difference from what this relay last
|
|
119
136
|
* saw is a reload.
|
|
120
137
|
*
|
|
138
|
+
* Every reconnect also re-checks each tab against the state its PAGE was
|
|
139
|
+
* rendered at (#1516). A tab sends `{ type: 'page', boot, seq }` from the
|
|
140
|
+
* page's `webjs-dev-reload` meta tag when it connects. A frame emitted while no
|
|
141
|
+
* stream was there to carry it is gone: a host that holds the quiet stream
|
|
142
|
+
* across a suspend and closes it at the next wake swallows the reload the wake
|
|
143
|
+
* itself caused, a fresh relay has no previous hello to compare with, and a tab
|
|
144
|
+
* that connects after a reload was fanned out never got it. In each case the
|
|
145
|
+
* next hello (or the tab's own connect) shows the page is behind, and that tab
|
|
146
|
+
* reloads.
|
|
147
|
+
*
|
|
121
148
|
* The browser's own EventSource retry is replaced by an explicit one with
|
|
122
149
|
* backoff (300ms doubling to 30s, reset by the next `hello`), so a server that
|
|
123
150
|
* is gone for a while is probed less and less often instead of every 300ms.
|
|
@@ -137,6 +164,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
137
164
|
const idleMs = opts && opts.idleMs > 0 ? opts.idleMs : 0;
|
|
138
165
|
/** @type {Map<any, boolean>} each connected tab's port, and whether it is visible */
|
|
139
166
|
const ports = new Map();
|
|
167
|
+
/** @type {Map<any, { boot: string, seq: number | null }>} the state each tab's page reflects (#1516) */
|
|
168
|
+
const pages = new Map();
|
|
140
169
|
/** @type {string | null} the last error frame, cached for late-joining tabs */
|
|
141
170
|
let lastError = null;
|
|
142
171
|
/** @type {string | null} the last-seen per-process boot id (#893) */
|
|
@@ -157,13 +186,26 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
157
186
|
|
|
158
187
|
/** @type {string} the strongest verdict seen in the CURRENT batch (#1398) */
|
|
159
188
|
let batchVerdict = 'page';
|
|
189
|
+
/** Whether the batch reloads every tab, or only the stale ones in `batchPorts` (#1516). */
|
|
190
|
+
let batchAll = false;
|
|
191
|
+
/** @type {Set<any>} tabs whose page fell behind, reloaded on their own */
|
|
192
|
+
const batchPorts = new Set();
|
|
160
193
|
|
|
161
194
|
function emitReload() {
|
|
162
195
|
if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
|
|
163
196
|
if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
|
|
164
197
|
const v = batchVerdict;
|
|
165
198
|
batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
|
|
166
|
-
|
|
199
|
+
const msg = { type: 'reload', verdict: v };
|
|
200
|
+
const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p));
|
|
201
|
+
batchAll = false;
|
|
202
|
+
batchPorts.clear();
|
|
203
|
+
for (const p of targets) {
|
|
204
|
+
// The tab applies this reload, so from here its page reflects the state
|
|
205
|
+
// the relay knows now; a later hello compares against that.
|
|
206
|
+
if (pages.has(p) && typeof lastBoot === 'string') pages.set(p, { boot: lastBoot, seq: lastSeq });
|
|
207
|
+
try { p.postMessage(msg); } catch (_) { ports.delete(p); pages.delete(p); }
|
|
208
|
+
}
|
|
167
209
|
}
|
|
168
210
|
|
|
169
211
|
/** Strength rank; an unrecognised name ranks strongest, so it can only ever
|
|
@@ -173,8 +215,12 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
173
215
|
return i === -1 ? 0 : i;
|
|
174
216
|
}
|
|
175
217
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
218
|
+
/**
|
|
219
|
+
* @param {string} verdict
|
|
220
|
+
* @param {any} [port] reload only this tab (its page fell behind); omitted, every tab
|
|
221
|
+
*/
|
|
222
|
+
function requestReload(verdict, port) {
|
|
223
|
+
if (port === undefined) batchAll = true; else batchPorts.add(port);
|
|
178
224
|
if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
|
|
179
225
|
if (quietTimer !== null) timers.clearTimeout(quietTimer);
|
|
180
226
|
quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
|
|
@@ -199,6 +245,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
199
245
|
idleTimer = timers.setTimeout(() => { idleTimer = null; close(); }, idleMs);
|
|
200
246
|
}
|
|
201
247
|
|
|
248
|
+
// Reload every tab whose page is behind what the server reports now (#1516).
|
|
249
|
+
function checkPages() {
|
|
250
|
+
for (const [p, page] of pages) {
|
|
251
|
+
if (pageIsStale(page, lastBoot, lastSeq)) requestReload('reload', p);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
202
255
|
function anyVisible() {
|
|
203
256
|
for (const v of ports.values()) if (v) return true;
|
|
204
257
|
return false;
|
|
@@ -218,6 +271,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
218
271
|
}
|
|
219
272
|
lastBoot = h.boot;
|
|
220
273
|
lastSeq = h.seq;
|
|
274
|
+
checkPages();
|
|
221
275
|
});
|
|
222
276
|
es.addEventListener('reload', (e) => {
|
|
223
277
|
touch();
|
|
@@ -258,8 +312,18 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
258
312
|
ports.set(port, true);
|
|
259
313
|
port.onmessage = (m) => {
|
|
260
314
|
const d = (m && m.data) || {};
|
|
315
|
+
if (d.type === 'page') {
|
|
316
|
+
// The state this tab's page was rendered at (#1516). Checked now
|
|
317
|
+
// against what the relay already knows, so a tab that missed a fanned
|
|
318
|
+
// out reload catches up, and again on every hello.
|
|
319
|
+
if (typeof d.boot === 'string') {
|
|
320
|
+
pages.set(port, { boot: d.boot, seq: typeof d.seq === 'number' ? d.seq : null });
|
|
321
|
+
if (pageIsStale(pages.get(port), lastBoot, lastSeq)) requestReload('reload', port);
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
261
325
|
if (d.type === 'visibility') ports.set(port, d.visible !== false);
|
|
262
|
-
else if (d.type === 'bye') ports.delete(port);
|
|
326
|
+
else if (d.type === 'bye') { ports.delete(port); pages.delete(port); }
|
|
263
327
|
else if (d.type === 'activity') { if (!ports.get(port)) return; touch(); }
|
|
264
328
|
else return;
|
|
265
329
|
sync();
|
package/src/listener-bun.js
CHANGED
|
@@ -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,
|
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 {}
|
package/src/ssr/head.js
CHANGED
|
@@ -7,6 +7,7 @@ import { vendorIntegrityFor } from '../importmap.js';
|
|
|
7
7
|
import { publicEnvShim } from './env-shim.js';
|
|
8
8
|
import { clientRouterEnabled } from './client-router-flag.js';
|
|
9
9
|
import { embedScriptTag } from '../dev-embed.js';
|
|
10
|
+
import { devReloadState } from '../dev-reload-state.js';
|
|
10
11
|
|
|
11
12
|
// Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
|
|
12
13
|
// Set at boot and on each route rebuild from the route table, the same shape
|
|
@@ -315,8 +316,12 @@ export function wrapHead(opts) {
|
|
|
315
316
|
: '';
|
|
316
317
|
|
|
317
318
|
const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
|
|
319
|
+
// The reload state this page is rendered at (#1516), so the reload client can
|
|
320
|
+
// tell on every reconnect whether the page fell behind the server.
|
|
321
|
+
const reloadState = opts.dev ? devReloadState() : null;
|
|
318
322
|
const reload = opts.dev
|
|
319
|
-
? `<
|
|
323
|
+
? (reloadState ? `<meta name="webjs-dev-reload" content="${escapeAttr(JSON.stringify(reloadState))}">\n` : '') +
|
|
324
|
+
`<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
|
|
320
325
|
: '';
|
|
321
326
|
const suspenseBoot = opts.streaming
|
|
322
327
|
? `<script${n}>(function(){` +
|
package/src/vendor/manifest.js
CHANGED
|
@@ -91,6 +91,33 @@ export function getPackageManifest(pkgName, appDir) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The names a package depends on at runtime (`dependencies`,
|
|
96
|
+
* `peerDependencies`, `optionalDependencies`) and its installed directory,
|
|
97
|
+
* resolved from `fromDir` (#1518). Resolving a dependency from its DEPENDENT's
|
|
98
|
+
* directory finds a nested install as well as a hoisted one, so the pin prune
|
|
99
|
+
* can walk the real installed graph. Null when the package is not installed.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} pkgName
|
|
102
|
+
* @param {string} fromDir a directory holding a package.json (the app, or a dependent package)
|
|
103
|
+
* @returns {{ dir: string, names: string[] } | null}
|
|
104
|
+
*/
|
|
105
|
+
export function getPackageDeps(pkgName, fromDir) {
|
|
106
|
+
const real = resolvePackageDir(pkgName, fromDir);
|
|
107
|
+
if (!real) return null;
|
|
108
|
+
try {
|
|
109
|
+
const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
|
|
110
|
+
const names = new Set([
|
|
111
|
+
...Object.keys(pkg.dependencies || {}),
|
|
112
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
113
|
+
...Object.keys(pkg.optionalDependencies || {}),
|
|
114
|
+
]);
|
|
115
|
+
return { dir: real, names: [...names] };
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
// ---------------------------------------------------------------------------
|
|
95
122
|
// JSPM Generator API client
|
|
96
123
|
// ---------------------------------------------------------------------------
|
package/src/vendor/pins.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, unlink, stat, rename, readdir } from 'node:fs/promises';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { BUILTIN, FRAMEWORK_SERVER_ONLY, extractPackageName, scanBareImports } from './scanner.js';
|
|
6
|
-
import { getPackageVersion } from './manifest.js';
|
|
6
|
+
import { getPackageVersion, getPackageDeps } from './manifest.js';
|
|
7
7
|
import { SUPPORTED_PROVIDERS } from './providers.js';
|
|
8
8
|
import { PIN_BUNDLE_TIMEOUT_MS, fetchIntegrity, sha384Integrity } from './integrity.js';
|
|
9
9
|
import { jspmGenerate } from './jspm.js';
|
|
@@ -506,17 +506,54 @@ export function basePackage(spec) {
|
|
|
506
506
|
* when code imports `dayjs/plugin/utc`, and vice versa). Integrity hashes for
|
|
507
507
|
* dropped URLs are pruned too.
|
|
508
508
|
*
|
|
509
|
+
* With `opts.appDir` it also keeps the TRANSITIVE dependencies of every kept
|
|
510
|
+
* package (#1518). App code never imports a transitive (`@codemirror/view`
|
|
511
|
+
* imports `style-mod`, `@codemirror/language` imports `@lezer/lr`), so the
|
|
512
|
+
* filter above alone dropped every one of them and the browser failed to
|
|
513
|
+
* resolve the bare specifier. The closure walks each kept package's installed
|
|
514
|
+
* `package.json`, resolving each dependency from its dependent's directory, and
|
|
515
|
+
* keeps every pin entry whose base package it reaches, which is the set the
|
|
516
|
+
* live resolve serves (jspm flattens the transitives of the reachable
|
|
517
|
+
* installs). When a kept package is not on disk the graph cannot be walked, so
|
|
518
|
+
* every pin entry the app does not itself declare is kept instead: an unused
|
|
519
|
+
* entry costs nothing, a missing one breaks the page.
|
|
520
|
+
*
|
|
509
521
|
* @param {Record<string, string>} imports pin entries (specifier -> URL)
|
|
510
522
|
* @param {Record<string, string>} integrity SRI hashes keyed by URL
|
|
511
523
|
* @param {Set<string>} reachable bare specifiers used by non-elided modules
|
|
524
|
+
* @param {{ appDir?: string }} [opts]
|
|
512
525
|
* @returns {{ imports: Record<string, string>, integrity: Record<string, string> }}
|
|
513
526
|
*/
|
|
514
|
-
export function prunePinToReachable(imports, integrity, reachable) {
|
|
515
|
-
const
|
|
527
|
+
export function prunePinToReachable(imports, integrity, reachable, opts = {}) {
|
|
528
|
+
const keepBases = new Set([...reachable].map(basePackage));
|
|
529
|
+
if (opts.appDir) {
|
|
530
|
+
const pinnedBases = new Set(Object.keys(imports || {}).map(basePackage));
|
|
531
|
+
let complete = true;
|
|
532
|
+
/** @type {Array<[string, string]>} base package, directory to resolve it from */
|
|
533
|
+
const queue = [...keepBases].filter((b) => pinnedBases.has(b)).map((b) => [b, opts.appDir]);
|
|
534
|
+
const walked = new Set();
|
|
535
|
+
while (queue.length) {
|
|
536
|
+
const [base, from] = /** @type {[string, string]} */ (queue.shift());
|
|
537
|
+
const key = `${base}\0${from}`;
|
|
538
|
+
if (walked.has(key)) continue;
|
|
539
|
+
walked.add(key);
|
|
540
|
+
const deps = getPackageDeps(base, from);
|
|
541
|
+
if (!deps) { complete = false; continue; }
|
|
542
|
+
for (const name of deps.names) {
|
|
543
|
+
if (!pinnedBases.has(name)) continue;
|
|
544
|
+
keepBases.add(name);
|
|
545
|
+
queue.push([name, deps.dir]);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (!complete) {
|
|
549
|
+
const declared = declaredDeps(opts.appDir);
|
|
550
|
+
for (const b of pinnedBases) if (!declared.has(b)) keepBases.add(b);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
516
553
|
/** @type {Record<string, string>} */
|
|
517
554
|
const keptImports = {};
|
|
518
555
|
for (const [spec, url] of Object.entries(imports || {})) {
|
|
519
|
-
if (reachable.has(spec) ||
|
|
556
|
+
if (reachable.has(spec) || keepBases.has(basePackage(spec))) {
|
|
520
557
|
keptImports[spec] = url;
|
|
521
558
|
}
|
|
522
559
|
}
|
|
@@ -529,6 +566,25 @@ export function prunePinToReachable(imports, integrity, reachable) {
|
|
|
529
566
|
return { imports: keptImports, integrity: keptIntegrity };
|
|
530
567
|
}
|
|
531
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Every package the app's own package.json declares, in any dependency field.
|
|
571
|
+
* @param {string} appDir
|
|
572
|
+
* @returns {Set<string>}
|
|
573
|
+
*/
|
|
574
|
+
function declaredDeps(appDir) {
|
|
575
|
+
try {
|
|
576
|
+
const pkg = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
|
|
577
|
+
return new Set([
|
|
578
|
+
...Object.keys(pkg.dependencies || {}),
|
|
579
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
580
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
581
|
+
...Object.keys(pkg.optionalDependencies || {}),
|
|
582
|
+
]);
|
|
583
|
+
} catch {
|
|
584
|
+
return new Set();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
532
588
|
// ---------------------------------------------------------------------------
|
|
533
589
|
// Importmap coherence check (issue #450)
|
|
534
590
|
// ---------------------------------------------------------------------------
|