@webjsdev/server 0.8.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/actions.js CHANGED
@@ -46,8 +46,9 @@ async function rpcResponse(payload, init = {}) {
46
46
  * ignored.
47
47
  *
48
48
  * The server:
49
- * 1. Scans the app tree on boot, classifying server files into
50
- * RPC-callable actions vs. server-only utilities.
49
+ * 1. Scans the app tree lazily on the first request (in `ensureReady`),
50
+ * classifying server files into RPC-callable actions vs. server-only
51
+ * utilities. Hashing is eager-per-file; only `expose()` files load.
51
52
  * 2. Serves a generated ES-module stub when the browser imports
52
53
  * the file URL (an RPC stub for actions, a throw-at-load stub
53
54
  * for server-only utilities).
@@ -106,7 +107,22 @@ export async function buildActionIndex(appDir, dev) {
106
107
  const h = await hashFile(file);
107
108
  hashToFile.set(h, file);
108
109
  fileToHash.set(file, h);
109
- // Load module once at scan time to pick up any expose() tags.
110
+ // Pure-RPC actions are NOT executed at boot: invokeAction and
111
+ // serveActionStub import the module on demand (first RPC call / first stub
112
+ // fetch), so the hash index above is all the analysis needs. Eagerly
113
+ // running every server module (and its transitive Prisma init, DB
114
+ // connects, etc.) would be wasted work. The one thing that DOES need eager loading is expose(),
115
+ // which registers a REST route the router must know before any request can
116
+ // hit it. So load only files that REFERENCE expose. We match the bare
117
+ // `expose` identifier (not `expose(`) so an aliased import
118
+ // (`import { expose as exp }`, whose import clause still names `expose`) is
119
+ // not missed: missing it would silently 404 that file's REST route. A stray
120
+ // mention in a comment or string only over-matches, costing one harmless
121
+ // extra module load; the common pure-RPC file never names `expose` and so
122
+ // still defers entirely.
123
+ let src = '';
124
+ try { src = await readFile(file, 'utf8'); } catch {}
125
+ if (!/\bexpose\b/.test(src)) continue;
110
126
  try {
111
127
  const mod = await loadModule(file, dev);
112
128
  for (const [name, fn] of Object.entries(mod)) {
package/src/check.js CHANGED
@@ -62,7 +62,7 @@ export const RULES = [
62
62
  {
63
63
  name: 'components-have-register',
64
64
  description:
65
- 'Component files that define a class extending WebComponent must register the class with ClassName.register(\'tag\') (or customElements.define). The server-side scanner derives the module URL from the file path at boot.',
65
+ 'Component files that define a class extending WebComponent must register the class with ClassName.register(\'tag\') (or customElements.define). The server-side scanner derives the module URL from the file path.',
66
66
  },
67
67
  {
68
68
  name: 'no-server-env-in-components',
@@ -648,28 +648,80 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
648
648
  }
649
649
  }
650
650
 
651
- // Ship any component whose transitive import closure does client work,
652
- // through ANY import (not just npm): a relative helper that imports a
653
- // reactive primitive (shared module-scope signal), enables the client
654
- // router, references a browser global, or side-effect imports a package.
655
- // Same closure rule the route analysis applies, so a display-only
656
- // component that pulls in a client-effecting helper still ships.
657
- const closureIsClientEffecting = (d) =>
658
- reactiveFiles.has(d) || clientRouterFiles.has(d) || clientGlobalOrBareFiles.has(d);
651
+ // Reverse import edges (who imports each file), built once from the graph.
652
+ // Drives both the closure-client-work reachability below and the fixpoint's
653
+ // import rule, each in O(N+E) rather than a per-component closure walk.
654
+ /** @type {Map<string, Set<string>>} */
655
+ const importersOf = new Map();
656
+ for (const [file, deps] of moduleGraph) {
657
+ for (const dep of deps) {
658
+ let set = importersOf.get(dep);
659
+ if (!set) { set = new Set(); importersOf.set(dep, set); }
660
+ set.add(file);
661
+ }
662
+ }
663
+
664
+ // Files that reach client work through their imports: a reactive primitive,
665
+ // the client router, a browser global, an `@event` binding, or a side-effect
666
+ // npm import. Computed by propagating BACKWARD from the client-effecting
667
+ // files through the reverse edges, stopping at `.server` files (the forward
668
+ // closure skips them, since the browser only ever sees their stub). This is
669
+ // O(N+E) instead of a full transitive-closure walk per component, which was
670
+ // the second O(N^2) on a deep component chain.
671
+ /** @type {Set<string>} */
672
+ const reachesClientWork = new Set();
673
+ {
674
+ const work = [];
675
+ for (const f of [...reactiveFiles, ...clientRouterFiles, ...clientGlobalOrBareFiles]) {
676
+ if (!reachesClientWork.has(f)) { reachesClientWork.add(f); work.push(f); }
677
+ }
678
+ while (work.length) {
679
+ const node = /** @type {string} */ (work.pop());
680
+ const importers = importersOf.get(node);
681
+ if (!importers) continue;
682
+ for (const imp of importers) {
683
+ if (serverFiles.has(imp)) continue; // a server file blocks the forward closure
684
+ if (!reachesClientWork.has(imp)) { reachesClientWork.add(imp); work.push(imp); }
685
+ }
686
+ }
687
+ }
688
+
689
+ // Ship any component whose transitive import closure does client work (the
690
+ // helper-imports-a-signal case): it ships if any of its direct, non-server
691
+ // deps reaches client work. Equivalent to the old per-component closure walk
692
+ // (a component that is itself client-effecting is already shipping via
693
+ // analyzeComponentSource), but linear.
659
694
  if (appDir) {
660
695
  for (const file of componentFiles) {
661
696
  if (mustShip.has(file)) continue;
662
- const deps = transitiveDeps(moduleGraph, [file], appDir, serverFiles);
663
- if (deps.some(closureIsClientEffecting)) mustShip.add(file);
697
+ const deps = moduleGraph.get(file);
698
+ if (!deps) continue;
699
+ for (const dep of deps) {
700
+ if (serverFiles.has(dep)) continue;
701
+ if (reachesClientWork.has(dep)) { mustShip.add(file); break; }
702
+ }
664
703
  }
665
704
  }
666
705
 
667
- // Tags each component can emit on a client re-render (own + helper closure).
706
+ // Tags each component can emit on a client re-render: its OWN rendered tags
707
+ // plus tags returned by the template HELPERS it imports (the lib/utils/ui.ts
708
+ // pattern). The closure deliberately SKIPS component and server files:
709
+ // importing another component does not mean rendering its tag (a rendered tag
710
+ // is already in the importer's own source via extractRenderedTags), and a
711
+ // server file renders nothing client-side. Following component edges here
712
+ // makes the closure O(N^2) in time AND memory on a deep component chain
713
+ // (every component would accumulate every downstream tag); helper-only
714
+ // closures keep it linear. This is verdict-SAFE: it never elides a component
715
+ // that the render rule requires to ship (so it can never break a page), and
716
+ // it actually elides strictly MORE in some shapes, because following
717
+ // component edges made the old version over-ship components whose tags
718
+ // nothing actually renders client-side.
719
+ const tagClosureSkip = new Set([...componentFiles, ...serverFiles]);
668
720
  /** @type {Map<string, Set<string>>} */
669
721
  const emittableTags = new Map();
670
722
  for (const file of componentFiles) {
671
723
  const tags = new Set(fileTags.get(file));
672
- const deps = appDir ? transitiveDeps(moduleGraph, [file], appDir) : [];
724
+ const deps = appDir ? transitiveDeps(moduleGraph, [file], appDir, tagClosureSkip) : [];
673
725
  for (const dep of deps) {
674
726
  const dt = fileTags.get(dep);
675
727
  if (dt) for (const t of dt) tags.add(t);
@@ -677,24 +729,26 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
677
729
  emittableTags.set(file, tags);
678
730
  }
679
731
 
680
- // Fixpoint: render rule + import rule.
681
- let changed = true;
682
- while (changed) {
683
- changed = false;
684
- for (const parent of mustShip) {
685
- const tags = emittableTags.get(parent);
686
- if (!tags) continue;
732
+ // Fixpoint by worklist (render rule + import rule), O(N+E). Seed with the
733
+ // components already known to ship; each shipping node forces the components
734
+ // whose tags it can emit (render rule) and the COMPONENT files that import it
735
+ // (import rule). Replaces the old iterate-until-stable double loop, which was
736
+ // O(N^2) per pass and O(N^3) / out-of-memory on a deep render chain.
737
+ const queue = [...mustShip];
738
+ while (queue.length) {
739
+ const node = /** @type {string} */ (queue.pop());
740
+ const tags = emittableTags.get(node);
741
+ if (tags) {
687
742
  for (const tag of tags) {
688
743
  const childFile = tagToFile.get(tag);
689
- if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); changed = true; }
744
+ if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); queue.push(childFile); }
690
745
  }
691
746
  }
692
- for (const file of componentFiles) {
693
- if (mustShip.has(file)) continue;
694
- const deps = moduleGraph.get(file);
695
- if (!deps) continue;
696
- for (const dep of deps) {
697
- if (componentFiles.has(dep) && mustShip.has(dep)) { mustShip.add(file); changed = true; break; }
747
+ const importers = importersOf.get(node);
748
+ if (importers) {
749
+ for (const imp of importers) {
750
+ if (!componentFiles.has(imp)) continue; // import rule is component -> component
751
+ if (!mustShip.has(imp)) { mustShip.add(imp); queue.push(imp); }
698
752
  }
699
753
  }
700
754
  }
@@ -2,7 +2,7 @@
2
2
  * Server-side scanner that walks the app tree and records the
3
3
  * browser-visible URL for every webjs component module.
4
4
  *
5
- * Called once at server boot. Results are used to prime the core
5
+ * Called once on the first request (lazily, via `ensureReady`), then memoized. Results are used to prime the core
6
6
  * registry (`primeModuleUrl`) BEFORE any SSR render: so when a page
7
7
  * renders a component tag, `lookupModuleUrl(tag)` already has the URL
8
8
  * ready for `<link rel="modulepreload">` hints.
@@ -18,11 +18,24 @@
18
18
  * we only need `{ tag, className, moduleUrl }` tuples.
19
19
  */
20
20
 
21
- import { readFile } from 'node:fs/promises';
21
+ import { readFile, stat } from 'node:fs/promises';
22
22
  import { sep } from 'node:path';
23
23
  import { walk } from './fs-walk.js';
24
24
  import { primeModuleUrl } from '@webjsdev/core';
25
25
 
26
+ /**
27
+ * mtime-keyed cache of extracted components per file, so a rebuild re-reads
28
+ * only files that changed (an unchanged file reuses its cached component list
29
+ * after a single `stat`). Makes the component scan incremental for large apps.
30
+ * Keyed by mtime AND size (a same-tick length-changing edit is caught even on
31
+ * coarse-mtime filesystems).
32
+ * @type {Map<string, { mtimeMs: number, size: number, comps: Array<{ tag: string, className: string }> }>}
33
+ */
34
+ const SCAN_CACHE = new Map();
35
+
36
+ /** Introspection for tests/ops: is `file` currently in the scan cache? */
37
+ export function _scanCacheHas(file) { return SCAN_CACHE.has(file); }
38
+
26
39
  /**
27
40
  * Recognise either registration pattern:
28
41
  *
@@ -70,21 +83,39 @@ export function extractComponents(src) {
70
83
  export async function scanComponents(appDir) {
71
84
  /** @type {Array<{ tag: string, className: string, moduleUrl: string, file: string }>} */
72
85
  const components = [];
86
+ /** @type {Set<string>} live component files this scan, for cache eviction */
87
+ const seen = new Set();
73
88
  const filter = (p) =>
74
89
  /\.m?[jt]sx?$/.test(p) &&
75
90
  !/\.(test|spec)\.m?[jt]sx?$/.test(p) &&
76
91
  !/\.server\.m?[jt]s$/.test(p);
77
92
 
78
93
  for await (const file of walk(appDir, filter)) {
79
- let src;
80
- try { src = await readFile(file, 'utf8'); } catch { continue; }
81
- const comps = extractComponents(src);
94
+ let mtimeMs, size;
95
+ try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; } catch { continue; }
96
+ seen.add(file); // mark live (hit and miss) for cache eviction
97
+ let comps;
98
+ const cached = SCAN_CACHE.get(file);
99
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
100
+ comps = cached.comps;
101
+ } else {
102
+ let src;
103
+ try { src = await readFile(file, 'utf8'); } catch { continue; }
104
+ comps = extractComponents(src);
105
+ SCAN_CACHE.set(file, { mtimeMs, size, comps });
106
+ }
82
107
  if (!comps.length) continue;
83
108
  const moduleUrl = toUrlPath(file, appDir);
84
109
  for (const c of comps) {
85
110
  components.push({ ...c, moduleUrl, file });
86
111
  }
87
112
  }
113
+ // Evict scan-cache entries for files no longer walked (renamed/deleted),
114
+ // scoped to this app so a multi-app process keeps other apps' entries.
115
+ const prefix = appDir.endsWith(sep) ? appDir : appDir + sep;
116
+ for (const key of SCAN_CACHE.keys()) {
117
+ if ((key === appDir || key.startsWith(prefix)) && !seen.has(key)) SCAN_CACHE.delete(key);
118
+ }
88
119
  return components;
89
120
  }
90
121
 
package/src/dev.js CHANGED
@@ -212,149 +212,271 @@ export async function createRequestHandler(opts) {
212
212
  existsSync(join(distDir, 'webjs-core-browser.js'));
213
213
  await setCoreInstall(coreDir, distComplete);
214
214
 
215
- // Build module dependency graph for transitive preload hints.
216
- const moduleGraph = await buildModuleGraph(appDir);
217
-
218
- // Scan for component classes and prime their module URLs into the
219
- // core registry. SSR uses this for modulepreload hints without
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
-
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.
226
222
  const routeTable = await buildRouteTable(appDir);
227
223
 
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);
251
-
252
- // Dev-time guardrail: warn about any class extending WebComponent
253
- // that isn't registered via customElements.define() in its own
254
- // module. Without registration, <my-tag> elements silently stay as
255
- // HTMLUnknownElement in the browser: a common early-stage footgun.
256
- if (dev) {
257
- const orphans = await findOrphanComponents(appDir);
258
- for (const { className, file } of orphans) {
259
- logger.warn?.(
260
- `[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
261
- `Add \`customElements.define('<tag-name>', ${className});\` at the bottom of the file ` +
262
- `or <${kebab(className)}> tags won't upgrade in the browser.`,
263
- );
264
- }
265
- }
266
-
267
224
  const state = {
268
225
  routeTable,
269
- actionIndex: await buildActionIndex(appDir, dev),
270
- middleware: await loadMiddleware(appDir, dev, logger),
226
+ actionIndex: null,
227
+ middleware: null,
271
228
  logger,
272
- bareImports,
273
- moduleGraph,
274
- elidableComponents,
275
- inertRouteModules,
276
- browserBoundFiles: computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir),
229
+ moduleGraph: null,
230
+ elidableComponents: new Set(),
231
+ inertRouteModules: new Set(),
232
+ browserBoundFiles: null,
277
233
  };
278
234
 
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.
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
+ }
344
+
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
+ }
367
+
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; }
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;
395
+ }
396
+
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.
286
401
  let rebuildInFlight = Promise.resolve();
