@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 +1 -1
- package/src/dev-reload-worker.js +18 -0
- package/src/dev.js +146 -9
- package/src/importmap.js +63 -9
- package/src/js-scan.js +7 -1
- package/src/listener-bun.js +24 -2
- package/src/listener-core.js +11 -0
- package/src/module-graph.js +77 -28
- package/src/ssr.js +33 -11
- package/src/websocket.js +12 -1
- package/webjs-config.schema.json +5 -0
package/package.json
CHANGED
package/src/dev-reload-worker.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
@@ -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('
|
|
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')
|
|
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()`
|
|
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/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
|
-
|
|
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;
|
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 */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
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
|
@@ -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+)?['"]([^'"]+)['"]/
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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[^'"
|
|
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*[,)]/
|
|
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
|
|
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
|
-
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
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.
|
|
495
|
-
//
|
|
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
|
|
498
|
-
|
|
499
|
-
const
|
|
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
|
-
|
|
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.
|
|
209
|
-
// module
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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
|
},
|