@webjsdev/server 0.7.3 → 0.8.1

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/src/dev.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createServer as createHttp1Server } from 'node:http';
2
- import { stat, readFile } from 'node:fs/promises';
3
- import { createHash } from 'node:crypto';
2
+ import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { digestHex } from './crypto-utils.js';
4
5
  import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
5
6
  import { join, extname, resolve, dirname, relative, sep } from 'node:path';
6
7
  import { createRequire, stripTypeScriptTypes } from 'node:module';
@@ -19,7 +20,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
19
20
  // equivalent built-in, we will need to install `amaro` directly (or
20
21
  // an equivalent: Sucrase preserves lines but not columns; SWC's
21
22
  // strip-only also works). The fast-path `stripTs` helper would
22
- // change one import line; the fallback path (esbuild) stays.
23
+ // change one import line.
23
24
  //
24
25
  // Suppress the one-shot ExperimentalWarning that Node prints the
25
26
  // first time `stripTypeScriptTypes` is called. The API is committed
@@ -57,15 +58,16 @@ import {
57
58
  import { defaultLogger } from './logger.js';
58
59
  import { withRequest } from './context.js';
59
60
  import { attachWebSocket } from './websocket.js';
60
- import { scanBareImports, vendorImportMapEntries, serveVendorBundle, clearVendorCache } from './vendor.js';
61
- import { buildModuleGraph, transitiveDeps } from './module-graph.js';
62
- import { primeComponentRegistry, findOrphanComponents } from './component-scanner.js';
61
+ import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache } from './vendor.js';
62
+ import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
63
+ import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
64
+ import { analyzeElision, elideImportsFromSource } from './component-elision.js';
63
65
 
64
66
  /** PascalCase → kebab-case for a helpful diagnostic example tag name. */
65
67
  function kebab(name) {
66
68
  return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
67
69
  }
68
- import { setVendorEntries } from './importmap.js';
70
+ import { setVendorEntries, setCoreInstall } from './importmap.js';
69
71
  import { urlFromRequest } from './forwarded.js';
70
72
 
