@webjsdev/server 0.7.3 → 0.8.0

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,21 +182,72 @@ 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);
130
-
131
- // Scan for bare npm imports and register vendor import map entries.
132
- const bareImports = await scanBareImports(appDir);
133
- setVendorEntries(vendorImportMapEntries(bareImports));
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);
134
214
 
135
215
  // Build module dependency graph for transitive preload hints.
136
216
  const moduleGraph = await buildModuleGraph(appDir);
137
217
 
138
218
  // Scan for component classes and prime their module URLs into the
139
219
  // core registry. SSR uses this for modulepreload hints without
140
- // requiring authors to pass `import.meta.url` themselves.
141
- await primeComponentRegistry(appDir);
220
+ // requiring authors to pass `import.meta.url` themselves. The same
221
+ // scan result feeds the browser-bound graph computation below,
222
+ // avoiding a duplicate appDir walk at boot.
223
+ const components = await scanComponents(appDir);
224
+ await primeComponentRegistry(appDir, components);
225
+
226
+ const routeTable = await buildRouteTable(appDir);
227
+
228
+ // Determine which component modules are display-only and which page/layout
229
+ // route modules are inert, so both can be elided from the browser (no JS
230
+ // download). Static analysis only; the sets bias conservatively toward
231
+ // shipping. See component-elision.js. The project-level `webjs.elide: false`
232
+ // switch in package.json skips the analysis entirely (empty sets, so nothing
233
+ // is stripped and the importmap keeps every vendor dep).
234
+ const elideEnabled = await readElideEnabled(appDir);
235
+ const { elidableComponents, inertRouteModules } = elideEnabled
236
+ ? await analyzeElision(
237
+ components,
238
+ collectRouteModules(routeTable),
239
+ moduleGraph,
240
+ (f) => readFile(f, 'utf8'),
241
+ appDir,
242
+ )
243
+ : { elidableComponents: new Set(), inertRouteModules: new Set() };
244
+
245
+ // Scan for bare npm imports and register vendor import map entries.
246
+ // Runs AFTER elision so vendor deps reachable only through display-only
247
+ // components are excluded from the importmap.
248
+ const bareImports = await scanBareImports(appDir, new Set([...elidableComponents, ...inertRouteModules]));
249
+ const initialVendor = await resolveVendorImports(bareImports, appDir);
250
+ await setVendorEntries(initialVendor.imports, initialVendor.integrity);
142
251
 
143
252
  // Dev-time guardrail: warn about any class extending WebComponent
144
253
  // that isn't registered via customElements.define() in its own
@@ -156,25 +265,81 @@ export async function createRequestHandler(opts) {
156
265
  }
157
266
 
158
267
  const state = {
159
- routeTable: await buildRouteTable(appDir),
268
+ routeTable,
160
269
  actionIndex: await buildActionIndex(appDir, dev),
161
270
  middleware: await loadMiddleware(appDir, dev, logger),
162
271
  logger,
163
272
  bareImports,
164
273
  moduleGraph,
274
+ elidableComponents,
275
+ inertRouteModules,
276
+ browserBoundFiles: computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir),
165
277
  };
166
278
 
