@webjsdev/server 0.8.46 → 0.8.48

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.48",
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,71 @@
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
+ /** @type {string | null} the last-seen per-process boot id (#893) */
25
+ let lastBoot = null;
26
+
27
+ // A MessagePort has no reliable close event, so prune a port when a post to it
28
+ // throws (a closed tab). Some browsers silently no-op instead of throwing,
29
+ // leaving a dead port in the set, but that is a harmless dev-only no-op and
30
+ // the set is bounded by the tabs opened in one session.
31
+ function fanout(msg) {
32
+ for (const p of ports) {
33
+ try { p.postMessage(msg); } catch (_) { ports.delete(p); }
34
+ }
35
+ }
36
+
37
+ const es = new EventSourceCtor(eventsUrl);
38
+
39
+ // The `hello` frame fires on every (re)connect and carries the server's
40
+ // per-process boot id (#893). A full server restart (Node's `node --watch`)
41
+ // drops this connection, and if the in-process rebuild's `reload` frame was
42
+ // killed with the old process no reload was delivered, so the edit would need
43
+ // a MANUAL refresh. The browser auto-reconnects to the FRESH process, whose
44
+ // boot id differs, so a CHANGED id is the edit signal: broadcast a reload (the
45
+ // tab still gates it on a readiness probe). A transient reconnect (sleep/wake,
46
+ // a network blip, a tab evicted at the HTTP/1.1 cap) reconnects to the SAME
47
+ // process with the SAME id, so it never reloads. The first `hello` only
48
+ // records the baseline.
49
+ es.addEventListener('hello', (e) => {
50
+ if (lastBoot !== null && e.data !== lastBoot) fanout({ type: 'reload' });
51
+ lastBoot = e.data;
52
+ });
53
+
54
+ es.addEventListener('reload', () => { lastError = null; fanout({ type: 'reload' }); });
55
+ es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
56
+
57
+ scope.onconnect = (e) => {
58
+ const port = e.ports[0];
59
+ ports.add(port);
60
+ port.start();
61
+ // A tab that connects AFTER a breaking edit still needs the current overlay.
62
+ // The single shared EventSource already consumed the server's replay (#264),
63
+ // so the worker caches the last error and hands it to each new tab itself.
64
+ if (lastError != null) {
65
+ try { port.postMessage({ type: 'webjs-error', data: lastError }); } catch (_) { ports.delete(port); }
66
+ }
67
+ };
68
+
69
+ // Returned for tests; the served worker ignores it.
70
+ return { ports, es };
71
+ }
package/src/dev.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createServer as createHttp1Server } from 'node:http';
2
2
  import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
3
3
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
4
5
  import { join, extname, resolve, dirname, relative, sep } from 'node:path';
5
6
  import { createRequire } from 'node:module';
6
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -44,9 +45,10 @@ import {
44
45
  negotiateEncoding,
45
46
  createCompressor,
46
47
  varyWithAcceptEncoding,
48
+ DEV_BOOT_ID,
47
49
  } from './listener-core.js';
48
50
  import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
49
- import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport, appImportsMap } from './module-graph.js';
51
+ import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport, appImportsMap, seenFilesFor } from './module-graph.js';
50
52
  import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
51
53
  import { analyzeElision, elideImportsFromSource } from './component-elision.js';
52
54
 
