@webjsdev/server 0.8.47 → 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.47",
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",
@@ -21,6 +21,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
21
21
  const ports = new Set();
22
22
  /** @type {string | null} the last error frame, cached for late-joining tabs */
23
23
  let lastError = null;
24
+ /** @type {string | null} the last-seen per-process boot id (#893) */
25
+ let lastBoot = null;
24
26
 
25
27
  // A MessagePort has no reliable close event, so prune a port when a post to it
26
28
  // throws (a closed tab). Some browsers silently no-op instead of throwing,
@@ -33,6 +35,22 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
33
35
  }
34
36
 
35
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
+
36
54
  es.addEventListener('reload', () => { lastError = null; fanout({ type: 'reload' }); });
37
55
  es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
38
56
 
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 = {
@@ -2663,15 +2772,43 @@ function reloadClientJs(bp) {
2663
2772
  // its own `EventSource`, the original behaviour. Correct, just not shared.
2664
2773
  const eventsUrl = JSON.stringify(withBasePath('/__webjs/events', bp));
2665
2774
  const workerUrl = JSON.stringify(withBasePath('/__webjs/reload-worker.js', bp));
2775
+ const versionUrl = JSON.stringify(withBasePath('/__webjs/version', bp));
2666
2776
  return `// webjs dev reload client
2667
2777
  ${DEV_OVERLAY_SRC}
2668
2778
  function __webjsApplyError(data) {
2669
2779
  let f; try { f = JSON.parse(data); } catch (_) { return; }
2670
2780
  renderDevOverlay(f);
2671
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
+ }
2672
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;
2673
2806
  const es = new EventSource(${eventsUrl});
2674
- es.addEventListener('reload', () => location.reload());
2807
+ es.addEventListener('hello', (e) => {
2808
+ if (lastBoot !== null && e.data !== lastBoot) __webjsReloadWhenReady();
2809
+ lastBoot = e.data;
2810
+ });
2811
+ es.addEventListener('reload', () => __webjsReloadWhenReady());
2675
2812
  es.addEventListener('webjs-error', (e) => __webjsApplyError(e.data));
2676
2813
  }
2677
2814
  try {
@@ -2679,7 +2816,7 @@ try {
2679
2816
  const w = new SharedWorker(${workerUrl});
2680
2817
  w.port.onmessage = (e) => {
2681
2818
  const m = e.data || {};
2682
- if (m.type === 'reload') location.reload();
2819
+ if (m.type === 'reload') __webjsReloadWhenReady();
2683
2820
  else if (m.type === 'webjs-error') __webjsApplyError(m.data);
2684
2821
  };
2685
2822
  w.port.start();
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
  },