@webjsdev/server 0.8.68 → 0.8.70

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.68",
3
+ "version": "0.8.70",
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
@@ -317,6 +317,28 @@ export async function readDevWatchPathsFromApp(appDir) {
317
317
  return out;
318
318
  }
319
319
 
320
+ /**
321
+ * The dev tooling switches in `webjs.dev` (#1504): `embedOrigins` (the embed
322
+ * bridge, #1498), `sourceLocations` (#1499) and `reloadIdle` (#1507). Raw values; the env overrides
323
+ * and validation live with each feature (`resolveEmbedOrigins`,
324
+ * `sourceLocationsRequested`). A missing or unreadable package.json is both
325
+ * off.
326
+ *
327
+ * @param {string} appDir
328
+ * @returns {Promise<{ embedOrigins: unknown, sourceLocations: unknown, reloadIdle: unknown }>}
329
+ */
330
+ export async function readDevToolingFromApp(appDir) {
331
+ let dev;
332
+ try {
333
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
334
+ dev = pkg && pkg.webjs && pkg.webjs.dev;
335
+ } catch {
336
+ dev = undefined;
337
+ }
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
+ }
341
+
320
342
  /**
321
343
  * Resolve the node:http server timeouts (issue #237) from the app's
322
344
  * package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
@@ -41,7 +41,7 @@ import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
41
41
  import { resolveActionIdentity } from '../form-action-identity.js';
42
42
  import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, resolveAssetUrl } from '../asset-hash.js';
43
43
  import { applySecurityHeaders, webRequestIsHttps } from '../headers.js';
44
- import { parseEmbedOrigins, setEmbedOrigins, allowEmbedFraming } from '../dev-embed.js';
44
+ import { resolveEmbedOrigins, setEmbedOrigins, allowEmbedFraming } from '../dev-embed.js';
45
45
  import {
46
46
  applyRedirects,
47
47
  applyTrailingSlash,
@@ -62,10 +62,13 @@ import {
62
62
  readAllowedOriginsFromApp,
63
63
  readCspConfigFromApp,
64
64
  readBodyLimitsFromApp,
65
+ readDevToolingFromApp,
65
66
  } from './config.js';
66
67
  import {
67
68
  exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
68
69
  appTopLevelDirs, locateCoreDir,
70
+ setReloadIdleMs,
71
+ resolveReloadIdleMs,
69
72
  } from './helpers.js';
70
73
  import {
71
74
  handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
@@ -216,12 +219,19 @@ export async function createRequestHandler(opts) {
216
219
  // analysis pass re-reads it each rebuild so a dev toggle takes effect live.
217
220
  setClientRouterEnabled(await readClientRouterEnabled(appDir));
218
221
 
219
- // Dev embed bridge (#1498): `WEBJS_EMBED_ORIGINS` lets the listed parent
222
+ // Dev embed bridge (#1498): `WEBJS_EMBED_ORIGINS` (or `webjs.dev.embedOrigins`) lets the listed parent
220
223
  // origins frame this dev server and receive the bridge's messages. Read once
221
224
  // here, and only in dev, so a production handler records no origins and
222
225
  // neither injects the script nor relaxes a framing header.
223
- const embedOriginsValue = dev ? parseEmbedOrigins(process.env.WEBJS_EMBED_ORIGINS) : [];
226
+ // `webjs.dev.embedOrigins` in package.json is the default (#1504); a set
227
+ // env var replaces it for one run.
228
+ const devTooling = dev ? await readDevToolingFromApp(appDir) : { embedOrigins: undefined, sourceLocations: undefined, reloadIdle: undefined };
229
+ const embedOriginsValue = dev ? resolveEmbedOrigins(process.env.WEBJS_EMBED_ORIGINS, devTooling.embedOrigins) : [];
224
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);
225
235
 
226
236
  const coreDir = locateCoreDir(appDir);
227
237
  // Switch the importmap between dist/ bundles and src/ per-file
@@ -288,13 +298,15 @@ export async function createRequestHandler(opts) {
288
298
  // on seeding would mean `webjs.seed: false` silently broke every no-JS form.
289
299
  await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
290
300
 
291
- // Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` under `webjs dev`
301
+ // Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
302
+ // `webjs.dev.sourceLocations: true`) under `webjs dev`
292
303
  // stamps `data-webjs-src="<file>:<line>"` on the element opening tags of the
293
304
  // app's `html` templates. Read once (the SSR load hook is process-global and
294
305
  // cannot be uninstalled), never in prod. The browser half reads
295
306
  // `state.sourceLocations` in serve.js; the SSR half is this load hook, which
296
307
  // must be in place before any app module is imported.
297
- const sourceLocations = dev && sourceLocationsRequested();
308
+ // `webjs.dev.sourceLocations` is the default (#1504); the env var wins.
309
+ const sourceLocations = dev && sourceLocationsRequested(process.env, devTooling.sourceLocations);
298
310
  if (sourceLocations) {
299
311
  registerSourceLocationHook(appDir);
300
312
  // Module URLs are realpaths, so a symlinked app dir registers both forms.
@@ -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,37 @@ 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
+ function report() {
535
+ try { send({ type: 'visibility', visible: document.visibilityState !== 'hidden' }); } catch (_) { /* relay gone */ }
536
+ }
537
+ document.addEventListener('visibilitychange', report);
538
+ addEventListener('pagehide', function () { try { send({ type: 'bye' }); } catch (_) { /* relay gone */ } });
539
+ addEventListener('pageshow', function (e) { if (e.persisted) report(); });
540
+ report();
541
+ // Interaction keeps an idle-closing stream (webjs.dev.reloadIdle) open and
542
+ // reopens a closed one, at most once a second. The dev embed bridge calls
543
+ // __webjsDevActivity for each host command, so a framing tool can resume it.
544
+ var lastActivity = 0;
545
+ function activity() {
546
+ var now = Date.now();
547
+ if (now - lastActivity < 1000) return;
548
+ lastActivity = now;
549
+ try { send({ type: 'activity' }); } catch (_) { /* relay gone */ }
550
+ }
551
+ ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'focus'].forEach(function (t) {
552
+ addEventListener(t, activity, { capture: true, passive: true });
553
+ });
554
+ globalThis.__webjsDevActivity = activity;
499
555
  }