@@ -86,7 +88,7 @@ function resolveRequestId(req) {
86
88
  function shouldAccessLog(pathname) {
87
89
  return !pathname.startsWith('/__webjs/');
88
90
  }
89
- import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
91
+ import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
90
92
  import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
91
93
  import { propagateTrustedRemoteIp } from './rate-limit.js';
92
94
  import { readAllowedOrigins } from './csrf.js';
@@ -421,6 +423,50 @@ export async function readBodyLimitsFromApp(appDir) {
421
423
  return readBodyLimits(pkg);
422
424
  }
423
425
 
426
+ /**
427
+ * Extra dev watch roots from the app's package.json `webjs.dev.watch` (#894).
428
+ * These are directories the app READS from but that live OUTSIDE its appDir, so
429
+ * the recursive `fs.watch(appDir)` never sees them (e.g. the website renders
430
+ * posts from a repo-root `blog/` dir, a sibling of the app, so editing a post
431
+ * would not live-reload). Each entry is resolved relative to the appDir and MAY
432
+ * escape it (`"../blog"`); a change under one runs the same rebuild + reload as
433
+ * an in-tree edit. Opt-in: a missing/empty config yields `[]`, so a plain app
434
+ * watches only its appDir, unchanged.
435
+ *
436
+ * Returns absolute, de-duped paths, skipping any that overlap the appDir (the
437
+ * appDir is already watched recursively, so an ancestor or descendant root
438
+ * would just double-fire the rebuild). Existence is NOT checked here (the caller
439
+ * filters missing paths so it can log them); the reader stays a pure
440
+ * package.json read, matching the other `readXFromApp` helpers.
441
+ *
442
+ * @param {string} appDir
443
+ * @returns {Promise<string[]>}
444
+ */
445
+ export async function readDevWatchPathsFromApp(appDir) {
446
+ let pkg;
447
+ try {
448
+ pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
449
+ } catch {
450
+ return [];
451
+ }
452
+ const raw = pkg && pkg.webjs && pkg.webjs.dev && pkg.webjs.dev.watch;
453
+ if (!Array.isArray(raw)) return [];
454
+ /** @type {string[]} */
455
+ const out = [];
456
+ const seen = new Set();
457
+ for (const entry of raw) {
458
+ if (typeof entry !== 'string' || !entry.trim()) continue;
459
+ const abs = resolve(appDir, entry);
460
+ // Skip the appDir itself and any ancestor/descendant overlap: the appDir is
461
+ // already watched recursively, so an overlapping extra root double-fires.
462
+ if (abs === appDir || appDir.startsWith(abs + sep) || abs.startsWith(appDir + sep)) continue;
463
+ if (seen.has(abs)) continue;
464
+ seen.add(abs);
465
+ out.push(abs);
466
+ }
467
+ return out;
468
+ }
469
+
424
470
  /**
425
471
  * Resolve the node:http server timeouts (issue #237) from the app's
426
472
  * package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
@@ -875,6 +921,25 @@ export async function createRequestHandler(opts) {
875
921
  } else {
876
922
  setAppSourceFingerprint('');
877
923
  }
924
+ // App-source deploy SIGNAL (#899), distinct from the #318 html-cache
925
+ // fingerprint above: hashes ALL app source (the module-graph `seen`
926
+ // set, INCLUDING server-only `.server.ts` that `browserBoundFiles`
927
+ // omits) with a raw per-file byte digest (decoupled from the
928
+ // asset-hash memo/elision machinery), plus the installed
929
+ // `@webjsdev/server` version, so an app-source OR a server-framework
930
+ // deploy moves it. Sorted + appDir-relative for determinism across
931
+ // instances. Drives the client's soft cache-evict (not a reload).
932
+ // PROD only; empty in dev (fs.watch + SSE handle staleness there).
933
+ if (!dev && state.moduleGraph) {
934
+ const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
935
+ const srcLines = [...seenFilesFor(state.moduleGraph)]
936
+ .map((abs) => `${relApp(abs)}:${fileByteHash(abs)}`)
937
+ .sort();
938
+ srcLines.push(`@webjsdev/server:${frameworkServerVersion()}`);
939
+ setAppSourceId(srcLines.join('\n'));
940
+ } else {
941
+ setAppSourceId('');
942
+ }
878
943
  t.elision = now() - m;
879
944
  if (dev) {
880
945
  for (const { className, file } of await findOrphanComponents(appDir)) {
@@ -1491,6 +1556,38 @@ export function shouldIgnoreWatchPath(filename) {
1491
1556
  return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(filename || '');
1492
1557
  }
1493
1558
 
1559
+ /**
1560
+ * A short content digest of a single file's bytes for the app-source deploy
1561
+ * signal (#899). Raw `sha256(bytes)`, deliberately independent of `assetHashFor`
1562
+ * (which folds the elision fingerprint and memoizes for `?v` emission): the
1563
+ * signal only needs "did the source bytes change", and server-only files never
1564
+ * enter the asset-hash memo anyway. Returns `''` on a read failure, so a
1565
+ * transient error degrades the id rather than throwing during analysis.
1566
+ * @param {string} abs
1567
+ * @returns {string}
1568
+ */
1569
+ function fileByteHash(abs) {
1570
+ try { return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16); }
1571
+ catch { return ''; }
1572
+ }
1573
+
1574
+ /** @type {string} memoized `@webjsdev/server` version, folded into the app-source signal (#899). */
1575
+ let _frameworkServerVersion;
1576
+ /**
1577
+ * The installed `@webjsdev/server` version (this package). Folded into the
1578
+ * app-source deploy signal so a server-framework release (which alters SSR
1579
+ * output but ships no new browser module) turns over the client's stale caches.
1580
+ * @returns {string}
1581
+ */
1582
+ function frameworkServerVersion() {
1583
+ if (_frameworkServerVersion !== undefined) return _frameworkServerVersion;
1584
+ try {
1585
+ const v = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
1586
+ _frameworkServerVersion = typeof v === 'string' ? v.replace(/[^\w.-]/g, '').slice(0, 32) : '';
1587
+ } catch { _frameworkServerVersion = ''; }
1588
+ return _frameworkServerVersion;
1589
+ }
1590
+
1494
1591
  /**
1495
1592
  * @param {{
1496
1593
  * appDir: string,
@@ -1543,9 +1640,9 @@ export async function startServer(opts) {
1543
1640
  // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
1544
1641
  const rebuild = debounce(() => app.rebuild(), 80);
1545
1642
  watcherAbort = new AbortController();
1546
- (async () => {
1643
+ const watchRoot = async (root) => {
1547
1644
  try {
1548
- const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
1645
+ const events = fsWatch(root, { recursive: true, signal: watcherAbort.signal });
1549
1646
  for await (const event of events) {
1550
1647
  const filename = event.filename || '';
1551
1648
  if (shouldIgnoreWatchPath(filename)) continue;
@@ -1553,10 +1650,17 @@ export async function startServer(opts) {
1553
1650
  }
1554
1651
  } catch (err) {
1555
1652
  if (err && /** @type any */(err).name !== 'AbortError') {
1556
- logger.warn({ err }, 'file watcher exited');
1653
+ logger.warn({ err }, `file watcher exited (${root})`);
1557
1654
  }
