@webjsdev/server 0.8.68 → 0.8.70
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 +22 -0
- package/src/dev/handler.js +17 -5
- package/src/dev/helpers.js +61 -4
- package/src/dev/server.js +11 -8
- package/src/dev-embed-client.js +5 -0
- package/src/dev-embed.js +20 -2
- package/src/dev-reload-worker.js +141 -59
- package/src/dev-source-locations.js +10 -4
- package/src/listener-bun.js +11 -10
- package/src/listener-core.js +39 -19
- package/src/router.js +6 -0
- package/webjs-config.schema.json +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.70",
|
|
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
|
@@ -317,6 +317,28 @@ export async function readDevWatchPathsFromApp(appDir) {
|
|
|
317
317
|
return out;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The dev tooling switches in `webjs.dev` (#1504): `embedOrigins` (the embed
|
|
322
|
+
* bridge, #1498), `sourceLocations` (#1499) and `reloadIdle` (#1507). Raw values; the env overrides
|
|
323
|
+
* and validation live with each feature (`resolveEmbedOrigins`,
|
|
324
|
+
* `sourceLocationsRequested`). A missing or unreadable package.json is both
|
|
325
|
+
* off.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} appDir
|
|
328
|
+
* @returns {Promise<{ embedOrigins: unknown, sourceLocations: unknown, reloadIdle: unknown }>}
|
|
329
|
+
*/
|
|
330
|
+
export async function readDevToolingFromApp(appDir) {
|
|
331
|
+
let dev;
|
|
332
|
+
try {
|
|
333
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
334
|
+
dev = pkg && pkg.webjs && pkg.webjs.dev;
|
|
335
|
+
} catch {
|
|
336
|
+
dev = undefined;
|
|
337
|
+
}
|
|
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
|
+
}
|
|
341
|
+
|
|
320
342
|
/**
|
|
321
343
|
* Resolve the node:http server timeouts (issue #237) from the app's
|
|
322
344
|
* package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
|
package/src/dev/handler.js
CHANGED
|
@@ -41,7 +41,7 @@ import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
|
41
41
|
import { resolveActionIdentity } from '../form-action-identity.js';
|
|
42
42
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, resolveAssetUrl } from '../asset-hash.js';
|
|
43
43
|
import { applySecurityHeaders, webRequestIsHttps } from '../headers.js';
|
|
44
|
-
import {
|
|
44
|
+
import { resolveEmbedOrigins, setEmbedOrigins, allowEmbedFraming } from '../dev-embed.js';
|
|
45
45
|
import {
|
|
46
46
|
applyRedirects,
|
|
47
47
|
applyTrailingSlash,
|
|
@@ -62,10 +62,13 @@ import {
|
|
|
62
62
|
readAllowedOriginsFromApp,
|
|
63
63
|
readCspConfigFromApp,
|
|
64
64
|
readBodyLimitsFromApp,
|
|
65
|
+
readDevToolingFromApp,
|
|
65
66
|
} from './config.js';
|
|
66
67
|
import {
|
|
67
68
|
exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
|
|
68
69
|
appTopLevelDirs, locateCoreDir,
|
|
70
|
+
setReloadIdleMs,
|
|
71
|
+
resolveReloadIdleMs,
|
|
69
72
|
} from './helpers.js';
|
|
70
73
|
import {
|
|
71
74
|
handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
|
|
@@ -216,12 +219,19 @@ export async function createRequestHandler(opts) {
|
|
|
216
219
|
// analysis pass re-reads it each rebuild so a dev toggle takes effect live.
|
|
217
220
|
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
218
221
|
|
|
219
|
-
// Dev embed bridge (#1498): `WEBJS_EMBED_ORIGINS` lets the listed parent
|
|
222
|
+
// Dev embed bridge (#1498): `WEBJS_EMBED_ORIGINS` (or `webjs.dev.embedOrigins`) lets the listed parent
|
|
220
223
|
// origins frame this dev server and receive the bridge's messages. Read once
|
|
221
224
|
// here, and only in dev, so a production handler records no origins and
|
|
222
225
|
// neither injects the script nor relaxes a framing header.
|
|
223
|
-
|
|
226
|
+
// `webjs.dev.embedOrigins` in package.json is the default (#1504); a set
|
|
227
|
+
// env var replaces it for one run.
|
|
228
|
+
const devTooling = dev ? await readDevToolingFromApp(appDir) : { embedOrigins: undefined, sourceLocations: undefined, reloadIdle: undefined };
|
|
229
|
+
const embedOriginsValue = dev ? resolveEmbedOrigins(process.env.WEBJS_EMBED_ORIGINS, devTooling.embedOrigins) : [];
|
|
224
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);
|
|
225
235
|
|
|
226
236
|
const coreDir = locateCoreDir(appDir);
|
|
227
237
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
@@ -288,13 +298,15 @@ export async function createRequestHandler(opts) {
|
|
|
288
298
|
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
289
299
|
await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
|
|
290
300
|
|
|
291
|
-
// Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1`
|
|
301
|
+
// Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
|
|
302
|
+
// `webjs.dev.sourceLocations: true`) under `webjs dev`
|
|
292
303
|
// stamps `data-webjs-src="<file>:<line>"` on the element opening tags of the
|
|
293
304
|
// app's `html` templates. Read once (the SSR load hook is process-global and
|
|
294
305
|
// cannot be uninstalled), never in prod. The browser half reads
|
|
295
306
|
// `state.sourceLocations` in serve.js; the SSR half is this load hook, which
|
|
296
307
|
// must be in place before any app module is imported.
|
|
297
|
-
|
|
308
|
+
// `webjs.dev.sourceLocations` is the default (#1504); the env var wins.
|
|
309
|
+
const sourceLocations = dev && sourceLocationsRequested(process.env, devTooling.sourceLocations);
|
|
298
310
|
if (sourceLocations) {
|
|
299
311
|
registerSourceLocationHook(appDir);
|
|
300
312
|
// Module URLs are realpaths, so a symlinked app dir registers both forms.
|
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,37 @@ 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
|
+
function report() {
|
|
535
|
+
try { send({ type: 'visibility', visible: document.visibilityState !== 'hidden' }); } catch (_) { /* relay gone */ }
|
|
536
|
+
}
|
|
537
|
+
document.addEventListener('visibilitychange', report);
|
|
538
|
+
addEventListener('pagehide', function () { try { send({ type: 'bye' }); } catch (_) { /* relay gone */ } });
|
|
539
|
+
addEventListener('pageshow', function (e) { if (e.persisted) report(); });
|
|
540
|
+
report();
|
|
541
|
+
// Interaction keeps an idle-closing stream (webjs.dev.reloadIdle) open and
|
|
542
|
+
// reopens a closed one, at most once a second. The dev embed bridge calls
|
|
543
|
+
// __webjsDevActivity for each host command, so a framing tool can resume it.
|
|
544
|
+
var lastActivity = 0;
|
|
545
|
+
function activity() {
|
|
546
|
+
var now = Date.now();
|
|
547
|
+
if (now - lastActivity < 1000) return;
|
|
548
|
+
lastActivity = now;
|
|
549
|
+
try { send({ type: 'activity' }); } catch (_) { /* relay gone */ }
|
|
550
|
+
}
|
|
551
|
+
['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'focus'].forEach(function (t) {
|
|
552
|
+
addEventListener(t, activity, { capture: true, passive: true });
|
|
553
|
+
});
|
|
554
|
+
globalThis.__webjsDevActivity = activity;
|
|
499
555
|
}
|
|
500
556
|
try {
|
|
501
557
|
if (typeof SharedWorker !== 'undefined') {
|
|
@@ -506,6 +562,7 @@ try {
|
|
|
506
562
|
else if (m.type === 'webjs-error') __webjsApplyError(m.data);
|
|
507
563
|
};
|
|
508
564
|
w.port.start();
|
|
565
|
+
__webjsReportVisibility(function (msg) { w.port.postMessage(msg); });
|
|
509
566
|
} else {
|
|
510
567
|
__webjsDirectEvents();
|
|
511
568
|
}
|
|
@@ -527,7 +584,7 @@ try {
|
|
|
527
584
|
export function reloadWorkerJs(bp) {
|
|
528
585
|
return `// webjs dev reload worker (one shared connection for all tabs)
|
|
529
586
|
${RELOAD_WORKER_SRC}
|
|
530
|
-
startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
|
|
587
|
+
startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))}, { idleMs: ${_reloadIdleMs} });
|
|
531
588
|
`;
|
|
532
589
|
}
|
|
533
590
|
|
package/src/dev/server.js
CHANGED
|
@@ -6,7 +6,7 @@ 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 } from '../listener-core.js';
|
|
10
10
|
import { urlFromRequest } from '../forwarded.js';
|
|
11
11
|
import { stripBasePath } from '../base-path.js';
|
|
12
12
|
import { basePath } from '../importmap.js';
|
|
@@ -102,9 +102,15 @@ function startNodeListener(ctx) {
|
|
|
102
102
|
// promptly. The `hello` data is a per-process boot id (#893): the client
|
|
103
103
|
// reloads on a reconnect ONLY when it changes (a real restart), so a
|
|
104
104
|
// transient reconnect never triggers a spurious reload.
|
|
105
|
-
res.write(
|
|
106
|
-
//
|
|
107
|
-
//
|
|
105
|
+
res.write(hub.helloFrame());
|
|
106
|
+
// The stream is silent between events (#1507), so no socket timeout may
|
|
107
|
+
// reap it. node:http sets none by default, but an inherited or future
|
|
108
|
+
// one would close a live reload stream that is simply quiet, so it is
|
|
109
|
+
// switched off for this request alone.
|
|
110
|
+
req.setTimeout?.(0);
|
|
111
|
+
res.setTimeout?.(0);
|
|
112
|
+
// Register a node client wrapper in the shared hub: the fanout lives in
|
|
113
|
+
// SseHub; only the transport write (res.write / res.end) is local.
|
|
108
114
|
const client = {
|
|
109
115
|
send: (s) => { try { res.write(s); } catch {} },
|
|
110
116
|
close: () => { try { res.end(); } catch {} },
|
|
@@ -189,8 +195,7 @@ function startNodeListener(ctx) {
|
|
|
189
195
|
server,
|
|
190
196
|
close: () => new Promise((r) => {
|
|
191
197
|
if (watcherAbort) watcherAbort.abort();
|
|
192
|
-
//
|
|
193
|
-
// (e.g. across a test run) don't accumulate live intervals.
|
|
198
|
+
// Close every open live-reload stream.
|
|
194
199
|
hub.closeAll();
|
|
195
200
|
server.close(() => r());
|
|
196
201
|
}),
|
|
@@ -236,8 +241,6 @@ export async function startServer(opts) {
|
|
|
236
241
|
onDevError: (frame) => hub.devError(frame),
|
|
237
242
|
});
|
|
238
243
|
} 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
244
|
hub.closeAll();
|
|
242
245
|
throw e;
|
|
243
246
|
}
|
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 {
|
package/src/dev-embed.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* The dev embed bridge, SERVER half (#1498).
|
|
3
3
|
*
|
|
4
4
|
* `webjs dev` with `WEBJS_EMBED_ORIGINS` set (a comma-separated list of parent
|
|
5
|
-
* origins, e.g. `https://
|
|
6
|
-
*
|
|
5
|
+
* origins, e.g. `https://builder.example,http://localhost:8080`), or the app's
|
|
6
|
+
* `webjs.dev.embedOrigins` list (#1504), makes an app ready to be previewed
|
|
7
|
+
* inside an iframe on those origins:
|
|
7
8
|
*
|
|
8
9
|
* 1. every HTML document gets a small inline script (the browser half,
|
|
9
10
|
* `dev-embed-client.js`) that reports page state to the parent and takes
|
|
@@ -73,6 +74,23 @@ export function parseEmbedOrigins(raw, opts) {
|
|
|
73
74
|
return out;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The effective embed origins (#1504): `WEBJS_EMBED_ORIGINS`, when set and
|
|
79
|
+
* non-empty, replaces the app's `webjs.dev.embedOrigins`; otherwise the config
|
|
80
|
+
* list applies. Both go through `parseEmbedOrigins`, so a bad entry is dropped
|
|
81
|
+
* with the same warning wherever it was written.
|
|
82
|
+
*
|
|
83
|
+
* @param {string | undefined} envRaw
|
|
84
|
+
* @param {unknown} configured the app's `webjs.dev.embedOrigins`
|
|
85
|
+
* @param {{ warn?: (msg: string) => void }} [opts]
|
|
86
|
+
* @returns {string[]}
|
|
87
|
+
*/
|
|
88
|
+
export function resolveEmbedOrigins(envRaw, configured, opts) {
|
|
89
|
+
if (typeof envRaw === 'string' && envRaw.trim()) return parseEmbedOrigins(envRaw, opts);
|
|
90
|
+
if (!Array.isArray(configured)) return [];
|
|
91
|
+
return parseEmbedOrigins(configured.filter((o) => typeof o === 'string').join(','), opts);
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
/**
|
|
77
95
|
* Record the allowed embed origins for this process. `createRequestHandler`
|
|
78
96
|
* calls it at construction: the parsed env value in dev, `[]` otherwise.
|
package/src/dev-reload-worker.js
CHANGED
|
@@ -77,44 +77,81 @@ 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
|
+
|
|
101
|
+
/** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
|
|
102
|
+
function reloadSeq(data) {
|
|
103
|
+
try {
|
|
104
|
+
const o = JSON.parse(data);
|
|
105
|
+
return o && typeof o.seq === 'number' ? o.seq : null;
|
|
106
|
+
} catch (_) { return null; }
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
/**
|
|
110
|
+
* Run the relay.
|
|
111
|
+
*
|
|
112
|
+
* The stream is open only while at least one tab is VISIBLE (#1507). Each tab
|
|
113
|
+
* reports `{ type: 'visibility', visible }` on `visibilitychange` and
|
|
114
|
+
* `{ type: 'bye' }` on `pagehide`; when the last visible tab goes away the
|
|
115
|
+
* relay closes its `EventSource`, so a backgrounded dev tab holds no request
|
|
116
|
+
* open and sends nothing, and it reopens the moment one becomes visible. An
|
|
117
|
+
* edit made meanwhile is not lost: the reopened stream's `hello` carries the
|
|
118
|
+
* server's boot id and reload `seq`, and a difference from what this relay last
|
|
119
|
+
* saw is a reload.
|
|
120
|
+
*
|
|
121
|
+
* The browser's own EventSource retry is replaced by an explicit one with
|
|
122
|
+
* backoff (300ms doubling to 30s, reset by the next `hello`), so a server that
|
|
123
|
+
* is gone for a while is probed less and less often instead of every 300ms.
|
|
124
|
+
*
|
|
81
125
|
* @param {{ onconnect: any, setTimeout?: any, clearTimeout?: any }} scope the
|
|
82
126
|
* worker global (`self`), or a plain shim object for the per-tab fallback.
|
|
83
127
|
* Timers are read off it when it has them, which is what lets a browser test
|
|
84
128
|
* drive the debounce on a fake clock.
|
|
85
129
|
* @param {new (url: string) => any} EventSourceCtor the `EventSource` constructor
|
|
86
130
|
* @param {string} eventsUrl the base-path-aware `/__webjs/events` URL
|
|
131
|
+
* @param {{ idleMs?: number }} [opts] `idleMs` (#1507, from `webjs.dev.reloadIdle`
|
|
132
|
+
* / `WEBJS_DEV_RELOAD_IDLE`): close the stream after this long with no edit
|
|
133
|
+
* and no interaction in any tab, and reopen on the next interaction. `0` (the
|
|
134
|
+
* default) keeps it open while a tab is visible.
|
|
87
135
|
*/
|
|
88
|
-
export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
|
|
89
|
-
|
|
90
|
-
|
|
136
|
+
export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
137
|
+
const idleMs = opts && opts.idleMs > 0 ? opts.idleMs : 0;
|
|
138
|
+
/** @type {Map<any, boolean>} each connected tab's port, and whether it is visible */
|
|
139
|
+
const ports = new Map();
|
|
91
140
|
/** @type {string | null} the last error frame, cached for late-joining tabs */
|
|
92
141
|
let lastError = null;
|
|
93
142
|
/** @type {string | null} the last-seen per-process boot id (#893) */
|
|
94
143
|
let lastBoot = null;
|
|
144
|
+
/** @type {number | null} the last reload seq seen from that process (#1507) */
|
|
145
|
+
let lastSeq = null;
|
|
95
146
|
|
|
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
147
|
const timers = scope && typeof scope.setTimeout === 'function' ? scope : globalThis;
|
|
102
148
|
|
|
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
149
|
function fanout(msg) {
|
|
108
|
-
for (const p of ports) {
|
|
150
|
+
for (const p of ports.keys()) {
|
|
109
151
|
try { p.postMessage(msg); } catch (_) { ports.delete(p); }
|
|
110
152
|
}
|
|
111
153
|
}
|
|
112
154
|
|
|
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
155
|
/** @type {any} */ let quietTimer = null;
|
|
119
156
|
/** @type {any} */ let capTimer = null;
|
|
120
157
|
|
|
@@ -125,8 +162,6 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
|
|
|
125
162
|
if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
|
|
126
163
|
if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
|
|
127
164
|
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
165
|
batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
|
|
131
166
|
fanout({ type: 'reload', verdict: v });
|
|
132
167
|
}
|
|
@@ -140,58 +175,105 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
|
|
|
140
175
|
|
|
141
176
|
/** @param {string} verdict */
|
|
142
177
|
function requestReload(verdict) {
|
|
143
|
-
// Lower rank is stronger, so keep the minimum across the batch.
|
|
144
178
|
if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
|
|
145
179
|
if (quietTimer !== null) timers.clearTimeout(quietTimer);
|
|
146
180
|
quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
|
|
147
181
|
if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
|
|
148
182
|
}
|
|
149
183
|
|
|
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
|
-
|
|
184
|
+
/** @type {any} */ let es = null;
|
|
185
|
+
/** @type {any} */ let retryTimer = null;
|
|
186
|
+
let retryDelay = RECONNECT_BASE_MS;
|
|
187
|
+
|
|
188
|
+
/** @type {any} */ let idleTimer = null;
|
|
189
|
+
|
|
190
|
+
// Idle close (#1507). A host that counts an in-flight request as activity
|
|
191
|
+
// (a pilots sandbox does, for the request's whole life) never sleeps while
|
|
192
|
+
// the stream is open, however quiet it is. With `idleMs` set, a stream that
|
|
193
|
+
// has carried nothing and seen no interaction for that long is closed, and
|
|
194
|
+
// the next interaction, a tab showing, or a host command through the embed
|
|
195
|
+
// bridge reopens it; the hello seq then says whether an edit landed meanwhile.
|
|
196
|
+
function touch() {
|
|
197
|
+
if (!idleMs) return;
|
|
198
|
+
if (idleTimer !== null) timers.clearTimeout(idleTimer);
|
|
199
|
+
idleTimer = timers.setTimeout(() => { idleTimer = null; close(); }, idleMs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function anyVisible() {
|
|
203
|
+
for (const v of ports.values()) if (v) return true;
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function open() {
|
|
208
|
+
if (es || retryTimer !== null) return;
|
|
209
|
+
es = new EventSourceCtor(eventsUrl);
|
|
210
|
+
const mine = es;
|
|
211
|
+
touch();
|
|
212
|
+
es.addEventListener('hello', (e) => {
|
|
213
|
+
retryDelay = RECONNECT_BASE_MS;
|
|
214
|
+
touch();
|
|
215
|
+
const h = parseHello(e.data);
|
|
216
|
+
if (lastBoot !== null && (h.boot !== lastBoot || (h.seq !== null && lastSeq !== null && h.seq !== lastSeq))) {
|
|
217
|
+
requestReload('reload');
|
|
218
|
+
}
|
|
219
|
+
lastBoot = h.boot;
|
|
220
|
+
lastSeq = h.seq;
|
|
221
|
+
});
|
|
222
|
+
es.addEventListener('reload', (e) => {
|
|
223
|
+
touch();
|
|
224
|
+
lastError = null;
|
|
225
|
+
const n = reloadSeq(e.data);
|
|
226
|
+
if (n !== null) lastSeq = n;
|
|
227
|
+
requestReload(parseVerdict(e.data));
|
|
228
|
+
});
|
|
229
|
+
es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
|
|
230
|
+
// The stream dropped (a restarting server, a network blip, a host that
|
|
231
|
+
// slept). Close it so the browser's fixed-interval retry never runs, and
|
|
232
|
+
// come back with backoff, but only while someone is looking.
|
|
233
|
+
es.addEventListener('error', () => {
|
|
234
|
+
if (es !== mine) return;
|
|
235
|
+
try { es.close(); } catch (_) { /* already closed */ }
|
|
236
|
+
es = null;
|
|
237
|
+
if (!anyVisible()) return;
|
|
238
|
+
retryTimer = timers.setTimeout(() => { retryTimer = null; if (anyVisible()) open(); }, retryDelay);
|
|
239
|
+
retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX_MS);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function close() {
|
|
244
|
+
if (idleTimer !== null) { timers.clearTimeout(idleTimer); idleTimer = null; }
|
|
245
|
+
if (retryTimer !== null) { timers.clearTimeout(retryTimer); retryTimer = null; }
|
|
246
|
+
if (es) { try { es.close(); } catch (_) { /* already closed */ } es = null; }
|
|
247
|
+
retryDelay = RECONNECT_BASE_MS;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sync() {
|
|
251
|
+
if (anyVisible()) open(); else close();
|
|
252
|
+
}
|
|
182
253
|
|
|
183
254
|
scope.onconnect = (e) => {
|
|
184
255
|
const port = e.ports[0];
|
|
185
|
-
|
|
256
|
+
// A tab counts as visible until it says otherwise, so a client older than
|
|
257
|
+
// the visibility messages keeps the stream open exactly as before.
|
|
258
|
+
ports.set(port, true);
|
|
259
|
+
port.onmessage = (m) => {
|
|
260
|
+
const d = (m && m.data) || {};
|
|
261
|
+
if (d.type === 'visibility') ports.set(port, d.visible !== false);
|
|
262
|
+
else if (d.type === 'bye') ports.delete(port);
|
|
263
|
+
else if (d.type === 'activity') { if (!ports.get(port)) return; touch(); }
|
|
264
|
+
else return;
|
|
265
|
+
sync();
|
|
266
|
+
};
|
|
186
267
|
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
268
|
if (lastError != null) {
|
|
191
269
|
try { port.postMessage({ type: 'webjs-error', data: lastError }); } catch (_) { ports.delete(port); }
|
|
192
270
|
}
|
|
271
|
+
sync();
|
|
193
272
|
};
|
|
194
273
|
|
|
195
|
-
|
|
196
|
-
|
|
274
|
+
return {
|
|
275
|
+
ports,
|
|
276
|
+
/** The live EventSource, or null while paused or between retries. */
|
|
277
|
+
get es() { return es; },
|
|
278
|
+
};
|
|
197
279
|
}
|
|
@@ -64,15 +64,21 @@ const REGEX_AFTER_KEYWORDS = new Set([
|
|
|
64
64
|
export const SOURCE_LOCATIONS_ENV = 'WEBJS_SOURCE_LOCATIONS';
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Whether
|
|
68
|
-
*
|
|
67
|
+
* Whether source locations are on (#1499, #1504). The app's
|
|
68
|
+
* `webjs.dev.sourceLocations` sets the default; the env var, when set, wins
|
|
69
|
+
* either way (`1` / `true` on, `0` / `false` off), so one run can flip it
|
|
70
|
+
* without editing package.json. Read by the dev handler once, at construction;
|
|
71
|
+
* the caller also requires `dev`, so production never consults it.
|
|
69
72
|
*
|
|
70
73
|
* @param {Record<string, string | undefined>} [env]
|
|
74
|
+
* @param {unknown} [configured] the app's `webjs.dev.sourceLocations`
|
|
71
75
|
* @returns {boolean}
|
|
72
76
|
*/
|
|
73
|
-
export function sourceLocationsRequested(env = process.env) {
|
|
77
|
+
export function sourceLocationsRequested(env = process.env, configured = false) {
|
|
74
78
|
const v = String(env[SOURCE_LOCATIONS_ENV] || '').trim().toLowerCase();
|
|
75
|
-
|
|
79
|
+
if (v === '1' || v === 'true') return true;
|
|
80
|
+
if (v === '0' || v === 'false') return false;
|
|
81
|
+
return configured === true;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/**
|
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/webjs-config.schema.json
CHANGED
|
@@ -202,6 +202,20 @@
|
|
|
202
202
|
"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).",
|
|
203
203
|
"type": "array",
|
|
204
204
|
"items": { "type": "string" }
|
|
205
|
+
},
|
|
206
|
+
"embedOrigins": {
|
|
207
|
+
"description": "Dev embed bridge (#1498, #1504), DEV-ONLY and off by default. Parent origins (e.g. https://builder.example) allowed to frame the dev server: X-Frame-Options is dropped and frame-ancestors widened for them, and every document carries a nonce-signed script that posts { source: 'webjs-embed', type } messages to such a parent and takes { source: 'webjs-embed-host', type } commands from it. A set WEBJS_EMBED_ORIGINS env var replaces this list. Read by the server (readDevToolingFromApp). Ignored by webjs start.",
|
|
208
|
+
"type": "array",
|
|
209
|
+
"items": { "type": "string" }
|
|
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
|
+
},
|
|
216
|
+
"sourceLocations": {
|
|
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.",
|
|
218
|
+
"type": "boolean"
|
|
205
219
|
}
|
|
206
220
|
}
|
|
207
221
|
},
|