@webjsdev/server 0.8.82 → 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/form-dispatch.js +29 -5
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.84",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/action-seed.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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
|
|
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) {
|
package/src/component-elision.js
CHANGED
|
@@ -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
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
for (const
|
|
1300
|
-
const f = tagToFile.get(
|
|
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
|
|
1306
|
-
const f = classToFile.get(
|
|
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
|
|
package/src/component-scanner.js
CHANGED
|
@@ -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 {
|
|
246
|
-
|
|
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
|
+
}
|
package/src/dev/handler.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
893
|
-
|
|
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,
|