@webjsdev/server 0.8.83 → 0.8.84
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 +92 -6
- package/src/dev-import.js +66 -9
- 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 +2 -2
- 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,6 +253,22 @@ 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
|
|
@@ -263,6 +295,10 @@ export async function startServer(opts) {
|
|
|
263
295
|
|
|
264
296
|
/** @type {AbortController | null} */
|
|
265
297
|
let watcherAbort = null;
|
|
298
|
+
/** The strongest verdict of the edits a poked re-run has yet to apply. */
|
|
299
|
+
/** @type {import('../dev-classify.js').ReloadVerdict | null} */
|
|
300
|
+
let pokedVerdict = null;
|
|
301
|
+
/** @type {any} */ let pokeFallback = null;
|
|
266
302
|
if (dev) {
|
|
267
303
|
// Watch the app tree with Node's built-in `fs.watch` (no external dep):
|
|
268
304
|
// `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
|
|
@@ -273,18 +309,35 @@ export async function startServer(opts) {
|
|
|
273
309
|
// (module-level, exported for tests) skips node_modules, .git, .webjs/, and
|
|
274
310
|
// the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
|
|
275
311
|
// Live-reload classification (#1398). Several files can change inside one
|
|
276
|
-
//
|
|
312
|
+
// 50ms debounce window (80ms before #1575), so hold the STRONGEST verdict of the window and
|
|
277
313
|
// hand it to the rebuild. Same rule as the browser relay's cross-batch
|
|
278
314
|
// accumulation, for the same reason: a window mixing a page edit and a
|
|
279
315
|
// component edit is a component edit, and taking the last one would morph
|
|
280
316
|
// fresh markup onto the old component class.
|
|
281
317
|
/** @type {import('../dev-classify.js').ReloadVerdict | null} */
|
|
282
318
|
let pendingVerdict = null;
|
|
319
|
+
// Under `bun --hot`, a window that changed a module a `Bun.plugin` serves
|
|
320
|
+
// pokes the re-run sentinel instead of rebuilding (#1575): the rebuild has
|
|
321
|
+
// to wait for the registry reset, or the next request would still import
|
|
322
|
+
// the stale module. The re-run (`hotRerun` below) rebuilds with the verdict.
|
|
323
|
+
let pendingPoke = false;
|
|
283
324
|
const rebuild = debounce(() => {
|
|
284
325
|
const v = pendingVerdict;
|
|
285
326
|
pendingVerdict = null;
|
|
327
|
+
if (pendingPoke) {
|
|
328
|
+
pendingPoke = false;
|
|
329
|
+
pokedVerdict = strongerVerdict(pokedVerdict, v);
|
|
330
|
+
pokeHotSentinel(app.appDir);
|
|
331
|
+
clearTimeout(pokeFallback);
|
|
332
|
+
pokeFallback = setTimeout(() => {
|
|
333
|
+
const pv = pokedVerdict;
|
|
334
|
+
pokedVerdict = null;
|
|
335
|
+
if (pv) app.rebuild(pv);
|
|
336
|
+
}, POKE_FALLBACK_MS);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
286
339
|
app.rebuild(v || undefined);
|
|
287
|
-
},
|
|
340
|
+
}, 50);
|
|
288
341
|
watcherAbort = new AbortController();
|
|
289
342
|
// One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
|
|
290
343
|
// file must never stop the dev server, and a burst of the same temp file
|
|
@@ -307,6 +360,7 @@ export async function startServer(opts) {
|
|
|
307
360
|
// lives in createRequestHandler's scope, not here.
|
|
308
361
|
if (app.isRegenerateOutput(filename)) return;
|
|
309
362
|
pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
|
|
363
|
+
if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true;
|
|
310
364
|
rebuild();
|
|
311
365
|
},
|
|
312
366
|
onError: onWatchError,
|
|
@@ -341,11 +395,43 @@ export async function startServer(opts) {
|
|
|
341
395
|
// req/s on the listening path (#511); the Bun shell is dynamically imported so
|
|
342
396
|
// the `Bun.*` global is never referenced on Node.
|
|
343
397
|
try {
|
|
398
|
+
/** @type {{ server: any, close: () => Promise<void> }} */
|
|
399
|
+
let handle;
|
|
344
400
|
if (serverRuntime() === 'bun') {
|
|
345
401
|
const { startBunListener } = await import('../listener-bun.js');
|
|
346
|
-
|
|
402
|
+
handle = startBunListener(ctx);
|
|
403
|
+
} else {
|
|
404
|
+
handle = await startNodeListener(ctx);
|
|
405
|
+
}
|
|
406
|
+
if (hotKey) {
|
|
407
|
+
// Own the process for every later `bun --hot` re-run (#1575).
|
|
408
|
+
await armHotSentinel(app.appDir);
|
|
409
|
+
const close = handle.close;
|
|
410
|
+
handle = { ...handle, close: () => { deleteHotHost(hotKey); removeHotSentinel(app.appDir); return close(); } };
|
|
411
|
+
const owned = handle;
|
|
412
|
+
setHotHost(hotKey, {
|
|
413
|
+
version: SERVER_VERSION,
|
|
414
|
+
/**
|
|
415
|
+
* The registry was reset: re-watch the sentinel from the fresh registry,
|
|
416
|
+
* then re-derive the analysis so it re-imports the app's modules (the
|
|
417
|
+
* middleware, the components it primes) from that registry. A re-run a
|
|
418
|
+
* poke asked for carries the verdict of the edits behind it and tells the
|
|
419
|
+
* browser; any other is silent, since the watcher already sent that
|
|
420
|
+
* edit's own reload frame.
|
|
421
|
+
*/
|
|
422
|
+
rerun: async () => {
|
|
423
|
+
await armHotSentinel(app.appDir);
|
|
424
|
+
const pv = pokedVerdict;
|
|
425
|
+
pokedVerdict = null;
|
|
426
|
+
clearTimeout(pokeFallback);
|
|
427
|
+
await app.rebuild(pv || undefined, { notify: !!pv });
|
|
428
|
+
return owned;
|
|
429
|
+
},
|
|
430
|
+
poke: () => pokeHotSentinel(app.appDir),
|
|
431
|
+
});
|
|
432
|
+
announceHotInPlace();
|
|
347
433
|
}
|
|
348
|
-
return
|
|
434
|
+
return handle;
|
|
349
435
|
} catch (e) {
|
|
350
436
|
// The listener never came up (a taken port, #1527): stop what was started
|
|
351
437
|
// 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
|
+
}
|
|
@@ -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
|
/**
|
package/src/metadata-routes.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { sitemap as sitemapXml, robots as robotsTxt } from './sitemap.js';
|
|
22
22
|
import { isUrlSegment } from './router.js';
|
|
23
|
-
import {
|
|
23
|
+
import { devImport } from './dev-import.js';
|
|
24
24
|
import { siteUrlFromEnv } from './ssr/seo.js';
|
|
25
25
|
import { basePath } from './importmap.js';
|
|
26
26
|
import { withBasePath } from './base-path.js';
|
|
@@ -42,7 +42,7 @@ async function isNoindex(files, dev) {
|
|
|
42
42
|
let robots;
|
|
43
43
|
for (const file of files) {
|
|
44
44
|
try {
|
|
45
|
-
const mod = await
|
|
45
|
+
const mod = await devImport(file, dev);
|
|
46
46
|
if (mod.metadata && typeof mod.metadata === 'object' && mod.metadata.robots !== undefined) {
|
|
47
47
|
robots = mod.metadata.robots;
|
|
48
48
|
}
|
|
@@ -120,7 +120,7 @@ export async function listPages(routeTable, { siteUrl, dev }) {
|
|
|
120
120
|
}
|
|
121
121
|
let mod;
|
|
122
122
|
try {
|
|
123
|
-
mod = await
|
|
123
|
+
mod = await devImport(page.file, dev);
|
|
124
124
|
} catch {
|
|
125
125
|
continue;
|
|
126
126
|
}
|
|
@@ -167,7 +167,7 @@ function absoluteEntries(entries, siteUrl) {
|
|
|
167
167
|
* @returns {Promise<Response | null>} null when the module has no default export
|
|
168
168
|
*/
|
|
169
169
|
export async function runMetadataRoute(meta, { req, url, path, dev, routeTable }) {
|
|
170
|
-
const mod = await
|
|
170
|
+
const mod = await devImport(meta.file, dev);
|
|
171
171
|
if (typeof mod.default !== 'function') return null;
|
|
172
172
|
const siteUrl = siteUrlFromEnv() || url.origin;
|
|
173
173
|
const ctx = {
|