@webjsdev/server 0.8.69 → 0.8.71

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.69",
3
+ "version": "0.8.71",
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/config.js CHANGED
@@ -319,13 +319,13 @@ export async function readDevWatchPathsFromApp(appDir) {
319
319
 
320
320
  /**
321
321
  * The dev tooling switches in `webjs.dev` (#1504): `embedOrigins` (the embed
322
- * bridge, #1498) and `sourceLocations` (#1499). Raw values; the env overrides
322
+ * bridge, #1498), `sourceLocations` (#1499) and `reloadIdle` (#1507). Raw values; the env overrides
323
323
  * and validation live with each feature (`resolveEmbedOrigins`,
324
324
  * `sourceLocationsRequested`). A missing or unreadable package.json is both
325
325
  * off.
326
326
  *
327
327
  * @param {string} appDir
328
- * @returns {Promise<{ embedOrigins: unknown, sourceLocations: unknown }>}
328
+ * @returns {Promise<{ embedOrigins: unknown, sourceLocations: unknown, reloadIdle: unknown }>}
329
329
  */
330
330
  export async function readDevToolingFromApp(appDir) {
331
331
  let dev;
@@ -335,8 +335,8 @@ export async function readDevToolingFromApp(appDir) {
335
335
  } catch {
336
336
  dev = undefined;
337
337
  }
338
- if (!dev || typeof dev !== 'object') return { embedOrigins: undefined, sourceLocations: undefined };
339
- return { embedOrigins: dev.embedOrigins, sourceLocations: dev.sourceLocations };
338
+ if (!dev || typeof dev !== 'object') return { embedOrigins: undefined, sourceLocations: undefined, reloadIdle: undefined };
339
+ return { embedOrigins: dev.embedOrigins, sourceLocations: dev.sourceLocations, reloadIdle: dev.reloadIdle };
340
340
  }
341
341
 
342
342
  /**
@@ -67,6 +67,8 @@ import {
67
67
  import {
68
68
  exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
69
69
  appTopLevelDirs, locateCoreDir,
70
+ setReloadIdleMs,
71
+ resolveReloadIdleMs,
70
72
  } from './helpers.js';
71
73
  import {
72
74
  handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
@@ -223,9 +225,13 @@ export async function createRequestHandler(opts) {
223
225
  // neither injects the script nor relaxes a framing header.
224
226
  // `webjs.dev.embedOrigins` in package.json is the default (#1504); a set
225
227
  // env var replaces it for one run.
226
- const devTooling = dev ? await readDevToolingFromApp(appDir) : { embedOrigins: undefined, sourceLocations: undefined };
228
+ const devTooling = dev ? await readDevToolingFromApp(appDir) : { embedOrigins: undefined, sourceLocations: undefined, reloadIdle: undefined };
227
229
  const embedOriginsValue = dev ? resolveEmbedOrigins(process.env.WEBJS_EMBED_ORIGINS, devTooling.embedOrigins) : [];
228
230
  setEmbedOrigins(embedOriginsValue);
231
+ // Live-reload idle close (#1507): `webjs.dev.reloadIdle` seconds, or a set
232
+ // `WEBJS_DEV_RELOAD_IDLE`. Off by default; for a host that counts an open
233
+ // request as activity, so it can sleep while a preview tab sits open.
234
+ setReloadIdleMs(dev ? resolveReloadIdleMs(process.env.WEBJS_DEV_RELOAD_IDLE, devTooling.reloadIdle) : 0);
229
235
 
230
236
  const coreDir = locateCoreDir(appDir);
231
237
  // Switch the importmap between dist/ bundles and src/ per-file
@@ -810,7 +816,7 @@ export async function createRequestHandler(opts) {
810
816
  // path skipped it. This runs on the first warm AND after every rebuild,
811
817
  // so the pruned map is the single source of truth.
812
818
  const reachable = await scan();
813
- ({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
819
+ ({ imports, integrity } = prunePinToReachable(imports, integrity, reachable, { appDir }));
814
820
  }
815
821
  await setVendorEntries(imports, integrity);
816
822
  return v.ok;
@@ -368,6 +368,32 @@ const DEV_STYLES_SRC = readFileSync(new URL('../dev-styles.js', import.meta.url)
368
368
  * @param {string} bp the normalized base path (`''` = no-op)
369
369
  * @returns {string}
370
370
  */
371
+ /** Idle close for the reload stream, in ms (#1507); 0 = off. Set by the dev handler. */
372
+ let _reloadIdleMs = 0;
373
+
374
+ /**
375
+ * Record `webjs.dev.reloadIdle` / `WEBJS_DEV_RELOAD_IDLE` (#1507) for the served
376
+ * reload scripts. A module-level switch like `setEmbedOrigins`, set once per
377
+ * dev handler.
378
+ * @param {number} ms
379
+ */
380
+ export function setReloadIdleMs(ms) {
381
+ _reloadIdleMs = Number.isFinite(ms) && ms > 0 ? Math.round(ms) : 0;
382
+ }
383
+
384
+ /**
385
+ * Resolve the reload idle close in ms: a set `WEBJS_DEV_RELOAD_IDLE` (seconds)
386
+ * wins over `webjs.dev.reloadIdle` (seconds); anything not a positive number
387
+ * is off.
388
+ * @param {string | undefined} envRaw
389
+ * @param {unknown} configured
390
+ * @returns {number}
391
+ */
392
+ export function resolveReloadIdleMs(envRaw, configured) {
393
+ const raw = typeof envRaw === 'string' && envRaw.trim() !== '' ? Number(envRaw) : configured;
394
+ return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.round(raw * 1000) : 0;
395
+ }
396
+
371
397
  export function reloadClientJs(bp) {
372
398
  // The overlay renderer uses textContent throughout (never innerHTML), so the
373
399
  // error message / code frame can never inject markup (#264). Served only in
@@ -471,8 +497,8 @@ function __webjsDirectEvents() {
471
497
  // the reload debounce (#1397). The shim scope has no setTimeout, so the relay
472
498
  // picks up the tab's own timers.
473
499
  const scope = {};
474
- startReloadWorker(scope, EventSource, ${eventsUrl});
475
- scope.onconnect({ ports: [{ start() {}, postMessage(m) {
500
+ startReloadWorker(scope, EventSource, ${eventsUrl}, { idleMs: ${_reloadIdleMs} });
501
+ const shim = { start() {}, postMessage(m) {
476
502
  // Nothing may throw out of here, and nothing may be silently dropped.
477
503
  //
478
504
  // The relay's fanout deletes a port whose postMessage throws, which is the
@@ -495,7 +521,52 @@ function __webjsDirectEvents() {
495
521
  } catch (_) {
496
522
  console.error('[webjs] dev reload handler threw', _);
497
523
  }
498
- } }] });
524
+ } };
525
+ scope.onconnect({ ports: [shim] });
526
+ __webjsReportVisibility(function (msg) { if (shim.onmessage) shim.onmessage({ data: msg }); });
527
+ }
528
+ // Tell the relay whether this tab is on screen (#1507). The relay holds the
529
+ // live-reload stream open only while some tab is visible, so a backgrounded
530
+ // dev tab keeps no request in flight and sends nothing; it reopens on return
531
+ // and its hello says whether an edit landed meanwhile. \`bye\` on pagehide
532
+ // drops the tab outright, and a bfcache restore reports in again.
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();
549
+ function report() {
550
+ try { send({ type: 'visibility', visible: document.visibilityState !== 'hidden' }); } catch (_) { /* relay gone */ }
551
+ }
552
+ document.addEventListener('visibilitychange', report);
553
+ addEventListener('pagehide', function () { try { send({ type: 'bye' }); } catch (_) { /* relay gone */ } });
554
+ addEventListener('pageshow', function (e) { if (e.persisted) { sendPage(); report(); } });
555
+ report();
556
+ // Interaction keeps an idle-closing stream (webjs.dev.reloadIdle) open and
557
+ // reopens a closed one, at most once a second. The dev embed bridge calls
558
+ // __webjsDevActivity for each host command, so a framing tool can resume it.
559
+ var lastActivity = 0;
560
+ function activity() {
561
+ var now = Date.now();
562
+ if (now - lastActivity < 1000) return;
563
+ lastActivity = now;
564
+ try { send({ type: 'activity' }); } catch (_) { /* relay gone */ }
565
+ }
566
+ ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'focus'].forEach(function (t) {
567
+ addEventListener(t, activity, { capture: true, passive: true });
568
+ });
569
+ globalThis.__webjsDevActivity = activity;
499
570
  }
