@webjsdev/server 0.8.69 → 0.8.71
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/config.js +4 -4
- package/src/dev/handler.js +8 -2
- package/src/dev/helpers.js +76 -4
- package/src/dev/server.js +15 -8
- package/src/dev-embed-client.js +5 -0
- package/src/dev-reload-state.js +31 -0
- package/src/dev-reload-worker.js +208 -62
- package/src/listener-bun.js +11 -10
- package/src/listener-core.js +39 -19
- package/src/router.js +6 -0
- package/src/ssr/head.js +6 -1
- package/src/vendor/manifest.js +27 -0
- package/src/vendor/pins.js +61 -5
- package/webjs-config.schema.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.71",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/dev/config.js
CHANGED
|
@@ -319,13 +319,13 @@ export async function readDevWatchPathsFromApp(appDir) {
|
|
|
319
319
|
|
|
320
320
|
/**
|
|
321
321
|
* The dev tooling switches in `webjs.dev` (#1504): `embedOrigins` (the embed
|
|
322
|
-
* bridge, #1498)
|
|
322
|
+
* bridge, #1498), `sourceLocations` (#1499) and `reloadIdle` (#1507). Raw values; the env overrides
|
|
323
323
|
* and validation live with each feature (`resolveEmbedOrigins`,
|
|
324
324
|
* `sourceLocationsRequested`). A missing or unreadable package.json is both
|
|
325
325
|
* off.
|
|
326
326
|
*
|
|
327
327
|
* @param {string} appDir
|
|
328
|
-
* @returns {Promise<{ embedOrigins: unknown, sourceLocations: unknown }>}
|
|
328
|
+
* @returns {Promise<{ embedOrigins: unknown, sourceLocations: unknown, reloadIdle: unknown }>}
|
|
329
329
|
*/
|
|
330
330
|
export async function readDevToolingFromApp(appDir) {
|
|
331
331
|
let dev;
|
|
@@ -335,8 +335,8 @@ export async function readDevToolingFromApp(appDir) {
|
|
|
335
335
|
} catch {
|
|
336
336
|
dev = undefined;
|
|
337
337
|
}
|
|
338
|
-
if (!dev || typeof dev !== 'object') return { embedOrigins: undefined, sourceLocations: undefined };
|
|
339
|
-
return { embedOrigins: dev.embedOrigins, sourceLocations: dev.sourceLocations };
|
|
338
|
+
if (!dev || typeof dev !== 'object') return { embedOrigins: undefined, sourceLocations: undefined, reloadIdle: undefined };
|
|
339
|
+
return { embedOrigins: dev.embedOrigins, sourceLocations: dev.sourceLocations, reloadIdle: dev.reloadIdle };
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
/**
|
package/src/dev/handler.js
CHANGED
|
@@ -67,6 +67,8 @@ import {
|
|
|
67
67
|
import {
|
|
68
68
|
exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
|
|
69
69
|
appTopLevelDirs, locateCoreDir,
|
|
70
|
+
setReloadIdleMs,
|
|
71
|
+
resolveReloadIdleMs,
|
|
70
72
|
} from './helpers.js';
|
|
71
73
|
import {
|
|
72
74
|
handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
|
|
@@ -223,9 +225,13 @@ export async function createRequestHandler(opts) {
|
|
|
223
225
|
// neither injects the script nor relaxes a framing header.
|
|
224
226
|
// `webjs.dev.embedOrigins` in package.json is the default (#1504); a set
|
|
225
227
|
// env var replaces it for one run.
|
|
226
|
-
const devTooling = dev ? await readDevToolingFromApp(appDir) : { embedOrigins: undefined, sourceLocations: undefined };
|
|
228
|
+
const devTooling = dev ? await readDevToolingFromApp(appDir) : { embedOrigins: undefined, sourceLocations: undefined, reloadIdle: undefined };
|
|
227
229
|
const embedOriginsValue = dev ? resolveEmbedOrigins(process.env.WEBJS_EMBED_ORIGINS, devTooling.embedOrigins) : [];
|
|
228
230
|
setEmbedOrigins(embedOriginsValue);
|
|
231
|
+
// Live-reload idle close (#1507): `webjs.dev.reloadIdle` seconds, or a set
|
|
232
|
+
// `WEBJS_DEV_RELOAD_IDLE`. Off by default; for a host that counts an open
|
|
233
|
+
// request as activity, so it can sleep while a preview tab sits open.
|
|
234
|
+
setReloadIdleMs(dev ? resolveReloadIdleMs(process.env.WEBJS_DEV_RELOAD_IDLE, devTooling.reloadIdle) : 0);
|
|
229
235
|
|
|
230
236
|
const coreDir = locateCoreDir(appDir);
|
|
231
237
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
@@ -810,7 +816,7 @@ export async function createRequestHandler(opts) {
|
|
|
810
816
|
// path skipped it. This runs on the first warm AND after every rebuild,
|
|
811
817
|
// so the pruned map is the single source of truth.
|
|
812
818
|
const reachable = await scan();
|
|
813
|
-
({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
|
|
819
|
+
({ imports, integrity } = prunePinToReachable(imports, integrity, reachable, { appDir }));
|
|
814
820
|
}
|
|
815
821
|
await setVendorEntries(imports, integrity);
|
|
816
822
|
return v.ok;
|
package/src/dev/helpers.js
CHANGED
|
@@ -368,6 +368,32 @@ const DEV_STYLES_SRC = readFileSync(new URL('../dev-styles.js', import.meta.url)
|
|
|
368
368
|
* @param {string} bp the normalized base path (`''` = no-op)
|
|
369
369
|
* @returns {string}
|
|
370
370
|
*/
|
|
371
|
+
/** Idle close for the reload stream, in ms (#1507); 0 = off. Set by the dev handler. */
|
|
372
|
+
let _reloadIdleMs = 0;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Record `webjs.dev.reloadIdle` / `WEBJS_DEV_RELOAD_IDLE` (#1507) for the served
|
|
376
|
+
* reload scripts. A module-level switch like `setEmbedOrigins`, set once per
|
|
377
|
+
* dev handler.
|
|
378
|
+
* @param {number} ms
|
|
379
|
+
*/
|
|
380
|
+
export function setReloadIdleMs(ms) {
|
|
381
|
+
_reloadIdleMs = Number.isFinite(ms) && ms > 0 ? Math.round(ms) : 0;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Resolve the reload idle close in ms: a set `WEBJS_DEV_RELOAD_IDLE` (seconds)
|
|
386
|
+
* wins over `webjs.dev.reloadIdle` (seconds); anything not a positive number
|
|
387
|
+
* is off.
|
|
388
|
+
* @param {string | undefined} envRaw
|
|
389
|
+
* @param {unknown} configured
|
|
390
|
+
* @returns {number}
|
|
391
|
+
*/
|
|
392
|
+
export function resolveReloadIdleMs(envRaw, configured) {
|
|
393
|
+
const raw = typeof envRaw === 'string' && envRaw.trim() !== '' ? Number(envRaw) : configured;
|
|
394
|
+
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.round(raw * 1000) : 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
371
397
|
export function reloadClientJs(bp) {
|
|
372
398
|
// The overlay renderer uses textContent throughout (never innerHTML), so the
|
|
373
399
|
// error message / code frame can never inject markup (#264). Served only in
|
|
@@ -471,8 +497,8 @@ function __webjsDirectEvents() {
|
|
|
471
497
|
// the reload debounce (#1397). The shim scope has no setTimeout, so the relay
|
|
472
498
|
// picks up the tab's own timers.
|
|
473
499
|
const scope = {};
|
|
474
|
-
startReloadWorker(scope, EventSource, ${eventsUrl});
|
|
475
|
-
|
|
500
|
+
startReloadWorker(scope, EventSource, ${eventsUrl}, { idleMs: ${_reloadIdleMs} });
|
|
501
|
+
const shim = { start() {}, postMessage(m) {
|
|
476
502
|
// Nothing may throw out of here, and nothing may be silently dropped.
|
|
477
503
|
//
|
|
478
504
|
// The relay's fanout deletes a port whose postMessage throws, which is the
|
|
@@ -495,7 +521,52 @@ function __webjsDirectEvents() {
|
|
|
495
521
|
} catch (_) {
|
|
496
522
|
console.error('[webjs] dev reload handler threw', _);
|
|
497
523
|
}
|
|
498
|
-
} }
|
|
524
|
+
} };
|
|
525
|
+
scope.onconnect({ ports: [shim] });
|
|
526
|
+
__webjsReportVisibility(function (msg) { if (shim.onmessage) shim.onmessage({ data: msg }); });
|
|
527
|
+
}
|
|
528
|
+
// Tell the relay whether this tab is on screen (#1507). The relay holds the
|
|
529
|
+
// live-reload stream open only while some tab is visible, so a backgrounded
|
|
530
|
+
// dev tab keeps no request in flight and sends nothing; it reopens on return
|
|
531
|
+
// and its hello says whether an edit landed meanwhile. \`bye\` on pagehide
|
|
532
|
+
// drops the tab outright, and a bfcache restore reports in again.
|
|
533
|
+
function __webjsReportVisibility(send) {
|
|
534
|
+
// The state this page was rendered at (#1516). The relay compares it with
|
|
535
|
+
// the server's on connect and on every reconnect, so an edit whose reload
|
|
536
|
+
// frame was lost (a stream replaced at a host's wake, a fresh relay, a tab
|
|
537
|
+
// that connected after the fanout) still reloads this page.
|
|
538
|
+
// Sent again on a bfcache restore, since \`bye\` dropped this tab's entry.
|
|
539
|
+
function sendPage() {
|
|
540
|
+
try {
|
|
541
|
+
var metas = document.querySelectorAll('meta[name="webjs-dev-reload"]');
|
|
542
|
+
var meta = metas.length ? metas[metas.length - 1] : null;
|
|
543
|
+
if (!meta) return;
|
|
544
|
+
var page = JSON.parse(meta.getAttribute('content') || 'null');
|
|
545
|
+
if (page && typeof page.boot === 'string') send({ type: 'page', boot: page.boot, seq: typeof page.seq === 'number' ? page.seq : null });
|
|
546
|
+
} catch (_) { /* no usable state: the relay's own hello check still runs */ }
|
|
547
|
+
}
|
|
548
|
+
sendPage();
|
|
549
|
+
function report() {
|
|
550
|
+
try { send({ type: 'visibility', visible: document.visibilityState !== 'hidden' }); } catch (_) { /* relay gone */ }
|
|
551
|
+
}
|
|
552
|
+
document.addEventListener('visibilitychange', report);
|
|
553
|
+
addEventListener('pagehide', function () { try { send({ type: 'bye' }); } catch (_) { /* relay gone */ } });
|
|
554
|
+
addEventListener('pageshow', function (e) { if (e.persisted) { sendPage(); report(); } });
|
|
555
|
+
report();
|
|
556
|
+
// Interaction keeps an idle-closing stream (webjs.dev.reloadIdle) open and
|
|
557
|
+
// reopens a closed one, at most once a second. The dev embed bridge calls
|
|
558
|
+
// __webjsDevActivity for each host command, so a framing tool can resume it.
|
|
559
|
+
var lastActivity = 0;
|
|
560
|
+
function activity() {
|
|
561
|
+
var now = Date.now();
|
|
562
|
+
if (now - lastActivity < 1000) return;
|
|
563
|
+
lastActivity = now;
|
|
564
|
+
try { send({ type: 'activity' }); } catch (_) { /* relay gone */ }
|
|
565
|
+
}
|
|
566
|
+
['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'focus'].forEach(function (t) {
|
|
567
|
+
addEventListener(t, activity, { capture: true, passive: true });
|
|
568
|
+
});
|
|
569
|
+
globalThis.__webjsDevActivity = activity;
|
|
499
570
|
}
|
|
500
571
|
try {
|
|
501
572
|
if (typeof SharedWorker !== 'undefined') {
|
|
@@ -506,6 +577,7 @@ try {
|
|
|
506
577
|
else if (m.type === 'webjs-error') __webjsApplyError(m.data);
|
|
507
578
|
};
|
|
508
579
|
w.port.start();
|
|
580
|
+
__webjsReportVisibility(function (msg) { w.port.postMessage(msg); });
|
|
509
581
|
} else {
|
|
510
582
|
__webjsDirectEvents();
|
|
511
583
|
}
|
|
@@ -527,7 +599,7 @@ try {
|
|
|
527
599
|
export function reloadWorkerJs(bp) {
|
|
528
600
|
return `// webjs dev reload worker (one shared connection for all tabs)
|
|
529
601
|
${RELOAD_WORKER_SRC}
|
|
530
|
-
startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
|
|
602
|
+
startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))}, { idleMs: ${_reloadIdleMs} });
|
|
531
603
|
`;
|
|
532
604
|
}
|
|
533
605
|
|
package/src/dev/server.js
CHANGED
|
@@ -6,7 +6,8 @@ import { createServer as createHttp1Server } from 'node:http';
|
|
|
6
6
|
import { createRequestHandler } from './handler.js';
|
|
7
7
|
import { readServerTimeoutsFromApp, readDevWatchPathsFromApp } from './config.js';
|
|
8
8
|
import { defaultLogger } from '../logger.js';
|
|
9
|
-
import { SseHub, makeShutdown, installProcessHandlers,
|
|
9
|
+
import { SseHub, makeShutdown, installProcessHandlers, serverRuntime, DEV_BOOT_ID } from '../listener-core.js';
|
|
10
|
+
import { setDevReloadState } from '../dev-reload-state.js';
|
|
10
11
|
import { urlFromRequest } from '../forwarded.js';
|
|
11
12
|
import { stripBasePath } from '../base-path.js';
|
|
12
13
|
import { basePath } from '../importmap.js';
|
|
@@ -102,9 +103,15 @@ function startNodeListener(ctx) {
|
|
|
102
103
|
// promptly. The `hello` data is a per-process boot id (#893): the client
|
|
103
104
|
// reloads on a reconnect ONLY when it changes (a real restart), so a
|
|
104
105
|
// transient reconnect never triggers a spurious reload.
|
|
105
|
-
res.write(
|
|
106
|
-
//
|
|
107
|
-
//
|
|
106
|
+
res.write(hub.helloFrame());
|
|
107
|
+
// The stream is silent between events (#1507), so no socket timeout may
|
|
108
|
+
// reap it. node:http sets none by default, but an inherited or future
|
|
109
|
+
// one would close a live reload stream that is simply quiet, so it is
|
|
110
|
+
// switched off for this request alone.
|
|
111
|
+
req.setTimeout?.(0);
|
|
112
|
+
res.setTimeout?.(0);
|
|
113
|
+
// Register a node client wrapper in the shared hub: the fanout lives in
|
|
114
|
+
// SseHub; only the transport write (res.write / res.end) is local.
|
|
108
115
|
const client = {
|
|
109
116
|
send: (s) => { try { res.write(s); } catch {} },
|
|
110
117
|
close: () => { try { res.end(); } catch {} },
|
|
@@ -189,8 +196,7 @@ function startNodeListener(ctx) {
|
|
|
189
196
|
server,
|
|
190
197
|
close: () => new Promise((r) => {
|
|
191
198
|
if (watcherAbort) watcherAbort.abort();
|
|
192
|
-
//
|
|
193
|
-
// (e.g. across a test run) don't accumulate live intervals.
|
|
199
|
+
// Close every open live-reload stream.
|
|
194
200
|
hub.closeAll();
|
|
195
201
|
server.close(() => r());
|
|
196
202
|
}),
|
|
@@ -224,6 +230,9 @@ export async function startServer(opts) {
|
|
|
224
230
|
// identically on both). Built before the handler so its onReload / onDevError
|
|
225
231
|
// callbacks can fan out through it.
|
|
226
232
|
const hub = new SseHub();
|
|
233
|
+
// Pages render the stream's state into a meta tag (#1516), so the relay can
|
|
234
|
+
// re-check a page against the server on every reconnect.
|
|
235
|
+
if (dev) setDevReloadState(() => ({ boot: DEV_BOOT_ID, seq: hub.seq }));
|
|
227
236
|
let app;
|
|
228
237
|
try {
|
|
229
238
|
app = await createRequestHandler({
|
|
@@ -236,8 +245,6 @@ export async function startServer(opts) {
|
|
|
236
245
|
onDevError: (frame) => hub.devError(frame),
|
|
237
246
|
});
|
|
238
247
|
} catch (e) {
|
|
239
|
-
// The hub starts its keepalive interval in its constructor (before this
|
|
240
|
-
// await), so a boot failure must clear it rather than leak a live timer.
|
|
241
248
|
hub.closeAll();
|
|
242
249
|
throw e;
|
|
243
250
|
}
|
package/src/dev-embed-client.js
CHANGED
|
@@ -297,6 +297,10 @@ export function installEmbedBridge(origins, opts) {
|
|
|
297
297
|
var d = e.data;
|
|
298
298
|
if (!d || typeof d !== 'object' || d.source !== 'webjs-embed-host') return;
|
|
299
299
|
known = e.origin;
|
|
300
|
+
// A host command is activity: it reopens a live-reload stream the dev
|
|
301
|
+
// server closed for idleness (webjs.dev.reloadIdle, #1507), and an edit
|
|
302
|
+
// made meanwhile then reloads the page.
|
|
303
|
+
if (typeof win.__webjsDevActivity === 'function') { try { win.__webjsDevActivity(); } catch (_) { /* no reload client */ } }
|
|
300
304
|
if (d.type === 'reload') {
|
|
301
305
|
win.location.reload();
|
|
302
306
|
} else if (d.type === 'navigate') {
|
|
@@ -319,6 +323,7 @@ export function installEmbedBridge(origins, opts) {
|
|
|
319
323
|
} else if (d.type === 'inspect') {
|
|
320
324
|
setInspect(d.enabled === true);
|
|
321
325
|
}
|
|
326
|
+
// { type: 'resume' } needs nothing beyond the activity call above.
|
|
322
327
|
});
|
|
323
328
|
|
|
324
329
|
return {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live-reload state a dev page was rendered at (#1516).
|
|
3
|
+
*
|
|
4
|
+
* `startServer` owns the reload stream's `SseHub`, which knows this process's
|
|
5
|
+
* boot id and how many reload frames it has sent. SSR renders that pair into a
|
|
6
|
+
* `webjs-dev-reload` meta tag, and the reload client hands it to the relay, so
|
|
7
|
+
* on every reconnect the relay can tell whether the page on screen is behind
|
|
8
|
+
* the server even when the reload frame that would have said so was lost.
|
|
9
|
+
*
|
|
10
|
+
* A module-level provider, the same shape as the base path and the embed
|
|
11
|
+
* origins, because the head is rendered far from the listener. An embedded
|
|
12
|
+
* `createRequestHandler` with no `startServer` has no reload stream, so no
|
|
13
|
+
* provider is set and no meta tag is rendered.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @type {(() => { boot: string, seq: number }) | null} */
|
|
17
|
+
let provider = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Set (or clear with null) the function that reports the current state.
|
|
21
|
+
* @param {(() => { boot: string, seq: number }) | null} fn
|
|
22
|
+
*/
|
|
23
|
+
export function setDevReloadState(fn) {
|
|
24
|
+
provider = typeof fn === 'function' ? fn : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The current `{ boot, seq }`, or null when there is no reload stream. */
|
|
28
|
+
export function devReloadState() {
|
|
29
|
+
if (!provider) return null;
|
|
30
|
+
try { return provider(); } catch { return null; }
|
|
31
|
+
}
|
package/src/dev-reload-worker.js
CHANGED
|
@@ -77,58 +77,135 @@ export function parseVerdict(data) {
|
|
|
77
77
|
return 'reload';
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** First reconnect wait after the stream drops (#1507), doubled per failure. */
|
|
81
|
+
export const RECONNECT_BASE_MS = 300;
|
|
82
|
+
/** The longest wait between reconnect attempts (#1507). */
|
|
83
|
+
export const RECONNECT_MAX_MS = 30000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read a `hello` frame's data (#893, #1507): `{ boot, seq }` JSON, or the bare
|
|
87
|
+
* boot id an older server sends (seq unknown).
|
|
88
|
+
* @param {string} data
|
|
89
|
+
* @returns {{ boot: string, seq: number | null }}
|
|
90
|
+
*/
|
|
91
|
+
export function parseHello(data) {
|
|
92
|
+
try {
|
|
93
|
+
const o = JSON.parse(data);
|
|
94
|
+
if (o && typeof o === 'object' && typeof o.boot === 'string') {
|
|
95
|
+
return { boot: o.boot, seq: typeof o.seq === 'number' ? o.seq : null };
|
|
96
|
+
}
|
|
97
|
+
} catch (_) { /* a bare boot id */ }
|
|
98
|
+
return { boot: String(data), seq: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
/**
|
|
102
|
+
* Whether a tab's page is behind the server (#1516). `page` is the
|
|
103
|
+
* `{ boot, seq }` the page was rendered at (the `webjs-dev-reload` meta tag),
|
|
104
|
+
* `boot` / `seq` what the server reports now. A different boot id is a
|
|
105
|
+
* restarted process; a different seq from the same process is an edit the page
|
|
106
|
+
* has not picked up. A missing seq on either side cannot say, so it is not
|
|
107
|
+
* stale (the boot id alone still decides).
|
|
108
|
+
* @param {{ boot: string, seq: number | null } | undefined} page
|
|
109
|
+
* @param {string | null} boot
|
|
110
|
+
* @param {number | null} seq
|
|
111
|
+
*/
|
|
112
|
+
export function pageIsStale(page, boot, seq) {
|
|
113
|
+
if (!page || boot === null) return false;
|
|
114
|
+
if (page.boot !== boot) return true;
|
|
115
|
+
return page.seq !== null && seq !== null && page.seq !== seq;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
|
|
119
|
+
function reloadSeq(data) {
|
|
120
|
+
try {
|
|
121
|
+
const o = JSON.parse(data);
|
|
122
|
+
return o && typeof o.seq === 'number' ? o.seq : null;
|
|
123
|
+
} catch (_) { return null; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run the relay.
|
|
128
|
+
*
|
|
129
|
+
* The stream is open only while at least one tab is VISIBLE (#1507). Each tab
|
|
130
|
+
* reports `{ type: 'visibility', visible }` on `visibilitychange` and
|
|
131
|
+
* `{ type: 'bye' }` on `pagehide`; when the last visible tab goes away the
|
|
132
|
+
* relay closes its `EventSource`, so a backgrounded dev tab holds no request
|
|
133
|
+
* open and sends nothing, and it reopens the moment one becomes visible. An
|
|
134
|
+
* edit made meanwhile is not lost: the reopened stream's `hello` carries the
|
|
135
|
+
* server's boot id and reload `seq`, and a difference from what this relay last
|
|
136
|
+
* saw is a reload.
|
|
137
|
+
*
|
|
138
|
+
* Every reconnect also re-checks each tab against the state its PAGE was
|
|
139
|
+
* rendered at (#1516). A tab sends `{ type: 'page', boot, seq }` from the
|
|
140
|
+
* page's `webjs-dev-reload` meta tag when it connects. A frame emitted while no
|
|
141
|
+
* stream was there to carry it is gone: a host that holds the quiet stream
|
|
142
|
+
* across a suspend and closes it at the next wake swallows the reload the wake
|
|
143
|
+
* itself caused, a fresh relay has no previous hello to compare with, and a tab
|
|
144
|
+
* that connects after a reload was fanned out never got it. In each case the
|
|
145
|
+
* next hello (or the tab's own connect) shows the page is behind, and that tab
|
|
146
|
+
* reloads.
|
|
147
|
+
*
|
|
148
|
+
* The browser's own EventSource retry is replaced by an explicit one with
|
|
149
|
+
* backoff (300ms doubling to 30s, reset by the next `hello`), so a server that
|
|
150
|
+
* is gone for a while is probed less and less often instead of every 300ms.
|
|
151
|
+
*
|
|
81
152
|
* @param {{ onconnect: any, setTimeout?: any, clearTimeout?: any }} scope the
|
|
82
153
|
* worker global (`self`), or a plain shim object for the per-tab fallback.
|
|
83
154
|
* Timers are read off it when it has them, which is what lets a browser test
|
|
84
155
|
* drive the debounce on a fake clock.
|
|
85
156
|
* @param {new (url: string) => any} EventSourceCtor the `EventSource` constructor
|
|
86
157
|
* @param {string} eventsUrl the base-path-aware `/__webjs/events` URL
|
|
158
|
+
* @param {{ idleMs?: number }} [opts] `idleMs` (#1507, from `webjs.dev.reloadIdle`
|
|
159
|
+
* / `WEBJS_DEV_RELOAD_IDLE`): close the stream after this long with no edit
|
|
160
|
+
* and no interaction in any tab, and reopen on the next interaction. `0` (the
|
|
161
|
+
* default) keeps it open while a tab is visible.
|
|
87
162
|
*/
|
|
88
|
-
export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
|
|
89
|
-
|
|
90
|
-
|
|
163
|
+
export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
164
|
+
const idleMs = opts && opts.idleMs > 0 ? opts.idleMs : 0;
|
|
165
|
+
/** @type {Map<any, boolean>} each connected tab's port, and whether it is visible */
|
|
166
|
+
const ports = new Map();
|
|
167
|
+
/** @type {Map<any, { boot: string, seq: number | null }>} the state each tab's page reflects (#1516) */
|
|
168
|
+
const pages = new Map();
|
|
91
169
|
/** @type {string | null} the last error frame, cached for late-joining tabs */
|
|
92
170
|
let lastError = null;
|
|
93
171
|
/** @type {string | null} the last-seen per-process boot id (#893) */
|
|
94
172
|
let lastBoot = null;
|
|
173
|
+
/** @type {number | null} the last reload seq seen from that process (#1507) */
|
|
174
|
+
let lastSeq = null;
|
|
95
175
|
|
|
96
|
-
// Timers come from the worker global when it has them (the real SharedWorker
|
|
97
|
-
// scope does, and so does a tab running the per-tab fallback), else the
|
|
98
|
-
// ambient globals. Reading them off `scope` is what lets the browser test
|
|
99
|
-
// drive a fake clock and keep its assertions synchronous, and calling them as
|
|
100
|
-
// `timers.setTimeout(...)` keeps `this` the global in a real browser.
|
|
101
176
|
const timers = scope && typeof scope.setTimeout === 'function' ? scope : globalThis;
|
|
102
177
|
|
|
103
|
-
// A MessagePort has no reliable close event, so prune a port when a post to it
|
|
104
|
-
// throws (a closed tab). Some browsers silently no-op instead of throwing,
|
|
105
|
-
// leaving a dead port in the set, but that is a harmless dev-only no-op and
|
|
106
|
-
// the set is bounded by the tabs opened in one session.
|
|
107
178
|
function fanout(msg) {
|
|
108
|
-
for (const p of ports) {
|
|
179
|
+
for (const p of ports.keys()) {
|
|
109
180
|
try { p.postMessage(msg); } catch (_) { ports.delete(p); }
|
|
110
181
|
}
|
|
111
182
|
}
|
|
112
183
|
|
|
113
|
-
// The ONE debounced reload emitter (#1397). Two timers rather than a clock:
|
|
114
|
-
// the quiet timer is re-armed by every signal, the cap timer is armed once
|
|
115
|
-
// per batch and NEVER re-armed, so the cap measures from the first signal of
|
|
116
|
-
// the batch instead of sliding with the burst. Whichever fires first emits
|
|
117
|
-
// and cancels the other, which also starts a fresh batch.
|
|
118
184
|
/** @type {any} */ let quietTimer = null;
|
|
119
185
|
/** @type {any} */ let capTimer = null;
|
|
120
186
|
|
|
121
187
|
/** @type {string} the strongest verdict seen in the CURRENT batch (#1398) */
|
|
122
188
|
let batchVerdict = 'page';
|
|
189
|
+
/** Whether the batch reloads every tab, or only the stale ones in `batchPorts` (#1516). */
|
|
190
|
+
let batchAll = false;
|
|
191
|
+
/** @type {Set<any>} tabs whose page fell behind, reloaded on their own */
|
|
192
|
+
const batchPorts = new Set();
|
|
123
193
|
|
|
124
194
|
function emitReload() {
|
|
125
195
|
if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
|
|
126
196
|
if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
|
|
127
197
|
const v = batchVerdict;
|
|
128
|
-
// Resetting HERE rather than in requestReload is load-bearing: emitReload is
|
|
129
|
-
// the single place a batch ends, and it is reached from both timers.
|
|
130
198
|
batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
|
|
131
|
-
|
|
199
|
+
const msg = { type: 'reload', verdict: v };
|
|
200
|
+
const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p));
|
|
201
|
+
batchAll = false;
|
|
202
|
+
batchPorts.clear();
|
|
203
|
+
for (const p of targets) {
|
|
204
|
+
// The tab applies this reload, so from here its page reflects the state
|
|
205
|
+
// the relay knows now; a later hello compares against that.
|
|
206
|
+
if (pages.has(p) && typeof lastBoot === 'string') pages.set(p, { boot: lastBoot, seq: lastSeq });
|
|
207
|
+
try { p.postMessage(msg); } catch (_) { ports.delete(p); pages.delete(p); }
|
|
208
|
+
}
|
|
132
209
|
}
|
|
133
210
|
|
|
134
211
|
/** Strength rank; an unrecognised name ranks strongest, so it can only ever
|
|
@@ -138,60 +215,129 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
|
|
|
138
215
|
return i === -1 ? 0 : i;
|
|
139
216
|
}
|
|
140
217
|
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
218
|
+
/**
|
|
219
|
+
* @param {string} verdict
|
|
220
|
+
* @param {any} [port] reload only this tab (its page fell behind); omitted, every tab
|
|
221
|
+
*/
|
|
222
|
+
function requestReload(verdict, port) {
|
|
223
|
+
if (port === undefined) batchAll = true; else batchPorts.add(port);
|
|
144
224
|
if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
|
|
145
225
|
if (quietTimer !== null) timers.clearTimeout(quietTimer);
|
|
146
226
|
quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
|
|
147
227
|
if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
|
|
148
228
|
}
|
|
149
229
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
230
|
+
/** @type {any} */ let es = null;
|
|
231
|
+
/** @type {any} */ let retryTimer = null;
|
|
232
|
+
let retryDelay = RECONNECT_BASE_MS;
|
|
233
|
+
|
|
234
|
+
/** @type {any} */ let idleTimer = null;
|
|
235
|
+
|
|
236
|
+
// Idle close (#1507). A host that counts an in-flight request as activity
|
|
237
|
+
// (a pilots sandbox does, for the request's whole life) never sleeps while
|
|
238
|
+
// the stream is open, however quiet it is. With `idleMs` set, a stream that
|
|
239
|
+
// has carried nothing and seen no interaction for that long is closed, and
|
|
240
|
+
// the next interaction, a tab showing, or a host command through the embed
|
|
241
|
+
// bridge reopens it; the hello seq then says whether an edit landed meanwhile.
|
|
242
|
+
function touch() {
|
|
243
|
+
if (!idleMs) return;
|
|
244
|
+
if (idleTimer !== null) timers.clearTimeout(idleTimer);
|
|
245
|
+
idleTimer = timers.setTimeout(() => { idleTimer = null; close(); }, idleMs);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Reload every tab whose page is behind what the server reports now (#1516).
|
|
249
|
+
function checkPages() {
|
|
250
|
+
for (const [p, page] of pages) {
|
|
251
|
+
if (pageIsStale(page, lastBoot, lastSeq)) requestReload('reload', p);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function anyVisible() {
|
|
256
|
+
for (const v of ports.values()) if (v) return true;
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function open() {
|
|
261
|
+
if (es || retryTimer !== null) return;
|
|
262
|
+
es = new EventSourceCtor(eventsUrl);
|
|
263
|
+
const mine = es;
|
|
264
|
+
touch();
|
|
265
|
+
es.addEventListener('hello', (e) => {
|
|
266
|
+
retryDelay = RECONNECT_BASE_MS;
|
|
267
|
+
touch();
|
|
268
|
+
const h = parseHello(e.data);
|
|
269
|
+
if (lastBoot !== null && (h.boot !== lastBoot || (h.seq !== null && lastSeq !== null && h.seq !== lastSeq))) {
|
|
270
|
+
requestReload('reload');
|
|
271
|
+
}
|
|
272
|
+
lastBoot = h.boot;
|
|
273
|
+
lastSeq = h.seq;
|
|
274
|
+
checkPages();
|
|
275
|
+
});
|
|
276
|
+
es.addEventListener('reload', (e) => {
|
|
277
|
+
touch();
|
|
278
|
+
lastError = null;
|
|
279
|
+
const n = reloadSeq(e.data);
|
|
280
|
+
if (n !== null) lastSeq = n;
|
|
281
|
+
requestReload(parseVerdict(e.data));
|
|
282
|
+
});
|
|
283
|
+
es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
|
|
284
|
+
// The stream dropped (a restarting server, a network blip, a host that
|
|
285
|
+
// slept). Close it so the browser's fixed-interval retry never runs, and
|
|
286
|
+
// come back with backoff, but only while someone is looking.
|
|
287
|
+
es.addEventListener('error', () => {
|
|
288
|
+
if (es !== mine) return;
|
|
289
|
+
try { es.close(); } catch (_) { /* already closed */ }
|
|
290
|
+
es = null;
|
|
291
|
+
if (!anyVisible()) return;
|
|
292
|
+
retryTimer = timers.setTimeout(() => { retryTimer = null; if (anyVisible()) open(); }, retryDelay);
|
|
293
|
+
retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX_MS);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function close() {
|
|
298
|
+
if (idleTimer !== null) { timers.clearTimeout(idleTimer); idleTimer = null; }
|
|
299
|
+
if (retryTimer !== null) { timers.clearTimeout(retryTimer); retryTimer = null; }
|
|
300
|
+
if (es) { try { es.close(); } catch (_) { /* already closed */ } es = null; }
|
|
301
|
+
retryDelay = RECONNECT_BASE_MS;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function sync() {
|
|
305
|
+
if (anyVisible()) open(); else close();
|
|
306
|
+
}
|
|
182
307
|
|
|
183
308
|
scope.onconnect = (e) => {
|
|
184
309
|
const port = e.ports[0];
|
|
185
|
-
|
|
310
|
+
// A tab counts as visible until it says otherwise, so a client older than
|
|
311
|
+
// the visibility messages keeps the stream open exactly as before.
|
|
312
|
+
ports.set(port, true);
|
|
313
|
+
port.onmessage = (m) => {
|
|
314
|
+
const d = (m && m.data) || {};
|
|
315
|
+
if (d.type === 'page') {
|
|
316
|
+
// The state this tab's page was rendered at (#1516). Checked now
|
|
317
|
+
// against what the relay already knows, so a tab that missed a fanned
|
|
318
|
+
// out reload catches up, and again on every hello.
|
|
319
|
+
if (typeof d.boot === 'string') {
|
|
320
|
+
pages.set(port, { boot: d.boot, seq: typeof d.seq === 'number' ? d.seq : null });
|
|
321
|
+
if (pageIsStale(pages.get(port), lastBoot, lastSeq)) requestReload('reload', port);
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (d.type === 'visibility') ports.set(port, d.visible !== false);
|
|
326
|
+
else if (d.type === 'bye') { ports.delete(port); pages.delete(port); }
|
|
327
|
+
else if (d.type === 'activity') { if (!ports.get(port)) return; touch(); }
|
|
328
|
+
else return;
|
|
329
|
+
sync();
|
|
330
|
+
};
|
|
186
331
|
port.start();
|
|
187
|
-
// A tab that connects AFTER a breaking edit still needs the current overlay.
|
|
188
|
-
// The single shared EventSource already consumed the server's replay (#264),
|
|
189
|
-
// so the worker caches the last error and hands it to each new tab itself.
|
|
190
332
|
if (lastError != null) {
|
|
191
333
|
try { port.postMessage({ type: 'webjs-error', data: lastError }); } catch (_) { ports.delete(port); }
|
|
192
334
|
}
|
|
335
|
+
sync();
|
|
193
336
|
};
|
|
194
337
|
|
|
195
|
-
|
|
196
|
-
|
|
338
|
+
return {
|
|
339
|
+
ports,
|
|
340
|
+
/** The live EventSource, or null while paused or between retries. */
|
|
341
|
+
get es() { return es; },
|
|
342
|
+
};
|
|
197
343
|
}
|
package/src/listener-bun.js
CHANGED
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
* natively on Bun), the SAME negotiation + compressor factory the node shell
|
|
29
29
|
* uses (shared in `listener-core.js`), so the Bun path gets brotli too (#517).
|
|
30
30
|
* - **Timeouts** (#237): the node `requestTimeout` maps to Bun's single
|
|
31
|
-
* `idleTimeout` (seconds)
|
|
32
|
-
*
|
|
31
|
+
* `idleTimeout` (seconds); the dev live-reload stream opts out of it per
|
|
32
|
+
* request with `server.timeout(req, 0)` (#1507), since it is silent
|
|
33
|
+
* between events.
|
|
33
34
|
* - **Proxy IP**: `server.requestIP(req).address` stamps the framework-trusted
|
|
34
35
|
* `x-webjs-remote-ip` (the node socket-address equivalent), after stripping
|
|
35
36
|
* any spoofed inbound value.
|
|
@@ -59,7 +60,6 @@ import {
|
|
|
59
60
|
MAX_SYNC_COMPRESS_BYTES,
|
|
60
61
|
varyWithAcceptEncoding,
|
|
61
62
|
readBufferedOrStream,
|
|
62
|
-
DEV_BOOT_ID,
|
|
63
63
|
} from './listener-core.js';
|
|
64
64
|
|
|
65
65
|
/* global Bun */
|
|
@@ -91,6 +91,9 @@ export function startBunListener(ctx) {
|
|
|
91
91
|
// on node, but on Bun it is a perfectly ordinary streaming Response.
|
|
92
92
|
if (isEventsPath(url.pathname, basePathStr)) {
|
|
93
93
|
if (!dev) return new Response(null, { status: 404 });
|
|
94
|
+
// Silent between events (#1507): switch Bun's idleTimeout off for this
|
|
95
|
+
// one request, so a quiet live-reload stream is never reaped.
|
|
96
|
+
try { srv.timeout?.(req, 0); } catch { /* an older Bun: idleTimeout still applies */ }
|
|
94
97
|
return bunSseResponse(req, hub, app);
|
|
95
98
|
}
|
|
96
99
|
|
|
@@ -298,11 +301,9 @@ function bunSseResponse(req, hub, app) {
|
|
|
298
301
|
send: (s) => { try { controller.enqueue(enc.encode(s)); } catch {} },
|
|
299
302
|
close: () => { try { controller.close(); } catch {} },
|
|
300
303
|
};
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
// restart, matching the node:http shell.
|
|
305
|
-
controller.enqueue(enc.encode(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`));
|
|
304
|
+
// The hello frame (`SseHub.helloFrame`, shared with the node:http shell)
|
|
305
|
+
// carries the retry hint, the boot id and the reload seq (#893, #1507).
|
|
306
|
+
controller.enqueue(enc.encode(hub.helloFrame()));
|
|
306
307
|
hub.add(client);
|
|
307
308
|
// Replay an unresolved dev error (#264) so a tab connecting AFTER the
|
|
308
309
|
// breaking edit still shows the overlay.
|
|
@@ -488,8 +489,8 @@ async function maybeCompress(resp, req) {
|
|
|
488
489
|
|
|
489
490
|
/**
|
|
490
491
|
* Map the node `requestTimeout` (ms) to Bun's single `idleTimeout` (seconds). Bun
|
|
491
|
-
* caps it at 255s;
|
|
492
|
-
* stream is
|
|
492
|
+
* caps it at 255s; the floor of 30s is kept for ordinary requests. The dev
|
|
493
|
+
* live-reload stream is exempt per request (`server.timeout(req, 0)`, #1507). `0` (the node "disable" sentinel) disables it
|
|
493
494
|
* on Bun too.
|
|
494
495
|
* @param {{ requestTimeout?: number } | undefined} timeouts
|
|
495
496
|
*/
|
package/src/listener-core.js
CHANGED
|
@@ -335,22 +335,28 @@ export function loadWsModule(file, dev) {
|
|
|
335
335
|
|
|
336
336
|
/**
|
|
337
337
|
* Runtime-neutral SSE registry + fanout for dev live-reload and the dev error
|
|
338
|
-
* overlay (#264). Owns the connected-client Set
|
|
339
|
-
*
|
|
340
|
-
*
|
|
338
|
+
* overlay (#264). Owns the connected-client Set; the fanout writes plain SSE
|
|
339
|
+
* frames through each client's `send`. Each shell adds a thin client wrapper
|
|
340
|
+
* over its own transport (node `res.write`, a Bun
|
|
341
341
|
* `ReadableStreamDefaultController`), so this fanout logic is written once.
|
|
342
|
+
*
|
|
343
|
+
* The stream is SILENT between events (#1507). It used to write a `: ka`
|
|
344
|
+
* comment every 25 seconds per open tab, which kept the host busy forever: a
|
|
345
|
+
* sandbox or a laptop that sleeps on network quiet never slept while a tab
|
|
346
|
+
* showed the app. Each shell instead switches the server's idle timeout off
|
|
347
|
+
* for this one request, so a silent stream is never reaped, and the browser
|
|
348
|
+
* relay reconnects (with backoff) only when the stream actually drops.
|
|
349
|
+
*
|
|
350
|
+
* `seq` counts the reload frames this process has sent. It rides every reload
|
|
351
|
+
* frame and the `hello` frame, so a relay that closed its stream while every
|
|
352
|
+
* tab was hidden can tell, on reconnecting, that it missed an edit.
|
|
342
353
|
*/
|
|
343
354
|
export class SseHub {
|
|
344
|
-
|
|
345
|
-
constructor(opts = {}) {
|
|
355
|
+
constructor() {
|
|
346
356
|
/** @type {Set<{ send: (s: string) => void, close: () => void }>} */
|
|
347
357
|
this.clients = new Set();
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// SSE comments are ignored by the client. Unref'd so it never holds the
|
|
351
|
-
// process open.
|
|
352
|
-
this._timer = setInterval(() => this._raw(': ka\n\n'), opts.keepaliveMs ?? 25_000);
|
|
353
|
-
if (typeof this._timer.unref === 'function') this._timer.unref();
|
|
358
|
+
/** Reload frames sent by this process (#1507). */
|
|
359
|
+
this.seq = 0;
|
|
354
360
|
}
|
|
355
361
|
|
|
356
362
|
/** @param {{ send: (s: string) => void, close: () => void }} client */
|
|
@@ -365,31 +371,45 @@ export class SseHub {
|
|
|
365
371
|
}
|
|
366
372
|
}
|
|
367
373
|
|
|
374
|
+
/**
|
|
375
|
+
* The frame that opens every stream. `retry: 300` shrinks the browser's
|
|
376
|
+
* native reconnect wait (#893). The `hello` data names this process (its
|
|
377
|
+
* boot id, so a reconnect to a RESTARTED server reloads) and how many reload
|
|
378
|
+
* frames it has sent (`seq`, so a reconnect after a pause that missed one
|
|
379
|
+
* reloads too). JSON since #1507; a relay older than that compares the raw
|
|
380
|
+
* data, sees a change, and reloads once, which is the safe direction.
|
|
381
|
+
* @returns {string}
|
|
382
|
+
*/
|
|
383
|
+
helloFrame() {
|
|
384
|
+
return `retry: 300\nevent: hello\ndata: ${JSON.stringify({ boot: DEV_BOOT_ID, seq: this.seq })}\n\n`;
|
|
385
|
+
}
|
|
386
|
+
|
|
368
387
|
/**
|
|
369
388
|
* Push a live-reload event to every open tab.
|
|
370
389
|
*
|
|
371
390
|
* The frame carries the change's `{ v, by, why }` classification (#1398) as a
|
|
372
|
-
* single-line JSON payload, matching `devError` below
|
|
373
|
-
* resolves ANY payload it cannot read to `reload`,
|
|
374
|
-
* verdict is emitted as `reload` here too, so the
|
|
375
|
-
* of the wire and a tab running against a
|
|
376
|
-
* skewed into a lighter response than the
|
|
391
|
+
* single-line JSON payload, matching `devError` below, plus the frame's `seq`
|
|
392
|
+
* (#1507). The browser relay resolves ANY payload it cannot read to `reload`,
|
|
393
|
+
* and an absent or malformed verdict is emitted as `reload` here too, so the
|
|
394
|
+
* fail-safe holds on both ends of the wire and a tab running against a
|
|
395
|
+
* restarted server can never be skewed into a lighter response than the
|
|
396
|
+
* change deserves.
|
|
377
397
|
*
|
|
378
398
|
* @param {{ v?: string, by?: string, why?: string } | null} [verdict]
|
|
379
399
|
*/
|
|
380
400
|
reload(verdict) {
|
|
381
401
|
const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
|
|
382
|
-
this.
|
|
402
|
+
this.seq++;
|
|
403
|
+
this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq })}\n\n`);
|
|
383
404
|
}
|
|
384
405
|
|
|
385
406
|
/** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */
|
|
386
407
|
devError(frame) { this._raw(`event: webjs-error\ndata: ${JSON.stringify(frame)}\n\n`); }
|
|
387
408
|
|
|
388
|
-
/** Close every client
|
|
409
|
+
/** Close every client (graceful shutdown). */
|
|
389
410
|
closeAll() {
|
|
390
411
|
for (const c of this.clients) { try { c.close(); } catch {} }
|
|
391
412
|
this.clients.clear();
|
|
392
|
-
clearInterval(this._timer);
|
|
393
413
|
}
|
|
394
414
|
}
|
|
395
415
|
|
package/src/router.js
CHANGED
|
@@ -187,6 +187,12 @@ export async function buildRouteTable(appDir) {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
pages.sort(compareSpecificity);
|
|
190
|
+
// Route handlers rank by the SAME positional specificity as pages (#1510).
|
|
191
|
+
// They used to keep filesystem walk order, so a catch-all such as
|
|
192
|
+
// `api/auth/[...path]/route.ts` could answer for a more specific sibling
|
|
193
|
+
// like `api/auth/callback/github/route.ts`, and which one won depended on
|
|
194
|
+
// the order the directory happened to list in.
|
|
195
|
+
apis.sort(compareSpecificity);
|
|
190
196
|
// `instrumentation-client.*` is an app-ROOT convention file, not a router
|
|
191
197
|
// stem, but it is a browser-bound entry (the boot imports it first), so the
|
|
192
198
|
// table is where every consumer already looks for one. Resolved HERE rather
|
package/src/ssr/head.js
CHANGED
|
@@ -7,6 +7,7 @@ import { vendorIntegrityFor } from '../importmap.js';
|
|
|
7
7
|
import { publicEnvShim } from './env-shim.js';
|
|
8
8
|
import { clientRouterEnabled } from './client-router-flag.js';
|
|
9
9
|
import { embedScriptTag } from '../dev-embed.js';
|
|
10
|
+
import { devReloadState } from '../dev-reload-state.js';
|
|
10
11
|
|
|
11
12
|
// Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
|
|
12
13
|
// Set at boot and on each route rebuild from the route table, the same shape
|
|
@@ -315,8 +316,12 @@ export function wrapHead(opts) {
|
|
|
315
316
|
: '';
|
|
316
317
|
|
|
317
318
|
const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
|
|
319
|
+
// The reload state this page is rendered at (#1516), so the reload client can
|
|
320
|
+
// tell on every reconnect whether the page fell behind the server.
|
|
321
|
+
const reloadState = opts.dev ? devReloadState() : null;
|
|
318
322
|
const reload = opts.dev
|
|
319
|
-
? `<
|
|
323
|
+
? (reloadState ? `<meta name="webjs-dev-reload" content="${escapeAttr(JSON.stringify(reloadState))}">\n` : '') +
|
|
324
|
+
`<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
|
|
320
325
|
: '';
|
|
321
326
|
const suspenseBoot = opts.streaming
|
|
322
327
|
? `<script${n}>(function(){` +
|
package/src/vendor/manifest.js
CHANGED
|
@@ -91,6 +91,33 @@ export function getPackageManifest(pkgName, appDir) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The names a package depends on at runtime (`dependencies`,
|
|
96
|
+
* `peerDependencies`, `optionalDependencies`) and its installed directory,
|
|
97
|
+
* resolved from `fromDir` (#1518). Resolving a dependency from its DEPENDENT's
|
|
98
|
+
* directory finds a nested install as well as a hoisted one, so the pin prune
|
|
99
|
+
* can walk the real installed graph. Null when the package is not installed.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} pkgName
|
|
102
|
+
* @param {string} fromDir a directory holding a package.json (the app, or a dependent package)
|
|
103
|
+
* @returns {{ dir: string, names: string[] } | null}
|
|
104
|
+
*/
|
|
105
|
+
export function getPackageDeps(pkgName, fromDir) {
|
|
106
|
+
const real = resolvePackageDir(pkgName, fromDir);
|
|
107
|
+
if (!real) return null;
|
|
108
|
+
try {
|
|
109
|
+
const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
|
|
110
|
+
const names = new Set([
|
|
111
|
+
...Object.keys(pkg.dependencies || {}),
|
|
112
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
113
|
+
...Object.keys(pkg.optionalDependencies || {}),
|
|
114
|
+
]);
|
|
115
|
+
return { dir: real, names: [...names] };
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
// ---------------------------------------------------------------------------
|
|
95
122
|
// JSPM Generator API client
|
|
96
123
|
// ---------------------------------------------------------------------------
|
package/src/vendor/pins.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, unlink, stat, rename, readdir } from 'node:fs/promises';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { BUILTIN, FRAMEWORK_SERVER_ONLY, extractPackageName, scanBareImports } from './scanner.js';
|
|
6
|
-
import { getPackageVersion } from './manifest.js';
|
|
6
|
+
import { getPackageVersion, getPackageDeps } from './manifest.js';
|
|
7
7
|
import { SUPPORTED_PROVIDERS } from './providers.js';
|
|
8
8
|
import { PIN_BUNDLE_TIMEOUT_MS, fetchIntegrity, sha384Integrity } from './integrity.js';
|
|
9
9
|
import { jspmGenerate } from './jspm.js';
|
|
@@ -506,17 +506,54 @@ export function basePackage(spec) {
|
|
|
506
506
|
* when code imports `dayjs/plugin/utc`, and vice versa). Integrity hashes for
|
|
507
507
|
* dropped URLs are pruned too.
|
|
508
508
|
*
|
|
509
|
+
* With `opts.appDir` it also keeps the TRANSITIVE dependencies of every kept
|
|
510
|
+
* package (#1518). App code never imports a transitive (`@codemirror/view`
|
|
511
|
+
* imports `style-mod`, `@codemirror/language` imports `@lezer/lr`), so the
|
|
512
|
+
* filter above alone dropped every one of them and the browser failed to
|
|
513
|
+
* resolve the bare specifier. The closure walks each kept package's installed
|
|
514
|
+
* `package.json`, resolving each dependency from its dependent's directory, and
|
|
515
|
+
* keeps every pin entry whose base package it reaches, which is the set the
|
|
516
|
+
* live resolve serves (jspm flattens the transitives of the reachable
|
|
517
|
+
* installs). When a kept package is not on disk the graph cannot be walked, so
|
|
518
|
+
* every pin entry the app does not itself declare is kept instead: an unused
|
|
519
|
+
* entry costs nothing, a missing one breaks the page.
|
|
520
|
+
*
|
|
509
521
|
* @param {Record<string, string>} imports pin entries (specifier -> URL)
|
|
510
522
|
* @param {Record<string, string>} integrity SRI hashes keyed by URL
|
|
511
523
|
* @param {Set<string>} reachable bare specifiers used by non-elided modules
|
|
524
|
+
* @param {{ appDir?: string }} [opts]
|
|
512
525
|
* @returns {{ imports: Record<string, string>, integrity: Record<string, string> }}
|
|
513
526
|
*/
|
|
514
|
-
export function prunePinToReachable(imports, integrity, reachable) {
|
|
515
|
-
const
|
|
527
|
+
export function prunePinToReachable(imports, integrity, reachable, opts = {}) {
|
|
528
|
+
const keepBases = new Set([...reachable].map(basePackage));
|
|
529
|
+
if (opts.appDir) {
|
|
530
|
+
const pinnedBases = new Set(Object.keys(imports || {}).map(basePackage));
|
|
531
|
+
let complete = true;
|
|
532
|
+
/** @type {Array<[string, string]>} base package, directory to resolve it from */
|
|
533
|
+
const queue = [...keepBases].filter((b) => pinnedBases.has(b)).map((b) => [b, opts.appDir]);
|
|
534
|
+
const walked = new Set();
|
|
535
|
+
while (queue.length) {
|
|
536
|
+
const [base, from] = /** @type {[string, string]} */ (queue.shift());
|
|
537
|
+
const key = `${base}\0${from}`;
|
|
538
|
+
if (walked.has(key)) continue;
|
|
539
|
+
walked.add(key);
|
|
540
|
+
const deps = getPackageDeps(base, from);
|
|
541
|
+
if (!deps) { complete = false; continue; }
|
|
542
|
+
for (const name of deps.names) {
|
|
543
|
+
if (!pinnedBases.has(name)) continue;
|
|
544
|
+
keepBases.add(name);
|
|
545
|
+
queue.push([name, deps.dir]);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (!complete) {
|
|
549
|
+
const declared = declaredDeps(opts.appDir);
|
|
550
|
+
for (const b of pinnedBases) if (!declared.has(b)) keepBases.add(b);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
516
553
|
/** @type {Record<string, string>} */
|
|
517
554
|
const keptImports = {};
|
|
518
555
|
for (const [spec, url] of Object.entries(imports || {})) {
|
|
519
|
-
if (reachable.has(spec) ||
|
|
556
|
+
if (reachable.has(spec) || keepBases.has(basePackage(spec))) {
|
|
520
557
|
keptImports[spec] = url;
|
|
521
558
|
}
|
|
522
559
|
}
|
|
@@ -529,6 +566,25 @@ export function prunePinToReachable(imports, integrity, reachable) {
|
|
|
529
566
|
return { imports: keptImports, integrity: keptIntegrity };
|
|
530
567
|
}
|
|
531
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Every package the app's own package.json declares, in any dependency field.
|
|
571
|
+
* @param {string} appDir
|
|
572
|
+
* @returns {Set<string>}
|
|
573
|
+
*/
|
|
574
|
+
function declaredDeps(appDir) {
|
|
575
|
+
try {
|
|
576
|
+
const pkg = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
|
|
577
|
+
return new Set([
|
|
578
|
+
...Object.keys(pkg.dependencies || {}),
|
|
579
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
580
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
581
|
+
...Object.keys(pkg.optionalDependencies || {}),
|
|
582
|
+
]);
|
|
583
|
+
} catch {
|
|
584
|
+
return new Set();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
532
588
|
// ---------------------------------------------------------------------------
|
|
533
589
|
// Importmap coherence check (issue #450)
|
|
534
590
|
// ---------------------------------------------------------------------------
|
package/webjs-config.schema.json
CHANGED
|
@@ -208,6 +208,11 @@
|
|
|
208
208
|
"type": "array",
|
|
209
209
|
"items": { "type": "string" }
|
|
210
210
|
},
|
|
211
|
+
"reloadIdle": {
|
|
212
|
+
"description": "Live-reload idle close (#1507), DEV-ONLY and off by default. Seconds with no edit and no interaction in any tab after which the dev live-reload stream is closed, so a host that counts an open request as activity (a sandbox that suspends when idle) can sleep while a preview tab is open; the next interaction, a tab becoming visible, or an embed-bridge host command reopens it, and an edit made meanwhile reloads the page. WEBJS_DEV_RELOAD_IDLE (seconds) overrides it. Read by the server (readDevToolingFromApp). Ignored by webjs start.",
|
|
213
|
+
"type": "number",
|
|
214
|
+
"minimum": 0
|
|
215
|
+
},
|
|
211
216
|
"sourceLocations": {
|
|
212
217
|
"description": "Dev source locations (#1499, #1504), DEV-ONLY and off by default. When true, element opening tags in the app's html templates carry data-webjs-src=\"<app-relative-file>:<line>\" in SSR output and client renders. WEBJS_SOURCE_LOCATIONS=1 or 0 overrides it for one run. Read by the server (readDevToolingFromApp). Ignored by webjs start.",
|
|
213
218
|
"type": "boolean"
|