287
- let latestRebuildToken = 0;
288
402
 
289
403
  async function rebuild() {
290
- const token = ++latestRebuildToken;
291
- rebuildInFlight = rebuildInFlight.then(() => doRebuild(token)).catch((e) => {
404
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild()).catch((e) => {
292
405
  logger.error?.(`[webjs] rebuild failed:`, e);
293
406
  });
294
407
  return rebuildInFlight;
295
408
  }
296
409
 
297
- async function doRebuild(token) {
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.
298
413
  state.routeTable = await buildRouteTable(appDir);
299
- state.actionIndex = await buildActionIndex(appDir, dev);
300
- state.middleware = await loadMiddleware(appDir, dev, logger);
301
414
  clearVendorCache();
302
- state.moduleGraph = await buildModuleGraph(appDir);
303
- // Re-scan components in case a new file was added or a tag renamed.
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
415
  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);
343
- if (dev) {
344
- const orphans = await findOrphanComponents(appDir);
345
- for (const { className, file } of orphans) {
346
- logger.warn?.(
347
- `[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
348
- `Add \`customElements.define('<tag-name>', ${className});\` or <${kebab(className)}> tags won't upgrade.`,
349
- );
350
- }
351
- }
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
352
431
  opts.onReload?.();
353
432
  }
