@webjsdev/server 0.8.46 → 0.8.47

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.46",
3
+ "version": "0.8.47",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The dev live-reload SharedWorker relay (#887), the BROWSER half. Kept as a
3
+ * standalone browser-safe module (no node imports) so the served worker inlines
4
+ * the EXACT source a browser test drives, with no drift, the same pattern as
5
+ * `dev-overlay.js` (#264). `reloadWorkerJs` in dev.js reads this file, strips
6
+ * the `export` keyword, and appends a
7
+ * `startReloadWorker(self, EventSource, '<eventsUrl>')` call.
8
+ *
9
+ * One SharedWorker is shared across every tab of the origin (a SharedWorker is
10
+ * keyed by its script URL), so it holds the ONE `EventSource` to
11
+ * `/__webjs/events` and fans each `reload` / `webjs-error` out to every tab over
12
+ * its `MessagePort`. Tab count never touches the browser's per-host HTTP/1.1
13
+ * connection cap, which the per-tab `EventSource` it replaces used to exhaust.
14
+ *
15
+ * @param {{ onconnect: any }} scope the worker global (`self`)
16
+ * @param {new (url: string) => any} EventSourceCtor the `EventSource` constructor
17
+ * @param {string} eventsUrl the base-path-aware `/__webjs/events` URL
18
+ */
19
+ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
20
+ /** @type {Set<any>} */
21
+ const ports = new Set();
22
+ /** @type {string | null} the last error frame, cached for late-joining tabs */
23
+ let lastError = null;
24
+
25
+ // A MessagePort has no reliable close event, so prune a port when a post to it
26
+ // throws (a closed tab). Some browsers silently no-op instead of throwing,
27
+ // leaving a dead port in the set, but that is a harmless dev-only no-op and
28
+ // the set is bounded by the tabs opened in one session.
29
+ function fanout(msg) {
30
+ for (const p of ports) {
31
+ try { p.postMessage(msg); } catch (_) { ports.delete(p); }
32
+ }
33
+ }
34
+
35
+ const es = new EventSourceCtor(eventsUrl);
36
+ es.addEventListener('reload', () => { lastError = null; fanout({ type: 'reload' }); });
37
+ es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
38
+
39
+ scope.onconnect = (e) => {
40
+ const port = e.ports[0];
41
+ ports.add(port);
42
+ port.start();
43
+ // A tab that connects AFTER a breaking edit still needs the current overlay.
44
+ // The single shared EventSource already consumed the server's replay (#264),
45
+ // so the worker caches the last error and hands it to each new tab itself.
46
+ if (lastError != null) {
47
+ try { port.postMessage({ type: 'webjs-error', data: lastError }); } catch (_) { ports.delete(port); }
48
+ }
49
+ };
50
+
51
+ // Returned for tests; the served worker ignores it.
52
+ return { ports, es };
53
+ }
package/src/dev.js CHANGED
@@ -1745,6 +1745,14 @@ async function tryServeFrameworkStatic(path, method, ctx) {
1745
1745
  });
1746
1746
  }
1747
1747
 
1748
+ // Dev live-reload SharedWorker: one shared connection for all tabs (#887).
1749
+ if (path === '/__webjs/reload-worker.js') {
1750
+ if (!dev) return new Response('Not found', { status: 404 });
1751
+ return new Response(reloadWorkerJs(basePath()), {
1752
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
1753
+ });
1754
+ }
1755
+
1748
1756
  // Core module: /__webjs/core/*
1749
1757
  //
1750
1758
  // ETag + ~1h max-age, NOT immutable. The URL path is un-versioned
