@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 +1 -1
- package/src/dev-reload-worker.js +71 -0
- package/src/dev.js +234 -14
- package/src/importmap.js +63 -9
- package/src/listener-bun.js +6 -1
- package/src/listener-core.js +11 -0
- package/src/module-graph.js +22 -0
- package/src/ssr.js +4 -1
- package/webjs-config.schema.json +5 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
1643
|
+
const watchRoot = async (root) => {
|
|
1547
1644
|
try {
|
|
1548
|
-
const events = fsWatch(
|
|
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 },
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2620
|
-
|
|
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()`
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* first publish (a rebuild in dev re-publishes
|
|
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 =
|
|
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
|
-
|
|
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/listener-bun.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/listener-core.js
CHANGED
|
@@ -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
|
package/src/module-graph.js
CHANGED
|
@@ -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)
|
package/webjs-config.schema.json
CHANGED
|
@@ -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
|
},
|