279
+ // Rebuilds are serialized so a slow rebuild #1 (e.g. waiting on a
280
+ // jspm.io fetch) cannot overwrite a fresher rebuild #2's
281
+ // setVendorEntries / route table when it finally finishes. Without
282
+ // this, two file edits inside one fs.watch debounce window could
283
+ // produce a permanently-stale importmap until the next rebuild.
284
+ // Each rebuild also gets a monotonic token; setVendorEntries is only
285
+ // applied if its token still matches the latest scheduled rebuild.
286
+ let rebuildInFlight = Promise.resolve();
287
+ let latestRebuildToken = 0;
288
+
167
289
  async function rebuild() {
290
+ const token = ++latestRebuildToken;
291
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild(token)).catch((e) => {
292
+ logger.error?.(`[webjs] rebuild failed:`, e);
293
+ });
294
+ return rebuildInFlight;
295
+ }
296
+
297
+ async function doRebuild(token) {
168
298
  state.routeTable = await buildRouteTable(appDir);
169
299
  state.actionIndex = await buildActionIndex(appDir, dev);
170
300
  state.middleware = await loadMiddleware(appDir, dev, logger);
171
- // Re-scan bare imports and module graph on rebuild
172
301
  clearVendorCache();
173
- state.bareImports = await scanBareImports(appDir);
174
- setVendorEntries(vendorImportMapEntries(state.bareImports));
175
302
  state.moduleGraph = await buildModuleGraph(appDir);
176
303
  // Re-scan components in case a new file was added or a tag renamed.
177
- await primeComponentRegistry(appDir);
304
+ // Share the scan with the browser-bound graph computation so we
305
+ // don't walk appDir twice per rebuild.
306
+ const components = await scanComponents(appDir);
307
+ await primeComponentRegistry(appDir, components);
308
+ // Recompute which components are elidable and which route modules are
309
+ // inert. A dependency's edit can flip a verdict WITHOUT changing an
310
+ // importer's mtime, so the TS transform cache (keyed by mtime) must be
311
+ // dropped or it would serve a stale strip decision for the unchanged
312
+ // importer.
313
+ {
314
+ const r = (await readElideEnabled(appDir))
315
+ ? await analyzeElision(
316
+ components,
317
+ collectRouteModules(state.routeTable),
318
+ state.moduleGraph,
319
+ (f) => readFile(f, 'utf8'),
320
+ appDir,
321
+ )
322
+ : { elidableComponents: new Set(), inertRouteModules: new Set() };
323
+ state.elidableComponents = r.elidableComponents;
324
+ state.inertRouteModules = r.inertRouteModules;
325
+ }
326
+ TS_CACHE.clear();
327
+ // Re-scan bare imports AFTER elision so the importmap drops vendor
328
+ // deps reachable only through display-only components.
329
+ state.bareImports = await scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules]));
330
+ const v = await resolveVendorImports(state.bareImports, appDir);
331
+ // Defensive: if a newer rebuild has been queued while we were
332
+ // awaiting resolveVendorImports, drop our result. The newer one
333
+ // will overwrite anyway, but checking the token here avoids a
334
+ // brief window of stale entries.
335
+ if (token === latestRebuildToken) {
336
+ await setVendorEntries(v.imports, v.integrity);
337
+ }
338
+ // Recompute the browser-bound file set: the page / layout / error /
339
+ // loading / not-found / component entries plus their transitive imports.
340
+ // This drives the dev server's "is this file allowed to be served as
341
+ // source?" gate at the file-extension catch-all branch below.
342
+ state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);
178
343
  if (dev) {
179
344
  const orphans = await findOrphanComponents(appDir);
180
345
  for (const { className, file } of orphans) {
@@ -267,16 +432,41 @@ export async function startServer(opts) {
267
432
  },
268
433
  });
269
434
 
435
+ /** @type {AbortController | null} */
436
+ let watcherAbort = null;
270
437
  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
- }
438
+ // Watch the app root recursively via Node's built-in
439
+ // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
440
+ // Node 24. No external dep needed.
441
+ //
442
+ // fs.watch returns relative paths in event.filename. We apply
443
+ // the same ignore filter chokidar used before: skip
444
+ // node_modules, .git, and prisma's dev artefacts (dev.db,
445
+ // dev.db-journal, migrations/) which the dev server writes
446
+ // during db:migrate and would otherwise loop.
447
+ //
448
+ // The prisma branch uses prefix-only matching (no required
449
+ // trailing separator) so the SQLite sidecar files like
450
+ // `prisma/dev.db` and `prisma/dev.db-journal` are ignored too.
451
+ // node_modules / .git stay separator-anchored so unrelated
452
+ // names like `node_modules.bak/foo` don't get caught.
453
+ const IGNORE = /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/;
454
+ const rebuild = debounce(() => app.rebuild(), 80);
455
+ watcherAbort = new AbortController();
456
+ (async () => {
457
+ try {
458
+ const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
459
+ for await (const event of events) {
460
+ const filename = event.filename || '';
461
+ if (IGNORE.test(filename)) continue;
462
+ rebuild();
463
+ }
464
+ } catch (err) {
465
+ if (err && /** @type any */(err).name !== 'AbortError') {
466
+ logger.warn({ err }, 'file watcher exited');
467
+ }
468
+ }
469
+ })();
280
470
  }
