@webjsdev/server 0.8.0 → 0.8.2

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.2",
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
@@ -58,7 +58,7 @@ import {
58
58
  import { defaultLogger } from './logger.js';
59
59
  import { withRequest } from './context.js';
60
60
  import { attachWebSocket } from './websocket.js';
61
- import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache } from './vendor.js';
61
+ import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile } from './vendor.js';
62
62
  import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
63
63
  import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
64
64
  import { analyzeElision, elideImportsFromSource } from './component-elision.js';
@@ -67,7 +67,7 @@ import { analyzeElision, elideImportsFromSource } from './component-elision.js';
67
67
  function kebab(name) {
68
68
  return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
69
69
  }
70
- import { setVendorEntries, setCoreInstall } from './importmap.js';
70
+ import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js';
71
71
  import { urlFromRequest } from './forwarded.js';
72
72
 
73
73
  const MIME = {
@@ -212,149 +212,346 @@ 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
-
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);
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
- );
215
+ // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
216
+ // deterministic vendor map that is cheap to read (one file, no analysis, no
217
+ // network). Resolve it AT BOOT and publish the build id immediately so the
218
+ // process advertises a stable, non-empty id from its very first response: a
219
+ // freshly-deployed pinned process is detected as a new deploy by old-deploy
220
+ // clients with zero warmup window. Mirrors Rails importmap (committed pins
221
+ // rendered deterministically at runtime). Pinning stays optional; an unpinned
222
+ // app does no vendor work at boot and publishes its id after the first
223
+ // successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
224
+ // gate, elision) and the UNPINNED jspm resolve stay deferred to the first
225
+ // request, so #143's win is intact; only the cheap committed-file read moves
226
+ // back to boot, and only when a VALID pin exists. A committed pin file is
227
+ // served as-is (elision never prunes it), so the boot-resolved map equals the
228
+ // final served map and the published id is authoritative.
229
+ //
230
+ // Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
231
+ // hasVendorPin is a cheap existence check; a malformed pin (exists but
232
+ // unparseable) must NOT short-circuit here, because resolveVendorImports would
233
+ // then fall through to its bare-import scan thunk, and the boot-time thunk is
234
+ // empty (the real scan is part of the deferred analysis). A broken pin instead
235
+ // falls through to the normal deferred resolve, which carries the real scan
236
+ // thunk and degrades gracefully, exactly as an unpinned app does.
237
+ let bootVendorPinned = false;
238
+ if (hasVendorPin(appDir) && (await readPinFile(appDir))) {
239
+ try {
240
+ const v = await resolveVendorImports(appDir, () => new Set());
241
+ await setVendorEntries(v.imports, v.integrity);
242
+ publishBuildId();
243
+ bootVendorPinned = true;
244
+ } catch (e) {
245
+ // An unexpected failure applying a VALID pin (e.g. setVendorEntries
246
+ // throwing) is non-fatal: leave bootVendorPinned false so the deferred
247
+ // resolve re-attempts on the first request. Boot stays resilient.
248
+ logger.error?.(`[webjs] applying the committed vendor pin at boot failed (will retry on the first request):`, e);
264
249
  }
265
250
  }
266
251
 
252
+ // Whole-app analysis (module graph, component scan, browser-bound gate,
253
+ // action index, middleware, elision, vendor) is NOT run at boot. It is
254
+ // computed on the first request via ensureReady() below and memoized, so the
255
+ // server starts without walking or reading the app's source, executing any
256
+ // server module, or hitting the network. Only the route table is built
257
+ // eagerly: it is a cheap directory scan (no code reads), and routing, Early
258
+ // Hints, and WebSocket lookups need it available before the first request.
259
+ const routeTable = await buildRouteTable(appDir);
260
+
267
261
  const state = {
268
262
  routeTable,
269
- actionIndex: await buildActionIndex(appDir, dev),
270
- middleware: await loadMiddleware(appDir, dev, logger),
263
+ actionIndex: null,
264
+ middleware: null,
271
265
  logger,
272
- bareImports,
273
- moduleGraph,
274
- elidableComponents,
275
- inertRouteModules,
276
- browserBoundFiles: computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir),
266
+ moduleGraph: null,
267
+ elidableComponents: new Set(),
268
+ inertRouteModules: new Set(),
269
+ browserBoundFiles: null,
277
270
  };
