@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 +1 -1
- package/src/actions.js +19 -3
- package/src/check.js +1 -1
- package/src/component-elision.js +80 -26
- package/src/component-scanner.js +36 -5
- package/src/dev.js +269 -128
- package/src/importmap.js +6 -5
- package/src/module-graph.js +44 -7
- package/src/vendor.js +40 -10
package/package.json
CHANGED
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
|
|
50
|
-
* RPC-callable actions vs. server-only
|
|
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
|
-
//
|
|
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
|
|
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',
|
package/src/component-elision.js
CHANGED
|
@@ -648,28 +648,80 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
648
648
|
}
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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 =
|
|
663
|
-
if (deps
|
|
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
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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);
|
|
744
|
+
if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); queue.push(childFile); }
|
|
690
745
|
}
|
|
691
746
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
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
|
}
|
package/src/component-scanner.js
CHANGED
|
@@ -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
|
|
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
|
|
80
|
-
try {
|
|
81
|
-
|
|
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
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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:
|
|
270
|
-
middleware:
|
|
226
|
+
actionIndex: null,
|
|
227
|
+
middleware: null,
|
|
271
228
|
logger,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
575
|
-
|
|
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
|
|
716
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/module-graph.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lightweight module dependency graph.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
32
|
-
* `
|
|
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
|
-
* `
|
|
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
|
|
1261
|
-
* at
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|