281
471
 
282
472
  // SSE keepalive: send a comment frame every 25s to defeat proxy idle timeouts.
@@ -354,7 +544,13 @@ export async function startServer(opts) {
354
544
  // corrupted, so log + start an orderly shutdown rather than continuing.
355
545
  installProcessHandlers(logger, () => shutdown('uncaughtException'));
356
546
 
357
- return { server, close: () => new Promise((r) => server.close(() => r())) };
547
+ return {
548
+ server,
549
+ close: () => new Promise((r) => {
550
+ if (watcherAbort) watcherAbort.abort();
551
+ server.close(() => r());
552
+ }),
553
+ };
358
554
  }
359
555
 
360
556
  /**
@@ -408,12 +604,31 @@ async function handleCore(req, ctx) {
408
604
  return fileResponse(abs, { dev, immutable: false });
409
605
  }
410
606
 
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.
607
+ // Vendor URL handler for `webjs vendor pin --download` mode only.
608
+ // In default pin mode (or no-pin mode) the importmap routes bare
609
+ // imports straight to ga.jspm.io URLs and the browser bypasses this
610
+ // server entirely. When the user ran `webjs vendor pin --download`,
611
+ // the importmap has local `/__webjs/vendor/<file>.js` URLs and this
612
+ // handler serves the committed bundle files from `.webjs/vendor/`.
414
613
  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);
614
+ // Vendor bundles are read-only static content. Allow GET/HEAD for
615
+ // the normal fetch, OPTIONS for any cross-origin preflight (we
616
+ // return 204 with the same Allow header rather than 405, which
617
+ // some intermediaries treat as a hard failure even for a CORS
618
+ // probe), and 405 everything else.
619
+ if (method === 'OPTIONS') {
620
+ return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
621
+ }
622
+ if (method !== 'GET' && method !== 'HEAD') {
623
+ return new Response(null, { status: 405, headers: { allow: 'GET, HEAD, OPTIONS' } });
624
+ }
625
+ const filename = path.slice('/__webjs/vendor/'.length);
626
+ const resp = await serveDownloadedBundle(filename, appDir, dev);
627
+ if (method === 'HEAD') {
628
+ // HEAD must return same headers as GET with no body.
629
+ return new Response(null, { status: resp.status, headers: resp.headers });
630
+ }
631
+ return resp;
417
632
  }
418
633
 
419
634
  // Internal server-action RPC endpoint
@@ -450,10 +665,32 @@ async function handleCore(req, ctx) {
450
665
  if (path.startsWith('/public/') || path === '/favicon.ico') {
451
666
  const p = path === '/favicon.ico' ? '/public/favicon.ico' : path;
452
667
  const abs = join(appDir, p);
668
+ // Containment check. `join` normalises `..` segments, so a path
669
+ // like `/public/%2E%2E/secret/x.svg` decodes (after URL parsing,
670
+ // which doesn't touch `%2E`) to `/public/../secret/x.svg` and
671
+ // `join(appDir, ...)` resolves it to `appDir/secret/x.svg`. The
672
+ // resulting `abs` could be inside `appDir` but OUTSIDE `appDir/
673
+ // public/`, exposing files the user reasonably thought were
674
+ // private under their non-public directories. Reject anything
675
+ // that doesn't stay under `appDir/public/` (and the favicon
676
+ // exception, which is already validated above).
677
+ const publicRoot = join(appDir, 'public') + sep;
678
+ if (!abs.startsWith(publicRoot)) {
679
+ return new Response(null, { status: 404 });
680
+ }
453
681
  if (await exists(abs)) return fileResponse(abs, { dev, immutable: false });
454
682
  }
455
683
 
456
- // User source modules (served as ES modules, with action-file rewriting)
684
+ // User source modules (served as ES modules, with action-file rewriting).
685
+ //
686
+ // Authorization gate: only files reachable from a browser-bound entry
687
+ // (page, layout, error, loading, not-found, component) via the module
688
+ // graph are servable. Same posture as Next.js, where the bundler's
689
+ // manifest is the source of truth for what the browser may fetch.
690
+ // Anything not in the set (node_modules/, top-level package.json,
691
+ // scripts/, etc.) 404s here regardless of whether the file exists on
692
+ // disk. The `.server.{js,ts}` stub guardrail runs below as a
693
+ // defense-in-depth layer.
457
694
  if (method === 'GET' && /\.(js|mjs|ts|mts|css|svg|png|jpg|jpeg|gif|webp|json|ico|txt)$/.test(path)) {
458
695
  let abs = join(appDir, path);
459
696
  // When the browser asks for `.js`, allow falling through to a sibling
@@ -466,7 +703,12 @@ async function handleCore(req, ctx) {
466
703
  if (await exists(mtsAbs)) abs = mtsAbs;
467
704
  }
468
705
  }
469
- if (abs.startsWith(appDir) && (await exists(abs))) {
706
+ // Gate: must be in the browser-bound module graph. Server-action
707
+ // files (.server.{js,ts}) get a stub via the guardrail below; they
708
+ // ARE included in browserBoundFiles because client code imports
709
+ // them by path (the import rewrites to an RPC stub at request time).
710
+ const inGraph = state.browserBoundFiles && state.browserBoundFiles.has(abs);
711
+ if (abs.startsWith(appDir) && inGraph && (await exists(abs))) {
470
712
  // Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
471
713
  // MUST NEVER be served as source to the browser. The extension is
472
714
  // the path-level boundary; we re-verify it on every request (not
@@ -484,7 +726,7 @@ async function handleCore(req, ctx) {
484
726
  // Lazily ensure the index knows about this file so serveActionStub
485
727
  // can mint a stable hash and function list.
486
728
  if (!state.actionIndex.fileToHash.has(abs)) {
487
- const h = hashFile(abs);
729
+ const h = await hashFile(abs);
488
730
  state.actionIndex.fileToHash.set(abs, h);
489
731
  state.actionIndex.hashToFile.set(h, abs);
490
732
  }
@@ -499,9 +741,19 @@ async function handleCore(req, ctx) {
499
741
  headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
500
742
  });
501
743
  }
502
- // TypeScript source: esbuild-strip types, cache by mtime.
744
+ // TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
745
+ // Both module paths also strip side-effect imports of display-only
746
+ // components so the browser never downloads their JS.
747
+ const elideOpts = {
748
+ moduleGraph: state.moduleGraph,
749
+ elidableComponents: state.elidableComponents,
750
+ appDir,
751
+ };
503
752
  if (/\.m?ts$/.test(abs)) {
504
- return tsResponse(abs, dev);
753
+ return tsResponse(abs, dev, elideOpts);
754
+ }
755
+ if (/\.m?js$/.test(abs)) {
756
+ return jsModuleResponse(abs, dev, elideOpts);
505
757
  }
506
758
  return fileResponse(abs, { dev, immutable: false });
507
759
  }
@@ -547,6 +799,8 @@ async function handleCore(req, ctx) {
547
799
  const handler = () => ssrPage(page.route, page.params, url, {
548
800
  dev, appDir, req, moduleGraph: state.moduleGraph,
549
801
  serverFiles: state.actionIndex.fileToHash,
802
+ elidableComponents: state.elidableComponents,
803
+ inertRouteModules: state.inertRouteModules,
550
804
  });
551
805
  return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
552
806
  }
@@ -693,12 +947,23 @@ function toWebRequest(req, url) {
693
947
  /** @type {Record<string,string>} */
