@webjsdev/server 0.8.47 → 0.8.49

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.49",
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
  }
package/src/js-scan.js CHANGED
@@ -432,7 +432,13 @@ export function maskComments(src) {
432
432
  */
433
433
  export function extractWebComponentClassBodies(content) {
434
434
  const bodies = [];
435
- const re = /class\s+\w+\s+extends\s+WebComponent/g;
435
+ // The optional `(?:\s*<[^{}();]*?>)?` skips a TypeScript generic parameter
436
+ // list on the class name (`class Grid<T> extends WebComponent`), so a generic
437
+ // component is not missed (which would let elision wrongly strip an
438
+ // interactive component, the unsafe direction). The exclusion class keeps the
439
+ // generic from swallowing the class body / a call, and stops it staying
440
+ // within the type-parameter list (#753, found differentially).
441
+ const re = /class\s+\w+(?:\s*<[^{}();]*?>)?\s+extends\s+WebComponent/g;
436
442
  let m;
437
443
  while ((m = re.exec(content)) !== null) {
438
444
  let i = m.index + m[0].length;
@@ -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 */
@@ -237,6 +238,16 @@ async function bunUpgrade(req, srv, ctx) {
237
238
 
238
239
  const wrapper = new BunWsAdapter();
239
240
  const handlerReq = upgradeRequest(req, url);
241
+ // Stamp the framework-trusted socket IP on the handler request so a `WS`
242
+ // handler's `clientIp(req)` returns the real peer, not a spoofed inbound
243
+ // `x-webjs-remote-ip` (#778). `srv.requestIP` must be queried with Bun's
244
+ // ORIGINAL `req` (the rebuilt `handlerReq` is unknown to it); the WeakMap
245
+ // stamp then makes `handlerReq` authoritative. Mirrors `stampRemoteIp` on the
246
+ // fetch path (which strips + stamps `req` directly). `upgradeRequest` already
247
+ // dropped any inbound copy of the header.
248
+ let ip;
249
+ try { ip = srv.requestIP(req)?.address; } catch {}
250
+ setTrustedRemoteIp(handlerReq, ip || '');
240
251
  const ok = srv.upgrade(req, {
241
252
  data: { wrapper, mod, req: handlerReq, params: match.params, pathname: url.pathname },
242
253
  });
@@ -253,7 +264,14 @@ async function bunUpgrade(req, srv, ctx) {
253
264
  */
254
265
  function upgradeRequest(req, url) {
255
266
  const headers = new Headers();
256
- req.headers.forEach((v, k) => { if (!k.startsWith(':')) headers.set(k, v); });
267
+ // Skip a client-supplied `x-webjs-remote-ip`: it is a framework-internal trust
268
+ // header the caller re-stamps via the authoritative WeakMap (#778). Copying it
269
+ // would let a client spoof `clientIp` inside the WS handler on the fallback
270
+ // header read.
271
+ req.headers.forEach((v, k) => {
272
+ if (k.startsWith(':') || k === 'x-webjs-remote-ip') return;
273
+ headers.set(k, v);
274
+ });
257
275
  return new Request(url, { method: 'GET', headers });
258
276
  }
259
277
 
@@ -275,7 +293,11 @@ function bunSseResponse(req, hub, app) {
275
293
  send: (s) => { try { controller.enqueue(enc.encode(s)); } catch {} },
276
294
  close: () => { try { controller.close(); } catch {} },
277
295
  };
278
- controller.enqueue(enc.encode('event: hello\ndata: webjs\n\n'));
296
+ // `retry: 300` shrinks the EventSource reconnect backoff so a reconnect
297
+ // after a dev restart re-triggers a reload promptly (#893); the `hello`
298
+ // data is the per-process boot id so a reconnect reloads only on a real
299
+ // restart, matching the node:http shell.
300
+ controller.enqueue(enc.encode(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`));
279
301
  hub.add(client);
280
302
  // Replay an unresolved dev error (#264) so a tab connecting AFTER the
281
303
  // 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
@@ -14,7 +14,7 @@
14
14
  import { readFile, readdir, stat } from 'node:fs/promises';
15
15
  import { existsSync, readFileSync } from 'node:fs';
16
16
  import { join, resolve, dirname, extname, sep } from 'node:path';
17
- import { redactStringsAndTemplates } from './js-scan.js';
17
+ import { redactStringsAndTemplates, redactToPlaceholders } from './js-scan.js';
18
18
 
19
19
  /**
20
20
  * Per-appDir cache of the parsed `package.json "imports"` subpath-alias map
@@ -78,7 +78,7 @@ export function expandImportAlias(spec, appDir) {
78
78
  }
79
79
 
80
80
  /** @type {RegExp} match static `import … from '…'` and `import '…'` */
81
- const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
81
+ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/gd;
82
82
 
83
83
  /**
84
84
  * @type {RegExp} match `export … from '…'` re-exports.
@@ -94,12 +94,16 @@ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
94
94
  *
95
95
  * Barrel files are common (`lib/index.ts` re-exports its siblings),
96
96
  * and the graph must follow these edges or downstream consumers of
97
- * the barrel see authorisation 404s on the underlying files. The
98
- * gap class excludes quotes and `;` (so the lazy match cannot cross
99
- * a statement boundary) but DOES allow newlines, so multi-line
100
- * brace lists are caught.
97
+ * the barrel see authorisation 404s on the underlying files. The gap
98
+ * class excludes quotes, `;`, AND the backtick but DOES allow newlines,
99
+ * so multi-line brace lists are caught. The primary defence against a
100
+ * stray `export` word inside a literal swallowing a real later edge is
101
+ * that `parseFile` scans over the FULLY-BLANKED mask (every comment /
102
+ * string / template / regex body blanked), so no keyword can anchor
103
+ * inside a literal at all (#753); the exclusions here are belt-and-
104
+ * suspenders on top of that.
101
105
  */
102
- const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
106
+ const EXPORT_FROM_RE = /\bexport\b[^'";`]+?\sfrom\s+['"]([^'"]+)['"]/gd;
103
107
 
104
108
  /**
105
109
  * @type {RegExp} match a dynamic `import('…')` with a STRING-LITERAL specifier
@@ -118,7 +122,7 @@ const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
118
122
  * `import('./pages/' + name)` the char after the closing quote is `+`, neither
119
123
  * `,` nor `)`, so it does not match.
120
124
  */
121
- const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*[,)]/g;
125
+ const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*[,)]/gd;
122
126
 
123
127
  /**
124
128
  * @typedef {Map<string, Set<string>>} ModuleGraph
@@ -170,6 +174,27 @@ export function bareImports(graph) {
170
174
  return BARE_EDGES.get(graph) || new Map();
171
175
  }
172
176
 
177
+ /**
178
+ * Every app source file walked while building a graph (#899). `graph` itself
179
+ * holds only files that HAVE imports, so a leaf (e.g. a no-import `.server.ts`)
180
+ * is absent from it; this side-channel carries the COMPLETE set of `.js/.ts/
181
+ * .mjs/.mts` files under the appDir (excluding `node_modules`/`.webjs`/`public`/
182
+ * dotfiles), INCLUDING server-only files. Used to derive the app-source deploy
183
+ * signal that covers an SSR-only change a browser-bound-only set would miss.
184
+ * @type {WeakMap<ModuleGraph, Set<string>>}
185
+ */
186
+ const SEEN_FILES = new WeakMap();
187
+
188
+ /**
189
+ * The complete set of app source files walked for a built graph, or an empty
190
+ * set if none. Absolute paths.
191
+ * @param {ModuleGraph} graph
192
+ * @returns {Set<string>}
193
+ */
194
+ export function seenFilesFor(graph) {
195
+ return SEEN_FILES.get(graph) || new Set();
196
+ }
197
+
173
198
  /**
174
199
  * True for a bare npm vendor specifier (`dayjs`, `@scope/pkg/sub`), excluding
175
200
  * relative / absolute / `#`-alias paths and `node:` / protocol specifiers.
@@ -205,6 +230,7 @@ export async function buildModuleGraph(appDir) {
205
230
  await walk(appDir, appDir, graph, seen, dynamic, bare);
206
231
  if (dynamic.size) DYNAMIC_EDGES.set(graph, dynamic);
207
232
  if (bare.size) BARE_EDGES.set(graph, bare);
233
+ if (seen.size) SEEN_FILES.set(graph, seen);
208
234
  // Evict parse-cache entries for files no longer in the tree (a rebuild after
209
235
  // a rename or delete), so a long dev session does not accumulate dead
210
236
  // entries. Scoped to appDir so a multi-app process (tests, dogfood smoke)
@@ -437,16 +463,26 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
437
463
  // graph edge to whatever path that string names. Since this caller only
438
464
  // checks keyword-in-code-position, blank every literal body so a
439
465
  // code-example `import` string never becomes an edge.
466
+ // Scan for edges over the FULLY-BLANKED mask: every comment / string /
467
+ // template / regex body is blanked to spaces (delimiters kept), `${...}`
468
+ // holes stay as code. Reason (#753, found differentially): a stray `import` /
469
+ // `export` word inside a comment, template, OR regex literal must not anchor a
470
+ // lazy regex match (e.g. `EXPORT_FROM_RE`'s `[^'";`]+?`) that spans across the
471
+ // literal's closing delimiter and consumes the next real `from '<spec>'`,
472
+ // whose keyword then sits in the blanked literal and is dropped, silently
473
+ // losing the real edge (the gate then 404s a legitimately imported module).
474
+ // Scanning over the blanked mask means NO keyword can anchor inside any
475
+ // literal or comment at all, so the whole class of delimiter blind spots
476
+ // (comment, string, template, regex) is closed at once, and there is nothing
477
+ // left for a per-match position guard to reject. The specifier itself is a
478
+ // string (blanked here), so it is read back from raw `src` via the match's
479
+ // group indices (the `d` flag).
440
480
  const masked = redactStringsAndTemplates(src, true);
441
481
  const deps = new Set();
442
482
  /** @type {Set<string>} bare npm vendor specifiers imported by this file (#754) */
443
483
  const bareDeps = new Set();
444
484
  for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
445
- for (const m of src.matchAll(re)) {
446
- // m.index is the keyword start (`\bimport` / `\bexport`). If that
447
- // position is blanked in the mask, the match lives inside a literal
448
- // and is not a real import edge.
449
- if (masked[m.index] === ' ') continue;
485
+ for (const m of masked.matchAll(re)) {
450
486
  // A statement-leading `import type { X }` / `import type * as X` /
451
487
  // `import type Foo` (default type import) / `export type { X }` /
452
488
  // `export type *` is fully erased by the TS stripper, so it never becomes
@@ -459,22 +495,17 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
459
495
  // followed by `from` is NOT type-only.
460
496
  // - a MIXED `import { type A, b }` does NOT lead with `type`, so it is
461
497
  // kept (its `b` binding is a real runtime edge).
498
+ // (m[0] here is over the mask, which keeps the binding syntax verbatim.)
462
499
  // #805
463
500
  const lead = m[0];
464
501
  const typeOnly =
465
502
  /^(?:import|export)\s+type\s*[{*]/.test(lead) ||
466
503
  (/^import\s+type\s+[A-Za-z_$]/.test(lead) && !/^import\s+type\s+from\b/.test(lead));
467
504
  if (typeOnly) continue;
468
- const spec = m[1];
469
- // Guard a match whose `from '<spec>'` tail reaches INTO a blanked literal:
470
- // EXPORT_FROM_RE's lazy `[^'";]+?` can span a template body to a `from`
471
- // written inside example code (`export const t = html\`...import x from
472
- // 'left-pad'\``), so the KEYWORD is real but the SPECIFIER is not. A real
473
- // string keeps its delimiters in the mask (only the body blanks), while a
474
- // template body blanks whole, so a blanked opening quote means the
475
- // specifier is inside a literal and the match is spurious.
476
- const quoteAt = m.index + m[0].length - spec.length - 2;
477
- if (masked[quoteAt] === ' ') continue;
505
+ // The specifier is read from RAW src at the match's captured-group indices
506
+ // (the group is the blanked string body in the mask; its position is the
507
+ // real specifier in src).
508
+ const spec = src.slice(m.indices[1][0], m.indices[1][1]);
478
509
  // Only resolve relative imports + `#`-style subpath aliases (#555)
479
510
  // within the project. A bare npm specifier (dayjs) has no alias match
480
511
  // and is skipped FROM THE GRAPH, but recorded as a vendor edge so SSR
@@ -491,12 +522,30 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
491
522
  }
492
523
  // Dynamic `import('…')` with a string-literal specifier (#751): a separate
493
524
  // edge class so the gate admits the lazily-loaded module (no 404) without
494
- // preloading it. Same redaction-mask + alias rules as the static scan. A
495
- // target already statically imported is not duplicated as a dynamic edge.
525
+ // preloading it. A target already statically imported is not duplicated as a
526
+ // dynamic edge.
527
+ //
528
+ // The static scan above runs over the FULLY-BLANKED mask, which also blanks
529
+ // the code inside `${...}` template holes. That drops a dynamic import written
530
+ // directly inside a hole (`html\`${import('./x.ts')}\``), which the AST counts
531
+ // as an edge but the mask scan did not, so the gate 404s a legitimately-loaded
532
+ // module (#918, the admit-fewer direction of the #753 family). So the dynamic
533
+ // scan instead runs over the PLACEHOLDER redaction (#634), which keeps `${...}`
534
+ // hole code readable (so a hole-position `import(...)` IS seen) while still
535
+ // tokenizing string / template-TEXT bodies to `__STR_<idx>__` and blanking
536
+ // comment / regex bodies (so a dynamic import written as literal TEXT is NOT
537
+ // an edge). The #753 swallow class stays closed because `DYNAMIC_IMPORT_RE` is
538
+ // tightly anchored on `import(`, never a lazy cross-delimiter span. The
539
+ // specifier surfaces as a `__STR_<idx>__` token, recovered from `literals`; a
540
+ // computed `import(expr)` still falls out (its char after the quote is not
541
+ // `,`/`)`), preserving the documented limitation.
542
+ const { redacted: holeAware, literals } = redactToPlaceholders(src);
496
543
  const dynDeps = new Set();
497
- for (const m of src.matchAll(DYNAMIC_IMPORT_RE)) {
498
- if (masked[m.index] === ' ') continue;
499
- const spec = m[1];
544
+ for (const m of holeAware.matchAll(DYNAMIC_IMPORT_RE)) {
545
+ const token = m[1];
546
+ const pm = /^__STR_(\d+)__$/.exec(token);
547
+ const spec = pm ? literals[Number(pm[1])] : token;
548
+ if (spec == null) continue;
500
549
  if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
501
550
  const resolved = resolveImport(spec, file, appDir);
502
551
  if (resolved && !deps.has(resolved)) dynDeps.add(resolved);
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';
@@ -194,25 +194,30 @@ export async function ssrPage(route, params, url, opts) {
194
194
  // enter the viewport.
195
195
  const { eager: eagerComponents, lazy: lazyComponents } =
196
196
  componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents);
197
+ // The walk roots for BOTH preload passes are the BOOT's actually-shipped
198
+ // module set (`moduleUrls`, which already drops inert page/layout modules and
199
+ // substitutes an import-only page with its components), NOT the raw route
200
+ // entries `[route.file, ...route.layouts]`. Rooting at the raw entries would
201
+ // walk a dropped page's SSR-only subtree (a direct import OR a relative
202
+ // helper) and hint a `modulepreload` for a module nothing that ships imports,
203
+ // an over-fetch (#780, the app-module analog of the #754 vendor over-fetch).
204
+ const shippedRoots = moduleUrls.map((u) =>
205
+ resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
197
206
  const preloads = deduplicatedPreloads(
198
207
  eagerComponents,
199
208
  moduleUrls,
200
209
  opts.moduleGraph,
201
- [route.file, ...route.layouts],
210
+ shippedRoots,
202
211
  opts.appDir,
203
212
  opts.serverFiles,
204
213
  opts.elidableComponents,
205
214
  );
206
215
  // Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
207
216
  // vendor URLs the page's SHIPPED modules actually import, fetched in parallel
208
- // instead of discovered level by level. The walk roots are the BOOT's shipped
209
- // module set (`moduleUrls`, which already drops inert page/layout modules and
210
- // substitutes an import-only page with its components) plus the rendered
211
- // components, so a vendor reached ONLY through a dropped module (a page's
212
- // SSR-only direct import OR its SSR-only relative helper) is never preloaded
213
- // (no over-fetch).
214
- const shippedRoots = moduleUrls.map((u) =>
215
- resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
217
+ // instead of discovered level by level. Same shipped-module roots as the
218
+ // app-module walk above, so a vendor reached ONLY through a dropped module
219
+ // (a page's SSR-only direct import OR its SSR-only relative helper) is never
220
+ // preloaded (no over-fetch).
216
221
  const vendorPreloads = vendorPreloadTargets(
217
222
  reachedVendorSpecifiers(
218
223
  opts.moduleGraph,
@@ -419,6 +424,7 @@ function htmlResponse(html, status, req, url, metadata) {
419
424
  // final, so a warming response is reload-safe. See router-client.js
420
425
  // applySwap and publishedBuildId() in importmap.js.
421
426
  headers.set('x-webjs-build', publishedBuildId());
427
+ headers.set('x-webjs-src', appSourceId());
422
428
  // Buffered (string) body: opt into the conditional-GET funnel so a
423
429
  // PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304.
424
430
  // The funnel still excludes the no-store default, so a private page is
@@ -446,6 +452,7 @@ function cachedHtmlResponse(rec, req, url) {
446
452
  const headers = new Headers({ 'content-type': rec.contentType });
447
453
  headers.set('cache-control', rec.cacheControl);
448
454
  headers.set('x-webjs-build', publishedBuildId());
455
+ headers.set('x-webjs-src', appSourceId());
449
456
  headers.set(BUFFERED_MARKER, '1');
450
457
  return new Response(rec.body, { status: rec.status, headers });
451
458
  }
@@ -1579,10 +1586,24 @@ function componentPreloads(usedTags, appDir, elidable) {
1579
1586
  * Merge component preloads with transitive dependencies from the module
1580
1587
  * graph, then deduplicate against the already-imported module URLs.
1581
1588
  *
1589
+ * The walk ROOTS (`entryFiles`) are the boot's actually-SHIPPED page/layout
1590
+ * module set (the caller passes the absolute paths of `moduleUrls`, which
1591
+ * already drops an inert page/layout and substitutes an import-only page with
1592
+ * its components), NOT the raw `[route.file, ...route.layouts]` route entries.
1593
+ * This matches `reachedVendorSpecifiers`' roots so the two walks stay
1594
+ * consistent. Rooting at the shipped set means a module reached ONLY through a
1595
+ * dropped page/layout (its SSR-only direct app import OR its SSR-only relative
1596
+ * helper) is never a walk root's dep, so it gets no `modulepreload` hint (no
1597
+ * over-fetch, #780). A module that also ships some other way (a component shared
1598
+ * with a live route, or reached via an import-only page's substituted
1599
+ * components) is still reached through a real shipped root, so its hint stays
1600
+ * (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped
1601
+ * modules' own URLs; the shipped-roots change closes the TRANSITIVE gap.
1602
+ *
1582
1603
  * @param {string[]} componentUrls direct component module URLs
1583
1604
  * @param {string[]} moduleUrls boot script imports (page + layouts)
1584
1605
  * @param {import('./module-graph.js').ModuleGraph | undefined} graph
1585
- * @param {string[]} entryFiles absolute paths of page + layout files
1606
+ * @param {string[]} entryFiles absolute paths of the SHIPPED page/layout modules (from `moduleUrls`)
1586
1607
  * @param {string} appDir
1587
1608
  * @param {Set<string>} [elidableComponents] absolute paths to skip in the walk
1588
1609
  * @returns {string[]}
@@ -1717,6 +1738,7 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1717
1738
  // See htmlResponse: published build id on every response for the
1718
1739
  // client router's importmap-mismatch detection on partial swaps.
1719
1740
  headers.set('x-webjs-build', publishedBuildId());
1741
+ headers.set('x-webjs-src', appSourceId());
1720
1742
 
1721
1743
  if (!ctx.pending.length) {
1722
1744
  // No pending boundaries: this degrades to a single buffered (string)
package/src/websocket.js CHANGED
@@ -4,6 +4,7 @@ import { urlFromRequest } from './forwarded.js';
4
4
  import { registerClient } from './broadcast.js';
5
5
  import { loadWsModule } from './listener-core.js';
6
6
  import { makeThenable } from './thenable-params.js';
7
+ import { setTrustedRemoteIp } from './rate-limit.js';
7
8
 
8
9
  /**
9
10
  * WebSocket support.
@@ -106,7 +107,17 @@ function buildRequestFromUpgrade(req, url) {
106
107
  const headers = {};
107
108
  for (const [k, v] of Object.entries(req.headers)) {
108
109
  if (k.startsWith(':')) continue;
110
+ // Never copy an inbound `x-webjs-remote-ip`: it is a framework-internal
111
+ // trust header a client could spoof on the upgrade. The real socket IP is
112
+ // stamped below via the authoritative WeakMap (#778, the WS-seam analog of
113
+ // the #773 fetch-path IP-trust fix).
114
+ if (k === 'x-webjs-remote-ip') continue;
109
115
  headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
110
116
  }
111
- return new Request(url, { method: 'GET', headers });
117
+ const webReq = new Request(url, { method: 'GET', headers });
118
+ // Stamp the framework-trusted socket IP so `clientIp(req)` inside the `WS`
119
+ // handler returns the real peer address (the WeakMap is authoritative and
120
+ // ignores the inbound header), matching the fetch path's `toWebRequest`.
121
+ setTrustedRemoteIp(webReq, req.socket?.remoteAddress || '');
122
+ return webReq;
112
123
  }
@@ -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
  },