500
556
  try {
501
557
  if (typeof SharedWorker !== 'undefined') {
@@ -506,6 +562,7 @@ try {
506
562
  else if (m.type === 'webjs-error') __webjsApplyError(m.data);
507
563
  };
508
564
  w.port.start();
565
+ __webjsReportVisibility(function (msg) { w.port.postMessage(msg); });
509
566
  } else {
510
567
  __webjsDirectEvents();
511
568
  }
@@ -527,7 +584,7 @@ try {
527
584
  export function reloadWorkerJs(bp) {
528
585
  return `// webjs dev reload worker (one shared connection for all tabs)
529
586
  ${RELOAD_WORKER_SRC}
530
- startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
587
+ startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))}, { idleMs: ${_reloadIdleMs} });
531
588
  `;
532
589
  }
533
590
 
package/src/dev/server.js CHANGED
@@ -6,7 +6,7 @@ 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 } from '../listener-core.js';
10
10
  import { urlFromRequest } from '../forwarded.js';
11
11
  import { stripBasePath } from '../base-path.js';
12
12
  import { basePath } from '../importmap.js';
@@ -102,9 +102,15 @@ function startNodeListener(ctx) {
102
102
  // promptly. The `hello` data is a per-process boot id (#893): the client
103
103
  // reloads on a reconnect ONLY when it changes (a real restart), so a
104
104
  // 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.
105
+ res.write(hub.helloFrame());
106
+ // The stream is silent between events (#1507), so no socket timeout may
107
+ // reap it. node:http sets none by default, but an inherited or future
108
+ // one would close a live reload stream that is simply quiet, so it is
109
+ // switched off for this request alone.
110
+ req.setTimeout?.(0);
111
+ res.setTimeout?.(0);
112
+ // Register a node client wrapper in the shared hub: the fanout lives in
113
+ // SseHub; only the transport write (res.write / res.end) is local.
108
114
  const client = {
109
115
  send: (s) => { try { res.write(s); } catch {} },
110
116
  close: () => { try { res.end(); } catch {} },
@@ -189,8 +195,7 @@ function startNodeListener(ctx) {
189
195
  server,
190
196
  close: () => new Promise((r) => {
191
197
  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.
198
+ // Close every open live-reload stream.
194
199
  hub.closeAll();
195
200
  server.close(() => r());
196
201
  }),
@@ -236,8 +241,6 @@ export async function startServer(opts) {
236
241
  onDevError: (frame) => hub.devError(frame),
237
242
  });
238
243
  } 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
244
  hub.closeAll();
242
245
  throw e;
243
246
  }
@@ -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 {
package/src/dev-embed.js CHANGED
@@ -2,8 +2,9 @@
2
2
  * The dev embed bridge, SERVER half (#1498).
3
3
  *
4
4
  * `webjs dev` with `WEBJS_EMBED_ORIGINS` set (a comma-separated list of parent
5
- * origins, e.g. `https://crisp.app,http://localhost:8080`) makes an app ready
6
- * to be previewed inside an iframe on those origins:
5
+ * origins, e.g. `https://builder.example,http://localhost:8080`), or the app's
6
+ * `webjs.dev.embedOrigins` list (#1504), makes an app ready to be previewed
7
+ * inside an iframe on those origins:
7
8
  *
8
9
  * 1. every HTML document gets a small inline script (the browser half,
9
10
  * `dev-embed-client.js`) that reports page state to the parent and takes
@@ -73,6 +74,23 @@ export function parseEmbedOrigins(raw, opts) {
73
74
  return out;
74
75
  }
75
76
 
77
+ /**
78
+ * The effective embed origins (#1504): `WEBJS_EMBED_ORIGINS`, when set and
79
+ * non-empty, replaces the app's `webjs.dev.embedOrigins`; otherwise the config
80
+ * list applies. Both go through `parseEmbedOrigins`, so a bad entry is dropped
81
+ * with the same warning wherever it was written.
82
+ *
83
+ * @param {string | undefined} envRaw
84
+ * @param {unknown} configured the app's `webjs.dev.embedOrigins`
85
+ * @param {{ warn?: (msg: string) => void }} [opts]
86
+ * @returns {string[]}
87
+ */
88
+ export function resolveEmbedOrigins(envRaw, configured, opts) {
89
+ if (typeof envRaw === 'string' && envRaw.trim()) return parseEmbedOrigins(envRaw, opts);
90
+ if (!Array.isArray(configured)) return [];
91
+ return parseEmbedOrigins(configured.filter((o) => typeof o === 'string').join(','), opts);
92
+ }
93
+
76
94
  /**
77
95
  * Record the allowed embed origins for this process. `createRequestHandler`
78
96
  * calls it at construction: the parsed env value in dev, `[]` otherwise.
@@ -77,44 +77,81 @@ 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
+
101
+ /** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
102
+ function reloadSeq(data) {
103
+ try {
104
+ const o = JSON.parse(data);
105
+ return o && typeof o.seq === 'number' ? o.seq : null;
106
+ } catch (_) { return null; }
107
+ }
108
+
80
109
  /**
110
+ * Run the relay.
111
+ *
112
+ * The stream is open only while at least one tab is VISIBLE (#1507). Each tab
113
+ * reports `{ type: 'visibility', visible }` on `visibilitychange` and
114
+ * `{ type: 'bye' }` on `pagehide`; when the last visible tab goes away the
115
+ * relay closes its `EventSource`, so a backgrounded dev tab holds no request
116
+ * open and sends nothing, and it reopens the moment one becomes visible. An
117
+ * edit made meanwhile is not lost: the reopened stream's `hello` carries the
118
+ * server's boot id and reload `seq`, and a difference from what this relay last
119
+ * saw is a reload.
120
+ *
121
+ * The browser's own EventSource retry is replaced by an explicit one with
122
+ * backoff (300ms doubling to 30s, reset by the next `hello`), so a server that
123
+ * is gone for a while is probed less and less often instead of every 300ms.
124
+ *
81
125
  * @param {{ onconnect: any, setTimeout?: any, clearTimeout?: any }} scope the
82
126
  * worker global (`self`), or a plain shim object for the per-tab fallback.
83
127
  * Timers are read off it when it has them, which is what lets a browser test
84
128
  * drive the debounce on a fake clock.
85
129
  * @param {new (url: string) => any} EventSourceCtor the `EventSource` constructor
86
130
  * @param {string} eventsUrl the base-path-aware `/__webjs/events` URL
131
+ * @param {{ idleMs?: number }} [opts] `idleMs` (#1507, from `webjs.dev.reloadIdle`
132
+ * / `WEBJS_DEV_RELOAD_IDLE`): close the stream after this long with no edit
133
+ * and no interaction in any tab, and reopen on the next interaction. `0` (the
134
+ * default) keeps it open while a tab is visible.
87
135
  */
88
- export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
89
- /** @type {Set<any>} */
90
- const ports = new Set();
136
+ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
137
+ const idleMs = opts && opts.idleMs > 0 ? opts.idleMs : 0;
138
+ /** @type {Map<any, boolean>} each connected tab's port, and whether it is visible */
139
+ const ports = new Map();
91
140
  /** @type {string | null} the last error frame, cached for late-joining tabs */
92
141
  let lastError = null;
93
142
  /** @type {string | null} the last-seen per-process boot id (#893) */
94
143
  let lastBoot = null;
144
+ /** @type {number | null} the last reload seq seen from that process (#1507) */
145
+ let lastSeq = null;
95
146
 
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
147
  const timers = scope && typeof scope.setTimeout === 'function' ? scope : globalThis;
102
148
 
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
149
  function fanout(msg) {
108
- for (const p of ports) {
150
+ for (const p of ports.keys()) {
109
151
  try { p.postMessage(msg); } catch (_) { ports.delete(p); }
110
152
  }
111
153
  }
112
154
 
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
155
  /** @type {any} */ let quietTimer = null;
119
156
  /** @type {any} */ let capTimer = null;
120
157
 
@@ -125,8 +162,6 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
125
162
  if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
126
163
  if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
127
164
  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
165
  batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
131
166
  fanout({ type: 'reload', verdict: v });
132
167
  }
@@ -140,58 +175,105 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
140
175
 
141
176
  /** @param {string} verdict */
142
177
  function requestReload(verdict) {
143
- // Lower rank is stronger, so keep the minimum across the batch.
144
178
  if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
145
179
  if (quietTimer !== null) timers.clearTimeout(quietTimer);
146
180
  quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
147
181
  if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
148
182
  }
149
183
 
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 }); });
184
+ /** @type {any} */ let es = null;
185
+ /** @type {any} */ let retryTimer = null;
186
+ let retryDelay = RECONNECT_BASE_MS;
187
+
188
+ /** @type {any} */ let idleTimer = null;
189
+
190
+ // Idle close (#1507). A host that counts an in-flight request as activity
191
+ // (a pilots sandbox does, for the request's whole life) never sleeps while
192
+ // the stream is open, however quiet it is. With `idleMs` set, a stream that
193
+ // has carried nothing and seen no interaction for that long is closed, and
194
+ // the next interaction, a tab showing, or a host command through the embed
195
+ // bridge reopens it; the hello seq then says whether an edit landed meanwhile.
196
+ function touch() {
197
+ if (!idleMs) return;
198
+ if (idleTimer !== null) timers.clearTimeout(idleTimer);
199
+ idleTimer = timers.setTimeout(() => { idleTimer = null; close(); }, idleMs);
200
+ }
201
+
202
+ function anyVisible() {
203
+ for (const v of ports.values()) if (v) return true;
204
+ return false;
205
+ }
206
+
207
+ function open() {
208
+ if (es || retryTimer !== null) return;
209
+ es = new EventSourceCtor(eventsUrl);
210
+ const mine = es;
211
+ touch();
212
+ es.addEventListener('hello', (e) => {
213
+ retryDelay = RECONNECT_BASE_MS;
214
+ touch();
215
+ const h = parseHello(e.data);
216
+ if (lastBoot !== null && (h.boot !== lastBoot || (h.seq !== null && lastSeq !== null && h.seq !== lastSeq))) {
217
+ requestReload('reload');
218
+ }
219
+ lastBoot = h.boot;
220
+ lastSeq = h.seq;
221
+ });
222
+ es.addEventListener('reload', (e) => {
223
+ touch();
224
+ lastError = null;
225
+ const n = reloadSeq(e.data);
226
+ if (n !== null) lastSeq = n;
227
+ requestReload(parseVerdict(e.data));
228
+ });
229
+ es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
230
+ // The stream dropped (a restarting server, a network blip, a host that
231
+ // slept). Close it so the browser's fixed-interval retry never runs, and
232
+ // come back with backoff, but only while someone is looking.
233
+ es.addEventListener('error', () => {
234
+ if (es !== mine) return;
235
+ try { es.close(); } catch (_) { /* already closed */ }
236
+ es = null;
237
+ if (!anyVisible()) return;
238
+ retryTimer = timers.setTimeout(() => { retryTimer = null; if (anyVisible()) open(); }, retryDelay);
239
+ retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX_MS);
240
+ });
241
+ }
242
+
243
+ function close() {
244
+ if (idleTimer !== null) { timers.clearTimeout(idleTimer); idleTimer = null; }
245
+ if (retryTimer !== null) { timers.clearTimeout(retryTimer); retryTimer = null; }
246
+ if (es) { try { es.close(); } catch (_) { /* already closed */ } es = null; }
247
+ retryDelay = RECONNECT_BASE_MS;
248
+ }
249
+
250
+ function sync() {
251
+ if (anyVisible()) open(); else close();
252
+ }
182
253
 
