@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.83",
3
+ "version": "0.8.85",
4
4
  "type": "module",
5
5
  "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
@@ -650,6 +650,14 @@ export async function registerActionHooks(opts = {}) {
650
650
  _registered = true;
651
651
 
652
652
  if (serverRuntime() === 'bun') {
653
+ // Once per PROCESS, not per module instance (#1575): `bun --hot` re-runs
654
+ // the server with fresh module state, and a `Bun.plugin` cannot be
655
+ // unregistered, so a module-level guard alone stacked one more plugin per
656
+ // edit (the first one registered keeps answering anyway).
657
+ const g = /** @type {any} */ (globalThis);
658
+ const installed = Symbol.for('webjs.actionSeed.bunPlugin');
659
+ if (g[installed]) { _hookInstalled = true; return; }
660
+ g[installed] = true;
653
661
  // Bun has no module.registerHooks; install the same facade via Bun.plugin.
654
662
  const { installBunSeedPlugin } = await import('./action-seed-bun.js');
655
663
  installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE });
package/src/actions.js CHANGED
@@ -20,7 +20,7 @@ import { getBodyLimits } from './context.js';
20
20
  import { basePath } from './importmap.js';
21
21
  import { withBasePath } from './base-path.js';
22
22
  import { FORM_ACTION_ID_KEY } from '@webjsdev/core';
23
- import { devImportSpecifier } from './dev-import.js';
23
+ import { devImport } from './dev-import.js';
24
24
 
25
25
  /**
26
26
  * The JSON / RPC body cap in effect for the current request: the per-request
@@ -638,5 +638,5 @@ async function actionErrorResponse(err, dev) {
638
638
  * @param {boolean} dev
639
639
  */
640
640
  async function loadModule(file, dev) {
641
- return import(devImportSpecifier(file, dev));
641
+ return devImport(file, dev);
642
642
  }
package/src/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { makeThenable } from './thenable-params.js';
2
- import { devImportSpecifier } from './dev-import.js';
2
+ import { devImport } from './dev-import.js';
3
3
 
4
4
  /**
5
5
  * Dispatch an incoming request to a matched API route.
@@ -14,7 +14,7 @@ import { devImportSpecifier } from './dev-import.js';
14
14
  * @returns {Promise<Response>}
15
15
  */