278
271
 
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.
272
+ // All whole-app analysis is built lazily on the first request, memoized so
273
+ // boot does none of it. It runs in two stages. The deterministic analysis
274
+ // (module graph, component scan + prime, browser-bound gate, action index,
275
+ // middleware, elision) is network-free and, once built, never re-runs unless
276
+ // a rebuild invalidates it; readiness gates on it. Vendor resolution is a
277
+ // SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
278
+ // an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
279
+ // offline or partially-unresolvable app still boots. A transient vendor
280
+ // failure is re-attempted on the NEXT ensureReady call (driven by an incoming
281
+ // request, a readiness probe, or the warm-up), with no background timer: the
282
+ // platform's traffic and probes are the retry loop. `readyError` holds a
283
+ // propagating analysis failure so /__webjs/ready can report it.
284
+ let analysisDone = false; // deterministic analysis complete (readiness gate)
285
+ // A pinned app already resolved + published its vendor map at boot (above), so
286
+ // the deferred vendor stage is a no-op from the start; an unpinned app starts
287
+ // false and resolves on the first request.
288
+ let vendorResolved = bootVendorPinned; // vendor map fully resolved (or permanently tolerated)
289
+ let vendorAttemptedOnce = bootVendorPinned; // the first (blocking) vendor attempt has run
290
+ let vendorGen = 0; // bumped on rebuild; a stale resolve cannot flip vendorResolved
291
+ let readyDone = false; // mirrors analysisDone; the /__webjs/ready gate
292
+ /** @type {unknown} */
293
+ let readyError = null;
294
+ /** @type {Promise<void> | null} */
295
+ let readyInFlight = null;
296
+ async function ensureReady() {
297
+ // Fully warm: analysis done and vendor resolved. Nothing to do.
298
+ if (analysisDone && vendorResolved) return;
299
+ // A warm pass is in flight (the analysis and/or the FIRST vendor attempt).
300
+ // Await it rather than serving past it: a concurrent early request must get
301
+ // the FINAL importmap, never a half-resolved one. This is what makes the
302
+ // unpinned warmup flawless. The first attempt's jspm resolve is
303
+ // timeout-bounded (vendor.js), so an offline app cannot hang here: on
304
+ // timeout the resolve returns and the response is served with an empty,
305
+ // reload-safe build id, then the retry below completes it. Without this
306
+ // wait, a request arriving mid-resolve would serve a partial map and an
307
+ // empty-then-changing build id, the exact warmup drift that hard-reloads
308
+ // and wipes a half-filled form.
309
+ if (readyInFlight) { await readyInFlight; return; }
310
+ // Analysis warm but the first vendor attempt already completed and failed:
311
+ // re-attempt WITHOUT blocking this request. The single-flight dedupes
312
+ // concurrent attempts; success flips the flag AND publishes the build id.
313
+ // This is the request/probe-driven retry (no timer). Until it succeeds the
314
+ // served build id stays empty (reload-safe), so no navigation hard-reloads.
315
+ if (analysisDone && vendorAttemptedOnce) {
316
+ const gen = vendorGen;
317
+ resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; publishBuildId(); } }).catch(() => {});
318
+ return;
319
+ }
320
+ // Otherwise run the (single-flighted) full warm: the analysis, then the
321
+ // first vendor attempt, awaited so the first response carries the import map.
322
+ if (!readyInFlight) {
323
+ readyInFlight = (async () => {
324
+ /** @type {Record<string, number>} */
325
+ const t = {};
326
+ let ranAnalysis = false, ranVendor = false;
327
+ const now = () => performance.now();
328
+ try {
329
+ if (!analysisDone) {
330
+ let m = now();
331
+ state.moduleGraph = await buildModuleGraph(appDir);
332
+ t.graph = now() - m; m = now();
333
+ const components = await scanComponents(appDir);
334
+ await primeComponentRegistry(appDir, components);
335
+ t.scan = now() - m; m = now();
336
+ state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);
337
+ t.gate = now() - m; m = now();
338
+ state.actionIndex = await buildActionIndex(appDir, dev);
339
+ t.actions = now() - m; m = now();
340
+ state.middleware = await loadMiddleware(appDir, dev, logger);
341
+ t.middleware = now() - m; m = now();
342
+ const r = (await readElideEnabled(appDir))
343
+ ? await analyzeElision(components, collectRouteModules(state.routeTable),
344
+ state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
345
+ : { elidableComponents: new Set(), inertRouteModules: new Set() };
346
+ state.elidableComponents = r.elidableComponents;
347
+ state.inertRouteModules = r.inertRouteModules;
348
+ t.elision = now() - m;
349
+ if (dev) {
350
+ for (const { className, file } of await findOrphanComponents(appDir)) {
351
+ logger.warn?.(
352
+ `[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
353
+ `Add \`customElements.define('<tag-name>', ${className});\` or <${kebab(className)}> tags won't upgrade.`,
354
+ );
355
+ }
356
+ }
357
+ analysisDone = true;
358
+ ranAnalysis = true;
359
+ }
360
+ readyError = null;
361
+ if (!vendorResolved) {
362
+ const m = now();
363
+ const gen = vendorGen;
364
+ vendorAttemptedOnce = true;
365
+ const ok = await resolveAndApplyVendor();
366
+ t.vendor = now() - m;
367
+ ranVendor = true;
368
+ // Only memoize success (and only if a rebuild didn't intervene). A
369
+ // transient failure leaves vendorResolved false; the next ensureReady
370
+ // call re-attempts it non-blocking. A permanent unresolvable (jspm
371
+ // 401) reports ok and is tolerated, so it does not loop. On success
372
+ // the importmap is now authoritatively final, so publish the build
373
+ // id: from here every response advertises the same stable value and
374
+ // the client router's deploy detection works without warmup drift.
375
+ if (ok && gen === vendorGen) { vendorResolved = true; publishBuildId(); }
376
+ }
377
+ // Readiness reflects a FULLY warm instance: the deterministic analysis
378
+ // AND the first vendor attempt have both completed (note: completed,
379
+ // not necessarily succeeded). A readiness-gated platform (Railway
380
+ // healthcheckPath, k8s readinessProbe) therefore admits traffic only
381
+ // AFTER the build id is published (vendor resolved) or definitively
382
+ // empty (a bounded vendor failure), never DURING the vendor-resolution
383
+ // window. This is what makes warm-up actually protect users: the prior
384
+ // instance keeps serving until the new one is fully warm, so a real
385
+ // request lands on a warm instance with a stable build id instead of
386
+ // racing the resolve. The first vendor attempt is bounded (the jspm
387
+ // fetch timeout in vendor.js), so an offline / CDN-degraded app still
388
+ // becomes ready shortly after that timeout, degraded but reload-safe,
389
+ // which preserves the boot resilience #143 introduced. The gate is the
390
+ // FIRST attempt only: a transient failure still flips readyDone here,
391
+ // so a later non-blocking retry never has to re-open the readiness gate.
392
+ readyDone = true;
393
+ if (ranAnalysis) {
394
+ const ms = (x) => Math.round(x || 0);
395
+ const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
396
+ logger.info?.(
397
+ `[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
398
+ `gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
399
+ `elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
400
+ );
401
+ } else if (ranVendor && vendorResolved) {
402
+ logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
403
+ }
404
+ } catch (e) {
405
+ readyError = e;
406
+ throw e;
407
+ } finally {
408
+ readyInFlight = null;
409
+ }
410
+ })();
411
+ }
412
+ await readyInFlight;
413
+ }
414
+
415
+ // All vendor resolves funnel through one single-flight so two never overlap
416
+ // (resolveVendorImports reports a transient failure via a module-global flag
417
+ // that only one in-flight resolve may safely touch). Never rejects; returns
418
+ // the resolve's ok flag (false on a transient failure, applying whatever
419
+ // partial map resolved so the app is no worse off).
420
+ /** @type {Promise<boolean> | null} */
421
+ let vendorResolveInFlight = null;
422
+ function resolveAndApplyVendor() {
423
+ if (vendorResolveInFlight) return vendorResolveInFlight;
424
+ vendorResolveInFlight = (async () => {
425
+ try {
426
+ const v = await resolveVendorImports(appDir,
427
+ () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules])));
428
+ await setVendorEntries(v.imports, v.integrity);
429
+ return v.ok;
430
+ } catch (e) {
431
+ logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
432
+ return false;
433
+ }
434
+ })().finally(() => { vendorResolveInFlight = null; });
435
+ return vendorResolveInFlight;
436
+ }
437
+
438
+ // Optional app-level readiness check. A `readiness.{js,ts}` file at the app
439
+ // root may default-export an async function; /__webjs/ready runs it once the
440
+ // analysis is warm, so readiness can reflect LIVE dependency health (a DB
441
+ // ping, a queue connection) that the static analysis cannot see. Returning
442
+ // false or throwing reports the instance not ready (503), so a readinessProbe
443
+ // holds traffic off an instance whose deps are down. Absent file => analysis-
444
+ // warm is the only gate. The module is cached per build (cleared on rebuild);
445
+ // the function itself runs on every probe so it reflects current state.
446
+ let readinessFn; // undefined = unloaded, null = no file, function = loaded
447
+ async function getReadinessCheck() {
448
+ if (readinessFn !== undefined) return readinessFn;
449
+ let file = null;
450
+ for (const name of ['readiness.ts', 'readiness.js', 'readiness.mts', 'readiness.mjs']) {
451
+ const p = join(appDir, name);
452
+ if (await exists(p)) { file = p; break; }
453
+ }
454
+ if (!file) { readinessFn = null; return null; }
455
+ try {
456
+ const url = pathToFileURL(file).toString();
457
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
458
+ const mod = await import(url + bust);
459
+ readinessFn = typeof mod.default === 'function' ? mod.default : null;
460
+ } catch (e) {
461
+ logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) });
462
+ readinessFn = null;
463
+ }
464
+ return readinessFn;
465
+ }
466
+
467
+ // Rebuilds are serialized so a slow rebuild #1 cannot overwrite a fresher
468
+ // rebuild #2's route table when it finally finishes. Without this, two file
469
+ // edits inside one fs.watch debounce window could produce a permanently
470
+ // stale state until the next rebuild.
286
471
  let rebuildInFlight = Promise.resolve();
287
- let latestRebuildToken = 0;
288
472
 
289
473
  async function rebuild() {
290
- const token = ++latestRebuildToken;
291
- rebuildInFlight = rebuildInFlight.then(() => doRebuild(token)).catch((e) => {
474
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild()).catch((e) => {
292
475
  logger.error?.(`[webjs] rebuild failed:`, e);
293
476
  });
294
477
  return rebuildInFlight;
295
478
  }
296
479
 
297
- async function doRebuild(token) {
480
+ async function doRebuild() {
481
+ // The route table is the only eager artifact (cheap directory scan); rebuild
482
+ // it so routing reflects added/removed route files immediately.
298
483
  state.routeTable = await buildRouteTable(appDir);
299
- state.actionIndex = await buildActionIndex(appDir, dev);
300
- state.middleware = await loadMiddleware(appDir, dev, logger);
301
484
  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
485
  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
- }
486
+ // Invalidate the lazy analysis; the next request rebuilds the graph,
487
+ // component scan, gate, action index, middleware, elision, and vendor map.
488
+ // Wait out any in-flight build first so it cannot commit stale results
489
+ // after the reset. A dependency edit can flip an elision verdict without
490
+ // changing an importer's mtime, hence the TS_CACHE.clear above.
491
+ if (readyInFlight) { try { await readyInFlight; } catch {} }
492
+ // Bump the vendor generation so a vendor resolve still in flight from the
493
+ // previous build cannot flip vendorResolved against the fresh state.
494
+ vendorGen++;
495
+ analysisDone = false;
496
+ vendorResolved = false;
497
+ vendorAttemptedOnce = false;
498
+ readyDone = false;
499
+ readyError = null;
500
+ readinessFn = undefined; // reload readiness.{js,ts} after a rebuild
352
501
  opts.onReload?.();
353
502
  }
354
503
 
355
504
  /** @param {Request} req */
356
505
  function handle(req) {
357
506
  return withRequest(req, async () => {
507
+ // Health and readiness probes are answered BEFORE ensureReady so a probe
508
+ // never blocks on the analysis. `/__webjs/health` is liveness (the
509
+ // process is up and accepting connections). `/__webjs/ready` is 503 until
510
+ // the instance is FULLY warm (the deterministic analysis AND the first
511
+ // vendor attempt have both completed, so the importmap build id is
512
+ // settled), then 200 unless an optional app readiness check
513
+ // (readiness.{js,ts}) reports a dependency down. So a readinessProbe holds
514
+ // traffic off a not-yet-warm or dependency-unhealthy instance, and admits
515
+ // it only once the build id is stable, never mid vendor-resolution.
516
+ // Probing `/__webjs/ready` also kicks off the warm in the background, so
517
+ // an embedder that never called warmup() still warms. The first vendor
518
+ // attempt is bounded (the jspm fetch timeout), so a vendor CDN failure
519
+ // delays readiness only briefly and then admits the instance (degraded but
520
+ // reload-safe); a transient failure is re-attempted on the next request.
521
+ let probePath;
522
+ try { probePath = new URL(req.url).pathname; } catch { probePath = ''; }
523
+ if (probePath === '/__webjs/health') {
524
+ return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
525
+ }
526
+ if (probePath === '/__webjs/ready') {
527
+ const noStore = { 'cache-control': 'no-store' };
528
+ if (!readyDone) {
529
+ ensureReady().catch(() => {}); // drive the warm; never block the probe
530
+ const body = readyError
531
+ ? { status: 'error', error: String((readyError && readyError.message) || readyError) }
532
+ : { status: 'pending' };
533
+ return Response.json(body, { status: 503, headers: noStore });
534
+ }
535
+ // Analysis is warm. Consult the optional app readiness check (live
536
+ // dependency health, e.g. a DB ping) if the app provides one.
537
+ const check = await getReadinessCheck();
538
+ if (check) {
539
+ try {
540
+ if ((await check()) === false) {
541
+ return Response.json({ status: 'unready' }, { status: 503, headers: noStore });
542
+ }
543
+ } catch (e) {
544
+ return Response.json(
545
+ { status: 'unready', error: String((e && e.message) || e) },
546
+ { status: 503, headers: noStore },
547
+ );
548
+ }
549
+ }
550
+ return Response.json({ status: 'ok' }, { headers: noStore });
551
+ }
552
+ // Build all whole-app analysis on the first request (memoized), before
553
+ // any SSR, module serve, gate check, action dispatch, or middleware runs.
554
+ await ensureReady();
358
555
  const next = () => handleCore(req, { state, appDir, coreDir, dev });
359
556
  if (state.middleware) {
360
557
  try {
@@ -389,6 +586,21 @@ export async function createRequestHandler(opts) {
389
586
  handle,
390
587
  rebuild,
391
588
  routeFor,
589
+ /**
590
+ * Proactively run the first-request analysis (module graph, component
591
+ * scan, gate, action index, middleware, elision, vendor map) in the
592
+ * background, so a real first request finds it already memoized. Safe to
593
+ * call any number of times and concurrently: the work is single-flighted,
594
+ * so this never duplicates it or races a real request. It is a single
595
+ * best-effort kick: errors are caught and logged rather than thrown (a
596
+ * background warm-up must not crash the process), and whatever failed simply
597
+ * re-runs on the next request or readiness probe (the platform's traffic and
598
+ * probes are the retry loop, so there is no internal backoff). `startServer`
599
+ * calls this once the HTTP server is listening; embedders can call it after
600
+ * their own listen.
601
+ * @returns {Promise<void>}
602
+ */
603
+ warmup: () => ensureReady().catch((e) => logger.error?.(`[webjs] background warm-up failed (will retry on the next request):`, e)),
392
604
  /** current route table getter: used by the WebSocket subsystem */
393
605
  getRouteTable: () => state.routeTable,
394
606
  appDir,
@@ -533,6 +745,11 @@ export async function startServer(opts) {
533
745
 
534
746
  server.listen(port, () => {
535
747
  logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
748
+ // The server is now accepting connections; warm the first-request analysis
749
+ // in the background so a real first request finds it memoized. Fire-and-
750
+ // forget: listening (and thus readiness probes / load-balancer health) does
751
+ // not wait on it, and a failure here does not bring the process down.
752
+ app.warmup();
536
753
  });
537
754
 
538
755
  const shutdown = gracefulShutdown(server, sseClients, logger);
@@ -571,10 +788,8 @@ async function handleCore(req, ctx) {
571
788
  try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
572
789
  const method = req.method.toUpperCase();
573
790
 
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
- }
791
+ // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) are handled
792
+ // in `handle()` BEFORE ensureReady, so they are not repeated here.
578
793
 
579
794
  // Dev live-reload client
580
795
  if (path === '/__webjs/reload.js') {
@@ -712,8 +927,9 @@ async function handleCore(req, ctx) {
712
927
  // Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
713
928
  // MUST NEVER be served as source to the browser. The extension is
714
929
  // 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.
930
+ // just rely on the action-index snapshot, which is built on the first
931
+ // request and refreshed on rebuild) so files created later, FS races,
932
+ // or developer error never punch through.
717
933
  //
718
934
  // What the browser gets depends on the file's `'use server'` status:
719
935
  // - With `'use server'` => server action: a generated RPC stub
@@ -1224,8 +1440,8 @@ function debounce(fn, ms) {
1224
1440
  * module graph into the full transitive closure.
1225
1441
  *
1226
1442
  * 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
1443
+ * manifest, derived lazily on the first request (and re-derived on every
1444
+ * rebuild) instead of at compile time. The dev server's source-file branch uses the returned
1229
1445
  * Set as an authorization gate: in-set → served (subject to the
1230
1446
  * .server.{js,ts} stub guardrail); out-of-set → 404.
1231
1447
  *
@@ -1245,7 +1461,7 @@ function debounce(fn, ms) {
1245
1461
  *
1246
1462
  * Components are passed in (rather than rescanned) so the caller can
1247
1463
  * share one scan with `primeComponentRegistry`. Saves a full
1248
- * appDir walk at boot and on every rebuild.
1464
+ * appDir walk on each analysis (the first request and every rebuild).
1249
1465
  *
1250
1466
  * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
1251
1467
  * @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
@@ -80,6 +81,46 @@ export function importMapHash() {
80
81
  return _importMapHash;
81
82
  }
82
83
 
84
+ /**
85
+ * The published, client-facing build id: the value stamped into the
86
+ * `data-webjs-build` attribute and the `X-Webjs-Build` header that the
87
+ * client router compares across navigations to detect a real deploy.
88
+ *
89
+ * Distinct from `importMapHash()` (the live hash of the current map).
90
+ * The published id is advertised ONLY once the importmap is
91
+ * authoritatively final, so the warmup window never advertises a value
92
+ * that later changes. Runtime-first boot resolves an unpinned app's
93
+ * vendor map over the first request; while that is in flight the live
94
+ * hash mutates (empty, then partial, then complete), but the published
95
+ * id stays `''` until the map is final. The router treats an empty
96
+ * build id as "version unknown" and never hard-reloads against it, so a
97
+ * not-yet-final response is reload-safe by construction and cannot wipe
98
+ * a half-filled form.
99
+ *
100
+ * Promoted by `publishBuildId()`: at boot for a pinned app (the
101
+ * committed map is deterministic), or after the first successful vendor
102
+ * resolve for an unpinned app.
103
+ *
104
+ * @returns {string} the advertised build id, or `''` until final
105
+ */
106
+ let _publishedBuildId = '';
107
+ export function publishedBuildId() {
108
+ return _publishedBuildId;
109
+ }
110
+
111
+ /**
112
+ * Promote the current `importMapHash()` to the advertised build id.
113
+ * Called by `dev.js` when the importmap becomes authoritatively final.
114
+ * Idempotent; the value only changes when the underlying map does, so
115
+ * re-publishing an unchanged map is a no-op for the client. Within a
116
+ * single process the published id therefore never changes after the
117
+ * first publish (a rebuild in dev re-publishes the fresh map, but dev
118
+ * already forces a full reload via SSE).
119
+ */
120
+ export function publishBuildId() {
121
+ _publishedBuildId = _importMapHash;
122
+ }
123
+
83
124
  /**
84
125
  * Look up the SRI integrity hash for a vendor URL, or empty string if
85
126
  * none. Used by ssr.js to add `integrity="..."` to modulepreload tags
@@ -228,8 +269,11 @@ export function buildCoreEntries(coreDir, distMode) {
228
269
  // The check is deliberately broad: `..` substring catches both
229
270
  // `../etc/passwd` and `./foo/../bar`.
230
271
  if (targetRel.includes('..')) continue;
231
- // `./directives` → `@webjsdev/core/directives`,
232
- // `./dist/webjs-core-directives.js` → `/__webjs/core/dist/webjs-core-directives.js`.
272
+ // `./lazy-loader` → `@webjsdev/core/lazy-loader`,
273
+ // `./dist/webjs-core-lazy-loader.js` → `/__webjs/core/dist/webjs-core-lazy-loader.js`.
274
+ // The browser-surface subpaths (`./directives`, `./context`, `./task`,
275
+ // `./client-router`) point their `default` at `webjs-core-browser.js`, so in
276
+ // dist mode they all collapse onto that one URL (the bundle re-exports them).
233
277
  out['@webjsdev/core' + subpath.slice(1)] = '/__webjs/core/' + targetRel.slice(2);
234
278
  }
235
279
  return out;
@@ -294,9 +338,12 @@ export function importMapTag(opts = {}) {
294
338
  // base64-ish. A misconfigured upstream emitting `nonce-<bad>` should
295
339
  // not get its `<` rendered raw into our HTML.
296
340
  const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
297
- // Stamp the build hash so the client router can detect post-deploy
298
- // importmap changes on intra-shell partial-response navigations.
299
- // See importMapHash() above for the rationale.
300
- const b = ` data-webjs-build="${importMapHash()}"`;
341
+ // Stamp the published build id so the client router can detect
342
+ // post-deploy importmap changes on intra-shell partial-response
343
+ // navigations. Uses publishedBuildId() (empty until the map is
344
+ // authoritatively final), NOT the live importMapHash(), so the warmup
345
+ // window never advertises an id that later changes. See
346
+ // publishedBuildId() above for the rationale.
347
+ const b = ` data-webjs-build="${publishedBuildId()}"`;
301
348
  return `<script type="importmap"${n}${b}>${jsonForScriptTag(buildImportMap())}</script>`;
302
349
  }
@@ -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/ssr.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy } from '@webjsdev/core';
4
- import { importMapTag, vendorIntegrityFor, importMapHash } from './importmap.js';
4
+ import { importMapTag, vendorIntegrityFor, publishedBuildId } from './importmap.js';
5
5
  import { jsonForScriptTag } from './script-tag-json.js';
6
6
  import { readToken, newToken, cookieHeader } from './csrf.js';
7
7
  import { transitiveDeps } from './module-graph.js';
@@ -174,11 +174,13 @@ function htmlResponse(html, status, req, url, metadata) {
174
174
  // Default: no caching. Pages are dynamic by default: the developer
175
175
  // opts in to caching explicitly via metadata.cacheControl.
176
176
  headers.set('cache-control', metadata?.cacheControl || 'no-store');
177
- // X-Webjs-Build carries the current importmap hash so the client
177
+ // X-Webjs-Build carries the published build id so the client
178
178
  // router can detect post-deploy importmap changes on EVERY
179
179
  // response, including the X-Webjs-Have partial responses that
180
- // omit the head entirely. See router-client.js applySwap.
181
- headers.set('x-webjs-build', importMapHash());
180
+ // omit the head entirely. Empty until the map is authoritatively
181
+ // final, so a warming response is reload-safe. See router-client.js
182
+ // applySwap and publishedBuildId() in importmap.js.
183
+ headers.set('x-webjs-build', publishedBuildId());
182
184
  if (req && !readToken(req)) {
183
185
  const secure = url ? url.protocol === 'https:' : false;
184
186
  headers.append('set-cookie', cookieHeader(newToken(), { secure }));
@@ -1189,9 +1191,9 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1189
1191
  // Default: no caching. Pages are dynamic by default: the developer
1190
1192
  // opts in to caching explicitly via metadata.cacheControl.
1191
1193
  headers.set('cache-control', metadata?.cacheControl || 'no-store');
1192
- // See htmlResponse: build hash on every response for the client
1193
- // router's importmap-mismatch detection on partial swaps.
1194
- headers.set('x-webjs-build', importMapHash());
1194
+ // See htmlResponse: published build id on every response for the
1195
+ // client router's importmap-mismatch detection on partial swaps.
1196
+ headers.set('x-webjs-build', publishedBuildId());
1195
1197
  if (req && !readToken(req)) {
1196
1198
  const secure = url ? url.protocol === 'https:' : false;
1197
1199
  headers.append('set-cookie', cookieHeader(newToken(), { secure }));
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
@@ -462,6 +480,21 @@ function pinFilePath(appDir) {
462
480
  return join(pinDir(appDir), PIN_FILE);
463
481
  }
464
482
 
483
+ /**
484
+ * True when the app commits a vendor pin file (`.webjs/vendor/importmap.json`).
485
+ * A pinned app's importmap is deterministic and cheap to read, so `dev.js`
486
+ * resolves it AT BOOT (no analysis, no network) and publishes the build id
487
+ * immediately, giving the recommended posture a stable id from the first
488
+ * response with zero warmup exposure. An unpinned app returns false and keeps
489
+ * its vendor resolution deferred to the first request.
490
+ *
491
+ * @param {string} appDir
492
+ * @returns {boolean}
493
+ */
494
+ export function hasVendorPin(appDir) {
495
+ return existsSync(pinFilePath(appDir));
496
+ }
497
+
465
498
  /**
466
499
  * Filesystem-safe filename for a downloaded bundle. Encodes the full
467
500
  * specifier (which may include a subpath) into a flat filename:
@@ -1257,8 +1290,8 @@ function maxSemverVersion(versions) {
1257
1290
 
1258
1291
  /**
1259
1292
  * 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.
1293
+ * committed pin file over a live api.jspm.io call. Called from
1294
+ * `ensureReady()` in dev.js on the first request, never at boot.
1262
1295
  *
1263
1296
  * Order of preference:
1264
1297
  * 1. `.webjs/vendor/importmap.json` (committed; no network needed)
@@ -1270,17 +1303,29 @@ function maxSemverVersion(versions) {
1270
1303
  * hash, defeating the live-mode speed advantage. Users who want SRI
1271
1304
  * run `webjs vendor pin`).
1272
1305
  *
1273
- * @param {Set<string>} bareImports
1274
1306
  * @param {string} appDir
1307
+ * @param {() => Promise<Set<string>>} getBareImports lazy scan, invoked ONLY
1308
+ * on the unpinned path (so a pinned app never pays the whole-app walk).
1275
1309
  * @returns {Promise<{ imports: Record<string, string>, integrity: Record<string, string> }>}
1276
1310
  */
1277
- export async function resolveVendorImports(bareImports, appDir) {
1311
+ export async function resolveVendorImports(appDir, getBareImports) {
1278
1312
  const file = await readPinFile(appDir);
1313
+ // A committed pin file IS the import map. The whole-app bare-import scan is
1314
+ // discarded in that case, so it must never run (runtime-first boot: no
1315
+ // static analysis when pinned). The scan is supplied as a thunk and invoked
1316
+ // solely here, only when there is no pin file.
1279
1317
  if (file) {
1280
- return { imports: file.imports, integrity: file.integrity || {} };
1318
+ // A pin file is a deterministic disk read: always "ok" (no live CDN call
1319
+ // that could partially fail). This is the recommended prod posture.
1320
+ return { imports: file.imports, integrity: file.integrity || {}, ok: true };
1281
1321
  }
1322
+ lastLiveResolveFailed = false;
1323
+ const bareImports = await getBareImports();
1282
1324
  const imports = await vendorImportMapEntries(bareImports, appDir);
1283
- return { imports, integrity: {} };
1325
+ // ok=false means at least one install could not be resolved (CDN unreachable
1326
+ // / timeout / non-ok), so `imports` is partial. The caller must not memoize
1327
+ // this as done; it should retry once the CDN recovers.
1328
+ return { imports, integrity: {}, ok: !lastLiveResolveFailed };
1284
1329
  }
1285
1330
 
1286
1331
  /**