183
254
  scope.onconnect = (e) => {
184
255
  const port = e.ports[0];
185
- ports.add(port);
256
+ // A tab counts as visible until it says otherwise, so a client older than
257
+ // the visibility messages keeps the stream open exactly as before.
258
+ ports.set(port, true);
259
+ port.onmessage = (m) => {
260
+ const d = (m && m.data) || {};
261
+ if (d.type === 'visibility') ports.set(port, d.visible !== false);
262
+ else if (d.type === 'bye') ports.delete(port);
263
+ else if (d.type === 'activity') { if (!ports.get(port)) return; touch(); }
264
+ else return;
265
+ sync();
266
+ };
186
267
  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
268
  if (lastError != null) {
191
269
  try { port.postMessage({ type: 'webjs-error', data: lastError }); } catch (_) { ports.delete(port); }
192
270
  }
271
+ sync();
193
272
  };
194
273
 
195
- // Returned for tests; the served worker ignores it.
196
- return { ports, es };
274
+ return {
275
+ ports,
276
+ /** The live EventSource, or null while paused or between retries. */
277
+ get es() { return es; },
278
+ };
197
279
  }
@@ -64,15 +64,21 @@ const REGEX_AFTER_KEYWORDS = new Set([
64
64
  export const SOURCE_LOCATIONS_ENV = 'WEBJS_SOURCE_LOCATIONS';
65
65
 
66
66
  /**
67
- * Whether the env asks for source locations. Read by the dev handler once, at
68
- * construction; the caller also requires `dev`, so production never consults it.
67
+ * Whether source locations are on (#1499, #1504). The app's
68
+ * `webjs.dev.sourceLocations` sets the default; the env var, when set, wins
69
+ * either way (`1` / `true` on, `0` / `false` off), so one run can flip it
70
+ * without editing package.json. Read by the dev handler once, at construction;
71
+ * the caller also requires `dev`, so production never consults it.
69
72
  *
70
73
  * @param {Record<string, string | undefined>} [env]
74
+ * @param {unknown} [configured] the app's `webjs.dev.sourceLocations`
71
75
  * @returns {boolean}
72
76
  */
73
- export function sourceLocationsRequested(env = process.env) {
77
+ export function sourceLocationsRequested(env = process.env, configured = false) {
74
78
  const v = String(env[SOURCE_LOCATIONS_ENV] || '').trim().toLowerCase();
75
- return v === '1' || v === 'true';
79
+ if (v === '1' || v === 'true') return true;
80
+ if (v === '0' || v === 'false') return false;
81
+ return configured === true;
76
82
  }
77
83
 
78
84
  /**
@@ -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
@@ -202,6 +202,20 @@
202
202
  "description": "Extra directories the dev live-reload watcher follows in addition to the appDir (#894), for content the app reads from outside its own tree (e.g. blog markdown in a repo-root blog/ dir). Each entry is resolved relative to the app root and may escape it (../blog). Read by the server (readDevWatchPathsFromApp).",
203
203
  "type": "array",
204
204
  "items": { "type": "string" }
205
+ },
206
+ "embedOrigins": {
207
+ "description": "Dev embed bridge (#1498, #1504), DEV-ONLY and off by default. Parent origins (e.g. https://builder.example) allowed to frame the dev server: X-Frame-Options is dropped and frame-ancestors widened for them, and every document carries a nonce-signed script that posts { source: 'webjs-embed', type } messages to such a parent and takes { source: 'webjs-embed-host', type } commands from it. A set WEBJS_EMBED_ORIGINS env var replaces this list. Read by the server (readDevToolingFromApp). Ignored by webjs start.",
208
+ "type": "array",
209
+ "items": { "type": "string" }
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
+ },
216
+ "sourceLocations": {
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.",
218
+ "type": "boolean"
205
219
  }
206
220
  }
207
221
  },