354
433
 
355
434
  /** @param {Request} req */
356
435
  function handle(req) {
357
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();
358
480
  const next = () => handleCore(req, { state, appDir, coreDir, dev });
359
481
  if (state.middleware) {
360
482
  try {
@@ -389,6 +511,21 @@ export async function createRequestHandler(opts) {
389
511
  handle,
390
512
  rebuild,
391
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)),
392
529
  /** current route table getter: used by the WebSocket subsystem */
393
530
  getRouteTable: () => state.routeTable,
394
531
  appDir,
@@ -533,6 +670,11 @@ export async function startServer(opts) {
533
670
 
534
671
  server.listen(port, () => {
535
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();
536
678
  });
537
679
 
538
680
  const shutdown = gracefulShutdown(server, sseClients, logger);
@@ -571,10 +713,8 @@ async function handleCore(req, ctx) {
571
713
  try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
572
714
  const method = req.method.toUpperCase();
573
715
 
574
- // Health / readiness probes for orchestrators (k8s, fly, etc.)
575
- if (path === '/__webjs/health' || path === '/__webjs/ready') {
576
- return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
577
- }
716
+ // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) are handled
717
+ // in `handle()` BEFORE ensureReady, so they are not repeated here.
578
718
 
579
719
  // Dev live-reload client
580
720
  if (path === '/__webjs/reload.js') {
@@ -712,8 +852,9 @@ async function handleCore(req, ctx) {
712
852
  // Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
713
853
  // MUST NEVER be served as source to the browser. The extension is
714
854
  // the path-level boundary; we re-verify it on every request (not
715
- // just the action-index snapshot taken at boot) so files created
716
- // 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.
717
858
  //
718
859
  // What the browser gets depends on the file's `'use server'` status:
719
860
  // - With `'use server'` => server action: a generated RPC stub
@@ -1224,8 +1365,8 @@ function debounce(fn, ms) {
1224
1365
  * module graph into the full transitive closure.
1225
1366
  *
1226
1367
  * 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
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
1229
1370
  * Set as an authorization gate: in-set → served (subject to the
1230
1371
  * .server.{js,ts} stub guardrail); out-of-set → 404.
1231
1372
  *
@@ -1245,7 +1386,7 @@ function debounce(fn, ms) {
1245
1386
  *
1246
1387
  * Components are passed in (rather than rescanned) so the caller can
1247
1388
  * share one scan with `primeComponentRegistry`. Saves a full
1248
- * appDir walk at boot and on every rebuild.
1389
+ * appDir walk on each analysis (the first request and every rebuild).
1249
1390
  *
1250
1391
  * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
1251
1392
  * @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
package/src/importmap.js CHANGED
@@ -17,7 +17,8 @@ function escapeAttr(s) {
17
17
  * scanner discovers npm packages used by client code. The resolution
18
18
  * happens via `vendor.js`'s `resolveVendorImports`, which reads the
19
19
  * committed `.webjs/vendor/importmap.json` if present, else calls
20
- * `api.jspm.io/generate` once at boot. Browser fetches vendor packages
20
+ * `api.jspm.io/generate` once on the first request (memoized), never at
21
+ * boot. Browser fetches vendor packages
21
22
  * directly from jspm.io's CDN (default) or from local `/__webjs/vendor/`
22
23
  * paths (after `webjs vendor pin --download`).
23
24
  */
@@ -36,8 +37,8 @@ let _vendorIntegrity = {};
36
37
  /**
37
38
  * Merge additional vendor entries into the import map and precompute
38
39
  * the importmap-hash so `importMapHash()` can stay synchronous on the
39
- * per-request SSR hot path. Called by the dev server at boot and on
40
- * every vendor rebuild.
40
+ * per-request SSR hot path. Called from `ensureReady()` on the first
41
+ * request and on every vendor rebuild.
41
42
  *
42
43
  * @param {Record<string, string>} entries
43
44
  * @param {Record<string, string>} [integrity] SRI hashes keyed by URL
@@ -64,8 +65,8 @@ export async function setVendorEntries(entries, integrity) {
64
65
  * the change and hard-reload before applying the swap.
65
66
  *
66
67
  * Synchronous accessor. The hash is precomputed eagerly inside
67
- * `setVendorEntries` (which the dev server `await`s during boot and
68
- * on every rebuild) so the per-request SSR hot path can return the
68
+ * `setVendorEntries` (which `ensureReady()` `await`s on the first request
69
+ * and on every rebuild) so the per-request SSR hot path can return the
69
70
  * cached string without crossing a Promise boundary.
70
71
  *
71
72
  * Returns an empty string if `setVendorEntries` has never run; the
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Lightweight module dependency graph.
3
3
  *
4
- * At startup, scans the app directory and builds an in-memory map of
4
+ * On the first request (lazily, via `ensureReady`), scans the app directory and builds an in-memory map of
5
5
  * `file → Set<imported files>`. The SSR pipeline queries this graph to
6
6
  * emit *complete* modulepreload hints: including transitive dependencies
7
7
  * of components: so the browser can fetch the entire tree in parallel
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { readFile, readdir, stat } from 'node:fs/promises';
15
15
  import { existsSync } from 'node:fs';
16
- import { join, resolve, dirname, extname } from 'node:path';
16
+ import { join, resolve, dirname, extname, sep } from 'node:path';
17
17
 
18
18
  /** @type {RegExp} match static `import … from '…'` and `import '…'` */
19
19
  const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
@@ -53,7 +53,18 @@ const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
53
53
  export async function buildModuleGraph(appDir) {
54
54
  /** @type {ModuleGraph} */
55
55
  const graph = new Map();
56
- await walk(appDir, appDir, graph);
56
+ /** @type {Set<string>} every file walked this build (graph holds only files
57
+ * with deps, so a separate set is needed to know what is still live). */
58
+ const seen = new Set();
59
+ await walk(appDir, appDir, graph, seen);
60
+ // Evict parse-cache entries for files no longer in the tree (a rebuild after
61
+ // a rename or delete), so a long dev session does not accumulate dead
62
+ // entries. Scoped to appDir so a multi-app process (tests, dogfood smoke)
63
+ // keeps other apps' entries.
64
+ const prefix = appDir.endsWith(sep) ? appDir : appDir + sep;
65
+ for (const key of PARSE_CACHE.keys()) {
66
+ if ((key === appDir || key.startsWith(prefix)) && !seen.has(key)) PARSE_CACHE.delete(key);
67
+ }
57
68
  return graph;
58
69
  }
59
70
 
@@ -173,7 +184,7 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
173
184
  * @param {string} appDir
174
185
  * @param {ModuleGraph} graph
175
186
  */
176
- async function walk(dir, appDir, graph) {
187
+ async function walk(dir, appDir, graph, seen) {
177
188
  let entries;
178
189
  try { entries = await readdir(dir, { withFileTypes: true }); }
179
190
  catch { return; }
@@ -191,13 +202,28 @@ async function walk(dir, appDir, graph) {
191
202
  if (e.name.startsWith('.')) continue;
192
203
  const full = join(dir, e.name);
193
204
  if (e.isDirectory()) {
194
- await walk(full, appDir, graph);
205
+ await walk(full, appDir, graph, seen);
195
206
  } else if (/\.(js|ts|mjs|mts)$/.test(e.name)) {
196
- await parseFile(full, appDir, graph);
207
+ await parseFile(full, appDir, graph, seen);
197
208
  }
198
209
  }
199
210
  }
200
211
 
212
+ /**
213
+ * mtime-keyed parse cache so a rebuild re-reads only files that actually
214
+ * changed. `buildModuleGraph` re-walks the (cheap) directory tree on every
215
+ * rebuild, but reading + regex-parsing each file is the cost; on an unchanged
216
+ * file the cached import set is reused after a single `stat`. This makes
217
+ * rebuilds incremental for large apps without restructuring the caller.
218
+ * Keyed by mtime AND size: a same-tick edit that also changes the file length
219
+ * is caught even on coarse-resolution filesystems where mtime alone could miss.
220
+ * @type {Map<string, { mtimeMs: number, size: number, deps: Set<string> }>}
221
+ */
222
+ const PARSE_CACHE = new Map();
223
+
224
+ /** Introspection for tests/ops: is `file` currently in the parse cache? */
225
+ export function _parseCacheHas(file) { return PARSE_CACHE.has(file); }
226
+
201
227
  /**
202
228
  * Parse a single file's imports and add them to the graph.
203
229
  * Only resolves relative imports (bare specifiers are npm deps, not in the graph).
@@ -206,7 +232,17 @@ async function walk(dir, appDir, graph) {
206
232
  * @param {string} appDir
207
233
  * @param {ModuleGraph} graph
208
234
  */
209
- async function parseFile(file, appDir, graph) {
235
+ async function parseFile(file, appDir, graph, seen) {
236
+ let mtimeMs, size;
237
+ try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; }
238
+ catch { return; }
239
+ seen?.add(file); // mark live (both cache-hit and miss paths) for cache eviction
240
+ const cached = PARSE_CACHE.get(file);
241
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
242
+ if (cached.deps.size) graph.set(file, cached.deps);
243
+ return;
244
+ }
245
+
210
246
  let src;
211
247
  try { src = await readFile(file, 'utf8'); }
212
248
  catch { return; }
@@ -221,6 +257,7 @@ async function parseFile(file, appDir, graph) {
221
257
  if (resolved) deps.add(resolved);
222
258
  }
223
259
  }
260
+ PARSE_CACHE.set(file, { mtimeMs, size, deps });
224
261
  if (deps.size) graph.set(file, deps);
225
262
  }
226
263
 
package/src/vendor.js CHANGED
@@ -25,11 +25,12 @@
25
25
  * returns metadata, not JavaScript. The correct entry file (e.g.,
26
26
  * `/dayjs.min.js`, `/index.js`) varies per package and must be
27
27
  * resolved from the JSPM Generator API. The Generator is called once
28
- * per server boot for the full set of bare imports; results are
28
+ * on the first request for the full set of bare imports; results are
29
29
  * cached in-memory for the process lifetime.
30
30
  *
31
- * Server boot connectivity: the Generator API call happens during
32
- * `setVendorEntries` at boot. If api.jspm.io is unreachable, the
31
+ * Connectivity: the Generator API call happens on the first request,
32
+ * inside `ensureReady` via `setVendorEntries`, never at boot. If
33
+ * api.jspm.io is unreachable, the
33
34
  * importmap will be missing vendor entries and the browser will
34
35
  * report "unresolved bare specifier" errors. The server itself still
35
36
  * boots and serves user routes; only vendor-importing pages break
@@ -269,6 +270,14 @@ export function getPackageVersion(pkgName, appDir) {
269
270
  */
270
271
  const jspmCache = new Map();
271
272
 
273
+ // Set by jspmResolveOne whenever a LIVE resolution attempt fails (network
274
+ // error, timeout, or a non-ok jspm response). resolveVendorImports resets it
275
+ // before a scan and reads it after, so a caller can tell "resolved cleanly"
276
+ // from "served a partial map because the CDN was unreachable" and avoid
277
+ // memoizing the failure as done. Safe under the single-flighted ensureReady
278
+ // (one live resolve at a time); the vendor CLI does not run alongside a server.
279
+ let lastLiveResolveFailed = false;
280
+
272
281
  const JSPM_GENERATE_ENDPOINT = 'https://api.jspm.io/generate';
273
282
  const JSPM_GENERATE_TIMEOUT_MS = 10_000;
274
283
 
@@ -362,6 +371,13 @@ async function jspmResolveOne(install, provider = 'jspm') {
362
371
  `[webjs] could not vendor '${install}' via ${provider} (status ${response.status})${detail}`,
363
372
  );
364
373
  jspmCache.delete(cacheKey);
374
+ // A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx means
375
+ // the install is genuinely unresolvable (jspm uses 401 for that): a
376
+ // private / workspace / server-only package (e.g. @webjsdev/server,
377
+ // @prisma/client) the browser never fetches anyway. That is tolerated
378
+ // exactly as before and must NOT block readiness, or an app with any
379
+ // such dep would never become ready.
380
+ if (response.status >= 500 || response.status === 429) lastLiveResolveFailed = true;
365
381
  return {};
366
382
  }
367
383
  const result = await response.json();
@@ -372,6 +388,7 @@ async function jspmResolveOne(install, provider = 'jspm') {
372
388
  : `${e && e.message}`;
373
389
  console.error(`[webjs] could not vendor '${install}' via ${provider}: ${msg}`);
374
390
  jspmCache.delete(cacheKey);
391
+ lastLiveResolveFailed = true;
375
392
  return {};
376
393
  } finally {
377
394
  clearTimeout(timer);
@@ -411,7 +428,8 @@ export async function jspmGenerate(installs, provider = 'jspm') {
411
428
  * api.jspm.io/generate for the full importmap fragment.
412
429
  *
413
430
  * Async because the Generator API call is networked. Called from
414
- * `setVendorEntries` during server boot and rebuild; not per request.
431
+ * `resolveVendorImports` on the first request (and after a rebuild),
432
+ * inside `ensureReady`; never at boot, and not on every request.
415
433
  *
416
434
  * @param {Set<string>} bareImports from scanBareImports()
417
435
  * @param {string} appDir
@@ -1257,8 +1275,8 @@ function maxSemverVersion(versions) {
1257
1275
 
1258
1276
  /**
1259
1277
  * Resolve the vendor importmap fragment for runtime use. Prefers the
1260
- * committed pin file over a live api.jspm.io call. Called by dev.js
1261
- * at server boot.
1278
+ * committed pin file over a live api.jspm.io call. Called from
1279
+ * `ensureReady()` in dev.js on the first request, never at boot.
1262
1280
  *
1263
1281
  * Order of preference:
1264
1282
  * 1. `.webjs/vendor/importmap.json` (committed; no network needed)
@@ -1270,17 +1288,29 @@ function maxSemverVersion(versions) {
1270
1288
  * hash, defeating the live-mode speed advantage. Users who want SRI
1271
1289
  * run `webjs vendor pin`).
1272
1290
  *
1273
- * @param {Set<string>} bareImports
1274
1291
  * @param {string} appDir
1292
+ * @param {() => Promise<Set<string>>} getBareImports lazy scan, invoked ONLY
1293
+ * on the unpinned path (so a pinned app never pays the whole-app walk).
1275
1294
  * @returns {Promise<{ imports: Record<string, string>, integrity: Record<string, string> }>}
1276
1295
  */
1277
- export async function resolveVendorImports(bareImports, appDir) {
1296
+ export async function resolveVendorImports(appDir, getBareImports) {
1278
1297
  const file = await readPinFile(appDir);
1298
+ // A committed pin file IS the import map. The whole-app bare-import scan is
1299
+ // discarded in that case, so it must never run (runtime-first boot: no
1300
+ // static analysis when pinned). The scan is supplied as a thunk and invoked
1301
+ // solely here, only when there is no pin file.
1279
1302
  if (file) {
1280
- return { imports: file.imports, integrity: file.integrity || {} };
1303
+ // A pin file is a deterministic disk read: always "ok" (no live CDN call
1304
+ // that could partially fail). This is the recommended prod posture.
1305
+ return { imports: file.imports, integrity: file.integrity || {}, ok: true };
1281
1306
  }
1307
+ lastLiveResolveFailed = false;
1308
+ const bareImports = await getBareImports();
1282
1309
  const imports = await vendorImportMapEntries(bareImports, appDir);
1283
- return { imports, integrity: {} };
1310
+ // ok=false means at least one install could not be resolved (CDN unreachable
1311
+ // / timeout / non-ok), so `imports` is partial. The caller must not memoize
1312
+ // this as done; it should retry once the CDN recovers.
1313
+ return { imports, integrity: {}, ok: !lastLiveResolveFailed };
1284
1314
  }
1285
1315
 
1286
1316
  /**