694
948
  const headers = {};
695
949
  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
950
+ // Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`).
951
+ // They're parsed separately into req.method / req.url and are rejected
698
952
  // by the standard Headers class if we pass them through verbatim.
699
953
  if (k.startsWith(':')) continue;
954
+ // Strip any inbound `x-webjs-remote-ip` header so clients cannot
955
+ // spoof the framework-stamped client IP that rate-limit's
956
+ // `clientIp(req, { trustProxy: false })` reads. We rewrite it
957
+ // below from the actual TCP socket. Node's IncomingMessage
958
+ // always lowercases header keys, so a literal compare is enough.
959
+ if (k === 'x-webjs-remote-ip') continue;
700
960
  headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
701
961
  }
962
+ // Stamp the framework-trusted remote IP from the socket. Read by
963
+ // `clientIp(req)` (rate-limit.js) as the bucket key when
964
+ // `trustProxy: false` (the safe default).
965
+ const remoteIp = req.socket?.remoteAddress;
966
+ if (remoteIp) headers['x-webjs-remote-ip'] = remoteIp;
702
967
  let body;
703
968
  if (method !== 'GET' && method !== 'HEAD') {
704
969
  body = new ReadableStream({
@@ -800,7 +1065,7 @@ async function fileResponse(abs, opts) {
800
1065
  if (opts.dev) {
801
1066
  headers['cache-control'] = 'no-cache';
802
1067
  } else {
803
- const etag = `"${createHash('sha1').update(data).digest('hex').slice(0, 16)}"`;
1068
+ const etag = `"${(await digestHex('SHA-1', data)).slice(0, 16)}"`;
804
1069
  headers['etag'] = etag;
805
1070
  headers['cache-control'] = opts.immutable
806
1071
  ? 'public, max-age=31536000, immutable'
@@ -812,54 +1077,72 @@ async function fileResponse(abs, opts) {
812
1077
  }
813
1078
  }
814
1079
 
1080
+ /**
1081
+ * Serve a plain `.js` / `.mjs` browser module, stripping side-effect
1082
+ * imports of display-only components. Mirrors {@link fileResponse}'s
1083
+ * headers but reads as text so the source can be transformed. Used only
1084
+ * for files that exist as `.js` on disk (TS apps usually hit
1085
+ * {@link tsResponse} via the .js to .ts sibling rewrite instead).
1086
+ *
1087
+ * @param {string} abs
1088
+ * @param {boolean} dev
1089
+ * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
1090
+ */
1091
+ async function jsModuleResponse(abs, dev, elideOpts) {
1092
+ let source;
1093
+ try { source = await readFile(abs, 'utf8'); }
1094
+ catch { return new Response('Not found', { status: 404 }); }
1095
+ const code = elideImportsFromSource(
1096
+ source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
1097
+ );
1098
+ const headers = { 'content-type': 'application/javascript; charset=utf-8' };
1099
+ if (dev) {
1100
+ headers['cache-control'] = 'no-cache';
1101
+ } else {
1102
+ headers['etag'] = `"${(await digestHex('SHA-1', code)).slice(0, 16)}"`;
1103
+ headers['cache-control'] = 'public, max-age=3600';
1104
+ }
1105
+ return new Response(code, { status: 200, headers });
1106
+ }
1107
+
815
1108
  async function exists(p) {
816
1109
  try { await stat(p); return true; } catch { return false; }
817
1110
  }
818
1111
 
819
1112
  /**
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).
1113
+ * Strip TypeScript types from `source` via Node's built-in
1114
+ * `module.stripTypeScriptTypes`. Position-preserving whitespace
1115
+ * replacement: no sourcemap is needed because every (line, column)
1116
+ * maps to itself in the source.
825
1117
  *
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.
1118
+ * Only erasable TypeScript is supported. Non-erasable syntax
1119
+ * (`enum`, `namespace` with values, parameter properties, legacy
1120
+ * decorators with `emitDecoratorMetadata`, `import = require`)
1121
+ * throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` from Node and the
1122
+ * dev server returns the error to the caller. The
1123
+ * `erasable-typescript-only` and `no-non-erasable-typescript` lint
1124
+ * rules catch these at edit time. There is no bundler fallback;
1125
+ * webjs is buildless end-to-end.
830
1126
  *
831
1127
  * @param {string} source
832
- * @param {string} abs
1128
+ * @param {string} _abs (unused; preserved for symmetry with prior signature)
833
1129
  * @returns {Promise<string>}
834
1130
  */
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
- }
1131
+ async function stripTs(source, _abs) {
1132
+ return stripTypeScriptTypes(source);
852
1133
  }
853
1134
 
854
1135
  /**
855
1136
  * Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
856
1137
  * Result is cached by mtime so subsequent requests are instant; a
857
- * file edit invalidates naturally.
1138
+ * file edit invalidates naturally. `elideOpts` additionally strips
1139
+ * side-effect imports of display-only components from the served code.
858
1140
  *
859
1141
  * @param {string} abs
860
1142
  * @param {boolean} dev
1143
+ * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
861
1144
  */
862
- async function tsResponse(abs, dev) {
1145
+ async function tsResponse(abs, dev, elideOpts) {
863
1146
  const st = await stat(abs);
864
1147
  const cached = TS_CACHE.get(abs);
865
1148
  if (cached && cached.mtimeMs === st.mtimeMs) {
@@ -871,7 +1154,48 @@ async function tsResponse(abs, dev) {
871
1154
  });
872
1155
  }
873
1156
  const source = await readFile(abs, 'utf8');
874
- const code = await stripTs(source, abs);
1157
+ let code;
1158
+ try {
1159
+ code = await stripTs(source, abs);
1160
+ } catch (err) {
1161
+ // Node's stripTypeScriptTypes throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
1162
+ // for enum, namespace with values, parameter properties, legacy
1163
+ // decorators with emitDecoratorMetadata, and import = require.
1164
+ // Return a clean 500 with the file path and a pointer at the
1165
+ // erasable-typescript-only lint rule rather than letting the
1166
+ // error bubble up unstyled.
1167
+ if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
1168
+ // Log full detail server-side regardless of mode so operators
1169
+ // see what went wrong in their logs.
1170
+ // eslint-disable-next-line no-console
1171
+ console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
1172
+ const msg = dev
1173
+ // Dev: include the file path and Node's error message so the
1174
+ // developer's browser tooling can point them at the offending
1175
+ // construct. Replace `*` + `/` with `*\\/` so a path or
1176
+ // message containing the comment-close sequence cannot
1177
+ // terminate the wrapper comment early.
1178
+ ? `[webjs] non-erasable TypeScript in ${abs}: ${err.message}\n\n` +
1179
+ `webjs is buildless: only erasable TS syntax is supported. ` +
1180
+ `Replace enum / namespace / parameter-property / legacy-decorator / ` +
1181
+ `import = require constructs with their erasable equivalents. ` +
1182
+ `Run \`webjs check\` for guidance (no-non-erasable-typescript rule).`
1183
+ // Prod: terse, no path leak, no Node-message leak (Node's
1184
+ // message can include source snippets). Operators get the
1185
+ // detail in server logs above.
1186
+ : `[webjs] server error transforming a .ts response. Check server logs.`;
1187
+ return new Response(`/* ${msg.replace(/\*\//g, '*\\/')} */`, {
1188
+ status: 500,
1189
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
1190
+ });
1191
+ }
1192
+ throw err;
1193
+ }
1194
+ if (elideOpts) {
1195
+ code = elideImportsFromSource(
1196
+ code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
1197
+ );
1198
+ }
875
1199
  // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
876
1200
  if (TS_CACHE.size >= TS_CACHE_MAX) {
877
1201
  const oldest = TS_CACHE.keys().next().value;
@@ -894,6 +1218,81 @@ function debounce(fn, ms) {
894
1218
  };
895
1219
  }
896
1220
 
1221
+ /**
1222
+ * Walk the route table + component scanner to collect every file the
1223
+ * browser may legitimately fetch as an ES module, then expand via the
1224
+ * module graph into the full transitive closure.
1225
+ *
1226
+ * This is webjs's equivalent of Next.js's bundler-produced page
1227
+ * manifest, applied at boot time (and on every rebuild) instead of
1228
+ * compile time. The dev server's source-file branch uses the returned
1229
+ * Set as an authorization gate: in-set → served (subject to the
1230
+ * .server.{js,ts} stub guardrail); out-of-set → 404.
1231
+ *
1232
+ * Browser-bound entries:
1233
+ * - page.{js,ts,mjs,mts} (re-runs on client for hydration)
1234
+ * - layout.{js,ts,mjs,mts} (same)
1235
+ * - error.{js,ts,mjs,mts} (same)
1236
+ * - loading.{js,ts,mjs,mts} (same)
1237
+ * - not-found.{js,ts,mjs,mts} (same)
1238
+ * - component files discovered by the scanner (eager + lazy)
1239
+ *
1240
+ * Server-only entries (NOT in the set):
1241
+ * - route.{js,ts} (API handlers, never fetched as JS module)
1242
+ * - middleware.{js,ts}
1243
+ * - metadata routes (sitemap.js, robots.js, manifest.js, …)
1244
+ * - .server.{js,ts} files (browser gets a stub, not the source)
1245
+ *
1246
+ * Components are passed in (rather than rescanned) so the caller can
1247
+ * share one scan with `primeComponentRegistry`. Saves a full
1248
+ * appDir walk at boot and on every rebuild.
1249
+ *
1250
+ * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
1251
+ * @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
1252
+ * @param {Awaited<ReturnType<typeof scanComponents>>} components
1253
+ * @param {string} appDir
1254
+ * @returns {Set<string>}
1255
+ */
1256
+ /**
1257
+ * Collect every page + layout file across the route table. These are the
1258
+ * modules the client boot script imports, and thus the candidates for
1259
+ * inert-route elision (dropping a module that does no client work).
1260
+ * `route.{js,ts}` / middleware / metadata are excluded: they never ship.
1261
+ *
1262
+ * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
1263
+ * @returns {string[]}
1264
+ */
1265
+ function collectRouteModules(routeTable) {
1266
+ /** @type {Set<string>} */
1267
+ const mods = new Set();
1268
+ for (const page of routeTable.pages || []) {
1269
+ if (page.file) mods.add(page.file);
1270
+ for (const f of page.layouts || []) mods.add(f);
1271
+ }
1272
+ return [...mods];
1273
+ }
1274
+
1275
+ function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
1276
+ /** @type {Set<string>} */
1277
+ const entries = new Set();
1278
+ for (const page of routeTable.pages) {
1279
+ if (page.file) entries.add(page.file);
1280
+ for (const f of page.layouts || []) entries.add(f);
1281
+ for (const f of page.errors || []) entries.add(f);
1282
+ for (const f of page.loadings || []) entries.add(f);
1283
+ }
1284
+ if (routeTable.notFound) entries.add(routeTable.notFound);
1285
+ if (routeTable.notFounds) {
1286
+ for (const f of routeTable.notFounds.values()) entries.add(f);
1287
+ }
1288
+ // Lazy components live in the registry but no page imports their
1289
+ // class directly; the lazy-loader fetches their module URLs on
1290
+ // viewport entry. Add every discovered component file as an entry so
1291
+ // the graph walk covers both eager and lazy paths.
1292
+ for (const c of components) entries.add(c.file);
1293
+ return reachableFromEntries(moduleGraph, [...entries], appDir);
1294
+ }
1295
+
897
1296
  /**
898
1297
  * Find the absolute directory of the `@webjsdev/core` package, regardless of
899
1298
  * whether we're running from the monorepo or an installed copy.
@@ -932,20 +1331,6 @@ function locatePackageDir(appDir, pkgName) {
932
1331
  return null;
933
1332
  }
934
1333
 
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
1334
  const RELOAD_CLIENT_JS = `// webjs dev reload client
950
1335
  const es = new EventSource('/__webjs/events');
951
1336
  es.addEventListener('reload', () => location.reload());