@webjsdev/server 0.8.83 → 0.8.85
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/action-seed.js +8 -0
- package/src/actions.js +2 -2
- package/src/api.js +2 -2
- package/src/component-elision.js +96 -43
- package/src/component-scanner.js +40 -16
- package/src/dev/bun-alias-resolve.js +71 -0
- package/src/dev/bun-app-source.js +66 -0
- package/src/dev/handler.js +76 -11
- package/src/dev/hot-host.js +176 -0
- package/src/dev/serve.js +3 -3
- package/src/dev/server.js +95 -7
- package/src/dev-import.js +66 -9
- package/src/dev-reload-worker.js +33 -3
- package/src/dev-source-locations-hook.js +7 -0
- package/src/env-schema.js +2 -2
- package/src/form-action-identity.js +2 -2
- package/src/instrumentation.js +2 -2
- package/src/listener-core.js +10 -4
- package/src/metadata-routes.js +4 -4
- package/src/module-graph.js +39 -12
- package/src/ssr/render.js +2 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One dev server per process under `bun --hot` (#1575).
|
|
3
|
+
*
|
|
4
|
+
* `bun --hot` re-evaluates the entry module whenever a file it loaded changes,
|
|
5
|
+
* with a fresh module registry and `globalThis` preserved. For `webjs dev` the
|
|
6
|
+
* entry is the CLI, so every page, layout or component edit ran `startServer`
|
|
7
|
+
* again: a second request handler, a second set of file watchers, a second SSE
|
|
8
|
+
* hub, another `instrumentation.register()`, and both `Bun.plugin` hooks
|
|
9
|
+
* registered again. Nothing from the previous run was disposed, because nothing
|
|
10
|
+
* could reach it. Measured on the scaffold: about 25 MB more per edit, 5.8 GB
|
|
11
|
+
* after 225 edits, after which `bun --hot` stopped reloading at all and a
|
|
12
|
+
* component edit was never served again until a restart. A second instance also
|
|
13
|
+
* built its route table before its watchers existed, so a file added or renamed
|
|
14
|
+
* while it booted was invisible to the instance that ended up serving (a 404
|
|
15
|
+
* for a route that exists, an unknown action).
|
|
16
|
+
*
|
|
17
|
+
* So the first run owns the process: its listener, hub, watchers and request
|
|
18
|
+
* handler stay, and a later run only tells it the module registry was reset
|
|
19
|
+
* (`rerun`). The handler then re-derives its analysis on the next request,
|
|
20
|
+
* which re-imports the app's modules from the fresh registry. The framework's
|
|
21
|
+
* own code stays the first run's, so its caches stay warm. Mixing that code
|
|
22
|
+
* with app modules from a later registry is safe by design: core's type checks
|
|
23
|
+
* are structural (`_$webjs`) and its shared state is keyed by `Symbol.for`.
|
|
24
|
+
*
|
|
25
|
+
* The state lives on `globalThis` under a `Symbol.for` key because a re-run
|
|
26
|
+
* evaluates a NEW copy of this module, whose own module variables start empty.
|
|
27
|
+
*
|
|
28
|
+
* @module dev/hot-host
|
|
29
|
+
*/
|
|
30
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
31
|
+
import { join, dirname } from 'node:path';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { pathToFileURL } from 'node:url';
|
|
35
|
+
|
|
36
|
+
const HOSTS = Symbol.for('webjs.dev.hotHosts');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sent to the `webjs dev` supervisor over IPC once the hot host is up, so it
|
|
40
|
+
* stops restarting the process for plugin-served edits this server now reloads
|
|
41
|
+
* in place. Mirrors `HOT_IN_PLACE_MESSAGE` in `@webjsdev/cli`'s `dev-reload.js`.
|
|
42
|
+
*/
|
|
43
|
+
export const HOT_IN_PLACE_MESSAGE = 'hot-reload-in-place';
|
|
44
|
+
|
|
45
|
+
/** Tell the supervisor (when there is one) that edits reload in place. */
|
|
46
|
+
export function announceHotInPlace() {
|
|
47
|
+
try {
|
|
48
|
+
if (typeof process.send === 'function' && process.connected) process.send({ webjs: HOT_IN_PLACE_MESSAGE });
|
|
49
|
+
} catch { /* no supervisor, or it is gone */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether this process re-runs its entry on a file change: Bun under `--hot`.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ isBun?: boolean, execArgv?: string[] }} [env]
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function hotRerunCapable({ isBun = !!process.versions.bun, execArgv = process.execArgv } = {}) {
|
|
59
|
+
return isBun && Array.isArray(execArgv) && execArgv.includes('--hot');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {{
|
|
64
|
+
* version: string,
|
|
65
|
+
* rerun: () => Promise<unknown>,
|
|
66
|
+
* poke: () => void,
|
|
67
|
+
* }} HotHost
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** @returns {Map<string, HotHost>} */
|
|
71
|
+
function hosts() {
|
|
72
|
+
const g = /** @type {any} */ (globalThis);
|
|
73
|
+
if (!(g[HOSTS] instanceof Map)) g[HOSTS] = new Map();
|
|
74
|
+
return g[HOSTS];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The host key: one dev server per app directory and port.
|
|
79
|
+
* @param {string} appDir absolute
|
|
80
|
+
* @param {number} port
|
|
81
|
+
*/
|
|
82
|
+
export function hotHostKey(appDir, port) {
|
|
83
|
+
return `${appDir}\0${port}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {string} key @returns {HotHost | undefined} */
|
|
87
|
+
export function getHotHost(key) {
|
|
88
|
+
return hosts().get(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {string} key @param {HotHost} host */
|
|
92
|
+
export function setHotHost(key, host) {
|
|
93
|
+
hosts().set(key, host);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {string} key */
|
|
97
|
+
export function deleteHotHost(key) {
|
|
98
|
+
hosts().delete(key);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The sentinel module that lets the dev server ask `bun --hot` for a re-run.
|
|
103
|
+
* `bun --hot` does not watch a module a `Bun.plugin` served (#1550), and in dev
|
|
104
|
+
* every app module is one (`*.server.*` through the seed plugin, the rest
|
|
105
|
+
* through `dev/bun-app-source.js`). A `*.server.*` edit used to restart the
|
|
106
|
+
* whole process, which dropped requests for 0.3 to 2 seconds. Writing this file
|
|
107
|
+
* instead re-runs the entry within milliseconds, which resets the registry.
|
|
108
|
+
*
|
|
109
|
+
* It lives in the OS temp dir, per process: inside the app it would match the
|
|
110
|
+
* app-source plugin's filter (and so not be watched) and the dev watcher.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} appDir
|
|
113
|
+
* @returns {string} absolute path
|
|
114
|
+
*/
|
|
115
|
+
export function hotSentinelPath(appDir) {
|
|
116
|
+
const tag = createHash('sha1').update(appDir).digest('hex').slice(0, 12);
|
|
117
|
+
return join(tmpdir(), 'webjs-dev-hot', `${process.pid}-${tag}.mjs`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create (if needed) and import the sentinel so `bun --hot` watches it. A
|
|
122
|
+
* re-run's fresh registry imports (and so watches) it again.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} appDir
|
|
125
|
+
* @returns {Promise<void>}
|
|
126
|
+
*/
|
|
127
|
+
export async function armHotSentinel(appDir) {
|
|
128
|
+
const file = hotSentinelPath(appDir);
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
131
|
+
writeFileSync(file, 'export default 0;\n', { flag: 'wx' });
|
|
132
|
+
} catch { /* already there */ }
|
|
133
|
+
try { await import(pathToFileURL(file).href); } catch { /* best effort: without it a poke is a no-op */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Remove the sentinel (the server is closing). @param {string} appDir */
|
|
137
|
+
export function removeHotSentinel(appDir) {
|
|
138
|
+
try { rmSync(hotSentinelPath(appDir), { force: true }); } catch { /* best effort */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Ask `bun --hot` for a re-run by changing the sentinel.
|
|
143
|
+
* @param {string} appDir
|
|
144
|
+
*/
|
|
145
|
+
export function pokeHotSentinel(appDir) {
|
|
146
|
+
try { writeFileSync(hotSentinelPath(appDir), `export default ${Date.now()};\n`); } catch { /* best effort */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const APP_MODULE = /\.m?[jt]s$/;
|
|
150
|
+
/** Files no module imports at runtime: an edit to one never needs a registry reset. */
|
|
151
|
+
const INERT_ASSET = /\.(?:css|md|mdx|txt|log|map|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf)$/i;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a changed path could leave a stale module in the registry, so the
|
|
155
|
+
* dev server has to ask `bun --hot` for a reset. In dev on Bun every JS/TS
|
|
156
|
+
* module of the app is served by a `Bun.plugin` (`*.server.*` through the seed
|
|
157
|
+
* plugin, the rest through `dev/bun-app-source.js` or the source-locations
|
|
158
|
+
* plugin), which `bun --hot` never watches.
|
|
159
|
+
*
|
|
160
|
+
* `isRouteOnly` answers whether a module path is one only the router loads
|
|
161
|
+
* (nothing imports it; see `needsRegistryReset` on the request handler): its
|
|
162
|
+
* dev re-import is keyed by content, so it needs no reset. Anything the
|
|
163
|
+
* dev server cannot place, such as the extensionless temp file `sed -i`
|
|
164
|
+
* renames over the real one, gets the reset, because the event may name the
|
|
165
|
+
* temp file rather than the module it replaced.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} path relative to the watched root
|
|
168
|
+
* @param {(path: string) => boolean} isRouteOnly
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
export function needsHotReset(path, isRouteOnly) {
|
|
172
|
+
if (/(?:^|[\\/])node_modules[\\/]/.test(path)) return false;
|
|
173
|
+
if (INERT_ASSET.test(path)) return false;
|
|
174
|
+
if (APP_MODULE.test(path)) return !isRouteOnly(path);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
package/src/dev/serve.js
CHANGED
|
@@ -34,7 +34,7 @@ import { versionModuleImports, withAssetHash } from '../asset-hash.js';
|
|
|
34
34
|
import { toUrlPath } from '../ssr/preloads.js';
|
|
35
35
|
import { BUFFERED_MARKER } from '../conditional-get.js';
|
|
36
36
|
import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js';
|
|
37
|
-
import {
|
|
37
|
+
import { devImport } from '../dev-import.js';
|
|
38
38
|
import { runMetadataRoute } from '../metadata-routes.js';
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -573,7 +573,7 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
|
|
|
573
573
|
const handlers = [];
|
|
574
574
|
for (const f of files) {
|
|
575
575
|
try {
|
|
576
|
-
const mod = await
|
|
576
|
+
const mod = await devImport(f, dev);
|
|
577
577
|
if (typeof mod.default === 'function') handlers.push(mod.default);
|
|
578
578
|
} catch {
|
|
579
579
|
// Bad middleware file: skip; top-level error handler will catch real problems.
|
|
@@ -617,7 +617,7 @@ export async function loadMiddleware(appDir, dev, logger) {
|
|
|
617
617
|
}
|
|
618
618
|
if (!file) return null;
|
|
619
619
|
try {
|
|
620
|
-
const mod = await
|
|
620
|
+
const mod = await devImport(file, dev);
|
|
621
621
|
return typeof mod.default === 'function' ? mod.default : null;
|
|
622
622
|
} catch (e) {
|
|
623
623
|
logger.error('failed to load root middleware', { file, err: String(e) });
|
package/src/dev/server.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { strongerVerdict } from '../dev-classify.js';
|
|
3
3
|
import { watchTree } from './watch-tree.js';
|
|
4
|
-
import { relative } from 'node:path';
|
|
4
|
+
import { relative, resolve } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
hotRerunCapable, hotHostKey, getHotHost, setHotHost, deleteHotHost,
|
|
7
|
+
armHotSentinel, pokeHotSentinel, removeHotSentinel, needsHotReset, announceHotInPlace,
|
|
8
|
+
} from './hot-host.js';
|
|
5
9
|
import { createServer as createHttp1Server } from 'node:http';
|
|
6
10
|
import { createRequestHandler } from './handler.js';
|
|
7
11
|
import { readServerTimeoutsFromApp, readDevWatchPathsFromApp } from './config.js';
|
|
@@ -15,6 +19,18 @@ import { attachWebSocket } from '../websocket.js';
|
|
|
15
19
|
import { toWebRequest, sendWebResponse } from './helpers.js';
|
|
16
20
|
import { isAddrInUse, portInUseError, reusePortRequested } from '../port-in-use.js';
|
|
17
21
|
|
|
22
|
+
/** This copy of `@webjsdev/server`'s version, so a hot re-run can tell the framework itself changed. */
|
|
23
|
+
const SERVER_VERSION = (() => {
|
|
24
|
+
try { return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || ''; } catch { return ''; }
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How long a poked re-run (#1575) may take before the dev server stops waiting
|
|
29
|
+
* for it and rebuilds anyway, so an edit is never lost to a sentinel `bun --hot`
|
|
30
|
+
* did not see.
|
|
31
|
+
*/
|
|
32
|
+
const POKE_FALLBACK_MS = 1500;
|
|
33
|
+
|
|
18
34
|
function debounce(fn, ms) {
|
|
19
35
|
let t;
|
|
20
36
|
return (...args) => {
|
|
@@ -237,11 +253,29 @@ export async function startServer(opts) {
|
|
|
237
253
|
const compress = opts.compress ?? !dev;
|
|
238
254
|
const logger = opts.logger || defaultLogger({ dev });
|
|
239
255
|
|
|
256
|
+
// `bun --hot` re-runs the CLI, and so this function, on every edit to a
|
|
257
|
+
// module it loaded (#1575). The first run owns the process; a later one only
|
|
258
|
+
// tells it the module registry was reset. See dev/hot-host.js.
|
|
259
|
+
const hotKey = dev && hotRerunCapable() ? hotHostKey(resolve(opts.appDir), port) : null;
|
|
260
|
+
const host = hotKey ? getHotHost(hotKey) : undefined;
|
|
261
|
+
if (host) {
|
|
262
|
+
if (host.version !== SERVER_VERSION) {
|
|
263
|
+
// The framework itself changed under a running dev server (an upgrade).
|
|
264
|
+
// Its first-run code cannot load the new copy in place, so exit and let
|
|
265
|
+
// the `webjs dev` supervisor start a fresh process on the new version.
|
|
266
|
+
logger.warn(`[webjs] @webjsdev/server changed (${host.version || '?'} -> ${SERVER_VERSION || '?'}), restarting the dev server`);
|
|
267
|
+
if (process.env.__WEBJS_DEV_CHILD === '1') process.exit(0);
|
|
268
|
+
}
|
|
269
|
+
return /** @type {any} */ (await host.rerun());
|
|
270
|
+
}
|
|
271
|
+
|
|
240
272
|
// Runtime-neutral SSE registry + fanout (shared by the node:http and Bun.serve
|
|
241
273
|
// shells via listener-core.js, so live-reload + the dev error overlay behave
|
|
242
274
|
// identically on both). Built before the handler so its onReload / onDevError
|
|
243
275
|
// callbacks can fan out through it.
|
|
244
|
-
|
|
276
|
+
// A Bun hot host, or a server outside the dev supervisor (`--no-hot`, an
|
|
277
|
+
// embedder), applies an edit in place; a supervised Node child is restarted.
|
|
278
|
+
const hub = new SseHub({ inPlace: dev && (!!hotKey || process.env.__WEBJS_DEV_CHILD !== '1') });
|
|
245
279
|
// Pages render the stream's state into a meta tag (#1516), so the relay can
|
|
246
280
|
// re-check a page against the server on every reconnect.
|
|
247
281
|
if (dev) setDevReloadState(() => ({ boot: DEV_BOOT_ID, seq: hub.seq }));
|
|
@@ -263,6 +297,10 @@ export async function startServer(opts) {
|
|
|
263
297
|
|
|
264
298
|
/** @type {AbortController | null} */
|
|
265
299
|
let watcherAbort = null;
|
|
300
|
+
/** The strongest verdict of the edits a poked re-run has yet to apply. */
|
|
301
|
+
/** @type {import('../dev-classify.js').ReloadVerdict | null} */
|
|
302
|
+
let pokedVerdict = null;
|
|
303
|
+
/** @type {any} */ let pokeFallback = null;
|
|
266
304
|
if (dev) {
|
|
267
305
|
// Watch the app tree with Node's built-in `fs.watch` (no external dep):
|
|
268
306
|
// `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
|
|
@@ -273,18 +311,35 @@ export async function startServer(opts) {
|
|
|
273
311
|
// (module-level, exported for tests) skips node_modules, .git, .webjs/, and
|
|
274
312
|
// the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
|
|
275
313
|
// Live-reload classification (#1398). Several files can change inside one
|
|
276
|
-
//
|
|
314
|
+
// 50ms debounce window (80ms before #1575), so hold the STRONGEST verdict of the window and
|
|
277
315
|
// hand it to the rebuild. Same rule as the browser relay's cross-batch
|
|
278
316
|
// accumulation, for the same reason: a window mixing a page edit and a
|
|
279
317
|
// component edit is a component edit, and taking the last one would morph
|
|
280
318
|
// fresh markup onto the old component class.
|
|
281
319
|
/** @type {import('../dev-classify.js').ReloadVerdict | null} */
|
|
282
320
|
let pendingVerdict = null;
|
|
321
|
+
// Under `bun --hot`, a window that changed a module a `Bun.plugin` serves
|
|
322
|
+
// pokes the re-run sentinel instead of rebuilding (#1575): the rebuild has
|
|
323
|
+
// to wait for the registry reset, or the next request would still import
|
|
324
|
+
// the stale module. The re-run (`hotRerun` below) rebuilds with the verdict.
|
|
325
|
+
let pendingPoke = false;
|
|
283
326
|
const rebuild = debounce(() => {
|
|
284
327
|
const v = pendingVerdict;
|
|
285
328
|
pendingVerdict = null;
|
|
329
|
+
if (pendingPoke) {
|
|
330
|
+
pendingPoke = false;
|
|
331
|
+
pokedVerdict = strongerVerdict(pokedVerdict, v);
|
|
332
|
+
pokeHotSentinel(app.appDir);
|
|
333
|
+
clearTimeout(pokeFallback);
|
|
334
|
+
pokeFallback = setTimeout(() => {
|
|
335
|
+
const pv = pokedVerdict;
|
|
336
|
+
pokedVerdict = null;
|
|
337
|
+
if (pv) app.rebuild(pv);
|
|
338
|
+
}, POKE_FALLBACK_MS);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
286
341
|
app.rebuild(v || undefined);
|
|
287
|
-
},
|
|
342
|
+
}, 50);
|
|
288
343
|
watcherAbort = new AbortController();
|
|
289
344
|
// One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
|
|
290
345
|
// file must never stop the dev server, and a burst of the same temp file
|
|
@@ -307,6 +362,7 @@ export async function startServer(opts) {
|
|
|
307
362
|
// lives in createRequestHandler's scope, not here.
|
|
308
363
|
if (app.isRegenerateOutput(filename)) return;
|
|
309
364
|
pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
|
|
365
|
+
if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true;
|
|
310
366
|
rebuild();
|
|
311
367
|
},
|
|
312
368
|
onError: onWatchError,
|
|
@@ -341,11 +397,43 @@ export async function startServer(opts) {
|
|
|
341
397
|
// req/s on the listening path (#511); the Bun shell is dynamically imported so
|
|
342
398
|
// the `Bun.*` global is never referenced on Node.
|
|
343
399
|
try {
|
|
400
|
+
/** @type {{ server: any, close: () => Promise<void> }} */
|
|
401
|
+
let handle;
|
|
344
402
|
if (serverRuntime() === 'bun') {
|
|
345
403
|
const { startBunListener } = await import('../listener-bun.js');
|
|
346
|
-
|
|
404
|
+
handle = startBunListener(ctx);
|
|
405
|
+
} else {
|
|
406
|
+
handle = await startNodeListener(ctx);
|
|
407
|
+
}
|
|
408
|
+
if (hotKey) {
|
|
409
|
+
// Own the process for every later `bun --hot` re-run (#1575).
|
|
410
|
+
await armHotSentinel(app.appDir);
|
|
411
|
+
const close = handle.close;
|
|
412
|
+
handle = { ...handle, close: () => { deleteHotHost(hotKey); removeHotSentinel(app.appDir); return close(); } };
|
|
413
|
+
const owned = handle;
|
|
414
|
+
setHotHost(hotKey, {
|
|
415
|
+
version: SERVER_VERSION,
|
|
416
|
+
/**
|
|
417
|
+
* The registry was reset: re-watch the sentinel from the fresh registry,
|
|
418
|
+
* then re-derive the analysis so it re-imports the app's modules (the
|
|
419
|
+
* middleware, the components it primes) from that registry. A re-run a
|
|
420
|
+
* poke asked for carries the verdict of the edits behind it and tells the
|
|
421
|
+
* browser; any other is silent, since the watcher already sent that
|
|
422
|
+
* edit's own reload frame.
|
|
423
|
+
*/
|
|
424
|
+
rerun: async () => {
|
|
425
|
+
await armHotSentinel(app.appDir);
|
|
426
|
+
const pv = pokedVerdict;
|
|
427
|
+
pokedVerdict = null;
|
|
428
|
+
clearTimeout(pokeFallback);
|
|
429
|
+
await app.rebuild(pv || undefined, { notify: !!pv });
|
|
430
|
+
return owned;
|
|
431
|
+
},
|
|
432
|
+
poke: () => pokeHotSentinel(app.appDir),
|
|
433
|
+
});
|
|
434
|
+
announceHotInPlace();
|
|
347
435
|
}
|
|
348
|
-
return
|
|
436
|
+
return handle;
|
|
349
437
|
} catch (e) {
|
|
350
438
|
// The listener never came up (a taken port, #1527): stop what was started
|
|
351
439
|
// for it, so an embedding caller that catches this is not left with a
|
package/src/dev-import.js
CHANGED
|
@@ -2,13 +2,21 @@
|
|
|
2
2
|
* The import specifier for a module the dev server re-imports on every request
|
|
3
3
|
* (#1550): a page, layout, route handler, action module, middleware, and so on.
|
|
4
4
|
*
|
|
5
|
-
* In dev the specifier carries a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* In dev the specifier carries a `?t=` query naming the file's CONTENT (#1575),
|
|
6
|
+
* so a just-edited top-level file is served without waiting for a restart. Node
|
|
7
|
+
* keys its ESM cache by the full file URL, query included. Bun keys its cache by
|
|
8
|
+
* path and DROPS the query of a `file://` specifier, so the same URL-plus-query
|
|
9
|
+
* is a silent no-op there: on Bun the query must ride a plain absolute path,
|
|
10
|
+
* which Bun loads fresh (and hands to a `Bun.plugin` `onLoad` with the query
|
|
11
|
+
* intact).
|
|
12
|
+
*
|
|
13
|
+
* The query used to be random per call, which made every request a NEW module
|
|
14
|
+
* instance that neither runtime ever frees: about 60 KB per page view, so a dev
|
|
15
|
+
* server left open on a page grew without bound. Keyed by content, an unchanged
|
|
16
|
+
* file maps to the module already loaded. Content rather than mtime, because
|
|
17
|
+
* Linux stamps mtime from a coarse clock, so two quick writes of the same
|
|
18
|
+
* length can share one and the second would never be seen. A file that cannot
|
|
19
|
+
* be read gets a unique query, the old behaviour.
|
|
12
20
|
*
|
|
13
21
|
* A path that already contains `?` or `#` cannot carry a query unambiguously as
|
|
14
22
|
* a plain path, so on Bun it falls back to the file URL (no fresh import; the
|
|
@@ -17,18 +25,67 @@
|
|
|
17
25
|
* Outside dev the specifier is the plain file URL, exactly as before.
|
|
18
26
|
*/
|
|
19
27
|
import { pathToFileURL } from 'node:url';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
32
|
* @param {string} file absolute file path
|
|
23
33
|
* @param {boolean} dev whether to cache-bust
|
|
24
|
-
* @param {{ bun?: boolean,
|
|
34
|
+
* @param {{ bun?: boolean, version?: (file: string) => string }} [opts] test
|
|
35
|
+
* seams: the runtime (default: detected) and the content version
|
|
25
36
|
* @returns {string}
|
|
26
37
|
*/
|
|
27
38
|
export function devImportSpecifier(file, dev, opts = {}) {
|
|
28
39
|
const url = pathToFileURL(file).toString();
|
|
29
40
|
if (!dev) return url;
|
|
30
41
|
const bun = opts.bun ?? Boolean(typeof process !== 'undefined' && process.versions && process.versions.bun);
|
|
31
|
-
const bust = `?t=${(opts.
|
|
42
|
+
const bust = `?t=${(opts.version ?? contentVersion)(file)}`;
|
|
32
43
|
if (bun && !/[?#]/.test(file)) return file + bust;
|
|
33
44
|
return url + bust;
|
|
34
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A short hash of the file's bytes, or a unique token when it cannot be read.
|
|
49
|
+
* @param {string} file
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function contentVersion(file) {
|
|
53
|
+
try {
|
|
54
|
+
return createHash('sha1').update(readFileSync(file)).digest('hex').slice(0, 16);
|
|
55
|
+
} catch {
|
|
56
|
+
return `u${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Specifiers whose import rejected, mapped to the key to use instead (#1575).
|
|
62
|
+
* Under `bun --hot`, importing a module that failed to parse a SECOND time
|
|
63
|
+
* through the same specifier never settles, and a later reload can then crash
|
|
64
|
+
* the process (Bun 1.3.14). A content-keyed specifier repeats for as long as
|
|
65
|
+
* the bytes do, so a broken file would hang every request after the first. A
|
|
66
|
+
* failed key is therefore never imported again: the next attempt gets a fresh
|
|
67
|
+
* one, which sticks once it loads.
|
|
68
|
+
* @type {Map<string, string>}
|
|
69
|
+
*/
|
|
70
|
+
const RETRY_KEYS = new Map();
|
|
71
|
+
let retrySeq = 0;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `import()` a module the dev server re-imports on every request, through
|
|
75
|
+
* `devImportSpecifier`. Use this rather than `import(devImportSpecifier(...))`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} file absolute file path
|
|
78
|
+
* @param {boolean} dev
|
|
79
|
+
* @returns {Promise<any>}
|
|
80
|
+
*/
|
|
81
|
+
export async function devImport(file, dev) {
|
|
82
|
+
const base = devImportSpecifier(file, dev);
|
|
83
|
+
if (!dev) return import(base);
|
|
84
|
+
const spec = RETRY_KEYS.get(base) || base;
|
|
85
|
+
try {
|
|
86
|
+
return await import(spec);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
RETRY_KEYS.set(base, `${base}-r${++retrySeq}`);
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/dev-reload-worker.js
CHANGED
|
@@ -36,6 +36,18 @@
|
|
|
36
36
|
*/
|
|
37
37
|
export const RELOAD_QUIET_MS = 2000;
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The quiet window for a batch made only of in-process frames from a server
|
|
41
|
+
* that reloads IN PLACE (#1575): Bun's one long-lived dev server, or any
|
|
42
|
+
* `--no-hot` server. No restart follows such a frame, so nothing is worth
|
|
43
|
+
* waiting 2000ms for, and that wait was most of the save-to-paint time (3.8s
|
|
44
|
+
* measured in a hosted preview). 300ms still folds a save's few frames and a
|
|
45
|
+
* quick burst into one reload. A batch holding any restart signal (a changed
|
|
46
|
+
* boot id, a stale page, a frame from a restarting server) keeps the full
|
|
47
|
+
* `RELOAD_QUIET_MS`, since the restarted process is still warming.
|
|
48
|
+
*/
|
|
49
|
+
export const RELOAD_QUIET_IN_PLACE_MS = 300;
|
|
50
|
+
|
|
39
51
|
/**
|
|
40
52
|
* The longest a reload is ever held, measured from the FIRST signal of a batch
|
|
41
53
|
* (#1397). A quiet window alone would freeze the page on stale content for a
|
|
@@ -116,6 +128,19 @@ export function pageIsStale(page, boot, seq) {
|
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
/** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
|
|
131
|
+
/**
|
|
132
|
+
* Whether a `reload` frame comes from a server that reloads in place (#1575).
|
|
133
|
+
* Absent or unreadable means it may restart, the safe (slower) reading.
|
|
134
|
+
* @param {string} data
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function frameInPlace(data) {
|
|
138
|
+
try {
|
|
139
|
+
const o = JSON.parse(data);
|
|
140
|
+
return !!(o && o.inPlace === true);
|
|
141
|
+
} catch (_) { return false; }
|
|
142
|
+
}
|
|
143
|
+
|
|
119
144
|
function reloadSeq(data) {
|
|
120
145
|
try {
|
|
121
146
|
const o = JSON.parse(data);
|
|
@@ -190,6 +215,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
190
215
|
let batchAll = false;
|
|
191
216
|
/** @type {Set<any>} tabs whose page fell behind, reloaded on their own */
|
|
192
217
|
const batchPorts = new Set();
|
|
218
|
+
/** Whether the batch holds a signal from a server that restarts (#1575). */
|
|
219
|
+
let batchRestart = false;
|
|
193
220
|
|
|
194
221
|
function emitReload() {
|
|
195
222
|
if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
|
|
@@ -200,6 +227,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
200
227
|
const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p));
|
|
201
228
|
batchAll = false;
|
|
202
229
|
batchPorts.clear();
|
|
230
|
+
batchRestart = false;
|
|
203
231
|
for (const p of targets) {
|
|
204
232
|
// The tab applies this reload, so from here its page reflects the state
|
|
205
233
|
// the relay knows now; a later hello compares against that.
|
|
@@ -219,11 +247,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
219
247
|
* @param {string} verdict
|
|
220
248
|
* @param {any} [port] reload only this tab (its page fell behind); omitted, every tab
|
|
221
249
|
*/
|
|
222
|
-
function requestReload(verdict, port) {
|
|
250
|
+
function requestReload(verdict, port, inPlace) {
|
|
223
251
|
if (port === undefined) batchAll = true; else batchPorts.add(port);
|
|
224
252
|
if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
|
|
253
|
+
// One restart signal anywhere in the batch keeps the long window (#1575).
|
|
254
|
+
if (!inPlace) batchRestart = true;
|
|
225
255
|
if (quietTimer !== null) timers.clearTimeout(quietTimer);
|
|
226
|
-
quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
|
|
256
|
+
quietTimer = timers.setTimeout(emitReload, batchRestart ? RELOAD_QUIET_MS : RELOAD_QUIET_IN_PLACE_MS);
|
|
227
257
|
if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
|
|
228
258
|
}
|
|
229
259
|
|
|
@@ -278,7 +308,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
|
|
|
278
308
|
lastError = null;
|
|
279
309
|
const n = reloadSeq(e.data);
|
|
280
310
|
if (n !== null) lastSeq = n;
|
|
281
|
-
requestReload(parseVerdict(e.data));
|
|
311
|
+
requestReload(parseVerdict(e.data), undefined, frameInPlace(e.data));
|
|
282
312
|
});
|
|
283
313
|
es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
|
|
284
314
|
// The stream dropped (a restarting server, a network blip, a host that
|
|
@@ -105,6 +105,13 @@ export function registerSourceLocationHook(appDir) {
|
|
|
105
105
|
if (_roots.has(appDir)) return _installed;
|
|
106
106
|
_roots.add(appDir);
|
|
107
107
|
if (serverRuntime() === 'bun') {
|
|
108
|
+
// Once per root per PROCESS (#1575): `bun --hot` re-runs this module with
|
|
109
|
+
// an empty `_roots`, and a `Bun.plugin` cannot be unregistered.
|
|
110
|
+
const g = /** @type {any} */ (globalThis);
|
|
111
|
+
const key = Symbol.for('webjs.sourceLocations.bunRoots');
|
|
112
|
+
if (!(g[key] instanceof Set)) g[key] = new Set();
|
|
113
|
+
if (g[key].has(appDir)) { _installed = true; return _installed; }
|
|
114
|
+
g[key].add(appDir);
|
|
108
115
|
// One plugin per root, each with a filter that can never match a
|
|
109
116
|
// `node_modules` or `*.server.*` path (see the module comment).
|
|
110
117
|
const filter = new RegExp(
|
package/src/env-schema.js
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { join } from 'node:path';
|
|
35
35
|
import { stat } from 'node:fs/promises';
|
|
36
|
-
import {
|
|
36
|
+
import { devImport } from './dev-import.js';
|
|
37
37
|
|
|
38
38
|
/** Field type names a schema may declare. */
|
|
39
39
|
const KNOWN_TYPES = new Set(['string', 'number', 'boolean', 'url', 'enum']);
|
|
@@ -223,7 +223,7 @@ export async function loadEnvSchema(appDir, opts = {}) {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
if (!file) return null;
|
|
226
|
-
const mod = await
|
|
226
|
+
const mod = await devImport(file, Boolean(opts.dev));
|
|
227
227
|
return mod.default ?? null;
|
|
228
228
|
}
|
|
229
229
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { actionIdentitiesOf, identityHookInstalled } from './action-seed.js';
|
|
18
18
|
import { pathToFileURL } from 'node:url';
|
|
19
|
-
import {
|
|
19
|
+
import { devImport } from './dev-import.js';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Resolved identities for functions the hook never registered. Only ever
|
|
@@ -283,7 +283,7 @@ export async function lookupActionIdentity(idx, id) {
|
|
|
283
283
|
if (!file) return { ok: false, reason: 'skew' };
|
|
284
284
|
let module;
|
|
285
285
|
try {
|
|
286
|
-
module = await
|
|
286
|
+
module = await devImport(file, Boolean(idx.dev));
|
|
287
287
|
} catch (error) {
|
|
288
288
|
return { ok: false, reason: 'load-failed', error };
|
|
289
289
|
}
|
package/src/instrumentation.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { stat } from 'node:fs/promises';
|
|
20
20
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
21
|
-
import {
|
|
21
|
+
import { devImport } from './dev-import.js';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Per-boot handoff channel for a `register()`-registered error sink. An
|
|
@@ -86,7 +86,7 @@ export async function runInstrumentation(appDir, opts = {}) {
|
|
|
86
86
|
const store = { onError: null };
|
|
87
87
|
await _als.run(store, async () => {
|
|
88
88
|
try {
|
|
89
|
-
const mod = await
|
|
89
|
+
const mod = await devImport(file, dev);
|
|
90
90
|
const register = typeof mod.default === 'function'
|
|
91
91
|
? mod.default
|
|
92
92
|
: (typeof mod.register === 'function' ? mod.register : null);
|
package/src/listener-core.js
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import { stripBasePath } from './base-path.js';
|
|
24
24
|
import { randomUUID } from 'node:crypto';
|
|
25
25
|
import { isWatchError } from './dev/watch-tree.js';
|
|
26
|
-
import {
|
|
26
|
+
import { devImport } from './dev-import.js';
|
|
27
27
|
|
|
28
28
|
/** The dev live-reload SSE path (matched after base-path stripping). */
|
|
29
29
|
export const EVENTS_PATH = '/__webjs/events';
|
|
@@ -329,7 +329,7 @@ export async function readBufferedOrStream(web, maxBytes) {
|
|
|
329
329
|
* @param {boolean} dev
|
|
330
330
|
*/
|
|
331
331
|
export function loadWsModule(file, dev) {
|
|
332
|
-
return
|
|
332
|
+
return devImport(file, dev);
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
/**
|
|
@@ -351,7 +351,13 @@ export function loadWsModule(file, dev) {
|
|
|
351
351
|
* tab was hidden can tell, on reconnecting, that it missed an edit.
|
|
352
352
|
*/
|
|
353
353
|
export class SseHub {
|
|
354
|
-
|
|
354
|
+
/**
|
|
355
|
+
* @param {{ inPlace?: boolean }} [opts] `inPlace`: this server applies an
|
|
356
|
+
* edit without restarting its process (#1575), so its reload frames say so
|
|
357
|
+
* and the browser relay need not wait for a restart to settle.
|
|
358
|
+
*/
|
|
359
|
+
constructor({ inPlace = false } = {}) {
|
|
360
|
+
this.inPlace = inPlace;
|
|
355
361
|
/** @type {Set<{ send: (s: string) => void, close: () => void }>} */
|
|
356
362
|
this.clients = new Set();
|
|
357
363
|
/** Reload frames sent by this process (#1507). */
|
|
@@ -399,7 +405,7 @@ export class SseHub {
|
|
|
399
405
|
reload(verdict) {
|
|
400
406
|
const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
|
|
401
407
|
this.seq++;
|
|
402
|
-
this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq })}\n\n`);
|
|
408
|
+
this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq, ...(this.inPlace ? { inPlace: true } : {}) })}\n\n`);
|
|
403
409
|
}
|
|
404
410
|
|
|
405
411
|
/** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */
|