@webjsdev/server 0.8.62 → 0.8.64
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/index.d.ts +32 -3
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +1 -1
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/rate-limit.js +50 -5
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { extractPackageName } from './scanner.js';
|
|
2
|
+
import { SUPPORTED_PROVIDERS } from './providers.js';
|
|
3
|
+
import { PIN_BUNDLE_TIMEOUT_MS, fetchIntegrity, satisfiesSemverRange } from './integrity.js';
|
|
4
|
+
import { jspmGenerate } from './jspm.js';
|
|
5
|
+
import { readPinFile, listPinned, writePinFile } from './pins.js';
|
|
6
|
+
|
|
7
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
8
|
+
// The npm registry is reached only by `audit`, `outdated`, and `update`,
|
|
9
|
+
// all CLI commands, so it takes the same generous budget the pin bundle
|
|
10
|
+
// fetch does rather than the server's. importmap-rails makes these same two
|
|
11
|
+
// calls with Ruby's 60s Net::HTTP default.
|
|
12
|
+
const NPM_TIMEOUT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fetch one URL from registry.npmjs.org with a small timeout. Returns
|
|
16
|
+
* the parsed JSON body on 2xx, or null on any non-2xx / network /
|
|
17
|
+
* timeout. Used by audit + outdated.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} url
|
|
20
|
+
* @param {RequestInit} [init]
|
|
21
|
+
* @returns {Promise<any | null>}
|
|
22
|
+
*/
|
|
23
|
+
async function fetchNpmJson(url, init) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), NPM_TIMEOUT_MS);
|
|
26
|
+
try {
|
|
27
|
+
const resp = await fetch(url, { ...init, signal: controller.signal });
|
|
28
|
+
if (!resp.ok) return null;
|
|
29
|
+
return await resp.json();
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
finally { clearTimeout(timer); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Group the pin file's entries by package name + the set of versions
|
|
36
|
+
* actually pinned (a single package can be pinned at multiple versions
|
|
37
|
+
* via subpath imports). Used by audit (npm advisories want
|
|
38
|
+
* `{ pkgName: [versions] }`) and outdated (one query per package).
|
|
39
|
+
*
|
|
40
|
+
* @param {Array<{ pkg: string, version: string }>} entries
|
|
41
|
+
* @returns {Map<string, Set<string>>}
|
|
42
|
+
*/
|
|
43
|
+
function groupPinnedByPackage(entries) {
|
|
44
|
+
const out = new Map();
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (!e.version || e.version === '(unknown)') continue;
|
|
47
|
+
// entries[].pkg can include a subpath (e.g. `dayjs/plugin/utc`).
|
|
48
|
+
// Extract the bare package name (`dayjs` or `@scope/name`).
|
|
49
|
+
const bare = extractPackageName(e.pkg) || e.pkg;
|
|
50
|
+
if (!out.has(bare)) out.set(bare, new Set());
|
|
51
|
+
out.get(bare).add(e.version);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Lightweight semver-aware comparison (no prerelease tags). Returns
|
|
58
|
+
* negative if a < b, zero if equal, positive if a > b. Used by
|
|
59
|
+
* findOutdated to decide if `current` lags `latest`. Non-numeric
|
|
60
|
+
* segments fall back to string compare so prerelease-ish strings
|
|
61
|
+
* still sort somewhere.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} a
|
|
64
|
+
* @param {string} b
|
|
65
|
+
* @returns {number}
|
|
66
|
+
*/
|
|
67
|
+
function compareSemver(a, b) {
|
|
68
|
+
const aParts = a.split(/[.+-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
|
|
69
|
+
const bParts = b.split(/[.+-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
|
|
70
|
+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
71
|
+
const ai = aParts[i] ?? 0;
|
|
72
|
+
const bi = bParts[i] ?? 0;
|
|
73
|
+
if (typeof ai === 'number' && typeof bi === 'number') {
|
|
74
|
+
if (ai !== bi) return ai - bi;
|
|
75
|
+
} else if (ai !== bi) {
|
|
76
|
+
return String(ai) < String(bi) ? -1 : 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {string[]} versions */
|
|
83
|
+
function maxSemverVersion(versions) {
|
|
84
|
+
return versions.reduce((max, v) => compareSemver(v, max) > 0 ? v : max, versions[0]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run a security audit against the pinned versions in the committed
|
|
89
|
+
* pin file. POSTs to npm's bulk-advisory endpoint, the same one
|
|
90
|
+
* `npm audit` uses internally.
|
|
91
|
+
*
|
|
92
|
+
* Returns `{ errored: true }` when the registry call failed (network
|
|
93
|
+
* down, timeout, 5xx) so the CLI can surface the failure clearly
|
|
94
|
+
* instead of misleading the user with "no vulnerabilities found".
|
|
95
|
+
*
|
|
96
|
+
* Mirrors importmap-rails's `bin/importmap audit`.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} appDir
|
|
99
|
+
* @returns {Promise<{
|
|
100
|
+
* vulnerable: Array<{ name: string, severity: string, vulnerableVersions: string, title: string }>,
|
|
101
|
+
* totalChecked: number,
|
|
102
|
+
* errored?: boolean,
|
|
103
|
+
* }>}
|
|
104
|
+
*/
|
|
105
|
+
export async function auditPinned(appDir) {
|
|
106
|
+
const entries = await listPinned(appDir);
|
|
107
|
+
if (!entries.length) return { vulnerable: [], totalChecked: 0 };
|
|
108
|
+
const grouped = groupPinnedByPackage(entries);
|
|
109
|
+
const body = {};
|
|
110
|
+
for (const [pkg, versions] of grouped) body[pkg] = [...versions];
|
|
111
|
+
const result = await fetchNpmJson(`${NPM_REGISTRY}/-/npm/v1/security/advisories/bulk`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify(body),
|
|
115
|
+
});
|
|
116
|
+
const totalChecked = grouped.size;
|
|
117
|
+
if (result === null) {
|
|
118
|
+
// Distinguish "registry returned no advisories" (success, empty
|
|
119
|
+
// object) from "couldn't reach registry" (null). The latter is
|
|
120
|
+
// user-visible because a silent "no vulnerabilities" on a failed
|
|
121
|
+
// call would falsely reassure the user.
|
|
122
|
+
return { vulnerable: [], totalChecked, errored: true };
|
|
123
|
+
}
|
|
124
|
+
if (typeof result !== 'object') return { vulnerable: [], totalChecked };
|
|
125
|
+
/** @type {Array<{ name: string, severity: string, vulnerableVersions: string, title: string }>} */
|
|
126
|
+
const vulnerable = [];
|
|
127
|
+
for (const [name, advisories] of Object.entries(result)) {
|
|
128
|
+
if (!Array.isArray(advisories)) continue;
|
|
129
|
+
for (const a of advisories) {
|
|
130
|
+
vulnerable.push({
|
|
131
|
+
name,
|
|
132
|
+
severity: String(a?.severity || 'unknown'),
|
|
133
|
+
vulnerableVersions: String(a?.vulnerable_versions || a?.range || ''),
|
|
134
|
+
title: String(a?.title || a?.overview || ''),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { vulnerable, totalChecked };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Find pinned packages that have a newer version available on npm.
|
|
143
|
+
* Queries `registry.npmjs.org/<pkg>` per pinned package, compares the
|
|
144
|
+
* pinned version against `dist-tags.latest` with semver-shaped string
|
|
145
|
+
* ordering (regex parse, then numeric compare per segment).
|
|
146
|
+
*
|
|
147
|
+
* Mirrors importmap-rails's `bin/importmap outdated`.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} appDir
|
|
150
|
+
* @returns {Promise<Array<{ pkg: string, current: string, latest: string }>>}
|
|
151
|
+
*/
|
|
152
|
+
export async function findOutdated(appDir) {
|
|
153
|
+
const entries = await listPinned(appDir);
|
|
154
|
+
if (!entries.length) return [];
|
|
155
|
+
const grouped = groupPinnedByPackage(entries);
|
|
156
|
+
// Fetch in parallel. With sequential awaits a 50-package project
|
|
157
|
+
// could take 50 × 10s = 500s in the worst case (one npm registry
|
|
158
|
+
// timeout each). Parallel `Promise.all` collapses this to one
|
|
159
|
+
// round-trip's wall-clock, while staying well below npm registry's
|
|
160
|
+
// unauthenticated-client soft rate limit (registry-side concern,
|
|
161
|
+
// not ours to throttle).
|
|
162
|
+
//
|
|
163
|
+
// Scoped packages: the `/` between `@scope` and `name` is part of
|
|
164
|
+
// the URL path, NOT a path separator that should be encoded.
|
|
165
|
+
// `encodeURIComponent` would emit `%2F`, which the npm registry
|
|
166
|
+
// accepts but other npm-compatible registries (Verdaccio, JFrog,
|
|
167
|
+
// GitHub Packages) sometimes reject. The npm-cli uses the literal
|
|
168
|
+
// form. npm package-name rules disallow URL-unsafe chars so this
|
|
169
|
+
// is safe.
|
|
170
|
+
const queries = [...grouped].map(async ([pkg, versions]) => {
|
|
171
|
+
const meta = await fetchNpmJson(`${NPM_REGISTRY}/${pkg}`);
|
|
172
|
+
const latest = meta?.['dist-tags']?.latest;
|
|
173
|
+
if (typeof latest !== 'string') return null;
|
|
174
|
+
// A package can be pinned at multiple versions (subpath imports).
|
|
175
|
+
// Take the max pinned version as the "current" for the comparison
|
|
176
|
+
// so we only report it as outdated when EVERY pinned version
|
|
177
|
+
// trails latest.
|
|
178
|
+
const current = maxSemverVersion([...versions]);
|
|
179
|
+
if (compareSemver(current, latest) >= 0) return null;
|
|
180
|
+
return { pkg, current, latest };
|
|
181
|
+
});
|
|
182
|
+
const results = await Promise.all(queries);
|
|
183
|
+
// `return` followed by a newline triggers ASI: `return; (expr);`
|
|
184
|
+
// returns undefined and drops the value. Keep the filter on the
|
|
185
|
+
// same line as `return` (or pull the result into a variable
|
|
186
|
+
// first) to avoid the trap.
|
|
187
|
+
return results.filter((x) => x !== null);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Re-pin every package returned by findOutdated to its latest version.
|
|
192
|
+
* Calls jspm.io's Generator API with `<pkg>@<latest>` for each
|
|
193
|
+
* outdated entry, then writes the new pin file.
|
|
194
|
+
*
|
|
195
|
+
* Mirrors importmap-rails's `bin/importmap update`, with the same
|
|
196
|
+
* caveat: this updates the pin file but does NOT update the user's
|
|
197
|
+
* `package.json` / `node_modules`. The user should run `npm install
|
|
198
|
+
* <pkg>@<latest>` afterward to keep package.json in sync.
|
|
199
|
+
*
|
|
200
|
+
* When `opts.from` is not passed, the existing pin file's `provider`
|
|
201
|
+
* field is used (so a user who pinned `--from jsdelivr` originally
|
|
202
|
+
* stays on jsdelivr after update). When the file has no provider
|
|
203
|
+
* field, defaults to `jspm`.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} appDir
|
|
206
|
+
* @param {{ from?: string }} [opts]
|
|
207
|
+
* @returns {Promise<{ updated: Array<{ pkg: string, from: string, to: string }>, noOutdated?: boolean, provider?: string }>}
|
|
208
|
+
*/
|
|
209
|
+
export async function updatePinned(appDir, opts = {}) {
|
|
210
|
+
const file = await readPinFile(appDir);
|
|
211
|
+
// Provider precedence:
|
|
212
|
+
// 1. explicit opts.from (CLI flag wins)
|
|
213
|
+
// 2. pin file's persisted provider
|
|
214
|
+
// 3. default 'jspm'
|
|
215
|
+
// Validate AFTER resolving so a stale pin file with a previously-
|
|
216
|
+
// valid-but-now-removed provider still errors clearly.
|
|
217
|
+
const from = opts.from || file?.provider || 'jspm';
|
|
218
|
+
if (!SUPPORTED_PROVIDERS.has(from)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[webjs] unknown provider '${from}'. Supported: ${[...SUPPORTED_PROVIDERS].join(', ')}.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const outdated = await findOutdated(appDir);
|
|
224
|
+
if (!outdated.length) return { updated: [], noOutdated: true, provider: from };
|
|
225
|
+
if (!file) return { updated: [], provider: from };
|
|
226
|
+
const newImports = { ...file.imports };
|
|
227
|
+
const newIntegrity = { ...(file.integrity || {}) };
|
|
228
|
+
/** @type {Array<{ pkg: string, from: string, to: string }>} */
|
|
229
|
+
const updated = [];
|
|
230
|
+
for (const { pkg, current, latest } of outdated) {
|
|
231
|
+
// Resolve the new version via jspm.io. The Generator API
|
|
232
|
+
// returns URLs for `<pkg>@<latest>` (and any subpath we ask
|
|
233
|
+
// for, but for update we just refresh the bare root pin and
|
|
234
|
+
// any subpaths that were already pinned).
|
|
235
|
+
let anySpecUpdated = false;
|
|
236
|
+
for (const [spec, oldUrl] of Object.entries(file.imports)) {
|
|
237
|
+
const specPkg = extractPackageName(spec) || spec;
|
|
238
|
+
if (specPkg !== pkg) continue;
|
|
239
|
+
const subpath = spec.slice(specPkg.length);
|
|
240
|
+
const install = `${pkg}@${latest}${subpath}`;
|
|
241
|
+
const resolved = await jspmGenerate([install], from, PIN_BUNDLE_TIMEOUT_MS);
|
|
242
|
+
const newUrl = resolved[spec];
|
|
243
|
+
if (!newUrl) continue;
|
|
244
|
+
newImports[spec] = newUrl;
|
|
245
|
+
// Recompute integrity for the new URL. Drop the stale entry
|
|
246
|
+
// even on fetch failure so the new pin doesn't carry the
|
|
247
|
+
// wrong hash silently.
|
|
248
|
+
delete newIntegrity[oldUrl];
|
|
249
|
+
const sri = await fetchIntegrity(newUrl);
|
|
250
|
+
if (sri) newIntegrity[newUrl] = sri;
|
|
251
|
+
anySpecUpdated = true;
|
|
252
|
+
}
|
|
253
|
+
// Only report `pkg` as updated when at least one spec actually
|
|
254
|
+
// got a new URL. If every subpath failed to resolve via
|
|
255
|
+
// jspm.io (transient outage, the new version not yet indexed),
|
|
256
|
+
// the CLI must not lie about having updated it.
|
|
257
|
+
if (anySpecUpdated) updated.push({ pkg, from: current, to: latest });
|
|
258
|
+
}
|
|
259
|
+
await writePinFile(appDir, newImports, newIntegrity, from);
|
|
260
|
+
return { updated, provider: from };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Extract `{ basePackage -> pinned version }` from an importmap's `imports`
|
|
265
|
+
* map. Each value is a CDN URL (jspm.io's `npm:dayjs@1.11.13/...`, jsdelivr's
|
|
266
|
+
* `npm/dayjs@1.11.13/...`, unpkg's bare `dayjs@1.11.13/...`, skypack's
|
|
267
|
+
* `dayjs@1.11.13`) or a local `/__webjs/vendor/<pkg>@<version>...js` path. The
|
|
268
|
+
* key is the bare package name parsed from the importmap KEY (the specifier),
|
|
269
|
+
* which is authoritative; the version is parsed from the URL.
|
|
270
|
+
*
|
|
271
|
+
* A specifier that resolves to a version we cannot parse from its URL is
|
|
272
|
+
* skipped (it contributes nothing to the dep graph rather than a wrong pin).
|
|
273
|
+
* When the same base package appears at several versions (subpath imports),
|
|
274
|
+
* the LAST parsed wins; in practice every subpath of a package resolves to the
|
|
275
|
+
* one installed version, so they agree.
|
|
276
|
+
*
|
|
277
|
+
* @param {Record<string, string>} imports importmap `imports` map (specifier -> URL)
|
|
278
|
+
* @returns {Map<string, string>} base package name -> pinned version
|
|
279
|
+
*/
|
|
280
|
+
export function extractPinnedVersions(imports) {
|
|
281
|
+
/** @type {Map<string, string>} */
|
|
282
|
+
const out = new Map();
|
|
283
|
+
for (const [spec, url] of Object.entries(imports || {})) {
|
|
284
|
+
if (typeof url !== 'string') continue;
|
|
285
|
+
const bare = extractPackageName(spec);
|
|
286
|
+
if (!bare) continue;
|
|
287
|
+
let version = null;
|
|
288
|
+
if (url.startsWith('/__webjs/vendor/')) {
|
|
289
|
+
// Local downloaded-pin path: `<name>@<version>[__subpath].js`. The name
|
|
290
|
+
// is `--`-encoded for scoped packages; we only need the version, which
|
|
291
|
+
// sits after the LAST `@` and before any `__subpath` / `.js` suffix.
|
|
292
|
+
const filename = url.slice('/__webjs/vendor/'.length);
|
|
293
|
+
const atIdx = filename.lastIndexOf('@');
|
|
294
|
+
if (atIdx > 0) {
|
|
295
|
+
const afterAt = filename.slice(atIdx + 1, filename.endsWith('.js') ? -3 : undefined);
|
|
296
|
+
const subIdx = afterAt.indexOf('__');
|
|
297
|
+
version = subIdx < 0 ? afterAt : afterAt.slice(0, subIdx);
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
// CDN URL: find `<bare>@<version>` anchored on a non-name char (or the
|
|
301
|
+
// string start) so a short name like `ms` does not false-match inside
|
|
302
|
+
// another package's URL. Mirrors listPinned's parser.
|
|
303
|
+
const escapedBare = bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
304
|
+
const m = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
|
|
305
|
+
if (m) version = m[1];
|
|
306
|
+
}
|
|
307
|
+
if (version && /\d/.test(version)) out.set(bare, version);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Validate that a produced importmap's pinned dependency graph is COHERENT
|
|
314
|
+
* (issue #450). For each resolved package, read its declared `dependencies`
|
|
315
|
+
* and `peerDependencies` (via the injected `getManifest`) and, for every
|
|
316
|
+
* declared range that targets ANOTHER package ALSO pinned in this importmap,
|
|
317
|
+
* check the pinned version satisfies the range. A miss is a conflict naming
|
|
318
|
+
* both packages, the required range, and the pinned version.
|
|
319
|
+
*
|
|
320
|
+
* This is the SAME function the doctor runs over the live importmap and over
|
|
321
|
+
* `.webjs/vendor/importmap.json`. It is pure in `(imports, getManifest)`, so
|
|
322
|
+
* the same pinned dep set yields the same verdict regardless of which input it
|
|
323
|
+
* came from (the runtime-vs-vendored parity invariant).
|
|
324
|
+
*
|
|
325
|
+
* Degrades gracefully: a package whose manifest `getManifest` cannot supply
|
|
326
|
+
* (not installed, unreadable, network unavailable) is recorded under
|
|
327
|
+
* `unverified` and contributes NO conflict, so the check reports "could not
|
|
328
|
+
* verify" rather than failing closed. A declared range in a shape we cannot
|
|
329
|
+
* statically evaluate (see `satisfiesSemverRange` -> null) is likewise skipped,
|
|
330
|
+
* never warned on.
|
|
331
|
+
*
|
|
332
|
+
* @param {Record<string, string>} imports importmap `imports` map
|
|
333
|
+
* @param {{
|
|
334
|
+
* getManifest: (pkg: string, version: string) =>
|
|
335
|
+
* ({ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null
|
|
336
|
+
* | Promise<{ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null>),
|
|
337
|
+
* }} opts `getManifest` returns the declared dep ranges for a resolved
|
|
338
|
+
* `pkg@version`, or null when unavailable (degrade to "unverified").
|
|
339
|
+
* @returns {Promise<CoherenceReport>}
|
|
340
|
+
*/
|
|
341
|
+
export async function checkImportmapCoherence(imports, opts) {
|
|
342
|
+
const pinned = extractPinnedVersions(imports);
|
|
343
|
+
/** @type {CoherenceConflict[]} */
|
|
344
|
+
const conflicts = [];
|
|
345
|
+
/** @type {Array<{ pkg: string, reason: string }>} */
|
|
346
|
+
const unverified = [];
|
|
347
|
+
// Sort for deterministic output: the same dep set always yields the same
|
|
348
|
+
// ordering, which keeps the verdict (and any test asserting it) stable and
|
|
349
|
+
// strengthens the parity guarantee end to end.
|
|
350
|
+
const packages = [...pinned.keys()].sort();
|
|
351
|
+
// Count of packages whose metadata we could actually read (so a conflict
|
|
352
|
+
// verdict is grounded). A package whose manifest is unavailable lands in
|
|
353
|
+
// `unverified` instead and does NOT count as checked, which lets the caller
|
|
354
|
+
// distinguish "verified coherent" from "could not verify anything".
|
|
355
|
+
let checked = 0;
|
|
356
|
+
for (const pkg of packages) {
|
|
357
|
+
const version = pinned.get(pkg);
|
|
358
|
+
let manifest;
|
|
359
|
+
try {
|
|
360
|
+
manifest = await opts.getManifest(pkg, version);
|
|
361
|
+
} catch {
|
|
362
|
+
manifest = null;
|
|
363
|
+
}
|
|
364
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
365
|
+
unverified.push({ pkg, reason: `could not read dependency metadata for ${pkg}@${version}` });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
checked++;
|
|
369
|
+
const groups = /** @type {const} */ ([
|
|
370
|
+
['dependency', manifest.dependencies],
|
|
371
|
+
['peerDependency', manifest.peerDependencies],
|
|
372
|
+
]);
|
|
373
|
+
for (const [kind, deps] of groups) {
|
|
374
|
+
if (!deps || typeof deps !== 'object') continue;
|
|
375
|
+
for (const [depName, range] of Object.entries(deps)) {
|
|
376
|
+
// Only edges INTO the pinned graph matter: a dep on a package that is
|
|
377
|
+
// not in this importmap is not the importmap's coherence problem (it
|
|
378
|
+
// is either bundled into a CDN megabundle or simply unused on the
|
|
379
|
+
// client). Self-edges cannot conflict.
|
|
380
|
+
if (depName === pkg) continue;
|
|
381
|
+
const depPinned = pinned.get(depName);
|
|
382
|
+
if (!depPinned) continue;
|
|
383
|
+
const ok = satisfiesSemverRange(depPinned, String(range));
|
|
384
|
+
if (ok === false) {
|
|
385
|
+
conflicts.push({
|
|
386
|
+
pkg,
|
|
387
|
+
version: String(version),
|
|
388
|
+
dependsOn: depName,
|
|
389
|
+
kind,
|
|
390
|
+
requiredRange: String(range),
|
|
391
|
+
pinnedVersion: depPinned,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// ok === null (range shape not understood) is silently skipped: the
|
|
395
|
+
// check never warns on a range it could not evaluate.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { conflicts, unverified, checked };
|
|
400
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { digestBase64 } from '../crypto-utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute the SHA-384 SRI hash for a bundle body. Matches the format
|
|
5
|
+
* the browser's importmap `integrity` field and the `integrity`
|
|
6
|
+
* attribute on `<link rel="modulepreload">` expect. Accepts a string
|
|
7
|
+
* or any ArrayBufferView / ArrayBuffer.
|
|
8
|
+
*
|
|
9
|
+
* @param {string | ArrayBufferView | ArrayBuffer} body
|
|
10
|
+
* @returns {Promise<string>} e.g. `sha384-<base64>`
|
|
11
|
+
*/
|
|
12
|
+
export async function sha384Integrity(body) {
|
|
13
|
+
return `sha384-${await digestBase64('SHA-384', body)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a version string to a `[major, minor, patch]` numeric triple, or null
|
|
18
|
+
* when it has no parseable numeric core (a `latest`, a git URL, a `*`).
|
|
19
|
+
*
|
|
20
|
+
* KNOWN LIMITATION: a prerelease / build suffix (`-rc.1`, `+sha`) is dropped, so
|
|
21
|
+
* a version is judged purely on its release line. A pinned prerelease is treated
|
|
22
|
+
* as its stable tuple: `6.42.0-beta.1` is judged as `6.42.0`, so a stable range
|
|
23
|
+
* like `^6.42.0` reports it as a MATCH even though npm semver excludes a
|
|
24
|
+
* prerelease from a stable range. This is a deliberate fail-safe simplification
|
|
25
|
+
* (we do not carry prerelease ordering): the only consequence is a MISSED
|
|
26
|
+
* coherence warning when a prerelease is pinned, never a spurious one on a
|
|
27
|
+
* coherent graph. Pinned prereleases are vanishingly rare in a vendored
|
|
28
|
+
* importmap, so the missed-warning risk is negligible.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} v
|
|
31
|
+
* @returns {[number, number, number] | null}
|
|
32
|
+
*/
|
|
33
|
+
export function parseSemver(v) {
|
|
34
|
+
const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(v == null ? '' : v));
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
return [Number(m[1]), Number(m[2] || 0), Number(m[3] || 0)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compare two `[major, minor, patch]` triples. Negative if a < b, 0 if equal,
|
|
41
|
+
* positive if a > b.
|
|
42
|
+
* @param {[number, number, number]} a
|
|
43
|
+
* @param {[number, number, number]} b
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
export function cmpSemver(a, b) {
|
|
47
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {{
|
|
52
|
+
* pkg: string,
|
|
53
|
+
* version: string,
|
|
54
|
+
* dependsOn: string,
|
|
55
|
+
* kind: 'dependency' | 'peerDependency',
|
|
56
|
+
* requiredRange: string,
|
|
57
|
+
* pinnedVersion: string,
|
|
58
|
+
* }} CoherenceConflict
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {{
|
|
63
|
+
* conflicts: CoherenceConflict[],
|
|
64
|
+
* unverified: Array<{ pkg: string, reason: string }>,
|
|
65
|
+
* checked: number,
|
|
66
|
+
* }} CoherenceReport
|
|
67
|
+
* `checked` counts the pinned packages whose dependency metadata was actually
|
|
68
|
+
* read (so a clean verdict is grounded in real data); a package whose manifest
|
|
69
|
+
* was unavailable is in `unverified` and is NOT counted as checked. `checked
|
|
70
|
+
* === 0` with a non-empty `unverified` therefore means "could not verify
|
|
71
|
+
* anything", which the caller surfaces as a soft degrade rather than "coherent".
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Does `version` satisfy the npm `range`? PRAGMATIC, no semver dependency
|
|
76
|
+
* (vendor.js stays dependency-free). Supports the shapes that appear in real
|
|
77
|
+
* `dependencies` / `peerDependencies`: `*` / `latest` / `x` / `''` (any),
|
|
78
|
+
* `||` alternation, a leading `>=` / `>` / `<=` / `<` / `=`, caret `^`, tilde
|
|
79
|
+
* `~`, an `x`/`*` wildcard segment (`6.x`, `6.39.x`), and an exact `1.2.3`.
|
|
80
|
+
*
|
|
81
|
+
* Returns `true` / `false` when the range can be evaluated, and `null` when
|
|
82
|
+
* the shape is one we do NOT statically understand (a URL range, a git range,
|
|
83
|
+
* a hyphen `1.2.3 - 1.4.0` range). `null` is the "could not verify" signal: the
|
|
84
|
+
* caller degrades to a soft "unverified" note rather than warning on a shape it
|
|
85
|
+
* cannot judge. Failing open here is deliberate, a coherence check must never
|
|
86
|
+
* cry wolf on a range it misread.
|
|
87
|
+
*
|
|
88
|
+
* Prerelease note: both the version and the range are judged on their release
|
|
89
|
+
* line only (the `-beta` / `-rc` tag is dropped, see `parseSemver`), so a
|
|
90
|
+
* prerelease pin is treated as its stable tuple. The worst case is a MISSED
|
|
91
|
+
* warning when a prerelease is pinned, never a spurious one.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} version e.g. `6.39.16`
|
|
94
|
+
* @param {string} range e.g. `^6.42.0`
|
|
95
|
+
* @returns {boolean | null}
|
|
96
|
+
*/
|
|
97
|
+
export function satisfiesSemverRange(version, range) {
|
|
98
|
+
const v = parseSemver(version);
|
|
99
|
+
if (!v) return null;
|
|
100
|
+
const r = String(range == null ? '' : range).trim();
|
|
101
|
+
if (r === '' || r === '*' || r === 'x' || r === 'X' || r === 'latest') return true;
|
|
102
|
+
if (r.startsWith('workspace:')) return true;
|
|
103
|
+
// Alternation: satisfied if ANY clause is satisfied. A clause we cannot
|
|
104
|
+
// evaluate (null) must not let a non-matching clause produce a false
|
|
105
|
+
// negative, so an unknown clause makes the whole result unknown unless a
|
|
106
|
+
// known clause already matched.
|
|
107
|
+
if (r.includes('||')) {
|
|
108
|
+
let sawUnknown = false;
|
|
109
|
+
for (const clause of r.split('||')) {
|
|
110
|
+
const res = satisfiesSemverRange(version, clause.trim());
|
|
111
|
+
if (res === true) return true;
|
|
112
|
+
if (res === null) sawUnknown = true;
|
|
113
|
+
}
|
|
114
|
+
return sawUnknown ? null : false;
|
|
115
|
+
}
|
|
116
|
+
// A space-joined comparator set (`>=6.0.0 <7.0.0`) must ALL be satisfied.
|
|
117
|
+
if (/\s/.test(r)) {
|
|
118
|
+
// A hyphen range (`1.2.3 - 1.4.0`) is not a comparator set; we do not
|
|
119
|
+
// parse it, so degrade to unknown rather than mis-AND its halves.
|
|
120
|
+
if (/\s-\s/.test(r)) return null;
|
|
121
|
+
let result = true;
|
|
122
|
+
for (const part of r.split(/\s+/)) {
|
|
123
|
+
if (!part) continue;
|
|
124
|
+
const res = satisfiesSemverRange(version, part);
|
|
125
|
+
if (res === null) return null;
|
|
126
|
+
if (res === false) result = false;
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
// Comparators: >= > <= < =.
|
|
131
|
+
const cmpMatch = /^(>=|<=|>|<|=)\s*(.+)$/.exec(r);
|
|
132
|
+
if (cmpMatch) {
|
|
133
|
+
const op = cmpMatch[1];
|
|
134
|
+
const bound = parseSemver(cmpMatch[2]);
|
|
135
|
+
if (!bound) return null;
|
|
136
|
+
const c = cmpSemver(v, bound);
|
|
137
|
+
switch (op) {
|
|
138
|
+
case '>=': return c >= 0;
|
|
139
|
+
case '>': return c > 0;
|
|
140
|
+
case '<=': return c <= 0;
|
|
141
|
+
case '<': return c < 0;
|
|
142
|
+
case '=': return c === 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Caret: same left-most non-zero segment. ^6.42.0 -> >=6.42.0 <7.0.0;
|
|
146
|
+
// ^0.7.0 -> >=0.7.0 <0.8.0; ^0.0.3 -> >=0.0.3 <0.0.4.
|
|
147
|
+
if (r.startsWith('^')) {
|
|
148
|
+
const b = parseSemver(r.slice(1));
|
|
149
|
+
if (!b) return null;
|
|
150
|
+
if (cmpSemver(v, b) < 0) return false;
|
|
151
|
+
if (b[0] > 0) return v[0] === b[0];
|
|
152
|
+
if (b[1] > 0) return v[0] === 0 && v[1] === b[1];
|
|
153
|
+
return v[0] === 0 && v[1] === 0 && v[2] === b[2];
|
|
154
|
+
}
|
|
155
|
+
// Tilde: ~6.42.0 -> >=6.42.0 <6.43.0; ~6 -> >=6.0.0 <7.0.0.
|
|
156
|
+
if (r.startsWith('~')) {
|
|
157
|
+
const raw = r.slice(1);
|
|
158
|
+
const b = parseSemver(raw);
|
|
159
|
+
if (!b) return null;
|
|
160
|
+
if (cmpSemver(v, b) < 0) return false;
|
|
161
|
+
// If the range named a minor (`~6.42` / `~6.42.0`), pin the minor; if it
|
|
162
|
+
// named only a major (`~6`), pin the major.
|
|
163
|
+
const namedMinor = /^\d+\.\d+/.test(raw);
|
|
164
|
+
return namedMinor ? v[0] === b[0] && v[1] === b[1] : v[0] === b[0];
|
|
165
|
+
}
|
|
166
|
+
// x / * wildcard segment: 6.x, 6.39.x, 6.*.
|
|
167
|
+
if (/^\d+(\.(\d+|[xX*])){0,2}$/.test(r) && /[xX*]/.test(r)) {
|
|
168
|
+
const segs = r.split('.');
|
|
169
|
+
for (let i = 0; i < segs.length; i++) {
|
|
170
|
+
const s = segs[i];
|
|
171
|
+
if (s === 'x' || s === 'X' || s === '*') break; // any beyond here
|
|
172
|
+
if (Number(s) !== v[i]) return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// Exact `1.2.3` (or shorter `1` / `1.2`, treated as that prefix pinned). A
|
|
177
|
+
// leading `v` (`v1.2.3`) is tolerated so a `v`-prefixed exact pin evaluates
|
|
178
|
+
// instead of degrading to unverified.
|
|
179
|
+
const exact = r.startsWith('v') ? r.slice(1) : r;
|
|
180
|
+
if (/^\d+(\.\d+){0,2}$/.test(exact)) {
|
|
181
|
+
const b = parseSemver(exact);
|
|
182
|
+
if (!b) return null;
|
|
183
|
+
const segs = exact.split('.').length;
|
|
184
|
+
for (let i = 0; i < segs; i++) if (v[i] !== b[i]) return false;
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Bounds a single bundle GET made by the pin command, which either writes the
|
|
191
|
+
// bytes to disk (`downloadBundle`) or fetches them to hash
|
|
192
|
+
// (`fetchIntegrity`). Deliberately six times the warmup budget, because the
|
|
193
|
+
// two are not the same situation: a pin is a one-shot command a person ran and
|
|
194
|
+
// is waiting on, with a whole multi-megabyte package to transfer, while the
|
|
195
|
+
// warmup is a server holding a request. Ten seconds is generous for the
|
|
196
|
+
// latter and tight for the former on a slow link.
|
|
197
|
+
//
|
|
198
|
+
// 60s matches what importmap-rails effectively allows. It sets no timeout at
|
|
199
|
+
// all, but Ruby's Net::HTTP defaults open_timeout and read_timeout to 60s, so
|
|
200
|
+
// a Rails pin is bounded at a minute without asking. JavaScript's fetch() has
|
|
201
|
+
// no default whatsoever, which is why this has to be explicit: without it a
|
|
202
|
+
// CDN that accepts the connection and then stalls hangs the pin forever, with
|
|
203
|
+
// no ambient deadline on a CLI run to cut it short.
|
|
204
|
+
export const PIN_BUNDLE_TIMEOUT_MS = 60_000;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Fetch a jspm.io URL just to compute its SHA-384 hash, without
|
|
208
|
+
* writing anything to disk. Used by `webjs vendor pin` (default mode)
|
|
209
|
+
* so the importmap can carry SRI hashes even when bundles aren't
|
|
210
|
+
* locally vendored.
|
|
211
|
+
*
|
|
212
|
+
* Bounded by PIN_BUNDLE_TIMEOUT_MS, the same budget `downloadBundle`
|
|
213
|
+
* gets, since it transfers the same bytes and differs only in whether
|
|
214
|
+
* they are written to disk. Default-mode `pinAll` runs this once per
|
|
215
|
+
* resolved URL, so a CDN that accepts the connection and then stalls
|
|
216
|
+
* would otherwise hang the pin with nothing to interrupt it: there is
|
|
217
|
+
* no ambient deadline on a CLI run (#1150).
|
|
218
|
+
*
|
|
219
|
+
* @param {string} url
|
|
220
|
+
* @returns {Promise<string | null>} the integrity string, or null on failure
|
|
221
|
+
*/
|
|
222
|
+
export async function fetchIntegrity(url) {
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
|
|
225
|
+
try {
|
|
226
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
console.error(`[webjs] hash ${url} returned ${response.status}`);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
// Hash raw response bytes so the integrity matches what the
|
|
232
|
+
// browser computes when fetching the same URL. See the
|
|
233
|
+
// matching comment in downloadBundle.
|
|
234
|
+
const buf = new Uint8Array(await response.arrayBuffer());
|
|
235
|
+
return await sha384Integrity(buf);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
const why = e && e.name === 'AbortError'
|
|
238
|
+
? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
|
|
239
|
+
: e && e.message;
|
|
240
|
+
console.error(`[webjs] hash ${url} failed: ${why}`);
|
|
241
|
+
return null;
|
|
242
|
+
} finally {
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
}
|
|
245
|
+
}
|