1558
1655
  }
1559
- })();
1656
+ };
1657
+ watchRoot(app.appDir);
1658
+ // Extra roots the app reads from OUTSIDE its appDir (#894): repo-root content
1659
+ // dirs (blog markdown, data fixtures) the recursive appDir watch can't see.
1660
+ // Opt-in via `webjs.dev.watch`; each funnels into the same debounced rebuild.
1661
+ const extraWatch = (await readDevWatchPathsFromApp(app.appDir)).filter((p) => existsSync(p));
1662
+ for (const root of extraWatch) watchRoot(root);
1663
+ if (extraWatch.length) logger.info?.(`[webjs] also watching ${extraWatch.length} extra dev path(s): ${extraWatch.join(', ')}`);
1560
1664
  }
1561
1665
 
1562
1666
  // Inbound server timeouts (issue #237). On node these are the node:http
@@ -1611,7 +1715,12 @@ function startNodeListener(ctx) {
1611
1715
  'cache-control': 'no-cache',
1612
1716
  connection: 'keep-alive',
1613
1717
  });
1614
- res.write(`event: hello\ndata: webjs\n\n`);
1718
+ // `retry: 300` shrinks the browser's EventSource reconnect backoff from
1719
+ // its ~3s default so a reconnect after a `node --watch` restart happens
1720
+ // promptly. The `hello` data is a per-process boot id (#893): the client
1721
+ // reloads on a reconnect ONLY when it changes (a real restart), so a
1722
+ // transient reconnect never triggers a spurious reload.
1723
+ res.write(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`);
1615
1724
  // Register a node client wrapper in the shared hub: the fanout + keepalive
1616
1725
  // live in SseHub; only the transport write (res.write / res.end) is local.
1617
1726
  const client = {
@@ -1745,6 +1854,14 @@ async function tryServeFrameworkStatic(path, method, ctx) {
1745
1854
  });
1746
1855
  }
1747
1856
 
1857
+ // Dev live-reload SharedWorker: one shared connection for all tabs (#887).
1858
+ if (path === '/__webjs/reload-worker.js') {
1859
+ if (!dev) return new Response('Not found', { status: 404 });
1860
+ return new Response(reloadWorkerJs(basePath()), {
1861
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
1862
+ });
1863
+ }
1864
+
1748
1865
  // Core module: /__webjs/core/*
1749
1866
  //
1750
1867
  // ETag + ~1h max-age, NOT immutable. The URL path is un-versioned
@@ -1822,7 +1939,8 @@ async function handleCore(req, ctx) {
1822
1939
 
1823
1940
  // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) and the
1824
1941
  // framework-internal static assets (`/__webjs/core/*`, `/__webjs/reload.js`,
1825
- // downloaded `/__webjs/vendor/*`) are served in `handle()` BEFORE ensureReady,
1942
+ // `/__webjs/reload-worker.js`, downloaded `/__webjs/vendor/*`) are served in
1943
+ // `handle()` BEFORE ensureReady,
1826
1944
  // so they are not repeated here. This fallback covers the (currently
1827
1945
  // unreachable) case of handleCore being entered for one of those assets, so
1828
1946
  // the routing stays correct if a future caller bypasses the early path.
@@ -2259,7 +2377,25 @@ async function sendWebResponse(res, webRes, req, opts) {
2259
2377
  */
2260
2378
  async function fileResponse(abs, opts) {
2261
2379
  try {
2262
- const data = await readFile(abs);
2380
+ let data = await readFile(abs);
2381
+ // In dev an external watcher (tailwindcss --watch, esbuild, ...) rewrites a
2382
+ // public asset with truncate-then-write, so the file is 0 bytes for a short
2383
+ // window during a rebuild. A hot reload that lands in that window would
2384
+ // serve empty CSS / JS and the page paints unstyled (#891). Close that
2385
+ // 0-byte window: when an empty read comes from a file that was JUST modified
2386
+ // (the mid-rewrite signal), re-read a few times so the truncated read does
2387
+ // not reach the browser. A genuinely empty, untouched asset is served
2388
+ // immediately (no delay), and a non-zero-but-partial write is a smaller
2389
+ // residual gap this does not cover. Prod has no such watcher and is
2390
+ // left untouched.
2391
+ if (opts.dev && data.length === 0) {
2392
+ let midRewrite = false;
2393
+ try { midRewrite = Date.now() - (await stat(abs)).mtimeMs < 500; } catch { /* gone */ }
2394
+ for (let i = 0; midRewrite && i < 12 && data.length === 0; i++) {
2395
+ await new Promise((r) => setTimeout(r, 20));
2396
+ try { data = await readFile(abs); } catch { break; }
2397
+ }
2398
+ }
2263
2399
  const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
2264
2400
  // The body is fully buffered (read into `data`), so opt it into the
2265
2401
  // conditional-GET funnel, which is the single place that hashes the bytes
@@ -2609,18 +2745,102 @@ function locatePackageDir(appDir, pkgName) {
2609
2745
  const DEV_OVERLAY_SRC = readFileSync(new URL('./dev-overlay.js', import.meta.url), 'utf8')
2610
2746
  .replace(/^export /gm, '');
2611
2747
 
2748
+ // The reload SharedWorker relay source (#887), read once + `export`-stripped so
2749
+ // it inlines into the served worker script. Sharing the one source
2750
+ // (`dev-reload-worker.js`, which the browser test imports directly) means the
2751
+ // test drives the EXACT relay code that ships, with no drift (same as #264).
2752
+ const RELOAD_WORKER_SRC = readFileSync(new URL('./dev-reload-worker.js', import.meta.url), 'utf8')
2753
+ .replace(/^export /gm, '');
2754
+
2612
2755
  function reloadClientJs(bp) {
2613
2756
  // The overlay renderer uses textContent throughout (never innerHTML), so the
2614
2757
  // error message / code frame can never inject markup (#264). Served only in
2615
2758
  // dev (the /__webjs/reload.js branch 404s in prod), so it never reaches a
2616
2759
  // production page.
2760
+ //
2761
+ // Every tab shares ONE live-reload connection through a SharedWorker (#887).
2762
+ // The old client opened an `EventSource` per tab, and because the dev server
2763
+ // is HTTP/1.1 and browsers cap concurrent connections per host at ~6, a
2764
+ // handful of open tabs would hold every connection slot with idle SSE streams
2765
+ // and later tabs could not even fetch their HTML. The SharedWorker holds the
2766
+ // single `EventSource` (see reloadWorkerJs) and relays each `reload` /
2767
+ // `webjs-error` to every tab. The overlay still renders on the main thread
2768
+ // (a worker has no DOM), so the worker forwards only the raw frame data.
2769
+ //
2770
+ // Fallback: where `SharedWorker` is missing (some mobile browsers) or its
2771
+ // construction throws (a strict dev CSP without `worker-src`), each tab opens
2772
+ // its own `EventSource`, the original behaviour. Correct, just not shared.
2773
+ const eventsUrl = JSON.stringify(withBasePath('/__webjs/events', bp));
2774
+ const workerUrl = JSON.stringify(withBasePath('/__webjs/reload-worker.js', bp));
2775
+ const versionUrl = JSON.stringify(withBasePath('/__webjs/version', bp));
2617
2776
  return `// webjs dev reload client
2618
2777
  ${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; }
2778
+ function __webjsApplyError(data) {
2779
+ let f; try { f = JSON.parse(data); } catch (_) { return; }
2623
2780
  renderDevOverlay(f);
2624
- });
2781
+ }
2782
+ // Never reload INTO a server that is still restarting (Node's node --watch
2783
+ // briefly kills the process on an edit), which would paint a style-less,
2784
+ // half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
2785
+ // until it answers, THEN reload. Under an in-process reload the server is up,
2786
+ // so the first probe passes and the reload is instant; under a restart the
2787
+ // probes fail until the fresh server is listening, so the old page stays put
2788
+ // until the new one is ready. Bounded, so a genuinely-dead server still
2789
+ // reloads (and shows the browser's own error) rather than hanging forever.
2790
+ function __webjsReloadWhenReady() {
2791
+ var tries = 0;
2792
+ function attempt() {
2793
+ fetch(${versionUrl}, { cache: 'no-store' }).then(function (r) {
2794
+ if (r && r.ok) location.reload(); else again();
2795
+ }).catch(again);
2796
+ }
2797
+ function again() { if (++tries > 100) location.reload(); else setTimeout(attempt, 100); }
2798
+ attempt();
2799
+ }
2800
+ function __webjsDirectEvents() {
2801
+ // Same restart-reload as the SharedWorker relay (#893), for the per-tab
2802
+ // fallback: the hello frame carries the server's per-process boot id, so a
2803
+ // CHANGED id on reconnect means a real restart (reload), while a transient
2804
+ // reconnect to the same process keeps the id (no spurious reload).
2805
+ var lastBoot = null;
2806
+ const es = new EventSource(${eventsUrl});
2807
+ es.addEventListener('hello', (e) => {
2808
+ if (lastBoot !== null && e.data !== lastBoot) __webjsReloadWhenReady();
2809
+ lastBoot = e.data;
2810
+ });
2811
+ es.addEventListener('reload', () => __webjsReloadWhenReady());
2812
+ es.addEventListener('webjs-error', (e) => __webjsApplyError(e.data));
2813
+ }
2814
+ try {
2815
+ if (typeof SharedWorker !== 'undefined') {
2816
+ const w = new SharedWorker(${workerUrl});
2817
+ w.port.onmessage = (e) => {
2818
+ const m = e.data || {};
2819
+ if (m.type === 'reload') __webjsReloadWhenReady();
2820
+ else if (m.type === 'webjs-error') __webjsApplyError(m.data);
2821
+ };
2822
+ w.port.start();
2823
+ } else {
2824
+ __webjsDirectEvents();
2825
+ }
2826
+ } catch (_) {
2827
+ __webjsDirectEvents();
2828
+ }
2829
+ `;
2830
+ }
2831
+
2832
+ /**
2833
+ * The dev live-reload SharedWorker. One instance is shared across every tab of
2834
+ * the same origin (a SharedWorker is keyed by its script URL), so it holds the
2835
+ * ONE `EventSource` to `/__webjs/events` and fans each event out to all tabs
2836
+ * over their `MessagePort`s (#887). The `EventSource` URL carries the base path
2837
+ * the same way the client's does (#256). Served only in dev.
2838
+ * @param {string} bp the normalized base path (`''` = no-op)
2839
+ * @returns {string}
2840
+ */
2841
+ function reloadWorkerJs(bp) {
2842
+ return `// webjs dev reload worker (one shared connection for all tabs)
2843
+ ${RELOAD_WORKER_SRC}
2844
+ startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
2625
2845
  `;
2626
2846
  }
package/src/importmap.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
  import { digestHex } from './crypto-utils.js';
4
5
  import { jsonForScriptTag } from './script-tag-json.js';
5
6
  import { withBasePath } from './base-path.js';
@@ -192,6 +193,9 @@ export function importMapHash() {
192
193
  return _importMapHash;
193
194
  }
194
195
 
196
+ /** The installed `@webjsdev/core` version, folded into the published build id (#899). */
197
+ let _coreVersion = '';
198
+
195
199
  /**
196
200
  * The published, client-facing build id: the value stamped into the
197
201
  * `data-webjs-build` attribute and the `X-Webjs-Build` header that the
@@ -220,16 +224,53 @@ export function publishedBuildId() {
220
224
  }
221
225
 
222
226
  /**
223
- * Promote the current `importMapHash()` to the advertised build id.
224
- * Called by `dev.js` when the importmap becomes authoritatively final.
225
- * Idempotent; the value only changes when the underlying map does, so
226
- * re-publishing an unchanged map is a no-op for the client. Within a
227
- * single process the published id therefore never changes after the
228
- * first publish (a rebuild in dev re-publishes the fresh map, but dev
229
- * already forces a full reload via SSE).
227
+ * Promote the current `importMapHash()` (folded with the installed
228
+ * `@webjsdev/core` version, #899) to the advertised build id. Called by `dev.js`
229
+ * when the importmap becomes authoritatively final. Idempotent; the value only
230
+ * changes when the map OR the core version does, so re-publishing an unchanged
231
+ * install is a no-op for the client. Within a single process the published id
232
+ * therefore never changes after the first publish (a rebuild in dev re-publishes
233
+ * the fresh map, but dev already forces a full reload via SSE).
234
+ *
235
+ * The empty-until-final guard holds: while `_importMapHash` is `''` (the warmup
236
+ * window) the published id stays `''`, so the router never hard-reloads against
237
+ * an unknown version even with the core version present.
230
238
  */
231
239
  export function publishBuildId() {
232
- _publishedBuildId = _importMapHash;
240
+ if (!_importMapHash) { _publishedBuildId = ''; return; }
241
+ _publishedBuildId = _coreVersion ? `${_importMapHash}.c${_coreVersion}` : _importMapHash;
242
+ }
243
+
244
+ /**
245
+ * The app-source deploy signal (#899): a short digest advertised as the
246
+ * `data-webjs-src` attribute and the `X-Webjs-Src` header, distinct from the
247
+ * build id above. The client compares it across navigations and, on a mismatch,
248
+ * EVICTS its snapshot/prefetch caches (a soft re-fetch, NOT a hard reload):
249
+ * an app-source or server-framework change alters the SSR output but ships no
250
+ * new browser module the running page must swap, so a full reload would be an
251
+ * over-correction. Set by `dev.js` from a content hash of ALL app source (the
252
+ * module-graph `seen` set, including server-only `.server.ts`) folded with the
253
+ * installed `@webjsdev/server` version, PROD only. Empty in dev / warmup, which
254
+ * the client reads as "unknown" and never acts on (mirrors the build id guard).
255
+ *
256
+ * @type {string}
257
+ */
258
+ let _appSourceId = '';
259
+
260
+ /** @returns {string} the advertised app-source id, or `''` when unknown. */
261
+ export function appSourceId() {
262
+ return _appSourceId;
263
+ }
264
+
265
+ /**
266
+ * Set the app-source id from a raw, deterministic input string (sorted
267
+ * `relpath:contentHash` lines plus the server version). Digested to a short hex
268
+ * so it stays a compact header/attribute value. An empty input clears it.
269
+ * @param {string} raw
270
+ * @returns {void}
271
+ */
272
+ export function setAppSourceId(raw) {
273
+ _appSourceId = raw ? createHash('sha256').update(raw).digest('hex').slice(0, 16) : '';
233
274
  }
234
275
 
235
276
  /**
@@ -299,6 +340,15 @@ let _coreEntries = {
299
340
  */
300
341
  export async function setCoreInstall(coreDir, distMode) {
301
342
  _coreEntries = buildCoreEntries(coreDir, !!distMode);
343
+ // Capture the installed @webjsdev/core version (#899). The importmap targets
344
+ // core at a version-independent URL, so a core RELEASE (new implementation,
345
+ // same exports) does not move `_importMapHash`; folding the version into the
346
+ // published build id makes such a release change the client's hard-reload
347
+ // signal, since a running page cannot hot-swap the committed importmap core.
348
+ try {
349
+ const v = JSON.parse(readFileSync(join(coreDir, 'package.json'), 'utf8')).version;
350
+ _coreVersion = typeof v === 'string' ? v.replace(/[^\w.-]/g, '').slice(0, 32) : '';
351
+ } catch { _coreVersion = ''; }
302
352
  _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
303
353
  }
304
354
 
@@ -562,5 +612,9 @@ export function importMapTag(opts = {}) {
562
612
  // window never advertises an id that later changes. See
563
613
  // publishedBuildId() above for the rationale.
564
614
  const b = ` data-webjs-build="${publishedBuildId()}"`;
565
- return `<script type="importmap"${n}${b}>${jsonForScriptTag(buildImportMap())}</script>`;
615
+ // The app-source deploy signal (#899), stamped alongside the build id. A
616
+ // change here evicts the client caches softly (see appSourceId()); empty until
617
+ // the analysis is ready, which the client reads as "unknown".
618
+ const s = ` data-webjs-src="${appSourceId()}"`;
619
+ return `<script type="importmap"${n}${b}${s}>${jsonForScriptTag(buildImportMap())}</script>`;
566
620
  }
@@ -58,6 +58,7 @@ import {
58
58
  MAX_SYNC_COMPRESS_BYTES,
59
59
  varyWithAcceptEncoding,
60
60
  readBufferedOrStream,
61
+ DEV_BOOT_ID,
61
62
  } from './listener-core.js';
62
63
 
63
64
  /* global Bun */
@@ -275,7 +276,11 @@ function bunSseResponse(req, hub, app) {
275
276
  send: (s) => { try { controller.enqueue(enc.encode(s)); } catch {} },
276
277
  close: () => { try { controller.close(); } catch {} },
277
278
  };
278
- controller.enqueue(enc.encode('event: hello\ndata: webjs\n\n'));
279
+ // `retry: 300` shrinks the EventSource reconnect backoff so a reconnect
280
+ // after a dev restart re-triggers a reload promptly (#893); the `hello`
281
+ // data is the per-process boot id so a reconnect reloads only on a real
282
+ // restart, matching the node:http shell.
283
+ controller.enqueue(enc.encode(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`));
279
284
  hub.add(client);
280
285
  // Replay an unresolved dev error (#264) so a tab connecting AFTER the
281
286
  // breaking edit still shows the overlay.
@@ -22,10 +22,21 @@ import {
22
22
  constants as zlibConstants,
23
23
  } from 'node:zlib';
24
24
  import { stripBasePath } from './base-path.js';
25
+ import { randomUUID } from 'node:crypto';
25
26
 
26
27
  /** The dev live-reload SSE path (matched after base-path stripping). */
27
28
  export const EVENTS_PATH = '/__webjs/events';
28
29
 
30
+ /**
31
+ * A per-PROCESS id sent on the dev SSE `hello` frame (#893). It is regenerated
32
+ * on every server start, so a `node --watch` RESTART yields a NEW id while a
33
+ * transient reconnect (laptop sleep/wake, a network blip, a tab evicted at the
34
+ * HTTP/1.1 connection cap) reconnects to the SAME process and sees the SAME id.
35
+ * The reload client reloads only when this id CHANGES, so a mere reconnect never
36
+ * triggers a spurious full reload that would discard in-page dev state.
37
+ */
38
+ export const DEV_BOOT_ID = randomUUID();
39
+
29
40
  /**
30
41
  * Detect the host runtime so `startServer` can pick an adapter. Bun sets
31
42
  * `process.versions.bun` (and also reports a `node` version via its compat
@@ -170,6 +170,27 @@ export function bareImports(graph) {
170
170
  return BARE_EDGES.get(graph) || new Map();
171
171
  }
172
172
 
173
+ /**
174
+ * Every app source file walked while building a graph (#899). `graph` itself
175
+ * holds only files that HAVE imports, so a leaf (e.g. a no-import `.server.ts`)
176
+ * is absent from it; this side-channel carries the COMPLETE set of `.js/.ts/
177
+ * .mjs/.mts` files under the appDir (excluding `node_modules`/`.webjs`/`public`/
178
+ * dotfiles), INCLUDING server-only files. Used to derive the app-source deploy
179
+ * signal that covers an SSR-only change a browser-bound-only set would miss.
180
+ * @type {WeakMap<ModuleGraph, Set<string>>}
181
+ */
182
+ const SEEN_FILES = new WeakMap();
183
+
184
+ /**
185
+ * The complete set of app source files walked for a built graph, or an empty
186
+ * set if none. Absolute paths.
187
+ * @param {ModuleGraph} graph
188
+ * @returns {Set<string>}
189
+ */
190
+ export function seenFilesFor(graph) {
191
+ return SEEN_FILES.get(graph) || new Set();
192
+ }
193
+
173
194
  /**
174
195
  * True for a bare npm vendor specifier (`dayjs`, `@scope/pkg/sub`), excluding
175
196
  * relative / absolute / `#`-alias paths and `node:` / protocol specifiers.
@@ -205,6 +226,7 @@ export async function buildModuleGraph(appDir) {
205
226
  await walk(appDir, appDir, graph, seen, dynamic, bare);
206
227
  if (dynamic.size) DYNAMIC_EDGES.set(graph, dynamic);
207
228
  if (bare.size) BARE_EDGES.set(graph, bare);
229
+ if (seen.size) SEEN_FILES.set(graph, seen);
208
230
  // Evict parse-cache entries for files no longer in the tree (a rebuild after
209
231
  // a rename or delete), so a long dev session does not accumulate dead
210
232
  // entries. Scoped to appDir so a multi-app process (tests, dogfood smoke)
package/src/ssr.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
- import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
4
+ import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
5
5
  import { withBasePath } from './base-path.js';
6
6
  import { withAssetHash } from './asset-hash.js';
7
7
  import { jsonForScriptTag } from './script-tag-json.js';
@@ -419,6 +419,7 @@ function htmlResponse(html, status, req, url, metadata) {
419
419
  // final, so a warming response is reload-safe. See router-client.js
420
420
  // applySwap and publishedBuildId() in importmap.js.
421
421
  headers.set('x-webjs-build', publishedBuildId());
422
+ headers.set('x-webjs-src', appSourceId());
422
423
  // Buffered (string) body: opt into the conditional-GET funnel so a
423
424
  // PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304.
424
425
  // The funnel still excludes the no-store default, so a private page is
@@ -446,6 +447,7 @@ function cachedHtmlResponse(rec, req, url) {
446
447
  const headers = new Headers({ 'content-type': rec.contentType });
447
448
  headers.set('cache-control', rec.cacheControl);
448
449
  headers.set('x-webjs-build', publishedBuildId());
450
+ headers.set('x-webjs-src', appSourceId());
449
451
  headers.set(BUFFERED_MARKER, '1');
450
452
  return new Response(rec.body, { status: rec.status, headers });
451
453
  }
@@ -1717,6 +1719,7 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1717
1719
  // See htmlResponse: published build id on every response for the
1718
1720
  // client router's importmap-mismatch detection on partial swaps.
1719
1721
  headers.set('x-webjs-build', publishedBuildId());
1722
+ headers.set('x-webjs-src', appSourceId());
1720
1723
 
1721
1724
  if (!ctx.pending.length) {
1722
1725
  // No pending boundaries: this degrades to a single buffered (string)
@@ -173,6 +173,11 @@
173
173
  "description": "Long-lived commands run as child processes alongside the dev server, torn down on exit so a watcher cannot leak past it.",
174
174
  "type": "array",
175
175
  "items": { "type": "string" }
176
+ },
177
+ "watch": {
178
+ "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).",
179
+ "type": "array",
180
+ "items": { "type": "string" }
176
181
  }
177
182
  }
178
183
  },