@@ -1822,7 +1830,8 @@ async function handleCore(req, ctx) {
1822
1830
 
1823
1831
  // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) and the
1824
1832
  // framework-internal static assets (`/__webjs/core/*`, `/__webjs/reload.js`,
1825
- // downloaded `/__webjs/vendor/*`) are served in `handle()` BEFORE ensureReady,
1833
+ // `/__webjs/reload-worker.js`, downloaded `/__webjs/vendor/*`) are served in
1834
+ // `handle()` BEFORE ensureReady,
1826
1835
  // so they are not repeated here. This fallback covers the (currently
1827
1836
  // unreachable) case of handleCore being entered for one of those assets, so
1828
1837
  // the routing stays correct if a future caller bypasses the early path.
@@ -2259,7 +2268,25 @@ async function sendWebResponse(res, webRes, req, opts) {
2259
2268
  */
2260
2269
  async function fileResponse(abs, opts) {
2261
2270
  try {
2262
- const data = await readFile(abs);
2271
+ let data = await readFile(abs);
2272
+ // In dev an external watcher (tailwindcss --watch, esbuild, ...) rewrites a
2273
+ // public asset with truncate-then-write, so the file is 0 bytes for a short
2274
+ // window during a rebuild. A hot reload that lands in that window would
2275
+ // serve empty CSS / JS and the page paints unstyled (#891). Close that
2276
+ // 0-byte window: when an empty read comes from a file that was JUST modified
2277
+ // (the mid-rewrite signal), re-read a few times so the truncated read does
2278
+ // not reach the browser. A genuinely empty, untouched asset is served
2279
+ // immediately (no delay), and a non-zero-but-partial write is a smaller
2280
+ // residual gap this does not cover. Prod has no such watcher and is
2281
+ // left untouched.
2282
+ if (opts.dev && data.length === 0) {
2283
+ let midRewrite = false;
2284
+ try { midRewrite = Date.now() - (await stat(abs)).mtimeMs < 500; } catch { /* gone */ }
2285
+ for (let i = 0; midRewrite && i < 12 && data.length === 0; i++) {
2286
+ await new Promise((r) => setTimeout(r, 20));
2287
+ try { data = await readFile(abs); } catch { break; }
2288
+ }
2289
+ }
2263
2290
  const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
2264
2291
  // The body is fully buffered (read into `data`), so opt it into the
2265
2292
  // conditional-GET funnel, which is the single place that hashes the bytes
@@ -2609,18 +2636,74 @@ function locatePackageDir(appDir, pkgName) {
2609
2636
  const DEV_OVERLAY_SRC = readFileSync(new URL('./dev-overlay.js', import.meta.url), 'utf8')
2610
2637
  .replace(/^export /gm, '');
2611
2638
 
2639
+ // The reload SharedWorker relay source (#887), read once + `export`-stripped so
2640
+ // it inlines into the served worker script. Sharing the one source
2641
+ // (`dev-reload-worker.js`, which the browser test imports directly) means the
2642
+ // test drives the EXACT relay code that ships, with no drift (same as #264).
2643
+ const RELOAD_WORKER_SRC = readFileSync(new URL('./dev-reload-worker.js', import.meta.url), 'utf8')
2644
+ .replace(/^export /gm, '');
2645
+
2612
2646
  function reloadClientJs(bp) {
2613
2647
  // The overlay renderer uses textContent throughout (never innerHTML), so the
2614
2648
  // error message / code frame can never inject markup (#264). Served only in
2615
2649
  // dev (the /__webjs/reload.js branch 404s in prod), so it never reaches a
2616
2650
  // production page.
2651
+ //
2652
+ // Every tab shares ONE live-reload connection through a SharedWorker (#887).
2653
+ // The old client opened an `EventSource` per tab, and because the dev server
2654
+ // is HTTP/1.1 and browsers cap concurrent connections per host at ~6, a
2655
+ // handful of open tabs would hold every connection slot with idle SSE streams
2656
+ // and later tabs could not even fetch their HTML. The SharedWorker holds the
2657
+ // single `EventSource` (see reloadWorkerJs) and relays each `reload` /
2658
+ // `webjs-error` to every tab. The overlay still renders on the main thread
2659
+ // (a worker has no DOM), so the worker forwards only the raw frame data.
2660
+ //
2661
+ // Fallback: where `SharedWorker` is missing (some mobile browsers) or its
2662
+ // construction throws (a strict dev CSP without `worker-src`), each tab opens
2663
+ // its own `EventSource`, the original behaviour. Correct, just not shared.
2664
+ const eventsUrl = JSON.stringify(withBasePath('/__webjs/events', bp));
2665
+ const workerUrl = JSON.stringify(withBasePath('/__webjs/reload-worker.js', bp));
2617
2666
  return `// webjs dev reload client
2618
2667
  ${DEV_OVERLAY_SRC}
2619
- const es = new EventSource(${JSON.stringify(withBasePath('/__webjs/events', bp))});
2620
- es.addEventListener('reload', () => location.reload());
2621
- es.addEventListener('webjs-error', (e) => {
2622
- let f; try { f = JSON.parse(e.data); } catch (_) { return; }
2668
+ function __webjsApplyError(data) {
2669
+ let f; try { f = JSON.parse(data); } catch (_) { return; }
2623
2670
  renderDevOverlay(f);
2624
- });
2671
+ }
2672
+ function __webjsDirectEvents() {
2673
+ const es = new EventSource(${eventsUrl});
2674
+ es.addEventListener('reload', () => location.reload());
2675
+ es.addEventListener('webjs-error', (e) => __webjsApplyError(e.data));
2676
+ }
2677
+ try {
2678
+ if (typeof SharedWorker !== 'undefined') {
2679
+ const w = new SharedWorker(${workerUrl});
2680
+ w.port.onmessage = (e) => {
2681
+ const m = e.data || {};
2682
+ if (m.type === 'reload') location.reload();
2683
+ else if (m.type === 'webjs-error') __webjsApplyError(m.data);
2684
+ };
2685
+ w.port.start();
2686
+ } else {
2687
+ __webjsDirectEvents();
2688
+ }
2689
+ } catch (_) {
2690
+ __webjsDirectEvents();
2691
+ }
2692
+ `;
2693
+ }
2694
+
2695
+ /**
2696
+ * The dev live-reload SharedWorker. One instance is shared across every tab of
2697
+ * the same origin (a SharedWorker is keyed by its script URL), so it holds the
2698
+ * ONE `EventSource` to `/__webjs/events` and fans each event out to all tabs
2699
+ * over their `MessagePort`s (#887). The `EventSource` URL carries the base path
2700
+ * the same way the client's does (#256). Served only in dev.
2701
+ * @param {string} bp the normalized base path (`''` = no-op)
2702
+ * @returns {string}
2703
+ */
2704
+ function reloadWorkerJs(bp) {
2705
+ return `// webjs dev reload worker (one shared connection for all tabs)
2706
+ ${RELOAD_WORKER_SRC}
2707
+ startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
2625
2708
  `;
2626
2709
  }