71
73
  const MIME = {
@@ -92,23 +94,79 @@ const MIME = {
92
94
  * Capped at 500 entries to prevent unbounded memory growth in
93
95
  * long-running production servers.
94
96
  *
95
- * Primary stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
97
+ * Stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
96
98
  * Position-preserving whitespace replacement. No sourcemap is
97
99
  * emitted because every (line, column) maps to itself in the source.
98
100
  *
99
- * Fallback stripper: `esbuild.transform`. Triggered only when the
100
- * primary path throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` (the file
101
- * uses `enum`, `namespace`, parameter properties, or legacy
102
- * decorators). Emits an inline sourcemap so DevTools can still
103
- * resolve source positions for the regenerated JS. Mostly fires for
104
- * third-party `.ts` files; user code is enforced erasable by
105
- * `webjs check`.
101
+ * Only erasable TypeScript is supported. Non-erasable syntax (`enum`,
102
+ * `namespace` with values, parameter properties, legacy decorators
103
+ * with `emitDecoratorMetadata`, `import = require`) throws at strip
104
+ * time. The `erasable-typescript-only` and `no-non-erasable-typescript`
105
+ * lint rules catch these at edit time. webjs is buildless end-to-end:
106
+ * there is no bundler fallback.
106
107
  *
107
108
  * @type {Map<string, { mtimeMs: number, code: string, map: string | null }>}
108
109
  */
109
110
  const TS_CACHE_MAX = 500;
110
111
  const TS_CACHE = new Map();
111
112
 
113
+ /**
114
+ * Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
115
+ * what Rails / Next / Astro do out of the box: a scaffolded app with
116
+ * a committed `.env.example` and a developer-copied `.env` should
117
+ * "just work" without the user having to add a dotenv import or set
118
+ * the file path on the CLI.
119
+ *
120
+ * Uses Node 24+'s built-in `process.loadEnvFile`, which is dotenv-
121
+ * compatible and DOES NOT override pre-existing `process.env` values.
122
+ * Calls that hit a missing file or parse error are silenced; the
123
+ * server should still come up cleanly when there's no `.env`.
124
+ *
125
+ * Idempotent: re-running is a no-op for any env var the user already
126
+ * exported (e.g. via the host shell or a process manager). That
127
+ * keeps the "shell-set wins over file" precedence Rails users
128
+ * expect.
129
+ *
130
+ * Must run before any server-only module is loaded by
131
+ * buildActionIndex, since module-init code in `lib/*.server.ts`
132
+ * (e.g. `createAuth({ secret: process.env.AUTH_SECRET })`) reads
133
+ * process.env at import time. createRequestHandler is the
134
+ * single entry point where this is guaranteed.
135
+ *
136
+ * @param {string} appDir
137
+ */
138
+ function loadAppEnv(appDir) {
139
+ try {
140
+ if (typeof process.loadEnvFile === 'function') {
141
+ process.loadEnvFile(join(appDir, '.env'));
142
+ }
143
+ } catch {
144
+ // No .env file, malformed file, or Node version without
145
+ // loadEnvFile. Either way, fall through silently: the user
146
+ // may not need any env vars, or they may set them via shell.
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Read the project-level elision switch from `package.json`.
152
+ * `{ "webjs": { "elide": false } }` disables display-only and inert-route
153
+ * elision app-wide (everything ships, like before the feature existed).
154
+ * Any other value, or an absent key, leaves elision enabled (the default).
155
+ * Re-read on every rebuild so toggling the switch takes effect without a
156
+ * server restart.
157
+ * @param {string} appDir
158
+ * @returns {Promise<boolean>}
159
+ */
160
+ async function readElideEnabled(appDir) {
161
+ try {
162
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
163
+ if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
164
+ } catch {
165
+ // No package.json, malformed JSON, or unreadable. Keep the default.
166
+ }
167
+ return true;
168
+ }
169
+
112
170
  /**
113
171
  * Create a reusable, framework-agnostic request handler for a webjs app.
114
172
  * The returned `handle(req)` takes a standard `Request` and resolves to a
@@ -124,72 +182,301 @@ const TS_CACHE = new Map();
124
182
  */
125
183
  export async function createRequestHandler(opts) {
126
184
  const appDir = resolve(opts.appDir);
185
+ // Load <appDir>/.env into process.env BEFORE anything else.
186
+ // buildActionIndex below imports server-only files (lib/*.server.ts,
187
+ // modules/**/*.server.ts), some of which read process.env at module
188
+ // init (e.g. createAuth reads AUTH_SECRET). Without this call,
189
+ // scaffolded apps with a committed .env.example + .env would fail
190
+ // to boot until the user discovered the missing env-load. See
191
+ // tracker #37.
192
+ loadAppEnv(appDir);
127
193
  const dev = !!opts.dev;
128
194
  const logger = opts.logger || defaultLogger({ dev });
129
195
  const coreDir = locateCoreDir(appDir);
196
+ // Switch the importmap between dist/ bundles and src/ per-file
197
+ // URLs depending on whether the resolved @webjsdev/core install
198
+ // has built bundles on disk. npm-installed copies always do;
199
+ // workspace dev does only after `npm run build:dist`. Without
200
+ // a built dist the server falls back to the historical per-file
201
+ // src/ URLs so dev iteration does not require a build step.
202
+ //
203
+ // Both required bundles must exist. An older @webjsdev/core
204
+ // install built BEFORE the browser-entry split (#119/#128) has
205
+ // `webjs-core.js` but no `webjs-core-browser.js`. Enabling dist
206
+ // mode in that case would route the bare `@webjsdev/core`
207
+ // specifier at a 404 on every page. Require both so a partial
208
+ // dist transparently degrades to src/ mode instead.
209
+ const distDir = join(coreDir, 'dist');
210
+ const distComplete =
211
+ existsSync(join(distDir, 'webjs-core.js')) &&
212
+ existsSync(join(distDir, 'webjs-core-browser.js'));
213
+ await setCoreInstall(coreDir, distComplete);
214
+
215
+ // Whole-app analysis (module graph, component scan, browser-bound gate,
216
+ // action index, middleware, elision, vendor) is NOT run at boot. It is
217
+ // computed on the first request via ensureReady() below and memoized, so the
218
+ // server starts without walking or reading the app's source, executing any
219
+ // server module, or hitting the network. Only the route table is built
220
+ // eagerly: it is a cheap directory scan (no code reads), and routing, Early
221
+ // Hints, and WebSocket lookups need it available before the first request.
222
+ const routeTable = await buildRouteTable(appDir);
130
223
 
131
- // Scan for bare npm imports and register vendor import map entries.
132
- const bareImports = await scanBareImports(appDir);
133
- setVendorEntries(vendorImportMapEntries(bareImports));
224
+ const state = {
225
+ routeTable,
226
+ actionIndex: null,
227
+ middleware: null,
228
+ logger,
229
+ moduleGraph: null,
230
+ elidableComponents: new Set(),
231
+ inertRouteModules: new Set(),
232
+ browserBoundFiles: null,
233
+ };
134
234
 
135
- // Build module dependency graph for transitive preload hints.
136
- const moduleGraph = await buildModuleGraph(appDir);
235
+ // All whole-app analysis is built lazily on the first request, memoized so
236
+ // boot does none of it. It runs in two stages. The deterministic analysis
237
+ // (module graph, component scan + prime, browser-bound gate, action index,
238
+ // middleware, elision) is network-free and, once built, never re-runs unless
239
+ // a rebuild invalidates it; readiness gates on it. Vendor resolution is a
240
+ // SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
241
+ // an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
242
+ // offline or partially-unresolvable app still boots. A transient vendor
243
+ // failure is re-attempted on the NEXT ensureReady call (driven by an incoming
244
+ // request, a readiness probe, or the warm-up), with no background timer: the
245
+ // platform's traffic and probes are the retry loop. `readyError` holds a
246
+ // propagating analysis failure so /__webjs/ready can report it.
247
+ let analysisDone = false; // deterministic analysis complete (readiness gate)
248
+ let vendorResolved = false; // vendor map fully resolved (or permanently tolerated)
249
+ let vendorAttemptedOnce = false; // the first (blocking) vendor attempt has run
250
+ let vendorGen = 0; // bumped on rebuild; a stale resolve cannot flip vendorResolved
251
+ let readyDone = false; // mirrors analysisDone; the /__webjs/ready gate
252
+ /** @type {unknown} */
253
+ let readyError = null;
254
+ /** @type {Promise<void> | null} */
255
+ let readyInFlight = null;
256
+ async function ensureReady() {
257
+ // Fully warm: analysis done and vendor resolved. Nothing to do.
258
+ if (analysisDone && vendorResolved) return;
259
+ // Analysis warm but a prior vendor attempt failed: re-attempt WITHOUT
260
+ // blocking this request. The single-flight dedupes concurrent attempts;
261
+ // success flips the flag. This is the request/probe-driven retry (no timer).
262
+ if (analysisDone && vendorAttemptedOnce) {
263
+ const gen = vendorGen;
264
+ resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) vendorResolved = true; }).catch(() => {});
265
+ return;
266
+ }
267
+ // Otherwise run the (single-flighted) full warm: the analysis, then the
268
+ // first vendor attempt, awaited so the first response carries the import map.
269
+ if (!readyInFlight) {
270
+ readyInFlight = (async () => {
271
+ /** @type {Record<string, number>} */
272
+ const t = {};
273
+ let ranAnalysis = false, ranVendor = false;
274
+ const now = () => performance.now();
275
+ try {
276
+ if (!analysisDone) {
277
+ let m = now();
278
+ state.moduleGraph = await buildModuleGraph(appDir);
279
+ t.graph = now() - m; m = now();
280
+ const components = await scanComponents(appDir);
281
+ await primeComponentRegistry(appDir, components);
282
+ t.scan = now() - m; m = now();
283
+ state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);
284
+ t.gate = now() - m; m = now();
285
+ state.actionIndex = await buildActionIndex(appDir, dev);
286
+ t.actions = now() - m; m = now();
287
+ state.middleware = await loadMiddleware(appDir, dev, logger);
288
+ t.middleware = now() - m; m = now();
289
+ const r = (await readElideEnabled(appDir))
290
+ ? await analyzeElision(components, collectRouteModules(state.routeTable),
291
+ state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
292
+ : { elidableComponents: new Set(), inertRouteModules: new Set() };
293
+ state.elidableComponents = r.elidableComponents;
294
+ state.inertRouteModules = r.inertRouteModules;
295
+ t.elision = now() - m;
296
+ if (dev) {
297
+ for (const { className, file } of await findOrphanComponents(appDir)) {
298
+ logger.warn?.(
299
+ `[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
300
+ `Add \`customElements.define('<tag-name>', ${className});\` or <${kebab(className)}> tags won't upgrade.`,
301
+ );
302
+ }
303
+ }
304
+ analysisDone = true;
305
+ ranAnalysis = true;
306
+ }
307
+ // Readiness gates on the analysis only; vendor is best-effort below.
308
+ readyDone = true;
309
+ readyError = null;
310
+ if (!vendorResolved) {
311
+ const m = now();
312
+ const gen = vendorGen;
313
+ vendorAttemptedOnce = true;
314
+ const ok = await resolveAndApplyVendor();
315
+ t.vendor = now() - m;
316
+ ranVendor = true;
317
+ // Only memoize success (and only if a rebuild didn't intervene). A
318
+ // transient failure leaves vendorResolved false; the next ensureReady
319
+ // call re-attempts it non-blocking. A permanent unresolvable (jspm
320
+ // 401) reports ok and is tolerated, so it does not loop.
321
+ if (ok && gen === vendorGen) vendorResolved = true;
322
+ }
323
+ if (ranAnalysis) {
324
+ const ms = (x) => Math.round(x || 0);
325
+ const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
326
+ logger.info?.(
327
+ `[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
328
+ `gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
329
+ `elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
330
+ );
331
+ } else if (ranVendor && vendorResolved) {
332
+ logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
333
+ }
334
+ } catch (e) {
335
+ readyError = e;
336
+ throw e;
337
+ } finally {
338
+ readyInFlight = null;
339
+ }
340
+ })();
341
+ }
342
+ await readyInFlight;
343
+ }
137
344
 
138
- // Scan for component classes and prime their module URLs into the
139
- // core registry. SSR uses this for modulepreload hints without
140
- // requiring authors to pass `import.meta.url` themselves.
141
- await primeComponentRegistry(appDir);
345
+ // All vendor resolves funnel through one single-flight so two never overlap
346
+ // (resolveVendorImports reports a transient failure via a module-global flag
347
+ // that only one in-flight resolve may safely touch). Never rejects; returns
348
+ // the resolve's ok flag (false on a transient failure, applying whatever
349
+ // partial map resolved so the app is no worse off).
350
+ /** @type {Promise<boolean> | null} */
351
+ let vendorResolveInFlight = null;
352
+ function resolveAndApplyVendor() {
353
+ if (vendorResolveInFlight) return vendorResolveInFlight;
354
+ vendorResolveInFlight = (async () => {
355
+ try {
356
+ const v = await resolveVendorImports(appDir,
357
+ () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules])));
358
+ await setVendorEntries(v.imports, v.integrity);
359
+ return v.ok;
360
+ } catch (e) {
361
+ logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
362
+ return false;
363
+ }
364
+ })().finally(() => { vendorResolveInFlight = null; });
365
+ return vendorResolveInFlight;
366
+ }
142
367
 
143
- // Dev-time guardrail: warn about any class extending WebComponent
144
- // that isn't registered via customElements.define() in its own
145
- // module. Without registration, <my-tag> elements silently stay as
146
- // HTMLUnknownElement in the browser: a common early-stage footgun.
147
- if (dev) {
148
- const orphans = await findOrphanComponents(appDir);
149
- for (const { className, file } of orphans) {
150
- logger.warn?.(
151
- `[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
152
- `Add \`customElements.define('<tag-name>', ${className});\` at the bottom of the file ` +
153
- `or <${kebab(className)}> tags won't upgrade in the browser.`,
154
- );
368
+ // Optional app-level readiness check. A `readiness.{js,ts}` file at the app
369
+ // root may default-export an async function; /__webjs/ready runs it once the
370
+ // analysis is warm, so readiness can reflect LIVE dependency health (a DB
371
+ // ping, a queue connection) that the static analysis cannot see. Returning
372
+ // false or throwing reports the instance not ready (503), so a readinessProbe
373
+ // holds traffic off an instance whose deps are down. Absent file => analysis-
374
+ // warm is the only gate. The module is cached per build (cleared on rebuild);
375
+ // the function itself runs on every probe so it reflects current state.
376
+ let readinessFn; // undefined = unloaded, null = no file, function = loaded
377
+ async function getReadinessCheck() {
378
+ if (readinessFn !== undefined) return readinessFn;
379
+ let file = null;
380
+ for (const name of ['readiness.ts', 'readiness.js', 'readiness.mts', 'readiness.mjs']) {
381
+ const p = join(appDir, name);
382
+ if (await exists(p)) { file = p; break; }
155
383
  }
384
+ if (!file) { readinessFn = null; return null; }
385
+ try {
386
+ const url = pathToFileURL(file).toString();
387
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
388
+ const mod = await import(url + bust);
389
+ readinessFn = typeof mod.default === 'function' ? mod.default : null;
390
+ } catch (e) {
391
+ logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) });
392
+ readinessFn = null;
393
+ }
394
+ return readinessFn;
156
395
  }
157
396
 
158
- const state = {
159
- routeTable: await buildRouteTable(appDir),
160
- actionIndex: await buildActionIndex(appDir, dev),
161
- middleware: await loadMiddleware(appDir, dev, logger),
162
- logger,
163
- bareImports,
164
- moduleGraph,
165
- };
397
+ // Rebuilds are serialized so a slow rebuild #1 cannot overwrite a fresher
398
+ // rebuild #2's route table when it finally finishes. Without this, two file
399
+ // edits inside one fs.watch debounce window could produce a permanently
400
+ // stale state until the next rebuild.
401
+ let rebuildInFlight = Promise.resolve();
166
402
 
167
403
  async function rebuild() {
404
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild()).catch((e) => {
405
+ logger.error?.(`[webjs] rebuild failed:`, e);
406
+ });
407
+ return rebuildInFlight;
408
+ }
409
+
410
+ async function doRebuild() {
411
+ // The route table is the only eager artifact (cheap directory scan); rebuild
412
+ // it so routing reflects added/removed route files immediately.
168
413
  state.routeTable = await buildRouteTable(appDir);
169
- state.actionIndex = await buildActionIndex(appDir, dev);
170
- state.middleware = await loadMiddleware(appDir, dev, logger);
171
- // Re-scan bare imports and module graph on rebuild
172
414
  clearVendorCache();
173
- state.bareImports = await scanBareImports(appDir);
174
- setVendorEntries(vendorImportMapEntries(state.bareImports));
175
- state.moduleGraph = await buildModuleGraph(appDir);
176
- // Re-scan components in case a new file was added or a tag renamed.
177
- await primeComponentRegistry(appDir);
178
- if (dev) {
179
- const orphans = await findOrphanComponents(appDir);
180
- for (const { className, file } of orphans) {
181
- logger.warn?.(
182
- `[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
183
- `Add \`customElements.define('<tag-name>', ${className});\` or <${kebab(className)}> tags won't upgrade.`,
184
- );
185
- }
186
- }
415
+ TS_CACHE.clear();
416
+ // Invalidate the lazy analysis; the next request rebuilds the graph,
417
+ // component scan, gate, action index, middleware, elision, and vendor map.
418
+ // Wait out any in-flight build first so it cannot commit stale results
419
+ // after the reset. A dependency edit can flip an elision verdict without
420
+ // changing an importer's mtime, hence the TS_CACHE.clear above.
421
+ if (readyInFlight) { try { await readyInFlight; } catch {} }
422
+ // Bump the vendor generation so a vendor resolve still in flight from the
423
+ // previous build cannot flip vendorResolved against the fresh state.
424
+ vendorGen++;
425
+ analysisDone = false;
426
+ vendorResolved = false;
427
+ vendorAttemptedOnce = false;
428
+ readyDone = false;
429
+ readyError = null;
430
+ readinessFn = undefined; // reload readiness.{js,ts} after a rebuild
187
431
  opts.onReload?.();
188
432
  }
189
433
 
190
434
  /** @param {Request} req */
191
435
  function handle(req) {
192
436
  return withRequest(req, async () => {
437
+ // Health and readiness probes are answered BEFORE ensureReady so a probe
438
+ // never blocks on the analysis. `/__webjs/health` is liveness (the
439
+ // process is up and accepting connections). `/__webjs/ready` is 503 until
440
+ // the analysis is warm, then 200 unless an optional app readiness check
441
+ // (readiness.{js,ts}) reports a dependency down. So a readinessProbe holds
442
+ // traffic off a not-yet-warm or dependency-unhealthy instance. Probing
443
+ // `/__webjs/ready` also kicks off the warm in the background, so an
444
+ // embedder that never called warmup() still warms. A vendor CDN failure
445
+ // does NOT block readiness (vendor is best-effort, retried on the next request).
446
+ let probePath;
447
+ try { probePath = new URL(req.url).pathname; } catch { probePath = ''; }
448
+ if (probePath === '/__webjs/health') {
449
+ return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
450
+ }
451
+ if (probePath === '/__webjs/ready') {
452
+ const noStore = { 'cache-control': 'no-store' };
453
+ if (!readyDone) {
454
+ ensureReady().catch(() => {}); // drive the warm; never block the probe
455
+ const body = readyError
456
+ ? { status: 'error', error: String((readyError && readyError.message) || readyError) }
457
+ : { status: 'pending' };
458
+ return Response.json(body, { status: 503, headers: noStore });
459
+ }
460
+ // Analysis is warm. Consult the optional app readiness check (live
461
+ // dependency health, e.g. a DB ping) if the app provides one.
462
+ const check = await getReadinessCheck();
463
+ if (check) {
464
+ try {
465
+ if ((await check()) === false) {
466
+ return Response.json({ status: 'unready' }, { status: 503, headers: noStore });
467
+ }
468
+ } catch (e) {
469
+ return Response.json(
470
+ { status: 'unready', error: String((e && e.message) || e) },
471
+ { status: 503, headers: noStore },
472
+ );
473
+ }
474
+ }
475
+ return Response.json({ status: 'ok' }, { headers: noStore });
476
+ }
477
+ // Build all whole-app analysis on the first request (memoized), before
478
+ // any SSR, module serve, gate check, action dispatch, or middleware runs.
479
+ await ensureReady();
193
480
  const next = () => handleCore(req, { state, appDir, coreDir, dev });
194
481
  if (state.middleware) {
195
482
  try {
@@ -224,6 +511,21 @@ export async function createRequestHandler(opts) {
224
511
  handle,
225
512
  rebuild,
226
513
  routeFor,
514
+ /**
515
+ * Proactively run the first-request analysis (module graph, component
516
+ * scan, gate, action index, middleware, elision, vendor map) in the
517
+ * background, so a real first request finds it already memoized. Safe to
518
+ * call any number of times and concurrently: the work is single-flighted,
519
+ * so this never duplicates it or races a real request. It is a single
520
+ * best-effort kick: errors are caught and logged rather than thrown (a
521
+ * background warm-up must not crash the process), and whatever failed simply
522
+ * re-runs on the next request or readiness probe (the platform's traffic and
523
+ * probes are the retry loop, so there is no internal backoff). `startServer`
524
+ * calls this once the HTTP server is listening; embedders can call it after
525
+ * their own listen.
526
+ * @returns {Promise<void>}
527
+ */
528
+ warmup: () => ensureReady().catch((e) => logger.error?.(`[webjs] background warm-up failed (will retry on the next request):`, e)),
227
529
  /** current route table getter: used by the WebSocket subsystem */
228
530
  getRouteTable: () => state.routeTable,
229
531
  appDir,
@@ -267,16 +569,41 @@ export async function startServer(opts) {
267
569
  },
268
570
  });
269
571
 
572
+ /** @type {AbortController | null} */
573
+ let watcherAbort = null;
270
574
  if (dev) {
271
- const { watch } = await import('chokidar').catch(() => ({ watch: null }));
272
- if (watch) {
273
- const watcher = watch(app.appDir, {
274
- ignored: [/node_modules/, /\.git/, /prisma\/(dev|migrations)/],
275
- ignoreInitial: true,
276
- });
277
- const rebuild = debounce(() => app.rebuild(), 80);
278
- watcher.on('all', rebuild);
279
- }
575
+ // Watch the app root recursively via Node's built-in
576
+ // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
577
+ // Node 24. No external dep needed.
578
+ //
579
+ // fs.watch returns relative paths in event.filename. We apply
580
+ // the same ignore filter chokidar used before: skip
581
+ // node_modules, .git, and prisma's dev artefacts (dev.db,
582
+ // dev.db-journal, migrations/) which the dev server writes
583
+ // during db:migrate and would otherwise loop.
584
+ //
585
+ // The prisma branch uses prefix-only matching (no required
586
+ // trailing separator) so the SQLite sidecar files like
587
+ // `prisma/dev.db` and `prisma/dev.db-journal` are ignored too.
588
+ // node_modules / .git stay separator-anchored so unrelated
589
+ // names like `node_modules.bak/foo` don't get caught.
590
+ const IGNORE = /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/;
591
+ const rebuild = debounce(() => app.rebuild(), 80);
592
+ watcherAbort = new AbortController();
593
+ (async () => {
594
+ try {
595
+ const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
596
+ for await (const event of events) {
597
+ const filename = event.filename || '';
598
+ if (IGNORE.test(filename)) continue;
599
+ rebuild();
600
+ }
601
+ } catch (err) {
602
+ if (err && /** @type any */(err).name !== 'AbortError') {
603
+ logger.warn({ err }, 'file watcher exited');
604
+ }
605
+ }
606
+ })();
280
607
  }
281
608
 
282
609
  // SSE keepalive: send a comment frame every 25s to defeat proxy idle timeouts.
@@ -343,6 +670,11 @@ export async function startServer(opts) {
343
670
 
344
671
  server.listen(port, () => {
345
672
  logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
673
+ // The server is now accepting connections; warm the first-request analysis
674
+ // in the background so a real first request finds it memoized. Fire-and-
675
+ // forget: listening (and thus readiness probes / load-balancer health) does
676
+ // not wait on it, and a failure here does not bring the process down.
677
+ app.warmup();
346
678
  });
347
679
 
348
680
  const shutdown = gracefulShutdown(server, sseClients, logger);
@@ -354,7 +686,13 @@ export async function startServer(opts) {
354
686
  // corrupted, so log + start an orderly shutdown rather than continuing.
355
687
  installProcessHandlers(logger, () => shutdown('uncaughtException'));
356
688
 
357
- return { server, close: () => new Promise((r) => server.close(() => r())) };
689
+ return {
690
+ server,
691
+ close: () => new Promise((r) => {
692
+ if (watcherAbort) watcherAbort.abort();
693
+ server.close(() => r());
694
+ }),
695
+ };
358
696
  }
359
697
 
360
698
  /**
@@ -375,10 +713,8 @@ async function handleCore(req, ctx) {
375
713
  try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
376
714
  const method = req.method.toUpperCase();
377
715
 
378
- // Health / readiness probes for orchestrators (k8s, fly, etc.)
379
- if (path === '/__webjs/health' || path === '/__webjs/ready') {
380
- return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
381
- }
716
+ // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) are handled
717
+ // in `handle()` BEFORE ensureReady, so they are not repeated here.
382
718
 
383
719
  // Dev live-reload client
384
720
  if (path === '/__webjs/reload.js') {
@@ -408,12 +744,31 @@ async function handleCore(req, ctx) {
408
744
  return fileResponse(abs, { dev, immutable: false });
409
745
  }
410
746
 
411
- // Vendor bundles: /__webjs/vendor/<pkg>.js: generic auto-bundler
412
- // (Vite-style optimizeDeps) for any bare npm import that webjs can't
413
- // serve directly as ESM.
747
+ // Vendor URL handler for `webjs vendor pin --download` mode only.
748
+ // In default pin mode (or no-pin mode) the importmap routes bare
749
+ // imports straight to ga.jspm.io URLs and the browser bypasses this
750
+ // server entirely. When the user ran `webjs vendor pin --download`,
751
+ // the importmap has local `/__webjs/vendor/<file>.js` URLs and this
752
+ // handler serves the committed bundle files from `.webjs/vendor/`.
414
753
  if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
415
- const pkgName = decodeURIComponent(path.slice('/__webjs/vendor/'.length, -'.js'.length));
416
- return serveVendorBundle(pkgName, appDir, dev);
754
+ // Vendor bundles are read-only static content. Allow GET/HEAD for
755
+ // the normal fetch, OPTIONS for any cross-origin preflight (we
756
+ // return 204 with the same Allow header rather than 405, which
757
+ // some intermediaries treat as a hard failure even for a CORS
758
+ // probe), and 405 everything else.
759
+ if (method === 'OPTIONS') {
760
+ return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
761
+ }
762
+ if (method !== 'GET' && method !== 'HEAD') {
763
+ return new Response(null, { status: 405, headers: { allow: 'GET, HEAD, OPTIONS' } });
764
+ }
765
+ const filename = path.slice('/__webjs/vendor/'.length);
766
+ const resp = await serveDownloadedBundle(filename, appDir, dev);
767
+ if (method === 'HEAD') {
768
+ // HEAD must return same headers as GET with no body.
769
+ return new Response(null, { status: resp.status, headers: resp.headers });
770
+ }
771
+ return resp;
417
772
  }
418
773
 
419
774
  // Internal server-action RPC endpoint
@@ -450,10 +805,32 @@ async function handleCore(req, ctx) {
450
805
  if (path.startsWith('/public/') || path === '/favicon.ico') {
451
806
  const p = path === '/favicon.ico' ? '/public/favicon.ico' : path;
452
807
  const abs = join(appDir, p);
808
+ // Containment check. `join` normalises `..` segments, so a path
809
+ // like `/public/%2E%2E/secret/x.svg` decodes (after URL parsing,
810
+ // which doesn't touch `%2E`) to `/public/../secret/x.svg` and
811
+ // `join(appDir, ...)` resolves it to `appDir/secret/x.svg`. The
812
+ // resulting `abs` could be inside `appDir` but OUTSIDE `appDir/
813
+ // public/`, exposing files the user reasonably thought were
814
+ // private under their non-public directories. Reject anything
815
+ // that doesn't stay under `appDir/public/` (and the favicon
816
+ // exception, which is already validated above).
817
+ const publicRoot = join(appDir, 'public') + sep;
818
+ if (!abs.startsWith(publicRoot)) {
819
+ return new Response(null, { status: 404 });
820
+ }
453
821
  if (await exists(abs)) return fileResponse(abs, { dev, immutable: false });
454
822
  }
455
823
 
456
- // User source modules (served as ES modules, with action-file rewriting)
824
+ // User source modules (served as ES modules, with action-file rewriting).
825
+ //
826
+ // Authorization gate: only files reachable from a browser-bound entry
827
+ // (page, layout, error, loading, not-found, component) via the module
828
+ // graph are servable. Same posture as Next.js, where the bundler's
829
+ // manifest is the source of truth for what the browser may fetch.
830
+ // Anything not in the set (node_modules/, top-level package.json,
831
+ // scripts/, etc.) 404s here regardless of whether the file exists on
832
+ // disk. The `.server.{js,ts}` stub guardrail runs below as a
833
+ // defense-in-depth layer.
457
834
  if (method === 'GET' && /\.(js|mjs|ts|mts|css|svg|png|jpg|jpeg|gif|webp|json|ico|txt)$/.test(path)) {
458
835
  let abs = join(appDir, path);
459
836
  // When the browser asks for `.js`, allow falling through to a sibling
@@ -466,12 +843,18 @@ async function handleCore(req, ctx) {
466
843
  if (await exists(mtsAbs)) abs = mtsAbs;
467
844
  }
468
845
  }
469
- if (abs.startsWith(appDir) && (await exists(abs))) {
846
+ // Gate: must be in the browser-bound module graph. Server-action
847
+ // files (.server.{js,ts}) get a stub via the guardrail below; they
848
+ // ARE included in browserBoundFiles because client code imports
849
+ // them by path (the import rewrites to an RPC stub at request time).
850
+ const inGraph = state.browserBoundFiles && state.browserBoundFiles.has(abs);
851
+ if (abs.startsWith(appDir) && inGraph && (await exists(abs))) {
470
852
  // Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
471
853
  // MUST NEVER be served as source to the browser. The extension is
472
854
  // the path-level boundary; we re-verify it on every request (not
473
- // just the action-index snapshot taken at boot) so files created
474
- // after boot, FS races, or developer error never punch through.
855
+ // just rely on the action-index snapshot, which is built on the first
856
+ // request and refreshed on rebuild) so files created later, FS races,
857
+ // or developer error never punch through.
475
858
  //
476
859
  // What the browser gets depends on the file's `'use server'` status:
477
860
  // - With `'use server'` => server action: a generated RPC stub
@@ -484,7 +867,7 @@ async function handleCore(req, ctx) {
484
867
  // Lazily ensure the index knows about this file so serveActionStub
485
868
  // can mint a stable hash and function list.
486
869
  if (!state.actionIndex.fileToHash.has(abs)) {
487
- const h = hashFile(abs);
870
+ const h = await hashFile(abs);
488
871
  state.actionIndex.fileToHash.set(abs, h);
489
872
  state.actionIndex.hashToFile.set(h, abs);
490
873
  }
@@ -499,9 +882,19 @@ async function handleCore(req, ctx) {
499
882
  headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
500
883
  });
501
884
  }
502
- // TypeScript source: esbuild-strip types, cache by mtime.
885
+ // TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
886
+ // Both module paths also strip side-effect imports of display-only
887
+ // components so the browser never downloads their JS.
888
+ const elideOpts = {
889
+ moduleGraph: state.moduleGraph,
890
+ elidableComponents: state.elidableComponents,
891
+ appDir,
892
+ };
503
893
  if (/\.m?ts$/.test(abs)) {
504
- return tsResponse(abs, dev);
894
+ return tsResponse(abs, dev, elideOpts);
895
+ }
896
+ if (/\.m?js$/.test(abs)) {
897
+ return jsModuleResponse(abs, dev, elideOpts);
505
898
  }
506
899
  return fileResponse(abs, { dev, immutable: false });
507
900
  }
@@ -547,6 +940,8 @@ async function handleCore(req, ctx) {
547
940
  const handler = () => ssrPage(page.route, page.params, url, {
548
941
  dev, appDir, req, moduleGraph: state.moduleGraph,
549
942
  serverFiles: state.actionIndex.fileToHash,
943
+ elidableComponents: state.elidableComponents,
944
+ inertRouteModules: state.inertRouteModules,
550
945
  });
551
946
  return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
552
947
  }
@@ -693,12 +1088,23 @@ function toWebRequest(req, url) {
693
1088
  /** @type {Record<string,string>} */
694
1089
  const headers = {};
695
1090
  for (const [k, v] of Object.entries(req.headers)) {
696
- // Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`) -
697
- // they're parsed separately into req.method / req.url and are rejected
1091
+ // Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`).
1092
+ // They're parsed separately into req.method / req.url and are rejected
698
1093
  // by the standard Headers class if we pass them through verbatim.
699
1094
  if (k.startsWith(':')) continue;
1095
+ // Strip any inbound `x-webjs-remote-ip` header so clients cannot
1096
+ // spoof the framework-stamped client IP that rate-limit's
1097
+ // `clientIp(req, { trustProxy: false })` reads. We rewrite it
1098
+ // below from the actual TCP socket. Node's IncomingMessage
1099
+ // always lowercases header keys, so a literal compare is enough.
1100
+ if (k === 'x-webjs-remote-ip') continue;
700
1101
  headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
701
1102
  }
1103
+ // Stamp the framework-trusted remote IP from the socket. Read by
1104
+ // `clientIp(req)` (rate-limit.js) as the bucket key when
1105
+ // `trustProxy: false` (the safe default).
1106
+ const remoteIp = req.socket?.remoteAddress;
1107
+ if (remoteIp) headers['x-webjs-remote-ip'] = remoteIp;
702
1108
  let body;
703
1109
  if (method !== 'GET' && method !== 'HEAD') {
704
1110
  body = new ReadableStream({
@@ -800,7 +1206,7 @@ async function fileResponse(abs, opts) {
800
1206
  if (opts.dev) {
801
1207
  headers['cache-control'] = 'no-cache';
802
1208
  } else {
803
- const etag = `"${createHash('sha1').update(data).digest('hex').slice(0, 16)}"`;
1209
+ const etag = `"${(await digestHex('SHA-1', data)).slice(0, 16)}"`;
804
1210
  headers['etag'] = etag;
805
1211
  headers['cache-control'] = opts.immutable
806
1212
  ? 'public, max-age=31536000, immutable'
@@ -812,54 +1218,72 @@ async function fileResponse(abs, opts) {
812
1218
  }
813
1219
  }
814
1220
 
1221
+ /**
1222
+ * Serve a plain `.js` / `.mjs` browser module, stripping side-effect
1223
+ * imports of display-only components. Mirrors {@link fileResponse}'s
1224
+ * headers but reads as text so the source can be transformed. Used only
1225
+ * for files that exist as `.js` on disk (TS apps usually hit
1226
+ * {@link tsResponse} via the .js to .ts sibling rewrite instead).
1227
+ *
1228
+ * @param {string} abs
1229
+ * @param {boolean} dev
1230
+ * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
1231
+ */
1232
+ async function jsModuleResponse(abs, dev, elideOpts) {
1233
+ let source;
1234
+ try { source = await readFile(abs, 'utf8'); }
1235
+ catch { return new Response('Not found', { status: 404 }); }
1236
+ const code = elideImportsFromSource(
1237
+ source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
1238
+ );
1239
+ const headers = { 'content-type': 'application/javascript; charset=utf-8' };
1240
+ if (dev) {
1241
+ headers['cache-control'] = 'no-cache';
1242
+ } else {
1243
+ headers['etag'] = `"${(await digestHex('SHA-1', code)).slice(0, 16)}"`;
1244
+ headers['cache-control'] = 'public, max-age=3600';
1245
+ }
1246
+ return new Response(code, { status: 200, headers });
1247
+ }
1248
+
815
1249
  async function exists(p) {
816
1250
  try { await stat(p); return true; } catch { return false; }
817
1251
  }
818
1252
 
819
1253
  /**
820
- * Strip TypeScript types from `source`, using Node's built-in
821
- * `module.stripTypeScriptTypes` first (whitespace replacement,
822
- * position-preserving, no sourcemap needed) and falling back to
823
- * esbuild for files using non-erasable syntax (`enum`, `namespace`,
824
- * parameter properties, legacy decorators).
1254
+ * Strip TypeScript types from `source` via Node's built-in
1255
+ * `module.stripTypeScriptTypes`. Position-preserving whitespace
1256
+ * replacement: no sourcemap is needed because every (line, column)
1257
+ * maps to itself in the source.
825
1258
  *
826
- * The framework's own code and the user's app code are kept on
827
- * erasable TS by the `erasable-typescript-only` convention check.
828
- * The fallback exists for third-party `.ts` files that the runtime
829
- * occasionally needs to serve.
1259
+ * Only erasable TypeScript is supported. Non-erasable syntax
1260
+ * (`enum`, `namespace` with values, parameter properties, legacy
1261
+ * decorators with `emitDecoratorMetadata`, `import = require`)
1262
+ * throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` from Node and the
1263
+ * dev server returns the error to the caller. The
1264
+ * `erasable-typescript-only` and `no-non-erasable-typescript` lint
1265
+ * rules catch these at edit time. There is no bundler fallback;
1266
+ * webjs is buildless end-to-end.
830
1267
  *
831
1268
  * @param {string} source
832
- * @param {string} abs
1269
+ * @param {string} _abs (unused; preserved for symmetry with prior signature)
833
1270
  * @returns {Promise<string>}
834
1271
  */
835
- async function stripTs(source, abs) {
836
- try {
837
- return stripTypeScriptTypes(source);
838
- } catch (err) {
839
- if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
840
- const { transform: esbuild } = await loadEsbuild();
841
- const r = await esbuild(source, {
842
- loader: 'ts',
843
- format: 'esm',
844
- target: 'es2022',
845
- sourcemap: 'inline',
846
- sourcefile: abs,
847
- });
848
- return r.code;
849
- }
850
- throw err;
851
- }
1272
+ async function stripTs(source, _abs) {
1273
+ return stripTypeScriptTypes(source);
852
1274
  }
853
1275
 
854
1276
  /**
855
1277
  * Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
856
1278
  * Result is cached by mtime so subsequent requests are instant; a
857
- * file edit invalidates naturally.
1279
+ * file edit invalidates naturally. `elideOpts` additionally strips
1280
+ * side-effect imports of display-only components from the served code.
858
1281
  *
859
1282
  * @param {string} abs
860
1283
  * @param {boolean} dev
1284
+ * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
861
1285
  */
862
- async function tsResponse(abs, dev) {
1286
+ async function tsResponse(abs, dev, elideOpts) {
863
1287
  const st = await stat(abs);
864
1288
  const cached = TS_CACHE.get(abs);
865
1289
  if (cached && cached.mtimeMs === st.mtimeMs) {
@@ -871,7 +1295,48 @@ async function tsResponse(abs, dev) {
871
1295
  });
872
1296
  }
873
1297
  const source = await readFile(abs, 'utf8');
874
- const code = await stripTs(source, abs);
1298
+ let code;
1299
+ try {
1300
+ code = await stripTs(source, abs);
1301
+ } catch (err) {
1302
+ // Node's stripTypeScriptTypes throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
1303
+ // for enum, namespace with values, parameter properties, legacy
1304
+ // decorators with emitDecoratorMetadata, and import = require.
1305
+ // Return a clean 500 with the file path and a pointer at the
1306
+ // erasable-typescript-only lint rule rather than letting the
1307
+ // error bubble up unstyled.
1308
+ if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
1309
+ // Log full detail server-side regardless of mode so operators
1310
+ // see what went wrong in their logs.
1311
+ // eslint-disable-next-line no-console
1312
+ console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
1313
+ const msg = dev
1314
+ // Dev: include the file path and Node's error message so the
1315
+ // developer's browser tooling can point them at the offending
1316
+ // construct. Replace `*` + `/` with `*\\/` so a path or
1317
+ // message containing the comment-close sequence cannot
1318
+ // terminate the wrapper comment early.
1319
+ ? `[webjs] non-erasable TypeScript in ${abs}: ${err.message}\n\n` +
1320
+ `webjs is buildless: only erasable TS syntax is supported. ` +
1321
+ `Replace enum / namespace / parameter-property / legacy-decorator / ` +
1322
+ `import = require constructs with their erasable equivalents. ` +
1323
+ `Run \`webjs check\` for guidance (no-non-erasable-typescript rule).`
1324
+ // Prod: terse, no path leak, no Node-message leak (Node's
1325
+ // message can include source snippets). Operators get the
1326
+ // detail in server logs above.
1327
+ : `[webjs] server error transforming a .ts response. Check server logs.`;
1328
+ return new Response(`/* ${msg.replace(/\*\//g, '*\\/')} */`, {
1329
+ status: 500,
1330
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
1331
+ });
1332
+ }
1333
+ throw err;
1334
+ }
1335
+ if (elideOpts) {
1336
+ code = elideImportsFromSource(
1337
+ code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
1338
+ );
1339
+ }
875
1340
  // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
876
1341
  if (TS_CACHE.size >= TS_CACHE_MAX) {
877
1342
  const oldest = TS_CACHE.keys().next().value;
@@ -894,6 +1359,81 @@ function debounce(fn, ms) {
894
1359
  };
895
1360
  }
896
1361
 
1362
+ /**
1363
+ * Walk the route table + component scanner to collect every file the
1364
+ * browser may legitimately fetch as an ES module, then expand via the
1365
+ * module graph into the full transitive closure.
1366
+ *
1367
+ * This is webjs's equivalent of Next.js's bundler-produced page
1368
+ * manifest, derived lazily on the first request (and re-derived on every
1369
+ * rebuild) instead of at compile time. The dev server's source-file branch uses the returned
1370
+ * Set as an authorization gate: in-set → served (subject to the
1371
+ * .server.{js,ts} stub guardrail); out-of-set → 404.
1372
+ *
1373
+ * Browser-bound entries:
1374
+ * - page.{js,ts,mjs,mts} (re-runs on client for hydration)
1375
+ * - layout.{js,ts,mjs,mts} (same)
1376
+ * - error.{js,ts,mjs,mts} (same)
1377
+ * - loading.{js,ts,mjs,mts} (same)
1378
+ * - not-found.{js,ts,mjs,mts} (same)
1379
+ * - component files discovered by the scanner (eager + lazy)
1380
+ *
1381
+ * Server-only entries (NOT in the set):
1382
+ * - route.{js,ts} (API handlers, never fetched as JS module)
1383
+ * - middleware.{js,ts}
1384
+ * - metadata routes (sitemap.js, robots.js, manifest.js, …)
1385
+ * - .server.{js,ts} files (browser gets a stub, not the source)
1386
+ *
1387
+ * Components are passed in (rather than rescanned) so the caller can
1388
+ * share one scan with `primeComponentRegistry`. Saves a full
1389
+ * appDir walk on each analysis (the first request and every rebuild).
1390
+ *
1391
+ * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
1392
+ * @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
1393
+ * @param {Awaited<ReturnType<typeof scanComponents>>} components
1394
+ * @param {string} appDir
1395
+ * @returns {Set<string>}
1396
+ */
1397
+ /**
1398
+ * Collect every page + layout file across the route table. These are the
1399
+ * modules the client boot script imports, and thus the candidates for
1400
+ * inert-route elision (dropping a module that does no client work).
1401
+ * `route.{js,ts}` / middleware / metadata are excluded: they never ship.
1402
+ *
1403
+ * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
1404
+ * @returns {string[]}
1405
+ */
1406
+ function collectRouteModules(routeTable) {
1407
+ /** @type {Set<string>} */
1408
+ const mods = new Set();
1409
+ for (const page of routeTable.pages || []) {
1410
+ if (page.file) mods.add(page.file);
1411
+ for (const f of page.layouts || []) mods.add(f);
1412
+ }
1413
+ return [...mods];
1414
+ }
1415
+
1416
+ function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
1417
+ /** @type {Set<string>} */
1418
+ const entries = new Set();
1419
+ for (const page of routeTable.pages) {
1420
+ if (page.file) entries.add(page.file);
1421
+ for (const f of page.layouts || []) entries.add(f);
1422
+ for (const f of page.errors || []) entries.add(f);
1423
+ for (const f of page.loadings || []) entries.add(f);
1424
+ }
1425
+ if (routeTable.notFound) entries.add(routeTable.notFound);
1426
+ if (routeTable.notFounds) {
1427
+ for (const f of routeTable.notFounds.values()) entries.add(f);
1428
+ }
1429
+ // Lazy components live in the registry but no page imports their
1430
+ // class directly; the lazy-loader fetches their module URLs on
1431
+ // viewport entry. Add every discovered component file as an entry so
1432
+ // the graph walk covers both eager and lazy paths.
1433
+ for (const c of components) entries.add(c.file);
1434
+ return reachableFromEntries(moduleGraph, [...entries], appDir);
1435
+ }
1436
+
897
1437
  /**
898
1438
  * Find the absolute directory of the `@webjsdev/core` package, regardless of
899
1439
  * whether we're running from the monorepo or an installed copy.
@@ -932,20 +1472,6 @@ function locatePackageDir(appDir, pkgName) {
932
1472
  return null;
933
1473
  }
934
1474
 
935
- /**
936
- * Load esbuild. Resolved as a real dependency of `@webjsdev/server`,
937
- * so the bare specifier always resolves regardless of where the cli is
938
- * installed (global, local, workspace-linked).
939
- *
940
- * @returns {Promise<typeof import('esbuild')>}
941
- */
942
- let _esbuild = null;
943
- async function loadEsbuild() {
944
- if (_esbuild) return _esbuild;
945
- _esbuild = await import('esbuild');
946
- return _esbuild;
947
- }
948
-
949
1475
  const RELOAD_CLIENT_JS = `// webjs dev reload client
950
1476
  const es = new EventSource('/__webjs/events');
951
1477
  es.addEventListener('reload', () => location.reload());