@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.70",
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",
@@ -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;
@@ -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 { watch as fsWatch } from 'node:fs/promises';
3
+ import { watchTree } from './watch-tree.js';
4
4
  import { relative } from 'node:path';
5
5
  import { createServer as createHttp1Server } from 'node:http';
6
6
  import { createRequestHandler } from './handler.js';
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 root recursively via Node's built-in
252
- // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
253
- // Node 24. No external dep needed.
255
+ // Watch the app tree with Node's built-in `fs.watch` (no external dep):
256
+ // `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
257
+ // so `node_modules` is never walked, and handles a per-path watch error
258
+ // (EACCES, ENOENT) with a warning instead of an uncaughtException.
254
259
  //
255
- // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
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
- const watchRoot = async (root) => {
273
- try {
274
- const events = fsWatch(root, { recursive: true, signal: watcherAbort.signal });
275
- for await (const event of events) {
276
- const filename = event.filename || '';
277
- if (shouldIgnoreWatchPath(filename)) continue;
277
+ // One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
278
+ // file must never stop the dev server, and a burst of the same temp file
279
+ // must not flood the log either.
280
+ const warned = new Set();
281
+ const onWatchError = (err) => {
282
+ const key = `${err && err.code}:${err && err.path}`;
283
+ if (warned.has(key) || warned.size > 200) return;
284
+ warned.add(key);
285
+ logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
286
+ };
287
+ const watchRoot = (root) => {
288
+ const close = watchTree(root, {
289
+ ignore: shouldIgnoreWatchPath,
290
+ onEvent: (filename) => {
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)) continue;
296
+ if (app.isRegenerateOutput(filename)) return;
284
297
  pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
285
298
  rebuild();
286
- }
287
- } catch (err) {
288
- if (err && /** @type any */(err).name !== 'AbortError') {
289
- logger.warn({ err }, `file watcher exited (${root})`);
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
+ }
@@ -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
- fanout({ type: 'reload', verdict: v });
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
- /** @param {string} verdict */
177
- function requestReload(verdict) {
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();
@@ -157,7 +157,7 @@ export function startBunListener(ctx) {
157
157
  const shutdown = makeShutdown({ closeServer, hub, logger });
158
158
  process.once('SIGINT', () => shutdown('SIGINT'));
159
159
  process.once('SIGTERM', () => shutdown('SIGTERM'));
160
- installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
160
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }), { dev });
161
161
 
162
162
  return {
163
163
  server,
@@ -23,6 +23,7 @@ import {
23
23
  } from 'node:zlib';
24
24
  import { stripBasePath } from './base-path.js';
25
25
  import { randomUUID } from 'node:crypto';
26
+ import { isWatchError } from './dev/watch-tree.js';
26
27
 
27
28
  /** The dev live-reload SSE path (matched after base-path stripping). */
28
29
  export const EVENTS_PATH = '/__webjs/events';
@@ -416,10 +417,18 @@ export class SseHub {
416
417
  /**
417
418
  * Install once-only process error handlers. Idempotent across multiple
418
419
  * `startServer` calls in the same process. Runtime-neutral (plain `process.on`).
420
+ *
421
+ * In dev, an uncaught file-watcher error (`syscall: 'watch'`, #1521) is logged
422
+ * as a warning and NOT fatal: one unreadable or vanished file in a watched dir
423
+ * says nothing about the server's state, and shutting down over it is how a
424
+ * `sed -i` from another user took a whole dev preview down. Prod keeps the
425
+ * strict contract, every uncaught exception is fatal.
426
+ *
419
427
  * @param {import('./logger.js').Logger} logger
420
428
  * @param {() => void} onFatal Starts the FATAL shutdown, which exits non-zero.
429
+ * @param {{ dev?: boolean }} [opts]
421
430
  */
422
- export function installProcessHandlers(logger, onFatal) {
431
+ export function installProcessHandlers(logger, onFatal, { dev = false } = {}) {
423
432
  if (/** @type any */ (globalThis).__webjsProcHandlers) return;
424
433
  /** @type any */ (globalThis).__webjsProcHandlers = true;
425
434
  process.on('unhandledRejection', (reason) => {
@@ -428,6 +437,10 @@ export function installProcessHandlers(logger, onFatal) {
428
437
  });
429
438
  });
430
439
  process.on('uncaughtException', (err) => {
440
+ if (dev && isWatchError(err)) {
441
+ logger.warn(`file watcher skipped ${/** @type any */ (err).path || 'a path'} (${/** @type any */ (err).code || err.message})`);
442
+ return;
443
+ }
431
444
  logger.error('uncaughtException', { err: err.stack || err.message });
432
445
  // Begin orderly shutdown; process state may be corrupt.
433
446
  try { onFatal(); } catch {}
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
- ? `<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
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(){` +
@@ -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
  // ---------------------------------------------------------------------------
@@ -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 reachableBases = new Set([...reachable].map(basePackage));
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) || reachableBases.has(basePackage(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
  // ---------------------------------------------------------------------------