@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.
@@ -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 { devImportSpecifier } from '../dev-import.js';
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 import(devImportSpecifier(f, dev));
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 import(devImportSpecifier(file, dev));
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
- const hub = new SseHub();
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
- // 80ms debounce window, so hold the STRONGEST verdict of the window and
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
- }, 80);
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
- return startBunListener(ctx);
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 await startNodeListener(ctx);
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 fresh `?t=` query, so each request gets a new
6
- * module instance and a just-edited top-level file is served without waiting
7
- * for a restart. Node keys its ESM cache by the full file URL, query included.
8
- * Bun keys its cache by path and DROPS the query of a `file://` specifier, so
9
- * the same URL-plus-query is a silent no-op there: on Bun the query must ride a
10
- * plain absolute path, which Bun loads fresh (and hands to a `Bun.plugin`
11
- * `onLoad` with the query intact).
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, now?: () => number }} [opts] test seams: the runtime (default: detected) and the clock
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.now ?? Date.now)()}-${Math.random().toString(36).slice(2)}`;
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
+ }
@@ -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 { devImportSpecifier } from './dev-import.js';
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 import(devImportSpecifier(file, Boolean(opts.dev)));
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 { devImportSpecifier } from './dev-import.js';
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 import(devImportSpecifier(file, Boolean(idx.dev)));
286
+ module = await devImport(file, Boolean(idx.dev));
287
287
  } catch (error) {
288
288
  return { ok: false, reason: 'load-failed', error };
289
289
  }
@@ -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 { devImportSpecifier } from './dev-import.js';
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 import(devImportSpecifier(file, dev));
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);
@@ -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 { devImportSpecifier } from './dev-import.js';
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 import(devImportSpecifier(file, dev));
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
- constructor() {
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 */