16
16
  export async function handleApi(route, params, webRequest, dev) {
17
- const mod = await import(devImportSpecifier(route.file, dev));
17
+ const mod = await devImport(route.file, dev);
18
18
  const method = webRequest.method.toUpperCase();
19
19
  const handler = mod[method];
20
20
  if (!handler) {
@@ -1157,6 +1157,84 @@ export async function computeElidableComponents(components, moduleGraph, readFil
1157
1157
  return elidableComponents;
1158
1158
  }
1159
1159
 
1160
+ /**
1161
+ * Per-file scan results, keyed by path and validated on the exact source and
1162
+ * the two facts about the file the scan depends on (#1575). The scan reads one
1163
+ * file and nothing else, so an unchanged file never needs it again; on a large
1164
+ * app it was most of the elision pass (about 350 ms of Crisp's 450 ms rebuild),
1165
+ * repeated in full after every edit.
1166
+ * @type {Map<string, { src: string, isRoute: boolean, isComponent: boolean, result: ReturnType<typeof scanFileUncached> }>}
1167
+ */
1168
+ const SCAN_RESULT_CACHE = new Map();
1169
+
1170
+ /**
1171
+ * The per-file half of `analyzeElision`: every signal it reads off ONE file.
1172
+ * @param {string} file
1173
+ * @param {string} src the raw source as read
1174
+ * @param {{ isRoute: boolean, isComponent: boolean, appDir: string, scanStripper: any }} ctx
1175
+ */
1176
+ function scanFileForElision(file, src, ctx) {
1177
+ const hit = SCAN_RESULT_CACHE.get(file);
1178
+ if (hit && hit.src === src && hit.isRoute === ctx.isRoute && hit.isComponent === ctx.isComponent) return hit.result;
1179
+ const result = scanFileUncached(file, src, ctx);
1180
+ SCAN_RESULT_CACHE.set(file, { src, isRoute: ctx.isRoute, isComponent: ctx.isComponent, result });
1181
+ return result;
1182
+ }
1183
+
1184
+ /**
1185
+ * @param {string} file
1186
+ * @param {string} src
1187
+ * @param {{ isRoute: boolean, isComponent: boolean, appDir: string, scanStripper: any }} ctx
1188
+ */
1189
+ function scanFileUncached(file, src, { isRoute, isComponent, appDir, scanStripper }) {
1190
+ // Erase type syntax first, so every scan below reads the code that RUNS
1191
+ // (#1423). This is the single point where the analyser reads a file, so
1192
+ // doing it here covers the module-scope, template, import, and component
1193
+ // scans at once.
1194
+ src = eraseTypesForScan(scanStripper, file, src);
1195
+ // Mask comments once for every signal scan below (#179): a `<tag>`, an
1196
+ // `@event`, a browser global, an `import`, or a `whenDefined` written in a
1197
+ // comment must not register as a real signal. String and template content
1198
+ // is kept, so a real rendered tag, a real `@click=${}` in an html template,
1199
+ // and a real `whenDefined('tag')` (the tag rides a string) still match.
1200
+ // (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` /
1201
+ // `analyzeComponentSource` also redact strings/templates internally; running
1202
+ // them on the comment-masked source just additionally drops comment prose.)
1203
+ const masked = maskComments(src);
1204
+ const tags = extractRenderedTags(masked);
1205
+ const { redacted, literals } = redactToPlaceholders(src);
1206
+ // A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is
1207
+ // SSR output, not module client work: an inline `<script>`'s browser
1208
+ // globals run from the rendered HTML, and a page-template `@event` is
1209
+ // dropped at SSR. For a route module, scan those two template-borne signals
1210
+ // on the template-redacted source so they do not pin the module, while a
1211
+ // genuine module-scope `document.x` OUTSIDE any template still flags (#623).
1212
+ // The import-based checks stay on `redacted`: a real `import 'pkg'` side
1213
+ // effect DOES run when a page/layout module loads in the browser, and
1214
+ // `importsSideEffectNonCorePackage` itself skips local `#`-alias imports.
1215
+ const templateScan = isRoute ? redacted : masked;
1216
+ const clientGlobalOrBare = EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) ||
1217
+ importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(templateScan) ||
1218
+ hasModuleScopeSideEffect(redacted, literals);
1219
+ let interactive = false;
1220
+ /** @type {string | null} */
1221
+ let interactiveReason = null;
1222
+ if (isComponent) {
1223
+ const v = analyzeComponentSource(masked);
1224
+ if (v.interactive) { interactive = true; interactiveReason = v.reason ?? null; }
1225
+ }
1226
+ return {
1227
+ tags,
1228
+ reactive: importsReactivePrimitive(redacted, literals),
1229
+ clientRouter: importsClientRouter(redacted, literals),
1230
+ clientGlobalOrBare,
1231
+ interactive,
1232
+ interactiveReason,
1233
+ observedTags: [...[...masked.matchAll(WHEN_DEFINED_RE)].map((m) => m[1]), ...[...masked.matchAll(TAG_DEFINED_RE)].map((m) => m[1])],
1234
+ observedClasses: [...masked.matchAll(INSTANCEOF_RE)].map((m) => m[1]),
1235
+ };
1236
+ }
1237
+
1160
1238
  /**
1161
1239
  * Full elision analysis: which display-only COMPONENT modules can be elided,
1162
1240
  * AND which page/layout ROUTE modules are inert (do no client work) and can
@@ -1257,53 +1335,28 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1257
1335
  // (#1423). This is the single point where the analyser reads a file, so
1258
1336
  // doing it here covers the module-scope, template, import, and component
1259
1337
  // scans at once.
1260
- src = eraseTypesForScan(scanStripper, file, src);
1261
- // Mask comments once for every signal scan below (#179): a `<tag>`, an
1262
- // `@event`, a browser global, an `import`, or a `whenDefined` written in a
1263
- // comment must not register as a real signal. String and template content
1264
- // is kept, so a real rendered tag, a real `@click=${}` in an html template,
1265
- // and a real `whenDefined('tag')` (the tag rides a string) still match.
1266
- // (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` /
1267
- // `analyzeComponentSource` also redact strings/templates internally; running
1268
- // them on the comment-masked source just additionally drops comment prose.)
1269
- const masked = maskComments(src);
1270
- fileTags.set(file, extractRenderedTags(masked));
1271
- const { redacted, literals } = redactToPlaceholders(src);
1272
- if (importsReactivePrimitive(redacted, literals)) reactiveFiles.add(file);
1273
- if (importsClientRouter(redacted, literals)) clientRouterFiles.add(file);
1274
- // A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is
1275
- // SSR output, not module client work: an inline `<script>`'s browser
1276
- // globals run from the rendered HTML, and a page-template `@event` is
1277
- // dropped at SSR. For a route module, scan those two template-borne signals
1278
- // on the template-redacted source so they do not pin the module, while a
1279
- // genuine module-scope `document.x` OUTSIDE any template still flags (#623).
1280
- // The import-based checks stay on `redacted`: a real `import 'pkg'` side
1281
- // effect DOES run when a page/layout module loads in the browser, and
1282
- // `importsSideEffectNonCorePackage` itself skips local `#`-alias imports.
1283
- const templateScan = routeModuleSet.has(file) ? redacted : masked;
1284
- if (EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) ||
1285
- importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(templateScan) ||
1286
- hasModuleScopeSideEffect(redacted, literals)) {
1287
- clientGlobalOrBareFiles.add(file);
1288
- }
1289
- if (componentFiles.has(file)) {
1290
- const v = analyzeComponentSource(masked);
1291
- if (v.interactive) { mustShip.add(file); noteShip(file, 'own', null, v.reason); }
1292
- }
1338
+ const scan = scanFileForElision(file, src, {
1339
+ isRoute: routeModuleSet.has(file),
1340
+ isComponent: componentFiles.has(file),
1341
+ appDir,
1342
+ scanStripper,
1343
+ });
1344
+ fileTags.set(file, scan.tags);
1345
+ if (scan.reactive) reactiveFiles.add(file);
1346
+ if (scan.clientRouter) clientRouterFiles.add(file);
1347
+ if (scan.clientGlobalOrBare) clientGlobalOrBareFiles.add(file);
1348
+ if (scan.interactive) { mustShip.add(file); noteShip(file, 'own', null, scan.interactiveReason); }
1293
1349
  // Cross-module registration observation (#169): if THIS module observes
1294
1350
  // another component's tag, that component must register client-side, so
1295
1351
  // it cannot be elided. Map each observed tag/class back to its component
1296
- // file. Resolution against tagToFile / classToFile happens after the loop
1297
- // (all components are known up front, but we collect here while we hold
1298
- // each source). Verdict-safe: only ever forces MORE components to ship.
1299
- for (const m of masked.matchAll(WHEN_DEFINED_RE)) {
1300
- const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1301
- }
1302
- for (const m of masked.matchAll(TAG_DEFINED_RE)) {
1303
- const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1352
+ // file. Resolution against tagToFile / classToFile happens here, outside
1353
+ // the per-file cache, because the component set is not a property of this
1354
+ // file. Verdict-safe: only ever forces MORE components to ship.
1355
+ for (const tag of scan.observedTags) {
1356
+ const f = tagToFile.get(tag); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1304
1357
  }
1305
- for (const m of masked.matchAll(INSTANCEOF_RE)) {
1306
- const f = classToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1358
+ for (const cls of scan.observedClasses) {
1359
+ const f = classToFile.get(cls); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1307
1360
  }
1308
1361
  }
1309
1362
 
@@ -165,6 +165,44 @@ export async function primeComponentRegistry(appDir, components) {
165
165
  return { count: components.length };
166
166
  }
167
167
 
168
+ /**
169
+ * Per-file results of the orphan scan, validated on the exact source (#1575):
170
+ * the dev server runs `findOrphanComponents` after every rebuild, and on a
171
+ * large app re-scanning every unchanged file cost about 100 ms an edit.
172
+ * @type {Map<string, { src: string, declared: Set<string>, registered: string[] }>}
173
+ */
174
+ const ORPHAN_SCAN_CACHE = new Map();
175
+
176
+ /**
177
+ * The classes one file declares as WebComponents and the ones it registers.
178
+ * @param {string} file
179
+ * @param {string} src
180
+ * @returns {{ declared: Set<string>, registered: string[] }}
181
+ */
182
+ function orphanScan(file, src) {
183
+ const hit = ORPHAN_SCAN_CACHE.get(file);
184
+ if (hit && hit.src === src) return hit;
185
+ const { redacted } = redactToPlaceholders(src);
186
+ // Find every class that extends WebComponent (exact name: we trust
187
+ // the framework convention).
188
+ const classRe = /\b(?:export\s+)?(?:default\s+)?class\s+([A-Z][A-Za-z0-9_$]*)\s+extends\s+WebComponent\b/g;
189
+ const declared = new Set();
190
+ let m;
191
+ while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]);
192
+
193
+ // "Registered" is whatever `extractComponents` accepts, reused rather than
194
+ // re-expressed. The two scans MUST agree on what a literal tag is: when
195
+ // this used its own looser pattern, a redacted interpolated template
196
+ // (`Class.register(\`${p}__STR_1__\`)`) satisfied the orphan scan but not
197
+ // `extractComponents`, so a computed template-literal tag was neither a
198
+ // component NOR an orphan and vanished from every surface, which is the
199
+ // exact shape the orphan report exists to catch.
200
+ const registered = extractComponents(src).map((c) => c.className);
201
+ const entry = { src, declared, registered };
202
+ ORPHAN_SCAN_CACHE.set(file, entry);
203
+ return entry;
204
+ }
205
+
168
206
  /**
169
207
  * Find ORPHAN components: a `class X extends WebComponent` that NOTHING in the
170
208
  * app registers with a LITERAL tag, via either `X.register('tag')` or
@@ -242,22 +280,8 @@ export async function findOrphanComponents(appDir) {
242
280
  // blank-strings argument: the default form keeps plain-string bodies and
243
281
  // single-line untagged templates verbatim, so a sample written either way
244
282
  // would match again and the false orphan would be back.
245
- const { redacted } = redactToPlaceholders(src);
246
- // Find every class that extends WebComponent (exact name: we trust
247
- // the framework convention).
248
- const classRe = /\b(?:export\s+)?(?:default\s+)?class\s+([A-Z][A-Za-z0-9_$]*)\s+extends\s+WebComponent\b/g;
249
- const declared = new Set();
250
- let m;
251
- while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]);
252
-
253
- // "Registered" is whatever `extractComponents` accepts, reused rather than
254
- // re-expressed. The two scans MUST agree on what a literal tag is: when
255
- // this used its own looser pattern, a redacted interpolated template
256
- // (`Class.register(\`${p}__STR_1__\`)`) satisfied the orphan scan but not
257
- // `extractComponents`, so a computed template-literal tag was neither a
258
- // component NOR an orphan and vanished from every surface, which is the
259
- // exact shape the orphan report exists to catch.
260
- for (const c of extractComponents(src)) registeredAnywhere.add(c.className);
283
+ const { declared, registered } = orphanScan(file, src);
284
+ for (const cls of registered) registeredAnywhere.add(cls);
261
285
  if (declared.size) declaredPerFile.push({ file, declared });
262
286
  }
263
287
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Resolve the app's `#` subpath imports ourselves under `webjs dev` on Bun
3
+ * (#1575).
4
+ *
5
+ * Bun caches a directory's entries when it resolves through it and, under
6
+ * `bun --hot`, refreshes that cache only for a directory it watches. It does
7
+ * not watch a directory that holds only modules a `Bun.plugin` served (an
8
+ * `actions/` folder of `*.server.ts` files), and a `package.json "imports"`
9
+ * specifier (`#modules/x/new.ts`) resolves through that stale listing, while a
10
+ * relative one does not. So a file an agent added next to an action was
11
+ * "Cannot find module" for the rest of the process, through every reload. This
12
+ * `onResolve` expands the alias through the same map the module graph uses
13
+ * (`expandImportAlias`) and hands Bun an absolute path that exists, which Bun
14
+ * then loads normally (the seed plugin's `onLoad` still applies by path). A
15
+ * specifier it cannot place falls through to Bun's own resolver, so an error
16
+ * reads exactly as it would without it.
17
+ *
18
+ * Dev only (production never reloads) and Bun only (Node's resolver reads the
19
+ * file system on every resolve).
20
+ *
21
+ * @module dev/bun-alias-resolve
22
+ */
23
+ import { existsSync, statSync } from 'node:fs';
24
+ import { resolve, sep } from 'node:path';
25
+ import { expandImportAlias } from '../module-graph.js';
26
+
27
+ const ROOTS = Symbol.for('webjs.dev.bunAliasRoots');
28
+
29
+ /**
30
+ * Resolve `spec` imported by `importer` against the app's `#` alias map, or
31
+ * null when it is not an app alias or the target is not a file.
32
+ *
33
+ * @param {string} spec
34
+ * @param {string} importer absolute path, possibly with a query
35
+ * @param {string} appDir absolute
36
+ * @returns {string | null}
37
+ */
38
+ export function resolveAppAlias(spec, importer, appDir) {
39
+ if (!spec.startsWith('#')) return null;
40
+ const from = String(importer || '').split('?')[0];
41
+ if (!from.startsWith(appDir + sep) || from.includes(`${sep}node_modules${sep}`)) return null;
42
+ const rel = expandImportAlias(spec, appDir);
43
+ if (!rel) return null;
44
+ const abs = resolve(appDir, rel);
45
+ try { return existsSync(abs) && statSync(abs).isFile() ? abs : null; } catch { return null; }
46
+ }
47
+
48
+ /**
49
+ * Install the resolver for `appDir`, once per process.
50
+ * @param {string} appDir absolute
51
+ */
52
+ export function registerBunAliasResolver(appDir) {
53
+ const g = /** @type {any} */ (globalThis);
54
+ if (!g.Bun || typeof g.Bun.plugin !== 'function') return;
55
+ if (!(g[ROOTS] instanceof Set)) g[ROOTS] = new Set();
56
+ if (g[ROOTS].has(appDir)) return;
57
+ g[ROOTS].add(appDir);
58
+ g.Bun.plugin({
59
+ name: 'webjs-dev-alias',
60
+ setup(build) {
61
+ build.onResolve({ filter: /^#/ }, (args) => {
62
+ try {
63
+ const path = resolveAppAlias(args.path, args.importer, appDir);
64
+ return path ? { path } : undefined;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ });
69
+ },
70
+ });
71
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Load the app's own modules through a `Bun.plugin` under `webjs dev` on Bun
3
+ * (#1575), so every load reads the file as it is on disk now.
4
+ *
5
+ * Under `bun --hot`, Bun keeps a per-path copy of a module's source and
6
+ * refreshes it from its file watcher. Once a file is replaced rather than
7
+ * rewritten (an editor's atomic save, `sed -i`, a `git checkout`), that watch
8
+ * is gone and the copy is never refreshed: every later import of that path, a
9
+ * fresh `?t=` query included, evaluates the old source for the rest of the
10
+ * process. Measured on the scaffold: one atomic write to a page, and the page
11
+ * served its previous version through every later edit until a restart. A
12
+ * plugin's `onLoad` is not served from that copy, so it is always fresh.
13
+ *
14
+ * The trade is that `bun --hot` does not watch a module a plugin served
15
+ * (#1550), so the dev server's own watcher asks for the registry reset itself
16
+ * (`pokeHotSentinel` in `dev/hot-host.js`) after an edit to any app module.
17
+ *
18
+ * The filter matches app modules only: never `node_modules`, and never a
19
+ * `*.server.*` module, which the `'use server'` seed plugin serves. When dev
20
+ * source locations are on, their plugin (same filter, registered first) wins
21
+ * and serves annotated source instead, which is just as fresh.
22
+ *
23
+ * @module dev/bun-app-source
24
+ */
25
+ import { readFileSync } from 'node:fs';
26
+
27
+ const ROOTS = Symbol.for('webjs.dev.bunAppSourceRoots');
28
+
29
+ /** @param {string} s */
30
+ function escapeRe(s) {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ }
33
+
34
+ /**
35
+ * The `onLoad` filter for one app root: its JS/TS modules outside
36
+ * `node_modules`, `*.server.*` excluded, with an optional query.
37
+ * @param {string} appDir absolute
38
+ * @returns {RegExp}
39
+ */
40
+ export function appSourceFilter(appDir) {
41
+ return new RegExp('^' + escapeRe(appDir) + '/(?:(?!node_modules/)(?!.*\\.server\\.).)*\\.m?[jt]s(\\?.*)?$');
42
+ }
43
+
44
+ /**
45
+ * Install the loader for `appDir`, once per process.
46
+ * @param {string} appDir absolute
47
+ */
48
+ export function registerBunAppSource(appDir) {
49
+ const g = /** @type {any} */ (globalThis);
50
+ if (!g.Bun || typeof g.Bun.plugin !== 'function') return;
51
+ if (!(g[ROOTS] instanceof Set)) g[ROOTS] = new Set();
52
+ if (g[ROOTS].has(appDir)) return;
53
+ g[ROOTS].add(appDir);
54
+ const filter = appSourceFilter(appDir);
55
+ g.Bun.plugin({
56
+ name: 'webjs-dev-app-source',
57
+ setup(build) {
58
+ build.onLoad({ filter }, (args) => {
59
+ const abs = args.path.split('?')[0];
60
+ // A read failure (the file is gone) propagates, which Bun reports as
61
+ // the load error it would have raised itself.
62
+ return { contents: readFileSync(abs, 'utf8'), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' };
63
+ });
64
+ },
65
+ });
66
+ }
@@ -1,7 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { registerBunAliasResolver } from './bun-alias-resolve.js';
4
+ import { registerBunAppSource } from './bun-app-source.js';
5
+ import { serverRuntime } from '../listener-core.js';
3
6
  import { createHash } from 'node:crypto';
4
- import { join, resolve, sep } from 'node:path';
7
+ import { join, relative, resolve, sep } from 'node:path';
5
8
 
6
9
  // Server-side `.ts` imports are handled natively by Node 24+'s default
7
10
  // type-stripping (`process.features.typescript === 'strip'`) or by Bun. The
@@ -30,7 +33,7 @@ import { mintNonce, buildCspHeader, cspHeaderName } from '../csp.js';
30
33
  import { propagateTrustedRemoteIp } from '../rate-limit.js';
31
34
  import { reachedBareImports, resolveVendorImports, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from '../vendor.js';
32
35
  import { browserEntryFiles } from '../browser-entries.js';
33
- import { buildModuleGraph, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport } from '../module-graph.js';
36
+ import { buildModuleGraph, dynamicEdges, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport } from '../module-graph.js';
34
37
  import { primeComponentRegistry, findOrphanComponents, scanComponents } from '../component-scanner.js';
35
38
  import { analyzeElision, lazyComponentFiles, bindingImportedLazyFiles } from '../component-elision.js';
36
39
 
@@ -72,7 +75,7 @@ import {
72
75
  import {
73
76
  handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
74
77
  } from './serve.js';
75
- import { devImportSpecifier } from '../dev-import.js';
78
+ import { devImport } from '../dev-import.js';
76
79
 
77
80
  /**
78
81
  * A short content digest of a single file's bytes for the app-source deploy
@@ -297,6 +300,12 @@ export async function createRequestHandler(opts) {
297
300
  // what a bound `<form action=${action}>` resolves through, so gating the hook
298
301
  // on seeding would mean `webjs.seed: false` silently broke every no-JS form.
299
302
  await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
303
+ // Bun's resolver keeps a stale directory listing for a `#` import under
304
+ // `bun --hot` (#1575); resolve the app's aliases ourselves in dev.
305
+ if (dev && serverRuntime() === 'bun') {
306
+ registerBunAliasResolver(appDir);
307
+ try { const real = realpathSync(appDir); if (real !== appDir) registerBunAliasResolver(real); } catch { /* appDir only */ }
308
+ }
300
309
 
301
310
  // Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
302
311
  // `webjs.dev.sourceLocations: true`) under `webjs dev`
@@ -312,6 +321,13 @@ export async function createRequestHandler(opts) {
312
321
  // Module URLs are realpaths, so a symlinked app dir registers both forms.
313
322
  try { const real = realpathSync(appDir); if (real !== appDir) registerSourceLocationHook(real); } catch { /* keep appDir only */ }
314
323
  }
324
+ // Bun under `bun --hot` serves a replaced file's old source forever (#1575):
325
+ // load app modules through a plugin that reads them fresh. After the
326
+ // source-locations plugin, so that one wins when it is on.
327
+ if (dev && serverRuntime() === 'bun') {
328
+ registerBunAppSource(appDir);
329
+ try { const real = realpathSync(appDir); if (real !== appDir) registerBunAppSource(real); } catch { /* appDir only */ }
330
+ }
315
331
 
316
332
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
317
333
  // deterministic vendor map that is cheap to read (one file, no analysis, no
@@ -370,6 +386,7 @@ export async function createRequestHandler(opts) {
370
386
  // developer remembering to run `webjs types`. Best-effort and fire-and-
371
387
  // forget: a failure logs and never blocks boot. Re-emitted after each route
372
388
  // rebuild (see doRebuild) so adding/removing a route refreshes the types.
389
+ let routeTypesTmpSeq = 0;
373
390
  /** @returns {Promise<void>} */
374
391
  async function emitRouteTypes() {
375
392
  try {
@@ -382,7 +399,9 @@ export async function createRequestHandler(opts) {
382
399
  // is atomic within the same dir. Both paths sit under the watcher-ignored
383
400
  // .webjs/, so neither the temp write nor the rename re-triggers a rebuild.
384
401
  const dest = join(outDir, 'routes.d.ts');
385
- const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`);
402
+ // pid AND a counter (#1575): two rebuilds in one process used to share
403
+ // one temp name, so the second rename failed with ENOENT.
404
+ const tmp = join(outDir, `routes.d.ts.${process.pid}.${++routeTypesTmpSeq}.tmp`);
386
405
  await writeFile(tmp, text);
387
406
  await rename(tmp, dest);
388
407
  } catch (e) {
@@ -858,7 +877,7 @@ export async function createRequestHandler(opts) {
858
877
  }
859
878
  if (!file) { readinessFn = null; return null; }
860
879
  try {
861
- const mod = await import(devImportSpecifier(file, dev));
880
+ const mod = await devImport(file, dev);
862
881
  readinessFn = typeof mod.default === 'function' ? mod.default : null;
863
882
  } catch (e) {
864
883
  logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) });
@@ -878,9 +897,11 @@ export async function createRequestHandler(opts) {
878
897
  * classification of the change that triggered this rebuild (#1398). Absent
879
898
  * (an embedded host calling `rebuild()`, or any caller with no filename to
880
899
  * classify) means a full reload, which is the fail-safe default.
900
+ * @param {{ notify?: boolean }} [rebuildOpts] `notify: false` re-derives
901
+ * without a reload frame (a `bun --hot` registry reset, #1575).
881
902
  */
882
- async function rebuild(verdict) {
883
- rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict)).catch((e) => {
903
+ async function rebuild(verdict, { notify = true } = {}) {
904
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify)).catch((e) => {
884
905
  logger.error?.(`[webjs] rebuild failed:`, e);
885
906
  // Push the failure to the open tab so the overlay appears live after a
886
907
  // breaking edit, not only on the next manual navigation (#264).
@@ -889,8 +910,13 @@ export async function createRequestHandler(opts) {
889
910
  return rebuildInFlight;
890
911
  }
891
912
 
892
- /** @param {import('../dev-classify.js').ReloadVerdict} [verdict] */
893
- async function doRebuild(verdict) {
913
+ /**
914
+ * @param {import('../dev-classify.js').ReloadVerdict} [verdict]
915
+ * @param {boolean} [notify] tell the open tabs (`onReload`). False for the
916
+ * re-derive a `bun --hot` re-run asks for (#1575), whose edit the watcher
917
+ * already announced.
918
+ */
919
+ async function doRebuild(verdict, notify = true) {
894
920
  // The route table is the only eager artifact (cheap directory scan); rebuild
895
921
  // it so routing reflects added/removed route files immediately.
896
922
  state.routeTable = await buildRouteTable(appDir);
@@ -934,7 +960,7 @@ export async function createRequestHandler(opts) {
934
960
  // reloads every open tab, and if the underlying error is still present the
935
961
  // reloaded request re-pushes a fresh frame (a brief dismiss-then-reappear
936
962
  // flicker on an unrelated edit, self-correcting to the right end state).
937
- state.lastDevError = null;
963
+ if (notify) state.lastDevError = null;
938
964
  // The verdict was computed against the PREVIOUS build's graph, because this
939
965
  // rebuild only INVALIDATES the lazy analysis (`analysisDone = false` above)
940
966
  // and the fresh graph is not built until the next request. Awaiting
@@ -945,7 +971,25 @@ export async function createRequestHandler(opts) {
945
971
  // an otherwise morphable page, is closed on the CLIENT: `addNewHeadElements`
946
972
  // runs on both boundary tiers and swaps in the changed boot script, which
947
973
  // executes and registers the new component.
948
- opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' });
974
+ if (notify) opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' });
975
+ }
976
+
977
+ /** A routing-convention module under `app/`, which only the router loads. */
978
+ const ROUTE_MODULE_FILE = /^app\/(?:.*\/)?(?:page|layout|route|loading|error|not-found|forbidden|unauthorized|global-error|global-not-found|middleware)\.m?[jt]s$/;
979
+ /** @type {WeakMap<object, Set<string>>} every file some app module imports, per graph */
980
+ const importedFilesCache = new WeakMap();
981
+ /**
982
+ * @param {Map<string, Set<string>>} graph
983
+ * @returns {Set<string>}
984
+ */
985
+ function importedFilesOf(graph) {
986
+ let set = importedFilesCache.get(graph);
987
+ if (set) return set;
988
+ set = new Set();
989
+ for (const deps of graph.values()) for (const d of deps) set.add(d);
990
+ for (const deps of dynamicEdges(graph).values()) for (const d of deps) set.add(d);
991
+ importedFilesCache.set(graph, set);
992
+ return set;
949
993
  }
950
994
 
951
995
  /** @param {Request} req */
@@ -1410,6 +1454,27 @@ export async function createRequestHandler(opts) {
1410
1454
  * @param {string} [root] the watched root, defaulting to the app dir
1411
1455
  * @returns {import('../dev-classify.js').ReloadVerdict}
1412
1456
  */
1457
+ /**
1458
+ * Whether an edit to this path needs the runtime's module registry reset
1459
+ * (#1575), the `bun --hot` re-run `startServer` pokes for. A module no
1460
+ * other app module imports is only ever loaded through `devImport`, whose
1461
+ * specifier names the file's content, so its edit is a new specifier and
1462
+ * needs nothing reset. Anything imported by another module, a file the last
1463
+ * analysis never saw (a new file may be the target of an import that
1464
+ * failed), or any edit before the first analysis does.
1465
+ *
1466
+ * @param {string} filename relative to `root`
1467
+ * @param {string} [root] the watched root, defaulting to the app dir
1468
+ * @returns {boolean}
1469
+ */
1470
+ needsRegistryReset: (filename, root) => {
1471
+ const abs = resolve(root || appDir, filename);
1472
+ if (!analysisEverDone) return true;
1473
+ // A new routing file (a page, layout, route handler, boundary) is only
1474
+ // ever loaded by the router, and nothing can have tried to import it yet.
1475
+ if (!state.graphFiles.has(abs)) return !ROUTE_MODULE_FILE.test(relative(appDir, abs).split(sep).join('/'));
1476
+ return importedFilesOf(state.moduleGraph).has(abs);
1477
+ },
1413
1478
  classifyWatchPath: (filename, root) => classifyChangedPath(resolve(root || appDir, filename), {
1414
1479
  appDir,
1415
1480
  shippedFiles: state.shippedFiles,