@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 +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 +345 -129
- package/src/importmap.js +58 -11
- package/src/module-graph.js +44 -7
- package/src/ssr.js +9 -7
- package/src/vendor.js +55 -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
|
@@ -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
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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:
|
|
270
|
-
middleware:
|
|
263
|
+
actionIndex: null,
|
|
264
|
+
middleware: null,
|
|
271
265
|
logger,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
|
575
|
-
|
|
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
|
|
716
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
-
// `./
|
|
232
|
-
// `./dist/webjs-core-
|
|
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
|
|
298
|
-
// importmap changes on intra-shell partial-response
|
|
299
|
-
//
|
|
300
|
-
|
|
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
|
}
|
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/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,
|
|
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
|
|
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.
|
|
181
|
-
|
|
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
|
|
1193
|
-
// router's importmap-mismatch detection on partial swaps.
|
|
1194
|
-
headers.set('x-webjs-build',
|
|
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
|
-
*
|
|
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
|
|
@@ -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
|
|
1261
|
-
* at
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|