500
571
  try {
501
572
  if (typeof SharedWorker !== 'undefined') {
@@ -506,6 +577,7 @@ try {
506
577
  else if (m.type === 'webjs-error') __webjsApplyError(m.data);
507
578
  };
508
579
  w.port.start();
580
+ __webjsReportVisibility(function (msg) { w.port.postMessage(msg); });
509
581
  } else {
510
582
  __webjsDirectEvents();
511
583
  }
@@ -527,7 +599,7 @@ try {
527
599
  export function reloadWorkerJs(bp) {
528
600
  return `// webjs dev reload worker (one shared connection for all tabs)
529
601
  ${RELOAD_WORKER_SRC}
530
- startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
602
+ startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))}, { idleMs: ${_reloadIdleMs} });
531
603
  `;
532
604
  }
533
605
 
package/src/dev/server.js CHANGED
@@ -6,7 +6,8 @@ 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, DEV_BOOT_ID, 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';
@@ -102,9 +103,15 @@ function startNodeListener(ctx) {
102
103
  // promptly. The `hello` data is a per-process boot id (#893): the client
103
104
  // reloads on a reconnect ONLY when it changes (a real restart), so a
104
105
  // transient reconnect never triggers a spurious reload.
105
- res.write(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`);
106
- // Register a node client wrapper in the shared hub: the fanout + keepalive
107
- // live in SseHub; only the transport write (res.write / res.end) is local.
106
+ res.write(hub.helloFrame());
107
+ // The stream is silent between events (#1507), so no socket timeout may
108
+ // reap it. node:http sets none by default, but an inherited or future
109
+ // one would close a live reload stream that is simply quiet, so it is
110
+ // switched off for this request alone.
111
+ req.setTimeout?.(0);
112
+ res.setTimeout?.(0);
113
+ // Register a node client wrapper in the shared hub: the fanout lives in
114
+ // SseHub; only the transport write (res.write / res.end) is local.
108
115
  const client = {
109
116
  send: (s) => { try { res.write(s); } catch {} },
110
117
  close: () => { try { res.end(); } catch {} },
@@ -189,8 +196,7 @@ function startNodeListener(ctx) {
189
196
  server,
190
197
  close: () => new Promise((r) => {
191
198
  if (watcherAbort) watcherAbort.abort();
192
- // Clear the shared SSE keepalive timer so repeated startServer/close cycles
193
- // (e.g. across a test run) don't accumulate live intervals.
199
+ // Close every open live-reload stream.
194
200
  hub.closeAll();
195
201
  server.close(() => r());
196
202
  }),
@@ -224,6 +230,9 @@ export async function startServer(opts) {
224
230
  // identically on both). Built before the handler so its onReload / onDevError
225
231
  // callbacks can fan out through it.
226
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 }));
227
236
  let app;
228
237
  try {
229
238
  app = await createRequestHandler({
@@ -236,8 +245,6 @@ export async function startServer(opts) {
236
245
  onDevError: (frame) => hub.devError(frame),
237
246
  });
238
247
  } catch (e) {
239
- // The hub starts its keepalive interval in its constructor (before this
240
- // await), so a boot failure must clear it rather than leak a live timer.
241
248
  hub.closeAll();
242
249
  throw e;
243
250
  }
@@ -297,6 +297,10 @@ export function installEmbedBridge(origins, opts) {
297
297
  var d = e.data;
298
298
  if (!d || typeof d !== 'object' || d.source !== 'webjs-embed-host') return;
299
299
  known = e.origin;
300
+ // A host command is activity: it reopens a live-reload stream the dev
301
+ // server closed for idleness (webjs.dev.reloadIdle, #1507), and an edit
302
+ // made meanwhile then reloads the page.
303
+ if (typeof win.__webjsDevActivity === 'function') { try { win.__webjsDevActivity(); } catch (_) { /* no reload client */ } }
300
304
  if (d.type === 'reload') {
301
305
  win.location.reload();
302
306
  } else if (d.type === 'navigate') {
@@ -319,6 +323,7 @@ export function installEmbedBridge(origins, opts) {
319
323
  } else if (d.type === 'inspect') {
320
324
  setInspect(d.enabled === true);
321
325
  }
326
+ // { type: 'resume' } needs nothing beyond the activity call above.
322
327
  });
323
328
 
324
329
  return {
@@ -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
+ }
@@ -77,58 +77,135 @@ export function parseVerdict(data) {
77
77
  return 'reload';
78
78
  }
79
79
 
80
+ /** First reconnect wait after the stream drops (#1507), doubled per failure. */
81
+ export const RECONNECT_BASE_MS = 300;
82
+ /** The longest wait between reconnect attempts (#1507). */
83
+ export const RECONNECT_MAX_MS = 30000;
84
+
85
+ /**
86
+ * Read a `hello` frame's data (#893, #1507): `{ boot, seq }` JSON, or the bare
87
+ * boot id an older server sends (seq unknown).
88
+ * @param {string} data
89
+ * @returns {{ boot: string, seq: number | null }}
90
+ */
91
+ export function parseHello(data) {
92
+ try {
93
+ const o = JSON.parse(data);
94
+ if (o && typeof o === 'object' && typeof o.boot === 'string') {
95
+ return { boot: o.boot, seq: typeof o.seq === 'number' ? o.seq : null };
96
+ }
97
+ } catch (_) { /* a bare boot id */ }
98
+ return { boot: String(data), seq: null };
99
+ }
100
+
80
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
+
118
+ /** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
119
+ function reloadSeq(data) {
120
+ try {
121
+ const o = JSON.parse(data);
122
+ return o && typeof o.seq === 'number' ? o.seq : null;
123
+ } catch (_) { return null; }
124
+ }
125
+
126
+ /**
127
+ * Run the relay.
128
+ *
129
+ * The stream is open only while at least one tab is VISIBLE (#1507). Each tab
130
+ * reports `{ type: 'visibility', visible }` on `visibilitychange` and
131
+ * `{ type: 'bye' }` on `pagehide`; when the last visible tab goes away the
132
+ * relay closes its `EventSource`, so a backgrounded dev tab holds no request
133
+ * open and sends nothing, and it reopens the moment one becomes visible. An
134
+ * edit made meanwhile is not lost: the reopened stream's `hello` carries the
135
+ * server's boot id and reload `seq`, and a difference from what this relay last
136
+ * saw is a reload.
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
+ *
148
+ * The browser's own EventSource retry is replaced by an explicit one with
149
+ * backoff (300ms doubling to 30s, reset by the next `hello`), so a server that
150
+ * is gone for a while is probed less and less often instead of every 300ms.
151
+ *
81
152
  * @param {{ onconnect: any, setTimeout?: any, clearTimeout?: any }} scope the
82
153
  * worker global (`self`), or a plain shim object for the per-tab fallback.
83
154
  * Timers are read off it when it has them, which is what lets a browser test
84
155
  * drive the debounce on a fake clock.
85
156
  * @param {new (url: string) => any} EventSourceCtor the `EventSource` constructor
86
157
  * @param {string} eventsUrl the base-path-aware `/__webjs/events` URL
158
+ * @param {{ idleMs?: number }} [opts] `idleMs` (#1507, from `webjs.dev.reloadIdle`
159
+ * / `WEBJS_DEV_RELOAD_IDLE`): close the stream after this long with no edit
160
+ * and no interaction in any tab, and reopen on the next interaction. `0` (the
161
+ * default) keeps it open while a tab is visible.
87
162
  */
88
- export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
89
- /** @type {Set<any>} */
90
- const ports = new Set();
163
+ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
164
+ const idleMs = opts && opts.idleMs > 0 ? opts.idleMs : 0;
165
+ /** @type {Map<any, boolean>} each connected tab's port, and whether it is visible */
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();
91
169
  /** @type {string | null} the last error frame, cached for late-joining tabs */
92
170
  let lastError = null;
93
171
  /** @type {string | null} the last-seen per-process boot id (#893) */
94
172
  let lastBoot = null;
173
+ /** @type {number | null} the last reload seq seen from that process (#1507) */
174
+ let lastSeq = null;
95
175
 
96
- // Timers come from the worker global when it has them (the real SharedWorker
97
- // scope does, and so does a tab running the per-tab fallback), else the
98
- // ambient globals. Reading them off `scope` is what lets the browser test
99
- // drive a fake clock and keep its assertions synchronous, and calling them as
100
- // `timers.setTimeout(...)` keeps `this` the global in a real browser.
101
176
  const timers = scope && typeof scope.setTimeout === 'function' ? scope : globalThis;
102
177
 
103
- // A MessagePort has no reliable close event, so prune a port when a post to it
104
- // throws (a closed tab). Some browsers silently no-op instead of throwing,
105
- // leaving a dead port in the set, but that is a harmless dev-only no-op and
106
- // the set is bounded by the tabs opened in one session.
107
178
  function fanout(msg) {
108
- for (const p of ports) {
179
+ for (const p of ports.keys()) {
109
180
  try { p.postMessage(msg); } catch (_) { ports.delete(p); }
110
181
  }
111
182
  }
112
183
 
113
- // The ONE debounced reload emitter (#1397). Two timers rather than a clock:
114
- // the quiet timer is re-armed by every signal, the cap timer is armed once
115
- // per batch and NEVER re-armed, so the cap measures from the first signal of
116
- // the batch instead of sliding with the burst. Whichever fires first emits
117
- // and cancels the other, which also starts a fresh batch.
118
184
  /** @type {any} */ let quietTimer = null;
119
185
  /** @type {any} */ let capTimer = null;
120
186
 
121
187
  /** @type {string} the strongest verdict seen in the CURRENT batch (#1398) */
122
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();
123
193
 
124
194
  function emitReload() {
125
195
  if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
126
196
  if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
127
197
  const v = batchVerdict;
128
- // Resetting HERE rather than in requestReload is load-bearing: emitReload is
129
- // the single place a batch ends, and it is reached from both timers.
130
198
  batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
131
- 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
+ }
132
209
  }
133
210
 
134
211
  /** Strength rank; an unrecognised name ranks strongest, so it can only ever
@@ -138,60 +215,129 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
138
215
  return i === -1 ? 0 : i;
139
216
  }
140
217
 
141
- /** @param {string} verdict */
142
- function requestReload(verdict) {
143
- // Lower rank is stronger, so keep the minimum across the batch.
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);
144
224
  if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
145
225
  if (quietTimer !== null) timers.clearTimeout(quietTimer);
146
226
  quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
147
227
  if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
148
228
  }
149
229
 
150
- const es = new EventSourceCtor(eventsUrl);
151
-
152
- // The `hello` frame fires on every (re)connect and carries the server's
153
- // per-process boot id (#893). A full server restart (Node's `node --watch`)
154
- // drops this connection, and if the in-process rebuild's `reload` frame was
155
- // killed with the old process no reload was delivered, so the edit would need
156
- // a MANUAL refresh. The browser auto-reconnects to the FRESH process, whose
157
- // boot id differs, so a CHANGED id is the edit signal: broadcast a reload (the
158
- // tab still gates it on a readiness probe). A transient reconnect (sleep/wake,
159
- // a network blip, a tab evicted at the HTTP/1.1 cap) reconnects to the SAME
160
- // process with the SAME id, so it never reloads. The first `hello` only
161
- // records the baseline. Both reload paths route through the ONE debounced
162
- // emitter above (#1397), since a burst of edits produces one signal of each
163
- // kind per save.
164
- //
165
- // A changed boot id is unconditionally a FULL RELOAD (#1398), and it is NOT a
166
- // "no verdict" signal to be overridden by a lighter one already in the batch.
167
- // A restart carries no filename, so nothing survives it to classify, and a
168
- // burst can hold a page edit whose in-process frame was delivered next to a
169
- // component edit whose frame died with the old process. In that burst the
170
- // changed boot id is the component edit's ONLY trace.
171
- es.addEventListener('hello', (e) => {
172
- if (lastBoot !== null && e.data !== lastBoot) requestReload('reload');
173
- lastBoot = e.data;
174
- });
175
-
176
- // The cached error is cleared IMMEDIATELY, not on the debounced emit: a tab
177
- // connecting during the pending window must not be replayed an overlay for an
178
- // error the rebuild already fixed. `webjs-error` itself stays undebounced,
179
- // since an overlay has to appear at once and it is not a reload.
180
- es.addEventListener('reload', (e) => { lastError = null; requestReload(parseVerdict(e.data)); });
181
- es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
230
+ /** @type {any} */ let es = null;
231
+ /** @type {any} */ let retryTimer = null;
232
+ let retryDelay = RECONNECT_BASE_MS;
233
+
234
+ /** @type {any} */ let idleTimer = null;
235
+
236
+ // Idle close (#1507). A host that counts an in-flight request as activity
237
+ // (a pilots sandbox does, for the request's whole life) never sleeps while
238
+ // the stream is open, however quiet it is. With `idleMs` set, a stream that
239
+ // has carried nothing and seen no interaction for that long is closed, and
240
+ // the next interaction, a tab showing, or a host command through the embed
241
+ // bridge reopens it; the hello seq then says whether an edit landed meanwhile.
242
+ function touch() {
243
+ if (!idleMs) return;
244
+ if (idleTimer !== null) timers.clearTimeout(idleTimer);
245
+ idleTimer = timers.setTimeout(() => { idleTimer = null; close(); }, idleMs);
246
+ }
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
+
255
+ function anyVisible() {
256
+ for (const v of ports.values()) if (v) return true;
257
+ return false;
258
+ }
259
+
260
+ function open() {
261
+ if (es || retryTimer !== null) return;
262
+ es = new EventSourceCtor(eventsUrl);
263
+ const mine = es;
264
+ touch();
265
+ es.addEventListener('hello', (e) => {
266
+ retryDelay = RECONNECT_BASE_MS;
267
+ touch();
268
+ const h = parseHello(e.data);
269
+ if (lastBoot !== null && (h.boot !== lastBoot || (h.seq !== null && lastSeq !== null && h.seq !== lastSeq))) {
270
+ requestReload('reload');
271
+ }
272
+ lastBoot = h.boot;
273
+ lastSeq = h.seq;
274
+ checkPages();
275
+ });
276
+ es.addEventListener('reload', (e) => {
277
+ touch();
278
+ lastError = null;
279
+ const n = reloadSeq(e.data);
280
+ if (n !== null) lastSeq = n;
281
+ requestReload(parseVerdict(e.data));
282
+ });
283
+ es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
284
+ // The stream dropped (a restarting server, a network blip, a host that
285
+ // slept). Close it so the browser's fixed-interval retry never runs, and
286
+ // come back with backoff, but only while someone is looking.
287
+ es.addEventListener('error', () => {
288
+ if (es !== mine) return;
289
+ try { es.close(); } catch (_) { /* already closed */ }
290
+ es = null;
291
+ if (!anyVisible()) return;
292
+ retryTimer = timers.setTimeout(() => { retryTimer = null; if (anyVisible()) open(); }, retryDelay);
293
+ retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX_MS);
294
+ });
295
+ }
296
+
297
+ function close() {
298
+ if (idleTimer !== null) { timers.clearTimeout(idleTimer); idleTimer = null; }
299
+ if (retryTimer !== null) { timers.clearTimeout(retryTimer); retryTimer = null; }
300
+ if (es) { try { es.close(); } catch (_) { /* already closed */ } es = null; }
301
+ retryDelay = RECONNECT_BASE_MS;
302
+ }
303
+
304
+ function sync() {
305
+ if (anyVisible()) open(); else close();
306
+ }
182
307
 
183
308
  scope.onconnect = (e) => {
184
309
  const port = e.ports[0];
185
- ports.add(port);
310
+ // A tab counts as visible until it says otherwise, so a client older than
311
+ // the visibility messages keeps the stream open exactly as before.
312
+ ports.set(port, true);
313
+ port.onmessage = (m) => {
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
+ }
325
+ if (d.type === 'visibility') ports.set(port, d.visible !== false);
326
+ else if (d.type === 'bye') { ports.delete(port); pages.delete(port); }
327
+ else if (d.type === 'activity') { if (!ports.get(port)) return; touch(); }
328
+ else return;
329
+ sync();
330
+ };
186
331
  port.start();
187
- // A tab that connects AFTER a breaking edit still needs the current overlay.
188
- // The single shared EventSource already consumed the server's replay (#264),
189
- // so the worker caches the last error and hands it to each new tab itself.
190
332
  if (lastError != null) {
191
333
  try { port.postMessage({ type: 'webjs-error', data: lastError }); } catch (_) { ports.delete(port); }
192
334
  }
335
+ sync();
193
336
  };
194
337
 
195
- // Returned for tests; the served worker ignores it.
196
- return { ports, es };
338
+ return {
339
+ ports,
340
+ /** The live EventSource, or null while paused or between retries. */
341
+ get es() { return es; },
342
+ };
197
343
  }
@@ -28,8 +28,9 @@
28
28
  * natively on Bun), the SAME negotiation + compressor factory the node shell
29
29
  * uses (shared in `listener-core.js`), so the Bun path gets brotli too (#517).
30
30
  * - **Timeouts** (#237): the node `requestTimeout` maps to Bun's single
31
- * `idleTimeout` (seconds), clamped above the 25s SSE keepalive so a dev
32
- * live-reload stream is never reaped.
31
+ * `idleTimeout` (seconds); the dev live-reload stream opts out of it per
32
+ * request with `server.timeout(req, 0)` (#1507), since it is silent
33
+ * between events.
33
34
  * - **Proxy IP**: `server.requestIP(req).address` stamps the framework-trusted
34
35
  * `x-webjs-remote-ip` (the node socket-address equivalent), after stripping
35
36
  * any spoofed inbound value.
@@ -59,7 +60,6 @@ import {
59
60
  MAX_SYNC_COMPRESS_BYTES,
60
61
  varyWithAcceptEncoding,
61
62
  readBufferedOrStream,
62
- DEV_BOOT_ID,
63
63
  } from './listener-core.js';
64
64
 
65
65
  /* global Bun */
@@ -91,6 +91,9 @@ export function startBunListener(ctx) {
91
91
  // on node, but on Bun it is a perfectly ordinary streaming Response.
92
92
  if (isEventsPath(url.pathname, basePathStr)) {
93
93
  if (!dev) return new Response(null, { status: 404 });
94
+ // Silent between events (#1507): switch Bun's idleTimeout off for this
95
+ // one request, so a quiet live-reload stream is never reaped.
96
+ try { srv.timeout?.(req, 0); } catch { /* an older Bun: idleTimeout still applies */ }
94
97
  return bunSseResponse(req, hub, app);
95
98
  }
96
99
 
@@ -298,11 +301,9 @@ function bunSseResponse(req, hub, app) {
298
301
  send: (s) => { try { controller.enqueue(enc.encode(s)); } catch {} },
299
302
  close: () => { try { controller.close(); } catch {} },
300
303
  };
301
- // `retry: 300` shrinks the EventSource reconnect backoff so a reconnect
302
- // after a dev restart re-triggers a reload promptly (#893); the `hello`
303
- // data is the per-process boot id so a reconnect reloads only on a real
304
- // restart, matching the node:http shell.
305
- controller.enqueue(enc.encode(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`));
304
+ // The hello frame (`SseHub.helloFrame`, shared with the node:http shell)
305
+ // carries the retry hint, the boot id and the reload seq (#893, #1507).
306
+ controller.enqueue(enc.encode(hub.helloFrame()));
306
307
  hub.add(client);
307
308
  // Replay an unresolved dev error (#264) so a tab connecting AFTER the
308
309
  // breaking edit still shows the overlay.
@@ -488,8 +489,8 @@ async function maybeCompress(resp, req) {
488
489
 
489
490
  /**
490
491
  * Map the node `requestTimeout` (ms) to Bun's single `idleTimeout` (seconds). Bun
491
- * caps it at 255s; we clamp it above the 25s SSE keepalive so a dev live-reload
492
- * stream is never reaped as idle. `0` (the node "disable" sentinel) disables it
492
+ * caps it at 255s; the floor of 30s is kept for ordinary requests. The dev
493
+ * live-reload stream is exempt per request (`server.timeout(req, 0)`, #1507). `0` (the node "disable" sentinel) disables it
493
494
  * on Bun too.
494
495
  * @param {{ requestTimeout?: number } | undefined} timeouts
495
496
  */
@@ -335,22 +335,28 @@ export function loadWsModule(file, dev) {
335
335
 
336
336
  /**
337
337
  * Runtime-neutral SSE registry + fanout for dev live-reload and the dev error
338
- * overlay (#264). Owns the connected-client Set and the keepalive timer; the
339
- * fanout writes plain SSE frames through each client's `send`. Each shell adds a
340
- * thin client wrapper over its own transport (node `res.write`, a Bun
338
+ * overlay (#264). Owns the connected-client Set; the fanout writes plain SSE
339
+ * frames through each client's `send`. Each shell adds a thin client wrapper
340
+ * over its own transport (node `res.write`, a Bun
341
341
  * `ReadableStreamDefaultController`), so this fanout logic is written once.
342
+ *
343
+ * The stream is SILENT between events (#1507). It used to write a `: ka`
344
+ * comment every 25 seconds per open tab, which kept the host busy forever: a
345
+ * sandbox or a laptop that sleeps on network quiet never slept while a tab
346
+ * showed the app. Each shell instead switches the server's idle timeout off
347
+ * for this one request, so a silent stream is never reaped, and the browser
348
+ * relay reconnects (with backoff) only when the stream actually drops.
349
+ *
350
+ * `seq` counts the reload frames this process has sent. It rides every reload
351
+ * frame and the `hello` frame, so a relay that closed its stream while every
352
+ * tab was hidden can tell, on reconnecting, that it missed an edit.
342
353
  */
343
354
  export class SseHub {
344
- /** @param {{ keepaliveMs?: number }} [opts] */
345
- constructor(opts = {}) {
355
+ constructor() {
346
356
  /** @type {Set<{ send: (s: string) => void, close: () => void }>} */
347
357
  this.clients = new Set();
348
- // Keepalive: a comment frame every 25s defeats proxy idle timeouts (and, on
349
- // Bun, keeps the connection under the server idleTimeout). Cheap and safe:
350
- // SSE comments are ignored by the client. Unref'd so it never holds the
351
- // process open.
352
- this._timer = setInterval(() => this._raw(': ka\n\n'), opts.keepaliveMs ?? 25_000);
353
- if (typeof this._timer.unref === 'function') this._timer.unref();
358
+ /** Reload frames sent by this process (#1507). */
359
+ this.seq = 0;
354
360
  }
355
361
 
356
362
  /** @param {{ send: (s: string) => void, close: () => void }} client */
@@ -365,31 +371,45 @@ export class SseHub {
365
371
  }
366
372
  }
367
373
 
374
+ /**
375
+ * The frame that opens every stream. `retry: 300` shrinks the browser's
376
+ * native reconnect wait (#893). The `hello` data names this process (its
377
+ * boot id, so a reconnect to a RESTARTED server reloads) and how many reload
378
+ * frames it has sent (`seq`, so a reconnect after a pause that missed one
379
+ * reloads too). JSON since #1507; a relay older than that compares the raw
380
+ * data, sees a change, and reloads once, which is the safe direction.
381
+ * @returns {string}
382
+ */
383
+ helloFrame() {
384
+ return `retry: 300\nevent: hello\ndata: ${JSON.stringify({ boot: DEV_BOOT_ID, seq: this.seq })}\n\n`;
385
+ }
386
+
368
387
  /**
369
388
  * Push a live-reload event to every open tab.
370
389
  *
371
390
  * The frame carries the change's `{ v, by, why }` classification (#1398) as a
372
- * single-line JSON payload, matching `devError` below. The browser relay
373
- * resolves ANY payload it cannot read to `reload`, and an absent or malformed
374
- * verdict is emitted as `reload` here too, so the fail-safe holds on both ends
375
- * of the wire and a tab running against a restarted server can never be
376
- * skewed into a lighter response than the change deserves.
391
+ * single-line JSON payload, matching `devError` below, plus the frame's `seq`
392
+ * (#1507). The browser relay resolves ANY payload it cannot read to `reload`,
393
+ * and an absent or malformed verdict is emitted as `reload` here too, so the
394
+ * fail-safe holds on both ends of the wire and a tab running against a
395
+ * restarted server can never be skewed into a lighter response than the
396
+ * change deserves.
377
397
  *
378
398
  * @param {{ v?: string, by?: string, why?: string } | null} [verdict]
379
399
  */
380
400
  reload(verdict) {
381
401
  const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
382
- this._raw(`event: reload\ndata: ${JSON.stringify(v)}\n\n`);
402
+ this.seq++;
403
+ this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq })}\n\n`);
383
404
  }
384
405
 
385
406
  /** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */
386
407
  devError(frame) { this._raw(`event: webjs-error\ndata: ${JSON.stringify(frame)}\n\n`); }
387
408
 
388
- /** Close every client and stop the keepalive (graceful shutdown). */
409
+ /** Close every client (graceful shutdown). */
389
410
  closeAll() {
390
411
  for (const c of this.clients) { try { c.close(); } catch {} }
391
412
  this.clients.clear();
392
- clearInterval(this._timer);
393
413
  }
394
414
  }
395
415
 
package/src/router.js CHANGED
@@ -187,6 +187,12 @@ export async function buildRouteTable(appDir) {
187
187
  }
188
188
 
189
189
  pages.sort(compareSpecificity);
190
+ // Route handlers rank by the SAME positional specificity as pages (#1510).
191
+ // They used to keep filesystem walk order, so a catch-all such as
192
+ // `api/auth/[...path]/route.ts` could answer for a more specific sibling
193
+ // like `api/auth/callback/github/route.ts`, and which one won depended on
194
+ // the order the directory happened to list in.
195
+ apis.sort(compareSpecificity);
190
196
  // `instrumentation-client.*` is an app-ROOT convention file, not a router
191
197
  // stem, but it is a browser-bound entry (the boot imports it first), so the
192
198
  // table is where every consumer already looks for one. Resolved HERE rather
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
  // ---------------------------------------------------------------------------
@@ -208,6 +208,11 @@
208
208
  "type": "array",
209
209
  "items": { "type": "string" }
210
210
  },
211
+ "reloadIdle": {
212
+ "description": "Live-reload idle close (#1507), DEV-ONLY and off by default. Seconds with no edit and no interaction in any tab after which the dev live-reload stream is closed, so a host that counts an open request as activity (a sandbox that suspends when idle) can sleep while a preview tab is open; the next interaction, a tab becoming visible, or an embed-bridge host command reopens it, and an edit made meanwhile reloads the page. WEBJS_DEV_RELOAD_IDLE (seconds) overrides it. Read by the server (readDevToolingFromApp). Ignored by webjs start.",
213
+ "type": "number",
214
+ "minimum": 0
215
+ },
211
216
  "sourceLocations": {
212
217
  "description": "Dev source locations (#1499, #1504), DEV-ONLY and off by default. When true, element opening tags in the app's html templates carry data-webjs-src=\"<app-relative-file>:<line>\" in SSR output and client renders. WEBJS_SOURCE_LOCATIONS=1 or 0 overrides it for one run. Read by the server (readDevToolingFromApp). Ignored by webjs start.",
213
218
  "type": "boolean"