@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
package/src/vendor.js
CHANGED
|
@@ -38,666 +38,18 @@
|
|
|
38
38
|
*
|
|
39
39
|
* No local bundler. No disk cache. No memory cache of bundle bytes.
|
|
40
40
|
* Matches Rails' "no build" posture literally.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
import { readFile, readdir, writeFile, mkdir, unlink, stat, rename } from 'node:fs/promises';
|
|
44
|
-
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
45
|
-
import { join, dirname, basename, sep } from 'node:path';
|
|
46
|
-
import { createRequire } from 'node:module';
|
|
47
|
-
import { spawnSync } from 'node:child_process';
|
|
48
|
-
import { digestBase64 } from './crypto-utils.js';
|
|
49
|
-
import { BUFFERED_MARKER } from './conditional-get.js';
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Set of package names whose importmap entries are populated by the
|
|
53
|
-
* framework, not by the vendor scanner. The scanner skips these to
|
|
54
|
-
* keep `@webjsdev/core` (and any future framework-internal package)
|
|
55
|
-
* off the jspm.io path: their bytes are served by the dev server's
|
|
56
|
-
* dedicated `/__webjs/core/*` route, and `buildCoreEntries()` in
|
|
57
|
-
* `importmap.js` derives one importmap line per exported subpath
|
|
58
|
-
* directly from the package's own `exports` field.
|
|
59
|
-
*
|
|
60
|
-
* The `'@webjsdev/core/'` prefix entry is here so that `extractPackageName`
|
|
61
|
-
* returning the bare name is enough to recognise core-subpath imports
|
|
62
|
-
* (`@webjsdev/core/directives`, `@webjsdev/core/task`, …) and skip
|
|
63
|
-
* them; the prefix form catches anything whose extractPackageName
|
|
64
|
-
* returns null but whose specifier starts with the prefix. Same
|
|
65
|
-
* mechanism, no special casing per subpath.
|
|
66
|
-
*/
|
|
67
|
-
const BUILTIN = new Set(['@webjsdev/core', '@webjsdev/core/']);
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Server-only framework packages that must NEVER be vendored to the browser.
|
|
71
|
-
* Unlike `@webjsdev/core` (browser-bound, served locally via `/__webjs/core/*`),
|
|
72
|
-
* these are pure server packages: the CLI, the SSR runtime, the MCP server. A
|
|
73
|
-
* stray browser-graph scan
|
|
74
|
-
* that surfaces one of them must not push it onto the jspm path (#713). Matched
|
|
75
|
-
* on the extracted package name, so subpaths (`@webjsdev/cli/bin/webjs.js`) are
|
|
76
|
-
* covered. `@webjsdev/ui` is intentionally absent: its components ARE
|
|
77
|
-
* browser-bound, so it stays vendorable.
|
|
78
|
-
*/
|
|
79
|
-
const FRAMEWORK_SERVER_ONLY = new Set(['@webjsdev/cli', '@webjsdev/server', '@webjsdev/mcp']);
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Scan source files under `dir` for bare import specifiers reachable
|
|
83
|
-
* from the browser. Returns a Set of package names.
|
|
84
|
-
*
|
|
85
|
-
* Excludes:
|
|
86
|
-
* - `node_modules`, `.webjs`, `public` directories
|
|
87
|
-
* - Any directory starting with `_` (webjs `_private/` convention)
|
|
88
|
-
* - `test/` and `tests/` (server-only by WebJs convention)
|
|
89
|
-
* - Files whose name marks them as server-only:
|
|
90
|
-
* * `*.server.{js,ts,mjs,mts}` (path-level boundary)
|
|
91
|
-
* * `route.{js,ts,mjs,mts}` (file-router HTTP handler)
|
|
92
|
-
* * `middleware.{js,ts,mjs,mts}` (file-router middleware)
|
|
93
|
-
* - Any file whose first non-whitespace content is `'use server'`
|
|
94
|
-
* - `import type` statements (TypeScript erases them at compile time)
|
|
95
|
-
* - `import` strings inside `/* … */` block comments or `//` line comments
|
|
96
|
-
*
|
|
97
|
-
* @param {string} dir
|
|
98
|
-
* @returns {Promise<Set<string>>}
|
|
99
|
-
*/
|
|
100
|
-
export async function scanBareImports(dir, skipFiles) {
|
|
101
|
-
/** @type {Set<string>} */
|
|
102
|
-
const found = new Set();
|
|
103
|
-
await walk(dir, found, skipFiles);
|
|
104
|
-
for (const b of BUILTIN) found.delete(b);
|
|
105
|
-
// Drop server-only framework packages (and their subpaths) so they never
|
|
106
|
-
// reach the importmap / jspm path (#713).
|
|
107
|
-
for (const spec of found) {
|
|
108
|
-
const p = extractPackageName(spec);
|
|
109
|
-
if (p && FRAMEWORK_SERVER_ONLY.has(p)) found.delete(spec);
|
|
110
|
-
}
|
|
111
|
-
return found;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Extract the package name from a bare specifier.
|
|
116
|
-
* `'dayjs'` → `'dayjs'`
|
|
117
|
-
* `'dayjs/locale/en'` → `'dayjs'`
|
|
118
|
-
* `'@tanstack/query'` → `'@tanstack/query'`
|
|
119
|
-
* `'@tanstack/query/x'` → `'@tanstack/query'`
|
|
120
|
-
* `'./foo'`, `'../bar'`, `'/baz'` → `null` (relative/absolute)
|
|
121
|
-
* `'#components/x.ts'`, `'#lib/db'` → `null` (`#` path alias, #555: resolves to
|
|
122
|
-
* a LOCAL file via `package.json` "imports", never an npm package, so it must
|
|
123
|
-
* not be sent to the vendor resolver, #623)
|
|
124
|
-
*
|
|
125
|
-
* @param {string} spec
|
|
126
|
-
* @returns {string | null}
|
|
127
|
-
*/
|
|
128
|
-
export function extractPackageName(spec) {
|
|
129
|
-
if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('__') || spec.startsWith('#')) return null;
|
|
130
|
-
if (/^[a-z]+:/.test(spec)) return null;
|
|
131
|
-
if (spec.startsWith('@')) {
|
|
132
|
-
const parts = spec.split('/');
|
|
133
|
-
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
|
|
134
|
-
}
|
|
135
|
-
return spec.split('/')[0];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Matches `import { x } from 'pkg'`, `import 'pkg'`, `import * as x from 'pkg'`.
|
|
139
|
-
// The `(?!type\s)` negative lookahead skips `import type … from 'pkg'`
|
|
140
|
-
// because TypeScript type-only imports are fully erased at compile time
|
|
141
|
-
// and never reach the browser.
|
|
142
|
-
const IMPORT_RE = /\bimport\s+(?!type\s)(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
143
|
-
const DYNAMIC_IMPORT_RE = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
144
|
-
|
|
145
|
-
const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
|
|
146
|
-
const LINE_COMMENT_RE = /(^|[^:])\/\/.*$/gm;
|
|
147
|
-
function stripComments(src) {
|
|
148
|
-
return src.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, '$1');
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Filename matches webjs's server-only file-router conventions.
|
|
153
|
-
*
|
|
154
|
-
* @param {string} name basename of the file
|
|
155
|
-
*/
|
|
156
|
-
function isServerOnlyFile(name) {
|
|
157
|
-
if (/\.server\.(js|ts|mjs|mts)$/.test(name)) return true;
|
|
158
|
-
if (/^route\.(js|ts|mjs|mts)$/.test(name)) return true;
|
|
159
|
-
if (/^middleware\.(js|ts|mjs|mts)$/.test(name)) return true;
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Tooling config files at any depth. They import test runners, build
|
|
165
|
-
* helpers, AI plugins etc. that legitimately cannot resolve through
|
|
166
|
-
* jspm.io (e.g. `@web/test-runner-playwright` pulls in `playwright-core`
|
|
167
|
-
* with subpaths jspm.io can't bundle). Their bare imports must never
|
|
168
|
-
* reach the importmap.
|
|
169
|
-
*/
|
|
170
|
-
const CONFIG_FILE_RE = /\.config\.(js|ts|mjs|mts|cjs|cts)$/;
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* @param {string} dir
|
|
174
|
-
* @param {Set<string>} found
|
|
175
|
-
*/
|
|
176
|
-
async function walk(dir, found, skipFiles) {
|
|
177
|
-
let entries;
|
|
178
|
-
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
179
|
-
catch { return; }
|
|
180
|
-
for (const e of entries) {
|
|
181
|
-
if (
|
|
182
|
-
e.name === 'node_modules' ||
|
|
183
|
-
e.name === '.webjs' ||
|
|
184
|
-
e.name === 'public' ||
|
|
185
|
-
e.name === 'test' ||
|
|
186
|
-
e.name === 'tests' ||
|
|
187
|
-
e.name.startsWith('_') ||
|
|
188
|
-
// Skip ALL dot-prefixed dirs (.opencode, .claude, .github, .husky,
|
|
189
|
-
// .git, .vscode, .idea, .cursor, …). They hold tooling / IDE /
|
|
190
|
-
// agent state that imports packages the browser will never load
|
|
191
|
-
// (e.g. `@opencode-ai/plugin`). The walker visits dirs and files
|
|
192
|
-
// separately; this guard only fires for directory entries because
|
|
193
|
-
// dot-prefixed *files* (e.g. `.env.d.ts` someday) still need the
|
|
194
|
-
// extension check below.
|
|
195
|
-
(e.isDirectory() && e.name.startsWith('.'))
|
|
196
|
-
) continue;
|
|
197
|
-
const full = join(dir, e.name);
|
|
198
|
-
if (e.isDirectory()) {
|
|
199
|
-
await walk(full, found, skipFiles);
|
|
200
|
-
} else if (skipFiles && skipFiles.has(full)) {
|
|
201
|
-
// Display-only component file: its imports are stripped from the
|
|
202
|
-
// served source, so a vendor specifier reachable ONLY through it
|
|
203
|
-
// never loads in the browser and must not enter the importmap. A
|
|
204
|
-
// specifier also imported by a shipping file still appears via that
|
|
205
|
-
// file's scan, so shared deps are retained.
|
|
206
|
-
continue;
|
|
207
|
-
} else if (/\.(js|ts|mjs|mts)$/.test(e.name) && !isServerOnlyFile(e.name) && !CONFIG_FILE_RE.test(e.name)) {
|
|
208
|
-
try {
|
|
209
|
-
const raw = await readFile(full, 'utf8');
|
|
210
|
-
if (raw.trimStart().startsWith("'use server'") || raw.trimStart().startsWith('"use server"')) continue;
|
|
211
|
-
const src = stripComments(raw);
|
|
212
|
-
// We keep the FULL specifier (with subpath), not just the package
|
|
213
|
-
// name. `import 'dayjs/plugin/utc'` adds `'dayjs/plugin/utc'` to the
|
|
214
|
-
// set, not just `'dayjs'`. vendorImportMapEntries needs the
|
|
215
|
-
// subpath to emit a per-specifier importmap entry; jspm.io
|
|
216
|
-
// resolves each subpath independently via the package's `exports`
|
|
217
|
-
// field. extractPackageName is still applied to filter out
|
|
218
|
-
// relative / absolute / protocol-URL specifiers.
|
|
219
|
-
for (const m of src.matchAll(IMPORT_RE)) {
|
|
220
|
-
if (extractPackageName(m[1])) found.add(m[1]);
|
|
221
|
-
}
|
|
222
|
-
for (const m of src.matchAll(DYNAMIC_IMPORT_RE)) {
|
|
223
|
-
if (extractPackageName(m[1])) found.add(m[1]);
|
|
224
|
-
}
|
|
225
|
-
} catch { /* unreadable file */ }
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Resolve a package's installed directory on disk, handling both direct
|
|
232
|
-
* installation and npm workspace hoisting.
|
|
233
|
-
*
|
|
234
|
-
* @param {string} pkgName
|
|
235
|
-
* @param {string} appDir
|
|
236
|
-
* @returns {string | null}
|
|
237
|
-
*/
|
|
238
|
-
function resolvePackageDir(pkgName, appDir) {
|
|
239
|
-
try {
|
|
240
|
-
const require = createRequire(join(appDir, 'package.json'));
|
|
241
|
-
const entry = require.resolve(pkgName);
|
|
242
|
-
const parts = entry.split(sep);
|
|
243
|
-
const nmIdx = parts.lastIndexOf('node_modules');
|
|
244
|
-
if (nmIdx < 0) {
|
|
245
|
-
let dir = dirname(entry);
|
|
246
|
-
for (let i = 0; i < 8; i++) {
|
|
247
|
-
if (existsSync(join(dir, 'package.json'))) return realpathSync(dir);
|
|
248
|
-
const parent = dirname(dir);
|
|
249
|
-
if (parent === dir) break;
|
|
250
|
-
dir = parent;
|
|
251
|
-
}
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
const segmentsAfterNm = pkgName.startsWith('@') ? 2 : 1;
|
|
255
|
-
const root = parts.slice(0, nmIdx + 1 + segmentsAfterNm).join(sep);
|
|
256
|
-
return realpathSync(root);
|
|
257
|
-
} catch {
|
|
258
|
-
return null;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Read the installed version of a package from `node_modules/<pkg>/
|
|
264
|
-
* package.json`. Handles workspace hoisting and packages that lock
|
|
265
|
-
* down `./package.json` in their exports field.
|
|
266
|
-
*
|
|
267
|
-
* @param {string} pkgName
|
|
268
|
-
* @param {string} appDir
|
|
269
|
-
* @returns {string | null}
|
|
270
|
-
*/
|
|
271
|
-
export function getPackageVersion(pkgName, appDir) {
|
|
272
|
-
const real = resolvePackageDir(pkgName, appDir);
|
|
273
|
-
if (!real) return null;
|
|
274
|
-
try {
|
|
275
|
-
const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
|
|
276
|
-
return pkg.version || null;
|
|
277
|
-
} catch {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Read the installed package's declared `dependencies` + `peerDependencies`
|
|
284
|
-
* from its `package.json`, hoist-aware (same resolution as `getPackageVersion`,
|
|
285
|
-
* so a monorepo-hoisted dep resolves from the workspace root). Returns null
|
|
286
|
-
* when the package is not installed / unreadable, which the importmap-coherence
|
|
287
|
-
* check (#450) treats as "could not verify" rather than a conflict.
|
|
288
|
-
*
|
|
289
|
-
* This is the "already-resolved metadata, no network" source the coherence
|
|
290
|
-
* check prefers: the package is on disk because the importmap pinned it, so its
|
|
291
|
-
* manifest is a local read.
|
|
292
|
-
*
|
|
293
|
-
* @param {string} pkgName
|
|
294
|
-
* @param {string} appDir
|
|
295
|
-
* @returns {{ dependencies: Record<string,string>, peerDependencies: Record<string,string> } | null}
|
|
296
|
-
*/
|
|
297
|
-
export function getPackageManifest(pkgName, appDir) {
|
|
298
|
-
const real = resolvePackageDir(pkgName, appDir);
|
|
299
|
-
if (!real) return null;
|
|
300
|
-
try {
|
|
301
|
-
const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
|
|
302
|
-
return {
|
|
303
|
-
dependencies: pkg.dependencies || {},
|
|
304
|
-
peerDependencies: pkg.peerDependencies || {},
|
|
305
|
-
};
|
|
306
|
-
} catch {
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// ---------------------------------------------------------------------------
|
|
312
|
-
// JSPM Generator API client
|
|
313
|
-
// ---------------------------------------------------------------------------
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* In-memory cache of resolved importmap fragments from api.jspm.io.
|
|
317
|
-
* Two kinds of key share this map:
|
|
318
|
-
* - The UNIFIED key (`<provider>::unified::<sorted installs joined>`)
|
|
319
|
-
* caches the whole-set resolve produced by one `generate` call, the
|
|
320
|
-
* default path (issue #446).
|
|
321
|
-
* - The PER-INSTALL key (`<provider>::<install>`) caches a single
|
|
322
|
-
* install's isolated resolve, used only on the fallback path when the
|
|
323
|
-
* unified call fails because some install is unresolvable.
|
|
324
|
-
* Per-process; cleared by `clearVendorCache` on file-watcher rebuild
|
|
325
|
-
* so new versions get re-resolved.
|
|
326
|
-
*
|
|
327
|
-
* @type {Map<string, Record<string, string>>}
|
|
328
|
-
*/
|
|
329
|
-
const jspmCache = new Map();
|
|
330
|
-
|
|
331
|
-
// Set by jspmResolveOne whenever a LIVE resolution attempt fails (network
|
|
332
|
-
// error, timeout, or a non-ok jspm response). resolveVendorImports resets it
|
|
333
|
-
// before a scan and reads it after, so a caller can tell "resolved cleanly"
|
|
334
|
-
// from "served a partial map because the CDN was unreachable" and avoid
|
|
335
|
-
// memoizing the failure as done. Safe under the single-flighted ensureReady
|
|
336
|
-
// (one live resolve at a time); the vendor CLI does not run alongside a server.
|
|
337
|
-
let lastLiveResolveFailed = false;
|
|
338
|
-
|
|
339
|
-
const JSPM_GENERATE_ENDPOINT = 'https://api.jspm.io/generate';
|
|
340
|
-
const JSPM_GENERATE_TIMEOUT_MS = 10_000;
|
|
341
|
-
// Bounds a single bundle GET during the SERVER's warmup live-integrity pass
|
|
342
|
-
// (`fetchLiveIntegrity`). Short on purpose: that pass gates readiness, so a
|
|
343
|
-
// stalled CDN must not hold the first request, and it is additionally capped
|
|
344
|
-
// by INTEGRITY_TOTAL_BUDGET_MS across all URLs.
|
|
345
|
-
const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
|
|
346
|
-
|
|
347
|
-
// Bounds a single bundle GET made by the pin command, which either writes the
|
|
348
|
-
// bytes to disk (`downloadBundle`) or fetches them to hash
|
|
349
|
-
// (`fetchIntegrity`). Deliberately six times the warmup budget, because the
|
|
350
|
-
// two are not the same situation: a pin is a one-shot command a person ran and
|
|
351
|
-
// is waiting on, with a whole multi-megabyte package to transfer, while the
|
|
352
|
-
// warmup is a server holding a request. Ten seconds is generous for the
|
|
353
|
-
// latter and tight for the former on a slow link.
|
|
354
|
-
//
|
|
355
|
-
// 60s matches what importmap-rails effectively allows. It sets no timeout at
|
|
356
|
-
// all, but Ruby's Net::HTTP defaults open_timeout and read_timeout to 60s, so
|
|
357
|
-
// a Rails pin is bounded at a minute without asking. JavaScript's fetch() has
|
|
358
|
-
// no default whatsoever, which is why this has to be explicit: without it a
|
|
359
|
-
// CDN that accepts the connection and then stalls hangs the pin forever, with
|
|
360
|
-
// no ambient deadline on a CLI run to cut it short.
|
|
361
|
-
const PIN_BUNDLE_TIMEOUT_MS = 60_000;
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Provider names accepted by `webjs vendor pin --from <provider>`.
|
|
365
|
-
* Default `jspm` resolves to jspm.io. Same set Rails's importmap-rails
|
|
366
|
-
* accepts (`packager.rb:normalize_provider`).
|
|
367
|
-
*
|
|
368
|
-
* jspm.io's Generator API itself supports multiple providers via the
|
|
369
|
-
* `provider` field in the request body. We surface the same choice as
|
|
370
|
-
* a CLI flag.
|
|
371
|
-
*
|
|
372
|
-
* @type {Set<string>}
|
|
373
|
-
*/
|
|
374
|
-
export const SUPPORTED_PROVIDERS = new Set(['jspm', 'jsdelivr', 'unpkg', 'skypack']);
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Normalize the user-facing provider name to what the jspm.io API
|
|
378
|
-
* expects in its `provider` field. Mirrors importmap-rails's
|
|
379
|
-
* `normalize_provider`: `jspm` is shorthand for `jspm.io`; the rest
|
|
380
|
-
* pass through verbatim.
|
|
381
|
-
*
|
|
382
|
-
* @param {string} name
|
|
383
|
-
* @returns {string}
|
|
384
|
-
*/
|
|
385
|
-
export function normalizeProvider(name) {
|
|
386
|
-
return name === 'jspm' ? 'jspm.io' : name;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Outcome of one api.jspm.io/generate POST.
|
|
391
|
-
* @typedef {Object} JspmCallResult
|
|
392
|
-
* @property {boolean} ok true when jspm returned a 2xx with a usable map
|
|
393
|
-
* @property {Record<string, string>} imports the resolved imports (empty on failure)
|
|
394
|
-
* @property {boolean} transient true when the failure is worth retrying
|
|
395
|
-
* (network / timeout / 5xx / 429), false for a permanent 4xx
|
|
396
|
-
* (jspm uses 401 for "this install is unresolvable")
|
|
397
|
-
*/
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Make ONE api.jspm.io/generate POST for a list of installs and return a
|
|
401
|
-
* structured result. The single point that talks to the network; both the
|
|
402
|
-
* unified path and the per-install fallback funnel through it.
|
|
403
|
-
*
|
|
404
|
-
* jspm fails the WHOLE batch (401) when ANY one install is unresolvable, so
|
|
405
|
-
* a multi-install POST is all-or-nothing: either the entire coherent graph
|
|
406
|
-
* comes back, or nothing does. `jspmGenerate` uses that property to decide
|
|
407
|
-
* when to fall back to per-install isolation.
|
|
408
|
-
*
|
|
409
|
-
* @param {Array<string>} installs e.g. ['dayjs@1.11.13', '@codemirror/lint@6.9.6']
|
|
410
|
-
* @param {string} provider one of SUPPORTED_PROVIDERS
|
|
411
|
-
* @param {number} [timeoutMs] defaults to the SERVER budget; a CLI caller
|
|
412
|
-
* passes the longer one, since it is a command someone is waiting on rather
|
|
413
|
-
* than a request being held open.
|
|
414
|
-
* @returns {Promise<JspmCallResult>}
|
|
415
|
-
*/
|
|
416
|
-
async function jspmCall(installs, provider, timeoutMs = JSPM_GENERATE_TIMEOUT_MS) {
|
|
417
|
-
const label = installs.length === 1 ? `'${installs[0]}'` : `${installs.length} packages`;
|
|
418
|
-
const controller = new AbortController();
|
|
419
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
420
|
-
try {
|
|
421
|
-
const response = await fetch(JSPM_GENERATE_ENDPOINT, {
|
|
422
|
-
method: 'POST',
|
|
423
|
-
headers: { 'content-type': 'application/json' },
|
|
424
|
-
body: JSON.stringify({
|
|
425
|
-
install: installs,
|
|
426
|
-
// flattenScope:true merges transitive ESM deps into the flat
|
|
427
|
-
// `imports` map instead of a separate `scopes` field. Webjs only
|
|
428
|
-
// consumes `imports`, so without this any package with an
|
|
429
|
-
// unbundled ESM transitive (e.g. react-dom imports `scheduler`,
|
|
430
|
-
// @codemirror/lint imports @codemirror/state) would break in the
|
|
431
|
-
// browser with an unresolved-bare-specifier error. With the
|
|
432
|
-
// WHOLE-set call (issue #446) the flattened transitives are now
|
|
433
|
-
// ALSO mutually consistent: one `@codemirror/view` URL shared by
|
|
434
|
-
// the direct import and lint's transitive need, instead of two
|
|
435
|
-
// skewed versions from independent per-package calls.
|
|
436
|
-
flattenScope: true,
|
|
437
|
-
env: ['browser', 'production', 'module'],
|
|
438
|
-
provider: normalizeProvider(provider),
|
|
439
|
-
}),
|
|
440
|
-
signal: controller.signal,
|
|
441
|
-
});
|
|
442
|
-
if (!response.ok) {
|
|
443
|
-
// jspm.io returns the error reason in the body with a 401 (its
|
|
444
|
-
// quirk: 401 is what it sends for unresolvable installs, not auth
|
|
445
|
-
// failures). Surface it so the user sees WHAT failed and why.
|
|
446
|
-
let detail = '';
|
|
447
|
-
try {
|
|
448
|
-
const body = await response.json();
|
|
449
|
-
if (body && typeof body.error === 'string') detail = `: ${body.error}`;
|
|
450
|
-
} catch { /* non-JSON body */ }
|
|
451
|
-
console.error(
|
|
452
|
-
`[webjs] could not vendor ${label} via ${provider} (status ${response.status})${detail}`,
|
|
453
|
-
);
|
|
454
|
-
// A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx
|
|
455
|
-
// means at least one install is genuinely unresolvable (jspm uses
|
|
456
|
-
// 401 for that): a private / workspace / server-only package (e.g.
|
|
457
|
-
// @webjsdev/server, pg) the browser never fetches
|
|
458
|
-
// anyway. Permanent failures must NOT block readiness.
|
|
459
|
-
const transient = response.status >= 500 || response.status === 429;
|
|
460
|
-
return { ok: false, imports: {}, transient };
|
|
461
|
-
}
|
|
462
|
-
const result = await response.json();
|
|
463
|
-
const imports = (result && result.map && result.map.imports) || {};
|
|
464
|
-
return { ok: true, imports, transient: false };
|
|
465
|
-
} catch (e) {
|
|
466
|
-
const msg = e && e.name === 'AbortError'
|
|
467
|
-
? `timed out after ${timeoutMs}ms`
|
|
468
|
-
: `${e && e.message}`;
|
|
469
|
-
console.error(`[webjs] could not vendor ${label} via ${provider}: ${msg}`);
|
|
470
|
-
return { ok: false, imports: {}, transient: true };
|
|
471
|
-
} finally {
|
|
472
|
-
clearTimeout(timer);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Resolve a SINGLE install in isolation, cached per install + provider.
|
|
478
|
-
* This is the FALLBACK path: it only runs when the unified whole-set call
|
|
479
|
-
* fails because some install is unresolvable. Isolating each install means
|
|
480
|
-
* one bad dep (a 401) drops out on its own instead of collapsing the map
|
|
481
|
-
* for its legitimate neighbours. The cross-package coherence the unified
|
|
482
|
-
* call provides is lost for this degraded set, which is acceptable: it is
|
|
483
|
-
* exactly the pre-#446 behaviour, reached only when the app already has an
|
|
484
|
-
* unresolvable dep.
|
|
485
|
-
*
|
|
486
|
-
* Sets `lastLiveResolveFailed` on a TRANSIENT failure (so the caller
|
|
487
|
-
* retries), never on a permanent 401 (tolerated).
|
|
488
|
-
*
|
|
489
|
-
* @param {string} install e.g. 'dayjs@1.11.13' or 'dayjs@1.11.13/plugin/utc'
|
|
490
|
-
* @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
|
|
491
|
-
* @returns {Promise<Record<string, string>>}
|
|
492
|
-
*/
|
|
493
|
-
async function jspmResolveOne(install, provider = 'jspm', timeoutMs) {
|
|
494
|
-
const { ok, imports, transient } = await jspmProbeOne(install, provider, timeoutMs);
|
|
495
|
-
// Preserve the public contract: an empty map on any failure, and the
|
|
496
|
-
// module-global retry flag set ONLY on a transient one (a permanent 401
|
|
497
|
-
// for an unresolvable private/server-only dep is tolerated).
|
|
498
|
-
if (!ok && transient) lastLiveResolveFailed = true;
|
|
499
|
-
return imports;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* Probe a SINGLE install and return the FULL classification, not just the
|
|
504
|
-
* imports. Unlike `jspmResolveOne` this does NOT collapse a transient
|
|
505
|
-
* failure into the same empty map a permanent one yields, and does NOT
|
|
506
|
-
* touch `lastLiveResolveFailed`: the caller (the 401 fallback in
|
|
507
|
-
* `jspmGenerate`) needs to tell "genuinely unresolvable, safe to drop"
|
|
508
|
-
* (`ok:false, transient:false`) from "a network blip mid-probe, do NOT
|
|
509
|
-
* drop" (`ok:false, transient:true`), and owns the retry flag itself.
|
|
510
41
|
*
|
|
511
|
-
*
|
|
512
|
-
* same value `jspmResolveOne` returns, so the two share the cache and the
|
|
513
|
-
* later unified re-run reuses it.
|
|
42
|
+
* Public API barrel re-exporting sub-modules under `./vendor/*`.
|
|
514
43
|
*
|
|
515
|
-
* @
|
|
516
|
-
* @param {string} provider
|
|
517
|
-
* @returns {Promise<JspmCallResult>}
|
|
44
|
+
* @module vendor
|
|
518
45
|
*/
|
|
519
|
-
function jspmProbeOne(install, provider, timeoutMs) {
|
|
520
|
-
const cacheKey = `${provider}::probe::${install}`;
|
|
521
|
-
const existing = jspmCache.get(cacheKey);
|
|
522
|
-
if (existing) return existing;
|
|
523
46
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
return result;
|
|
531
|
-
})();
|
|
532
|
-
|
|
533
|
-
jspmCache.set(cacheKey, promise);
|
|
534
|
-
return promise;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Resolve a list of `pkg@version` installs to importmap entries.
|
|
539
|
-
*
|
|
540
|
-
* Issue #446: the WHOLE set is resolved in ONE api.jspm.io/generate call
|
|
541
|
-
* (a single `install[]` array) so jspm computes one mutually-consistent
|
|
542
|
-
* dependency graph. A directly-imported package and a transitive that
|
|
543
|
-
* needs a newer version of the same package now agree on one URL, instead
|
|
544
|
-
* of the old per-package-in-isolation merge that pinned the direct dep to
|
|
545
|
-
* its local version while the transitive floated to jspm-latest, producing
|
|
546
|
-
* a missing-export crash in the browser.
|
|
547
|
-
*
|
|
548
|
-
* The per-package-isolation property is PRESERVED as a fallback only: if
|
|
549
|
-
* the unified call fails because some install is unresolvable (a 401 for a
|
|
550
|
-
* private / server-only dep), one bad install must not collapse the map
|
|
551
|
-
* for the rest. So:
|
|
552
|
-
* 1. Try the unified call. On success, return its coherent graph.
|
|
553
|
-
* 2. On a PERMANENT failure (401/4xx), probe each install in isolation
|
|
554
|
-
* to learn which ones resolve, then RE-RUN the unified call over only
|
|
555
|
-
* the resolvable subset so the survivors stay mutually consistent.
|
|
556
|
-
* Only installs whose probe fails PERMANENTLY drop out (genuinely
|
|
557
|
-
* unresolvable, the browser never fetched them anyway); if any probe
|
|
558
|
-
* fails TRANSIENTLY, no one is dropped and the resolve is flagged for
|
|
559
|
-
* retry, so a network blip mid-probe cannot evict a good package. If
|
|
560
|
-
* the re-run itself fails, fall back to the merged per-install
|
|
561
|
-
* fragments so the app is no worse off than pre-#446.
|
|
562
|
-
* 3. On a TRANSIENT failure (network / timeout / 5xx / 429), set the
|
|
563
|
-
* retry flag and serve whatever the per-install probe produced.
|
|
564
|
-
*
|
|
565
|
-
* The unified result is cached per sorted-install-set + provider; the
|
|
566
|
-
* per-install fallback reuses the per-install cache entries.
|
|
567
|
-
*
|
|
568
|
-
* @param {Array<string>} installs e.g. ['dayjs@1.11.13', 'clsx@2.1.1']
|
|
569
|
-
* @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
|
|
570
|
-
* @param {number} [timeoutMs] per-call budget. Defaults to the SERVER one,
|
|
571
|
-
* because this runs on a cold first request as well as from the CLI; the two
|
|
572
|
-
* pin commands pass PIN_BUNDLE_TIMEOUT_MS instead. importmap-rails only ever
|
|
573
|
-
* resolves from the CLI, so its flat 60s has no request path to slow down.
|
|
574
|
-
* @returns {Promise<Record<string, string>>}
|
|
575
|
-
*/
|
|
576
|
-
export async function jspmGenerate(installs, provider = 'jspm', timeoutMs) {
|
|
577
|
-
if (installs.length === 0) return {};
|
|
578
|
-
|
|
579
|
-
// A single install has no cross-package graph to reconcile, so the
|
|
580
|
-
// isolated path IS the coherent path; reuse the per-install cache.
|
|
581
|
-
if (installs.length === 1) return jspmResolveOne(installs[0], provider, timeoutMs);
|
|
582
|
-
|
|
583
|
-
// Stable key regardless of scan order so the same dep set hits cache.
|
|
584
|
-
const unifiedKey = `${provider}::unified::${[...installs].sort().join('\n')}`;
|
|
585
|
-
const cached = jspmCache.get(unifiedKey);
|
|
586
|
-
if (cached) return cached;
|
|
587
|
-
|
|
588
|
-
const promise = (async () => {
|
|
589
|
-
const unified = await jspmCall(installs, provider, timeoutMs);
|
|
590
|
-
if (unified.ok) return unified.imports;
|
|
591
|
-
|
|
592
|
-
// The unified call failed. Drop the cached failure so a later retry
|
|
593
|
-
// re-attempts; the per-install fallback owns the retry flag.
|
|
594
|
-
jspmCache.delete(unifiedKey);
|
|
595
|
-
|
|
596
|
-
if (unified.transient) {
|
|
597
|
-
// Network / 5xx: nothing resolved coherently. Fall back to merged
|
|
598
|
-
// per-install fragments (each may still be cached / reachable) so we
|
|
599
|
-
// serve whatever we can, and flag the transient failure for retry.
|
|
600
|
-
lastLiveResolveFailed = true;
|
|
601
|
-
return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider, timeoutMs))));
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// Permanent failure: at least one install is unresolvable. Probe each
|
|
605
|
-
// in isolation to learn which ones jspm can resolve, then re-run the
|
|
606
|
-
// unified call over only those so the survivors form one consistent
|
|
607
|
-
// graph (restores #446 coherence for the resolvable subset).
|
|
608
|
-
const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider, timeoutMs)));
|
|
609
|
-
|
|
610
|
-
// A GOOD package whose isolated probe failed TRANSIENTLY (a network blip
|
|
611
|
-
// mid-probe) must NOT be classified as unresolvable and dropped. Only a
|
|
612
|
-
// PERMANENT probe failure (401/404) means the install is genuinely
|
|
613
|
-
// unresolvable. If any probe failed transiently, we cannot safely decide
|
|
614
|
-
// the resolvable set this pass, so flag the whole resolve transient-
|
|
615
|
-
// failed and serve the merged fragments WITHOUT dropping anyone; the next
|
|
616
|
-
// ensureReady retry re-resolves once the blip clears. Conflating the two
|
|
617
|
-
// here is exactly the bug this guard prevents.
|
|
618
|
-
const transientProbe = probes.some(p => !p.ok && p.transient);
|
|
619
|
-
if (transientProbe) {
|
|
620
|
-
lastLiveResolveFailed = true;
|
|
621
|
-
return mergePerInstall(probes.map(p => p.imports));
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// From here every failed probe is PERMANENT, so dropping it is safe.
|
|
625
|
-
const resolvable = installs.filter((_, idx) => probes[idx].ok);
|
|
626
|
-
|
|
627
|
-
if (resolvable.length === installs.length) {
|
|
628
|
-
// Every install resolved alone but the batch 401'd: a genuine
|
|
629
|
-
// cross-package CONFLICT jspm could not satisfy as one graph (rare).
|
|
630
|
-
// The coherent graph is unavailable, so serve the merged fragments
|
|
631
|
-
// (pre-#446 behaviour) rather than nothing. NOTE: this degraded path
|
|
632
|
-
// can REINTRODUCE the #446 skew, because last-write-wins on a shared
|
|
633
|
-
// transitive across independent fragments is exactly the merge the
|
|
634
|
-
// unified call exists to avoid. It is a deliberate degrade-not-crash
|
|
635
|
-
// fallback for an unsatisfiable graph: no coherent graph exists, so a
|
|
636
|
-
// possibly-skewed map beats no map. The common conflicting-deps case
|
|
637
|
-
// (one shared transitive needing a newer version, issue #446's repro)
|
|
638
|
-
// IS satisfiable and resolves coherently on the unified path above;
|
|
639
|
-
// only a genuinely unsatisfiable set reaches here.
|
|
640
|
-
return mergePerInstall(probes.map(p => p.imports));
|
|
641
|
-
}
|
|
642
|
-
if (resolvable.length === 0) return {};
|
|
643
|
-
if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider, timeoutMs);
|
|
644
|
-
|
|
645
|
-
// Re-run unified over the resolvable subset. If even that fails (a
|
|
646
|
-
// conflict among the survivors), fall back to their merged fragments.
|
|
647
|
-
const retry = await jspmCall(resolvable, provider, timeoutMs);
|
|
648
|
-
if (retry.ok) return retry.imports;
|
|
649
|
-
return mergePerInstall(resolvable.map(i => probes[installs.indexOf(i)].imports));
|
|
650
|
-
})();
|
|
651
|
-
|
|
652
|
-
jspmCache.set(unifiedKey, promise);
|
|
653
|
-
return promise;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* Last-write-wins merge of per-install import fragments. Subpath installs
|
|
658
|
-
* never collide (their keys include the subpath); a shared base package
|
|
659
|
-
* resolves to the same root URL across fragments, so the merge is stable.
|
|
660
|
-
*
|
|
661
|
-
* @param {Array<Record<string, string>>} fragments
|
|
662
|
-
* @returns {Record<string, string>}
|
|
663
|
-
*/
|
|
664
|
-
function mergePerInstall(fragments) {
|
|
665
|
-
const merged = {};
|
|
666
|
-
for (const fragment of fragments) Object.assign(merged, fragment);
|
|
667
|
-
return merged;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Build importmap entries for discovered bare imports. For each scanned
|
|
672
|
-
* package, resolve its installed version from node_modules, then ask
|
|
673
|
-
* api.jspm.io/generate for the full importmap fragment.
|
|
674
|
-
*
|
|
675
|
-
* Async because the Generator API call is networked. Called from
|
|
676
|
-
* `resolveVendorImports` on the first request (and after a rebuild),
|
|
677
|
-
* inside `ensureReady`; never at boot, and not on every request.
|
|
678
|
-
*
|
|
679
|
-
* @param {Set<string>} bareImports from scanBareImports()
|
|
680
|
-
* @param {string} appDir
|
|
681
|
-
* @returns {Promise<Record<string, string>>}
|
|
682
|
-
*/
|
|
683
|
-
export async function vendorImportMapEntries(bareImports, appDir) {
|
|
684
|
-
const installs = [];
|
|
685
|
-
for (const spec of bareImports) {
|
|
686
|
-
if (BUILTIN.has(spec)) continue;
|
|
687
|
-
const pkg = extractPackageName(spec);
|
|
688
|
-
if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
|
|
689
|
-
const version = getPackageVersion(pkg, appDir);
|
|
690
|
-
if (!version) continue;
|
|
691
|
-
// Splice the version into the specifier: 'dayjs/plugin/utc' with
|
|
692
|
-
// version 1.11.13 becomes 'dayjs@1.11.13/plugin/utc'. jspm.io's
|
|
693
|
-
// Generator API resolves subpaths individually via the package's
|
|
694
|
-
// `exports` field. Root imports stay as `<pkg>@<version>` with no
|
|
695
|
-
// trailing subpath.
|
|
696
|
-
const subpath = spec.slice(pkg.length);
|
|
697
|
-
installs.push(`${pkg}@${version}${subpath}`);
|
|
698
|
-
}
|
|
699
|
-
return jspmGenerate(installs);
|
|
700
|
-
}
|
|
47
|
+
export { scanBareImports, reachedBareImports, extractPackageName } from './vendor/scanner.js';
|
|
48
|
+
export { getPackageVersion, getPackageManifest } from './vendor/manifest.js';
|
|
49
|
+
export { SUPPORTED_PROVIDERS, normalizeProvider } from './vendor/providers.js';
|
|
50
|
+
export { sha384Integrity, satisfiesSemverRange } from './vendor/integrity.js';
|
|
51
|
+
import { clearJspmCache } from './vendor/jspm.js';
|
|
52
|
+
import { clearLiveIntegrityCache } from './vendor/resolver.js';
|
|
701
53
|
|
|
702
54
|
/**
|
|
703
55
|
* Clear the resolved-importmap cache. Called on file-watcher rebuild
|
|
@@ -705,8 +57,8 @@ export async function vendorImportMapEntries(bareImports, appDir) {
|
|
|
705
57
|
* call on the next request to populate the in-memory cache.
|
|
706
58
|
*/
|
|
707
59
|
export function clearVendorCache() {
|
|
708
|
-
|
|
709
|
-
|
|
60
|
+
clearJspmCache();
|
|
61
|
+
clearLiveIntegrityCache();
|
|
710
62
|
// Deliberately does NOT touch `lastLiveResolveFailed`, which looks like an
|
|
711
63
|
// omission and is not. `resolveVendorImports` is the flag's only reader and
|
|
712
64
|
// it resets the flag on entry, while the pinned short-circuit above that
|
|
@@ -715,1716 +67,22 @@ export function clearVendorCache() {
|
|
|
715
67
|
// here would be a change nothing could write a failing test for (#1150).
|
|
716
68
|
}
|
|
717
69
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
const match = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
|
|
738
|
-
if (!match) return null;
|
|
739
|
-
return { pkg, version: match[1], subpath };
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// ---------------------------------------------------------------------------
|
|
743
|
-
// File-based pin (.webjs/vendor/importmap.json, optional --download bundles)
|
|
744
|
-
// ---------------------------------------------------------------------------
|
|
745
|
-
|
|
746
|
-
const PIN_DIR_REL = ['.webjs', 'vendor'];
|
|
747
|
-
const PIN_FILE = 'importmap.json';
|
|
748
|
-
|
|
749
|
-
/** Compute the absolute path of the pin directory for an app. */
|
|
750
|
-
function pinDir(appDir) {
|
|
751
|
-
return join(appDir, ...PIN_DIR_REL);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/** Compute the absolute path of the importmap config file for an app. */
|
|
755
|
-
function pinFilePath(appDir) {
|
|
756
|
-
return join(pinDir(appDir), PIN_FILE);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* The three-line `.gitignore` pattern that ignores the transient
|
|
761
|
-
* `.webjs` caches at any depth while re-including the committed
|
|
762
|
-
* `.webjs/vendor/` pin output. This mirrors the scaffold template
|
|
763
|
-
* (`packages/cli/templates/gitignore`, shipped dotless so npm cannot
|
|
764
|
-
* strip it, renamed to `.gitignore` on copy) and the `vendor-gitignore`
|
|
765
|
-
* check in `doctor.js` verbatim, so a self-healed `.gitignore`
|
|
766
|
-
* ends up byte-identical to a freshly scaffolded one.
|
|
767
|
-
*/
|
|
768
|
-
const VENDOR_GITIGNORE_LINES = [
|
|
769
|
-
'**/.webjs/*',
|
|
770
|
-
'!**/.webjs/vendor/',
|
|
771
|
-
'!**/.webjs/vendor/**',
|
|
772
|
-
];
|
|
773
|
-
|
|
774
|
-
/**
|
|
775
|
-
* Probe whether `appDir`'s `.gitignore` would swallow the vendor pin
|
|
776
|
-
* output, via `git check-ignore`. Best-effort: returns false when the
|
|
777
|
-
* directory is not a git repo, git is absent, or the spawn fails.
|
|
778
|
-
*
|
|
779
|
-
* The inherited GIT_* env vars are stripped so `cwd` is the sole
|
|
780
|
-
* authority on which repo + `.gitignore` stack is consulted. Git
|
|
781
|
-
* exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE / GIT_PREFIX into
|
|
782
|
-
* hook processes (a pre-commit hook from a linked worktree exports
|
|
783
|
-
* GIT_WORK_TREE), and those OVERRIDE cwd-based discovery; without the
|
|
784
|
-
* strip the probe would consult the outer repo instead of `appDir`.
|
|
785
|
-
* Same reasoning as the `vendor-gitignore` doctor check.
|
|
786
|
-
*
|
|
787
|
-
* @param {string} appDir
|
|
788
|
-
* @returns {boolean} true when `.webjs/vendor/importmap.json` is ignored
|
|
789
|
-
*/
|
|
790
|
-
function vendorPinIsIgnored(appDir) {
|
|
791
|
-
try {
|
|
792
|
-
const {
|
|
793
|
-
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
794
|
-
...gitEnv
|
|
795
|
-
} = process.env;
|
|
796
|
-
const probe = `.webjs/vendor/${PIN_FILE}`;
|
|
797
|
-
// `git check-ignore -q` exits 0 when ignored, 1 when not ignored,
|
|
798
|
-
// 128 on error (not a git repo, etc.). Treat anything but 0 as
|
|
799
|
-
// "not ignored" so a non-git project never gets its .gitignore
|
|
800
|
-
// touched.
|
|
801
|
-
const result = spawnSync('git', ['check-ignore', '-q', probe], {
|
|
802
|
-
cwd: appDir,
|
|
803
|
-
stdio: 'pipe',
|
|
804
|
-
env: gitEnv,
|
|
805
|
-
});
|
|
806
|
-
return result.status === 0;
|
|
807
|
-
} catch {
|
|
808
|
-
return false;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
/**
|
|
813
|
-
* Make the `webjs vendor pin` output committable, idempotently.
|
|
814
|
-
*
|
|
815
|
-
* Vendoring is an OPTIONAL opt-in: the no-build default resolves bare
|
|
816
|
-
* specifiers at runtime via jspm.io and needs nothing committed. But a
|
|
817
|
-
* user who runs `webjs vendor pin` deliberately creates pins they want
|
|
818
|
-
* in source control, and a `.gitignore` that excludes `.webjs/` (the
|
|
819
|
-
* older scaffold pattern, or one an editor/agent "simplified") silently
|
|
820
|
-
* swallows that output. Fresh scaffolds already carry the vendor
|
|
821
|
-
* exception (a glob exclusion plus a re-include negation), so for them
|
|
822
|
-
* this is a no-op.
|
|
823
|
-
*
|
|
824
|
-
* Behaviour (only ever called from the opt-in `vendor pin` path):
|
|
825
|
-
* - The pin output is NOT ignored (the common, already-correct case):
|
|
826
|
-
* return `{ ignored: false, patched: false }`. Nothing is written.
|
|
827
|
-
* - The pin output IS ignored AND a `.gitignore` exists: heal it (see
|
|
828
|
-
* below), then re-probe. If the pin is committable afterwards return
|
|
829
|
-
* `{ ignored: true, patched: true, gitignorePath }`; if a broader
|
|
830
|
-
* unrelated rule still swallows it (e.g. a root `*.json`), the heal
|
|
831
|
-
* cannot help, so revert the edit and return `patched: false` so the
|
|
832
|
-
* caller prints a notice rather than claiming a fix that did not take.
|
|
833
|
-
* - The pin output IS ignored but there is NO `.gitignore` to patch
|
|
834
|
-
* (e.g. the ignore comes from a parent repo's `.gitignore`, or from
|
|
835
|
-
* `.git/info/exclude`): leave the tree untouched and return
|
|
836
|
-
* `{ ignored: true, patched: false, gitignorePath: null }` so the
|
|
837
|
-
* caller can print a notice instead of writing a file the user did
|
|
838
|
-
* not create.
|
|
839
|
-
*
|
|
840
|
-
* Healing has two parts, because a plain append is NOT enough. A bare
|
|
841
|
-
* directory exclusion (`.webjs/`, `/.webjs/`, `.webjs`, with or without a
|
|
842
|
-
* leading glob) excludes the directory itself, and git CANNOT re-include
|
|
843
|
-
* a child of an excluded directory, so any later negation is silently
|
|
844
|
-
* dead. So: (1) rewrite each such line IN PLACE to the glob form
|
|
845
|
-
* (`**` + `/.webjs/*`), which ignores the directory's CONTENTS at any
|
|
846
|
-
* depth while leaving the directory re-includable; (2) append whichever
|
|
847
|
-
* of the three exception lines are still missing. The heal is idempotent:
|
|
848
|
-
* a re-run finds the pin already committable and short-circuits.
|
|
849
|
-
*
|
|
850
|
-
* @param {string} appDir
|
|
851
|
-
* @returns {Promise<{ ignored: boolean, patched: boolean, gitignorePath: string | null }>}
|
|
852
|
-
*/
|
|
853
|
-
export async function ensureVendorCommittable(appDir) {
|
|
854
|
-
if (!vendorPinIsIgnored(appDir)) {
|
|
855
|
-
return { ignored: false, patched: false, gitignorePath: null };
|
|
856
|
-
}
|
|
857
|
-
const gitignorePath = join(appDir, '.gitignore');
|
|
858
|
-
let original;
|
|
859
|
-
try {
|
|
860
|
-
original = await readFile(gitignorePath, 'utf8');
|
|
861
|
-
} catch {
|
|
862
|
-
// No app-level .gitignore to patch. The ignore is coming from a
|
|
863
|
-
// parent repo or from .git/info/exclude; do not fabricate a
|
|
864
|
-
// .gitignore the user never had. Let the caller print a notice.
|
|
865
|
-
return { ignored: true, patched: false, gitignorePath: null };
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// The exclusion glob, assembled so the literal `*` + `/` sequence never
|
|
869
|
-
// appears in this file's source comments above.
|
|
870
|
-
const exclude = VENDOR_GITIGNORE_LINES[0]; // **/.webjs/*
|
|
871
|
-
|
|
872
|
-
// Preserve the file's line ending so a CRLF .gitignore stays all-CRLF
|
|
873
|
-
// (and an LF one all-LF). Splitting on bare `\n` keeps each existing
|
|
874
|
-
// line's trailing `\r`; the lines we WRITE (the rewritten exclusion and
|
|
875
|
-
// the appended block) must use the same ending or the file goes mixed.
|
|
876
|
-
// A file with any CRLF is treated as CRLF; otherwise LF.
|
|
877
|
-
const eol = /\r\n/.test(original) ? '\r\n' : '\n';
|
|
878
|
-
|
|
879
|
-
// 1. Rewrite any bare `.webjs` DIRECTORY exclusion to the glob form. A
|
|
880
|
-
// directory exclusion blocks all child negations, so it must become
|
|
881
|
-
// `**/.webjs/*` (ignore contents, keep the dir re-includable).
|
|
882
|
-
const lines = original.split('\n');
|
|
883
|
-
let rewroteDir = false;
|
|
884
|
-
const rewritten = lines.map((line) => {
|
|
885
|
-
// Trim CR too, so a CRLF file's `.webjs/\r` still matches.
|
|
886
|
-
const t = line.replace(/\r$/, '').trim();
|
|
887
|
-
// Match the bare-directory shapes only (no `/*` suffix, not already a
|
|
888
|
-
// negation): `.webjs`, `.webjs/`, `/.webjs`, `/.webjs/`, `**/.webjs`,
|
|
889
|
-
// `**/.webjs/`. These all exclude the directory itself. Emit the
|
|
890
|
-
// replacement with the file's own ending if the original line carried
|
|
891
|
-
// one (every line but a no-trailing-newline last line does).
|
|
892
|
-
if (/^(\*\*\/|\/)?\.webjs\/?$/.test(t)) {
|
|
893
|
-
rewroteDir = true;
|
|
894
|
-
return line.endsWith('\r') ? exclude + '\r' : exclude;
|
|
895
|
-
}
|
|
896
|
-
return line;
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
// 2. Append whichever exception lines are still missing.
|
|
900
|
-
const present = new Set(rewritten.map((l) => l.replace(/\r$/, '').trim()));
|
|
901
|
-
const missing = VENDOR_GITIGNORE_LINES.filter((l) => !present.has(l));
|
|
902
|
-
|
|
903
|
-
let next = rewritten.join('\n');
|
|
904
|
-
if (missing.length > 0) {
|
|
905
|
-
const block =
|
|
906
|
-
[
|
|
907
|
-
'# webjs: keep the committed vendor pin (`webjs vendor pin`) out of',
|
|
908
|
-
'# the `.webjs` cache exclusion so the pinned importmap is committable.',
|
|
909
|
-
...missing,
|
|
910
|
-
].join(eol) + eol;
|
|
911
|
-
const sep = next.endsWith('\n') || next === '' ? '' : eol;
|
|
912
|
-
next = next + sep + block;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
if (!rewroteDir && missing.length === 0) {
|
|
916
|
-
// Nothing to change, yet git still ignores the pin: a broader,
|
|
917
|
-
// unrelated rule is the cause and the vendor exception cannot fix it.
|
|
918
|
-
return { ignored: true, patched: false, gitignorePath };
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
await writeFile(gitignorePath, next);
|
|
922
|
-
|
|
923
|
-
// 3. Re-probe. If a broader unrelated rule still swallows the pin, the
|
|
924
|
-
// edit did not achieve the goal, so revert it and report not-patched
|
|
925
|
-
// so the caller prints a notice instead of an inaccurate success.
|
|
926
|
-
if (vendorPinIsIgnored(appDir)) {
|
|
927
|
-
await writeFile(gitignorePath, original);
|
|
928
|
-
return { ignored: true, patched: false, gitignorePath };
|
|
929
|
-
}
|
|
930
|
-
return { ignored: true, patched: true, gitignorePath };
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
/**
|
|
934
|
-
* True when the app commits a vendor pin file (`.webjs/vendor/importmap.json`).
|
|
935
|
-
* A pinned app's importmap is deterministic and cheap to read, so `dev.js`
|
|
936
|
-
* resolves it AT BOOT (no analysis, no network) and publishes the build id
|
|
937
|
-
* immediately, giving the recommended posture a stable id from the first
|
|
938
|
-
* response with zero warmup exposure. An unpinned app returns false and keeps
|
|
939
|
-
* its vendor resolution deferred to the first request.
|
|
940
|
-
*
|
|
941
|
-
* @param {string} appDir
|
|
942
|
-
* @returns {boolean}
|
|
943
|
-
*/
|
|
944
|
-
export function hasVendorPin(appDir) {
|
|
945
|
-
return existsSync(pinFilePath(appDir));
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
* Filesystem-safe filename for a downloaded bundle. Encodes the full
|
|
950
|
-
* specifier (which may include a subpath) into a flat filename:
|
|
951
|
-
*
|
|
952
|
-
* bundleFilenameWithSubpath('dayjs', '1.11.13', '') returns 'dayjs@1.11.13.js'
|
|
953
|
-
* bundleFilenameWithSubpath('dayjs', '1.11.13', '/plugin/utc') returns 'dayjs@1.11.13__plugin__utc.js'
|
|
954
|
-
* bundleFilenameWithSubpath('@hotwired/turbo', '8.0.0', '') returns '@hotwired--turbo@8.0.0.js'
|
|
955
|
-
*
|
|
956
|
-
* Scoped names use `--` to encode `/`; subpath separators use `__`.
|
|
957
|
-
* Both are reversible round-trip so unpin / list can parse the
|
|
958
|
-
* package + version + subpath back from the filename.
|
|
959
|
-
*/
|
|
960
|
-
function bundleFilenameWithSubpath(pkgName, version, subpath) {
|
|
961
|
-
const safeName = pkgName.replace(/\//g, '--');
|
|
962
|
-
const safeSubpath = subpath.replace(/\//g, '__');
|
|
963
|
-
return `${safeName}@${version}${safeSubpath}.js`;
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/**
|
|
967
|
-
* Compute the SHA-384 SRI hash for a bundle body. Matches the format
|
|
968
|
-
* the browser's importmap `integrity` field and the `integrity`
|
|
969
|
-
* attribute on `<link rel="modulepreload">` expect. Accepts a string
|
|
970
|
-
* or any ArrayBufferView / ArrayBuffer.
|
|
971
|
-
*
|
|
972
|
-
* @param {string | ArrayBufferView | ArrayBuffer} body
|
|
973
|
-
* @returns {Promise<string>} e.g. `sha384-<base64>`
|
|
974
|
-
*/
|
|
975
|
-
export async function sha384Integrity(body) {
|
|
976
|
-
return `sha384-${await digestBase64('SHA-384', body)}`;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
/**
|
|
980
|
-
* Read the committed pin importmap if one exists. Returns the parsed
|
|
981
|
-
* `{ imports, integrity?, provider? }` shape or null if no pin file.
|
|
982
|
-
* The `integrity` and `provider` fields are optional: pin files
|
|
983
|
-
* written before SRI / multi-CDN support lack them; pin files written
|
|
984
|
-
* by current `webjs vendor pin` include them (provider only when
|
|
985
|
-
* non-default).
|
|
986
|
-
*
|
|
987
|
-
* @param {string} appDir
|
|
988
|
-
* @returns {Promise<{ imports: Record<string, string>, integrity?: Record<string, string>, provider?: string } | null>}
|
|
989
|
-
*/
|
|
990
|
-
export async function readPinFile(appDir) {
|
|
991
|
-
try {
|
|
992
|
-
const raw = await readFile(pinFilePath(appDir), 'utf8');
|
|
993
|
-
const parsed = JSON.parse(raw);
|
|
994
|
-
if (!parsed || typeof parsed.imports !== 'object' || Array.isArray(parsed.imports)) {
|
|
995
|
-
return null;
|
|
996
|
-
}
|
|
997
|
-
// Validate every imports entry. Drop:
|
|
998
|
-
// - non-string keys/values (numbers, nulls, objects from malformed
|
|
999
|
-
// hand-edits would otherwise land structurally-invalid entries in
|
|
1000
|
-
// the served importmap and break the browser parser);
|
|
1001
|
-
// - keys containing newlines or other control chars (they would
|
|
1002
|
-
// serialize to escape sequences in JSON and confuse downstream
|
|
1003
|
-
// diffing logic);
|
|
1004
|
-
// - values whose URL scheme isn't `http(s)://` or a path starting
|
|
1005
|
-
// with `/` (relative to the app's origin). `javascript:` and
|
|
1006
|
-
// `data:` URLs in a malicious pin file would otherwise be
|
|
1007
|
-
// accepted by the browser's importmap parser and let an attacker
|
|
1008
|
-
// ship code via a single-line pin diff. Tightest acceptable
|
|
1009
|
-
// set: matches what `webjs vendor pin` itself produces
|
|
1010
|
-
// (`https://ga.jspm.io/...` or `/__webjs/vendor/...`).
|
|
1011
|
-
/** @type {Record<string, string>} */
|
|
1012
|
-
const cleanImports = {};
|
|
1013
|
-
for (const [k, v] of Object.entries(parsed.imports)) {
|
|
1014
|
-
if (typeof k !== 'string' || typeof v !== 'string') continue;
|
|
1015
|
-
if (/[\x00-\x1f\x7f]/.test(k)) continue;
|
|
1016
|
-
// Require a non-slash byte after the scheme prefix so a
|
|
1017
|
-
// hand-edited or tampered pin file cannot smuggle a
|
|
1018
|
-
// protocol-relative URL like `//attacker.tld/x.js` past the
|
|
1019
|
-
// filter. Browsers resolve `//host/path` against the document
|
|
1020
|
-
// origin and would happily fetch attacker-controlled code if
|
|
1021
|
-
// the importmap accepted it. The framework itself only writes
|
|
1022
|
-
// `https://ga.jspm.io/...` or `/__webjs/vendor/...`, which both
|
|
1023
|
-
// satisfy the tighter form.
|
|
1024
|
-
if (!/^(?:https?:\/\/[^/]|\/[^/])/.test(v)) continue;
|
|
1025
|
-
cleanImports[k] = v;
|
|
1026
|
-
}
|
|
1027
|
-
if (Object.keys(cleanImports).length === 0) return null;
|
|
1028
|
-
|
|
1029
|
-
/** @type {Record<string, string>} */
|
|
1030
|
-
const cleanIntegrity = {};
|
|
1031
|
-
if (parsed.integrity && typeof parsed.integrity === 'object' && !Array.isArray(parsed.integrity)) {
|
|
1032
|
-
for (const [k, v] of Object.entries(parsed.integrity)) {
|
|
1033
|
-
// Integrity values must look like SRI hashes end-to-end
|
|
1034
|
-
// (`sha(256|384|512)-<base64>`). Anchor the regex on both
|
|
1035
|
-
// ends and constrain the body to the base64 alphabet so a
|
|
1036
|
-
// hand-edited or tampered pin file can't slip an attribute
|
|
1037
|
-
// injection (e.g. `sha384-x"><script>`) past the prefix
|
|
1038
|
-
// check and through to `integrity="..."` emission in ssr.js
|
|
1039
|
-
// unescaped.
|
|
1040
|
-
if (typeof k === 'string' && typeof v === 'string' && /^sha(256|384|512)-[A-Za-z0-9+/=]+$/.test(v)) {
|
|
1041
|
-
cleanIntegrity[k] = v;
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
/** @type {{ imports: Record<string,string>, integrity?: Record<string,string>, provider?: string }} */
|
|
1046
|
-
const out = { imports: cleanImports };
|
|
1047
|
-
if (Object.keys(cleanIntegrity).length) out.integrity = cleanIntegrity;
|
|
1048
|
-
// Provider is optional in the pin file. Validate against the
|
|
1049
|
-
// supported set so a tampered file can't smuggle an arbitrary
|
|
1050
|
-
// string into downstream code paths.
|
|
1051
|
-
if (typeof parsed.provider === 'string' && SUPPORTED_PROVIDERS.has(parsed.provider)) {
|
|
1052
|
-
out.provider = parsed.provider;
|
|
1053
|
-
}
|
|
1054
|
-
return out;
|
|
1055
|
-
} catch {
|
|
1056
|
-
return null;
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
/**
|
|
1061
|
-
* Write the pin importmap to `.webjs/vendor/importmap.json`. Ensures
|
|
1062
|
-
* the directory exists. Pretty-printed for human-reviewable diffs.
|
|
1063
|
-
*
|
|
1064
|
-
* When `integrity` is provided and non-empty, it's included alongside
|
|
1065
|
-
* `imports` as a sibling key (matching the browser importmap-integrity
|
|
1066
|
-
* spec: a flat `{url: 'sha384-...'}` map). Omitted entirely when empty
|
|
1067
|
-
* so older WebJs versions read the file as before.
|
|
1068
|
-
*
|
|
1069
|
-
* `provider` is persisted alongside imports when non-default. It lets
|
|
1070
|
-
* `webjs vendor update` know which CDN to re-resolve against, and
|
|
1071
|
-
* makes the pin file self-describing for incident response: if jspm.io
|
|
1072
|
-
* has an outage you can read the file and know which alternate CDN
|
|
1073
|
-
* the deploy targets. Omitted for the default jspm provider so the
|
|
1074
|
-
* pin file shape stays stable for the 99% case.
|
|
1075
|
-
*
|
|
1076
|
-
* @param {string} appDir
|
|
1077
|
-
* @param {Record<string, string>} imports
|
|
1078
|
-
* @param {Record<string, string>} [integrity]
|
|
1079
|
-
* @param {string} [provider]
|
|
1080
|
-
*/
|
|
1081
|
-
async function writePinFile(appDir, imports, integrity, provider) {
|
|
1082
|
-
await mkdir(pinDir(appDir), { recursive: true });
|
|
1083
|
-
/** @type {Record<string, any>} */
|
|
1084
|
-
const payload = { imports };
|
|
1085
|
-
if (integrity && Object.keys(integrity).length) payload.integrity = integrity;
|
|
1086
|
-
if (provider && provider !== 'jspm') payload.provider = provider;
|
|
1087
|
-
const body = JSON.stringify(payload, null, 2) + '\n';
|
|
1088
|
-
// Atomic write: stage into a sibling tmp file, then rename onto the
|
|
1089
|
-
// final path. Rename within the same directory is atomic on POSIX
|
|
1090
|
-
// and on Windows since Node 14+, so a crash mid-write can leave the
|
|
1091
|
-
// tmp file as garbage but cannot corrupt the live pin file. Without
|
|
1092
|
-
// this, a partially-written importmap.json round-trips through
|
|
1093
|
-
// readPinFile as null (fail-closed) but still requires the user to
|
|
1094
|
-
// notice and rerun pin; the rename keeps the live file intact across
|
|
1095
|
-
// every failure mode.
|
|
1096
|
-
const finalPath = pinFilePath(appDir);
|
|
1097
|
-
const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
|
|
1098
|
-
await writeFile(tmpPath, body, 'utf8');
|
|
1099
|
-
await rename(tmpPath, finalPath);
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/**
|
|
1103
|
-
* Download a single jspm.io URL and write the body to
|
|
1104
|
-
* `.webjs/vendor/<filename>`. Returns `{ bytes, integrity }` on
|
|
1105
|
-
* success or null on failure. The integrity hash is computed from the
|
|
1106
|
-
* downloaded bytes so it's always consistent with what's on disk.
|
|
1107
|
-
*
|
|
1108
|
-
* Bounded by PIN_BUNDLE_TIMEOUT_MS. `pinAll(dir, { download: true })`
|
|
1109
|
-
* runs this once per resolved URL on a CLI run with no ambient
|
|
1110
|
-
* deadline, so a CDN that accepts the connection and then stalls would
|
|
1111
|
-
* otherwise hang the pin with nothing to interrupt it (#1150).
|
|
1112
|
-
*
|
|
1113
|
-
* @param {string} url
|
|
1114
|
-
* @param {string} appDir
|
|
1115
|
-
* @param {string} filename
|
|
1116
|
-
* @returns {Promise<{ bytes: number, integrity: string } | null>}
|
|
1117
|
-
*/
|
|
1118
|
-
async function downloadBundle(url, appDir, filename) {
|
|
1119
|
-
const controller = new AbortController();
|
|
1120
|
-
const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
|
|
1121
|
-
try {
|
|
1122
|
-
const response = await fetch(url, { signal: controller.signal });
|
|
1123
|
-
if (!response.ok) {
|
|
1124
|
-
console.error(`[webjs] download ${url} returned ${response.status}`);
|
|
1125
|
-
return null;
|
|
1126
|
-
}
|
|
1127
|
-
// Hash raw response bytes, not the UTF-8 decoded string. The
|
|
1128
|
-
// browser's SRI implementation hashes the raw body bytes; if we
|
|
1129
|
-
// hashed `.text()` here we'd risk encoding round-trip drift on
|
|
1130
|
-
// any byte sequence the decode-then-re-encode pipeline doesn't
|
|
1131
|
-
// round-trip exactly. arrayBuffer + Uint8Array gives us the
|
|
1132
|
-
// same primitive the browser uses.
|
|
1133
|
-
const buf = new Uint8Array(await response.arrayBuffer());
|
|
1134
|
-
await mkdir(pinDir(appDir), { recursive: true });
|
|
1135
|
-
await writeFile(join(pinDir(appDir), filename), buf);
|
|
1136
|
-
return { bytes: buf.byteLength, integrity: await sha384Integrity(buf) };
|
|
1137
|
-
} catch (e) {
|
|
1138
|
-
const why = e && e.name === 'AbortError'
|
|
1139
|
-
? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
|
|
1140
|
-
: e && e.message;
|
|
1141
|
-
console.error(`[webjs] download ${url} failed: ${why}`);
|
|
1142
|
-
return null;
|
|
1143
|
-
} finally {
|
|
1144
|
-
clearTimeout(timer);
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* Fetch a jspm.io URL just to compute its SHA-384 hash, without
|
|
1150
|
-
* writing anything to disk. Used by `webjs vendor pin` (default mode)
|
|
1151
|
-
* so the importmap can carry SRI hashes even when bundles aren't
|
|
1152
|
-
* locally vendored.
|
|
1153
|
-
*
|
|
1154
|
-
* Bounded by PIN_BUNDLE_TIMEOUT_MS, the same budget `downloadBundle`
|
|
1155
|
-
* gets, since it transfers the same bytes and differs only in whether
|
|
1156
|
-
* they are written to disk. Default-mode `pinAll` runs this once per
|
|
1157
|
-
* resolved URL, so a CDN that accepts the connection and then stalls
|
|
1158
|
-
* would otherwise hang the pin with nothing to interrupt it: there is
|
|
1159
|
-
* no ambient deadline on a CLI run (#1150).
|
|
1160
|
-
*
|
|
1161
|
-
* @param {string} url
|
|
1162
|
-
* @returns {Promise<string | null>} the integrity string, or null on failure
|
|
1163
|
-
*/
|
|
1164
|
-
async function fetchIntegrity(url) {
|
|
1165
|
-
const controller = new AbortController();
|
|
1166
|
-
const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
|
|
1167
|
-
try {
|
|
1168
|
-
const response = await fetch(url, { signal: controller.signal });
|
|
1169
|
-
if (!response.ok) {
|
|
1170
|
-
console.error(`[webjs] hash ${url} returned ${response.status}`);
|
|
1171
|
-
return null;
|
|
1172
|
-
}
|
|
1173
|
-
// Hash raw response bytes so the integrity matches what the
|
|
1174
|
-
// browser computes when fetching the same URL. See the
|
|
1175
|
-
// matching comment in downloadBundle.
|
|
1176
|
-
const buf = new Uint8Array(await response.arrayBuffer());
|
|
1177
|
-
return await sha384Integrity(buf);
|
|
1178
|
-
} catch (e) {
|
|
1179
|
-
const why = e && e.name === 'AbortError'
|
|
1180
|
-
? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
|
|
1181
|
-
: e && e.message;
|
|
1182
|
-
console.error(`[webjs] hash ${url} failed: ${why}`);
|
|
1183
|
-
return null;
|
|
1184
|
-
} finally {
|
|
1185
|
-
clearTimeout(timer);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
/**
|
|
1190
|
-
* After writing the new pin output, delete any file in the pin
|
|
1191
|
-
* directory that doesn't belong. Handles three orphan scenarios
|
|
1192
|
-
* uniformly: version-bump leftovers, removed packages, and mode
|
|
1193
|
-
* switches (default <-> download).
|
|
1194
|
-
*
|
|
1195
|
-
* @param {string} appDir
|
|
1196
|
-
* @param {Set<string>} expected filenames that should remain
|
|
1197
|
-
* @returns {Promise<string[]>} list of pruned filenames
|
|
1198
|
-
*/
|
|
1199
|
-
async function pruneOrphans(appDir, expected) {
|
|
1200
|
-
const dir = pinDir(appDir);
|
|
1201
|
-
let files;
|
|
1202
|
-
try { files = await readdir(dir); } catch { return []; }
|
|
1203
|
-
const pruned = [];
|
|
1204
|
-
for (const f of files) {
|
|
1205
|
-
if (expected.has(f)) continue;
|
|
1206
|
-
try {
|
|
1207
|
-
await unlink(join(dir, f));
|
|
1208
|
-
pruned.push(f);
|
|
1209
|
-
} catch { /* race or permission; ignore */ }
|
|
1210
|
-
}
|
|
1211
|
-
return pruned;
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/**
|
|
1215
|
-
* Pin all currently-imported npm packages to `.webjs/vendor/
|
|
1216
|
-
* importmap.json`. Two modes:
|
|
1217
|
-
*
|
|
1218
|
-
* - Default: importmap URLs point at jspm.io (browser fetches from
|
|
1219
|
-
* CDN directly at runtime). Only `importmap.json` is committed.
|
|
1220
|
-
* - `download: true`: also fetches each bundle from jspm.io and
|
|
1221
|
-
* writes it to `.webjs/vendor/<pkg>@<version>.js`. importmap URLs
|
|
1222
|
-
* become local paths (`/__webjs/vendor/<filename>`), and the
|
|
1223
|
-
* server handler serves them from disk. Both `importmap.json` and
|
|
1224
|
-
* the bundle files are committed to source control.
|
|
1225
|
-
*
|
|
1226
|
-
* After pinning, prunes any orphan file in `.webjs/vendor/` not
|
|
1227
|
-
* produced by the current run. Pin is idempotent with respect to the
|
|
1228
|
-
* current source + node_modules: removed packages, bumped versions,
|
|
1229
|
-
* and mode switches all leave a clean directory.
|
|
1230
|
-
*
|
|
1231
|
-
* On success (at least one install resolved), returns
|
|
1232
|
-
* `{ pins, pruned, downloaded, provider }`. On total failure (one or
|
|
1233
|
-
* more installs were attempted but every jspm.io resolution failed),
|
|
1234
|
-
* the pin file is NOT written and the function returns
|
|
1235
|
-
* `{ pins: [], pruned: [], downloaded: 0, failed: true, attemptedInstalls }`
|
|
1236
|
-
* instead. When the app has zero bare-specifier imports at all
|
|
1237
|
-
* (scanned source produced nothing), returns
|
|
1238
|
-
* `{ pins: [], pruned: [], downloaded: 0, noBareImports: true }`
|
|
1239
|
-
* WITHOUT writing the pin file. When the scan DID find specifiers but
|
|
1240
|
-
* every one was dropped because no local version resolved (the package
|
|
1241
|
-
* is not installed under `node_modules`), returns
|
|
1242
|
-
* `{ pins: [], pruned: [], downloaded: 0, droppedUnresolvable: [...] }`
|
|
1243
|
-
* (also WITHOUT a pin file), so the caller can name the found-but-
|
|
1244
|
-
* uninstalled specifiers instead of claiming there were none. A
|
|
1245
|
-
* partial pin (some resolved, some dropped) still writes the file and
|
|
1246
|
-
* carries `droppedUnresolvable` alongside `pins`. Callers that need to
|
|
1247
|
-
* surface a non-zero exit code key off `failed`, `noBareImports`, or an
|
|
1248
|
-
* all-dropped `droppedUnresolvable`; all are absent on a clean success.
|
|
1249
|
-
*
|
|
1250
|
-
* The `from` option mirrors importmap-rails's `bin/importmap pin foo
|
|
1251
|
-
* --from jsdelivr`. Default `jspm` resolves to jspm.io; other values
|
|
1252
|
-
* (jsdelivr, unpkg, skypack) are passed through to jspm.io's
|
|
1253
|
-
* Generator API which returns URLs from the chosen CDN. The provider
|
|
1254
|
-
* is persisted in the pin file so `vendor update` and incident
|
|
1255
|
-
* response know which CDN to re-resolve against.
|
|
1256
|
-
*
|
|
1257
|
-
* @param {string} appDir
|
|
1258
|
-
* @param {{ download?: boolean, from?: string }} [opts]
|
|
1259
|
-
* @returns {Promise<{
|
|
1260
|
-
* pins: Array<{ pkg: string, version: string, url: string, bytes?: number, integrity?: string }>,
|
|
1261
|
-
* pruned: string[],
|
|
1262
|
-
* downloaded: number,
|
|
1263
|
-
* provider?: string,
|
|
1264
|
-
* failed?: boolean,
|
|
1265
|
-
* noBareImports?: boolean,
|
|
1266
|
-
* droppedUnresolvable?: string[],
|
|
1267
|
-
* attemptedInstalls?: string[],
|
|
1268
|
-
* }>}
|
|
1269
|
-
*/
|
|
1270
|
-
export async function pinAll(appDir, opts = {}) {
|
|
1271
|
-
const download = !!opts.download;
|
|
1272
|
-
// Provider precedence (same as updatePinned for consistency):
|
|
1273
|
-
// 1. explicit opts.from (CLI --from flag wins)
|
|
1274
|
-
// 2. existing pin file's persisted provider (stickiness: user
|
|
1275
|
-
// who pinned via jsdelivr stays on jsdelivr until they
|
|
1276
|
-
// explicitly switch back)
|
|
1277
|
-
// 3. default 'jspm'
|
|
1278
|
-
// Pre-read the file once to access its provider.
|
|
1279
|
-
const existing = await readPinFile(appDir);
|
|
1280
|
-
const from = opts.from || existing?.provider || 'jspm';
|
|
1281
|
-
if (!SUPPORTED_PROVIDERS.has(from)) {
|
|
1282
|
-
throw new Error(
|
|
1283
|
-
`[webjs] unknown provider '${from}'. Supported: ${[...SUPPORTED_PROVIDERS].join(', ')}.`,
|
|
1284
|
-
);
|
|
1285
|
-
}
|
|
1286
|
-
const bare = await scanBareImports(appDir);
|
|
1287
|
-
const installs = [];
|
|
1288
|
-
/**
|
|
1289
|
-
* Map from install spec (`pkg@version<subpath>`) to its components,
|
|
1290
|
-
* so we can recover the pkg + version + subpath when iterating jspm.io's
|
|
1291
|
-
* resolved imports.
|
|
1292
|
-
* @type {Map<string, { pkg: string, version: string, subpath: string }>}
|
|
1293
|
-
*/
|
|
1294
|
-
const partsByInstall = new Map();
|
|
1295
|
-
/**
|
|
1296
|
-
* Bare specifiers the scan FOUND but that were dropped because no local
|
|
1297
|
-
* version could be resolved (the package is not installed under the app's
|
|
1298
|
-
* `node_modules`). Kept distinct from "scan found nothing" so the CLI can
|
|
1299
|
-
* name them and point at the remedy instead of claiming there were none.
|
|
1300
|
-
* @type {string[]}
|
|
1301
|
-
*/
|
|
1302
|
-
const droppedUnresolvable = [];
|
|
1303
|
-
for (const spec of bare) {
|
|
1304
|
-
if (BUILTIN.has(spec)) continue;
|
|
1305
|
-
const pkg = extractPackageName(spec);
|
|
1306
|
-
if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
|
|
1307
|
-
const version = getPackageVersion(pkg, appDir);
|
|
1308
|
-
if (!version) {
|
|
1309
|
-
droppedUnresolvable.push(spec);
|
|
1310
|
-
continue;
|
|
1311
|
-
}
|
|
1312
|
-
const subpath = spec.slice(pkg.length);
|
|
1313
|
-
const install = `${pkg}@${version}${subpath}`;
|
|
1314
|
-
installs.push(install);
|
|
1315
|
-
partsByInstall.set(spec, { pkg, version, subpath });
|
|
1316
|
-
}
|
|
1317
|
-
const resolved = await jspmGenerate(installs, from, PIN_BUNDLE_TIMEOUT_MS);
|
|
1318
|
-
|
|
1319
|
-
/** @type {Record<string, string>} */
|
|
1320
|
-
const importmap = {};
|
|
1321
|
-
/**
|
|
1322
|
-
* SRI integrity by FINAL URL (post-rewrite). The browser's
|
|
1323
|
-
* importmap-integrity spec keys on the URL that appears in the
|
|
1324
|
-
* importmap, not the source jspm.io URL. For default mode the two
|
|
1325
|
-
* are identical; for --download mode the URL is the local
|
|
1326
|
-
* /__webjs/vendor/<filename> path.
|
|
1327
|
-
* @type {Record<string, string>}
|
|
1328
|
-
*/
|
|
1329
|
-
const integrity = {};
|
|
1330
|
-
/** @type {Array<{ pkg: string, version: string, url: string, bytes?: number, integrity?: string }>} */
|
|
1331
|
-
const pins = [];
|
|
1332
|
-
const expected = new Set([PIN_FILE]);
|
|
1333
|
-
let downloaded = 0;
|
|
1334
|
-
|
|
1335
|
-
// Specs that were directly scanned (`partsByInstall`) AND the flattened
|
|
1336
|
-
// transitive specs the unified resolve returns (issue #446) must BOTH be
|
|
1337
|
-
// pinned, or a pinned app's importmap would be missing the transitive
|
|
1338
|
-
// entries the runtime live-resolve serves (e.g. `@codemirror/state` pulled
|
|
1339
|
-
// in by `@codemirror/lint`), breaking parity: the browser would hit an
|
|
1340
|
-
// unresolved-bare-specifier error for the transitive. For a transitive we
|
|
1341
|
-
// recover pkg + version + subpath by parsing the spec against the resolved
|
|
1342
|
-
// jspm URL (`derivePinParts`), since it has no `partsByInstall` entry.
|
|
1343
|
-
/** @type {Set<string>} */
|
|
1344
|
-
const pinnedDirectSpecs = new Set();
|
|
1345
|
-
for (const [spec, jspmUrl] of Object.entries(resolved)) {
|
|
1346
|
-
const parts = partsByInstall.get(spec) || derivePinParts(spec, jspmUrl);
|
|
1347
|
-
if (!parts) continue;
|
|
1348
|
-
const direct = partsByInstall.has(spec);
|
|
1349
|
-
const { pkg, version, subpath } = parts;
|
|
1350
|
-
if (download) {
|
|
1351
|
-
const filename = bundleFilenameWithSubpath(pkg, version, subpath);
|
|
1352
|
-
const result = await downloadBundle(jspmUrl, appDir, filename);
|
|
1353
|
-
if (result == null) continue;
|
|
1354
|
-
const localUrl = `/__webjs/vendor/${filename}`;
|
|
1355
|
-
importmap[spec] = localUrl;
|
|
1356
|
-
integrity[localUrl] = result.integrity;
|
|
1357
|
-
expected.add(filename);
|
|
1358
|
-
pins.push({ pkg: spec, version, url: localUrl, bytes: result.bytes, integrity: result.integrity });
|
|
1359
|
-
downloaded++;
|
|
1360
|
-
} else {
|
|
1361
|
-
importmap[spec] = jspmUrl;
|
|
1362
|
-
// Fetch the bundle just to hash it. Bytes aren't written to
|
|
1363
|
-
// disk; only the SHA-384 reaches the pin file. CDN compromise
|
|
1364
|
-
// defense for default mode: if jspm.io serves different bytes
|
|
1365
|
-
// later, the browser refuses to execute (integrity mismatch).
|
|
1366
|
-
const sri = await fetchIntegrity(jspmUrl);
|
|
1367
|
-
if (sri) integrity[jspmUrl] = sri;
|
|
1368
|
-
else console.warn(
|
|
1369
|
-
`[webjs] could not compute SRI for ${jspmUrl}; pinning without ` +
|
|
1370
|
-
`integrity (browser will accept any bytes from this URL on ` +
|
|
1371
|
-
`next load). Rerun \`webjs vendor pin\` when jspm.io is healthy ` +
|
|
1372
|
-
`to lock in the integrity hash.`,
|
|
1373
|
-
);
|
|
1374
|
-
pins.push({ pkg: spec, version, url: jspmUrl, integrity: sri || undefined });
|
|
1375
|
-
}
|
|
1376
|
-
if (direct) pinnedDirectSpecs.add(spec);
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
// If pin was attempted (installs non-empty) but resolved zero, do
|
|
1380
|
-
// NOT write the pin file. Writing `{ imports: {} }` would shadow
|
|
1381
|
-
// the live-API fallback (which reads when no pin file exists) and
|
|
1382
|
-
// leave the browser with an empty importmap, silently breaking
|
|
1383
|
-
// every bare-specifier import. Better: surface the failure so the
|
|
1384
|
-
// user knows pin didn't take, and let the next boot fall back to
|
|
1385
|
-
// live API resolution (which may have recovered by then).
|
|
1386
|
-
//
|
|
1387
|
-
// Account on DIRECT specs only: pins also carries flattened transitive
|
|
1388
|
-
// entries (#446), so `pins.length === 0` would no longer mean "every
|
|
1389
|
-
// direct install failed". A resolve that returned only transitives but
|
|
1390
|
-
// no direct spec is still a total failure for the user's deps.
|
|
1391
|
-
if (installs.length > 0 && pinnedDirectSpecs.size === 0) {
|
|
1392
|
-
return { pins, pruned: [], downloaded, failed: true, attemptedInstalls: installs, provider: from };
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
// Partial-failure surface. Some DIRECT installs were attempted but not
|
|
1396
|
-
// every one made it into pins (jspm.io returned the package OK,
|
|
1397
|
-
// but downloadBundle failed mid-stream in --download mode, or the
|
|
1398
|
-
// resolver response was missing the package entirely). Write the
|
|
1399
|
-
// pin file anyway so the working packages get committed, but warn
|
|
1400
|
-
// so the user knows the next runtime fetch for the missing
|
|
1401
|
-
// packages will fall through to a live jspm.io call (or 404 in
|
|
1402
|
-
// --download mode).
|
|
1403
|
-
//
|
|
1404
|
-
// Derive the missing set from partsByInstall (the bare-spec keys)
|
|
1405
|
-
// rather than from `installs` (the versioned strings). Compare against
|
|
1406
|
-
// the DIRECT specs that pinned, NOT pins[].pkg (which now includes
|
|
1407
|
-
// transitives), so a transitive can't mask a missing direct dep.
|
|
1408
|
-
if (pinnedDirectSpecs.size < partsByInstall.size) {
|
|
1409
|
-
/** @type {string[]} */
|
|
1410
|
-
const missing = [];
|
|
1411
|
-
for (const [spec, parts] of partsByInstall.entries()) {
|
|
1412
|
-
if (!pinnedDirectSpecs.has(spec)) {
|
|
1413
|
-
missing.push(`${parts.pkg}@${parts.version}${parts.subpath}`);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
console.warn(
|
|
1417
|
-
`[webjs] pin: partial success. The following installs did NOT ` +
|
|
1418
|
-
`make it into the pin file and will fall back to live ` +
|
|
1419
|
-
`resolution on next boot:`,
|
|
1420
|
-
);
|
|
1421
|
-
for (const m of missing) console.warn(` ${m}`);
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
// The app legitimately has zero bare-specifier imports (or the
|
|
1425
|
-
// scanner is running outside a WebJs project). Don't create an
|
|
1426
|
-
// empty `.webjs/vendor/importmap.json`. Without this guard the file
|
|
1427
|
-
// gets written as `{ imports: {} }` in whatever cwd the CLI was
|
|
1428
|
-
// invoked from, then immediately rejected by readPinFile's empty
|
|
1429
|
-
// -imports filter, so the file exists but does nothing. The CLI
|
|
1430
|
-
// surfaces this as a clearer "no bare imports found" message.
|
|
1431
|
-
if (installs.length === 0) {
|
|
1432
|
-
// Distinguish the two empty-set causes the CLI must report differently:
|
|
1433
|
-
// the scan genuinely found nothing (noBareImports) vs it found specifiers
|
|
1434
|
-
// that were all dropped for a missing local version (droppedUnresolvable).
|
|
1435
|
-
if (droppedUnresolvable.length > 0) {
|
|
1436
|
-
return { pins, pruned: [], downloaded, droppedUnresolvable, provider: from };
|
|
1437
|
-
}
|
|
1438
|
-
return { pins, pruned: [], downloaded, noBareImports: true, provider: from };
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
await writePinFile(appDir, importmap, integrity, from);
|
|
1442
|
-
const pruned = await pruneOrphans(appDir, expected);
|
|
1443
|
-
// Some specifiers may have pinned while others were dropped for a missing
|
|
1444
|
-
// version; surface the dropped ones so a partial pin is not silent.
|
|
1445
|
-
return droppedUnresolvable.length > 0
|
|
1446
|
-
? { pins, pruned, downloaded, provider: from, droppedUnresolvable }
|
|
1447
|
-
: { pins, pruned, downloaded, provider: from };
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
/**
|
|
1451
|
-
* Remove a single package from the committed pin output. Deletes the
|
|
1452
|
-
* package's entry from `importmap.json`, and (if a bundle file
|
|
1453
|
-
* exists for it) deletes that file too.
|
|
1454
|
-
*
|
|
1455
|
-
* @param {string} appDir
|
|
1456
|
-
* @param {string} pkg
|
|
1457
|
-
* @returns {Promise<{ removed: boolean, deletedFile?: string }>}
|
|
1458
|
-
*/
|
|
1459
|
-
export async function unpinPackage(appDir, pkg) {
|
|
1460
|
-
const file = await readPinFile(appDir);
|
|
1461
|
-
if (!file || !(pkg in file.imports)) return { removed: false };
|
|
1462
|
-
const url = file.imports[pkg];
|
|
1463
|
-
delete file.imports[pkg];
|
|
1464
|
-
// Also strip the integrity entry for this URL, if present.
|
|
1465
|
-
const newIntegrity = { ...(file.integrity || {}) };
|
|
1466
|
-
delete newIntegrity[url];
|
|
1467
|
-
if (Object.keys(file.imports).length === 0) {
|
|
1468
|
-
// The pin file would now be empty. Delete it so the next boot
|
|
1469
|
-
// falls back to live API resolution rather than seeing an empty
|
|
1470
|
-
// importmap. Same reasoning as pinAll's "don't write empty pin"
|
|
1471
|
-
// guard.
|
|
1472
|
-
try { await unlink(pinFilePath(appDir)); } catch { /* race or never existed */ }
|
|
1473
|
-
} else {
|
|
1474
|
-
// Preserve the pin file's persisted provider (jsdelivr, unpkg,
|
|
1475
|
-
// etc.). Without this, `webjs vendor unpin <pkg>` would silently
|
|
1476
|
-
// revert the file to the default jspm provider, defeating
|
|
1477
|
-
// pinAll's stickiness for the remaining packages.
|
|
1478
|
-
await writePinFile(appDir, file.imports, newIntegrity, file.provider);
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
let deletedFile;
|
|
1482
|
-
if (url.startsWith('/__webjs/vendor/')) {
|
|
1483
|
-
const filename = url.slice('/__webjs/vendor/'.length);
|
|
1484
|
-
try {
|
|
1485
|
-
await unlink(join(pinDir(appDir), filename));
|
|
1486
|
-
deletedFile = filename;
|
|
1487
|
-
} catch { /* file already gone; ignore */ }
|
|
1488
|
-
}
|
|
1489
|
-
return { removed: true, deletedFile };
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
/**
|
|
1493
|
-
* List entries from the committed pin file. Parses the package
|
|
1494
|
-
* version from the URL (jspm.io URL or the local file's @version).
|
|
1495
|
-
*
|
|
1496
|
-
* @param {string} appDir
|
|
1497
|
-
* @returns {Promise<Array<{ pkg: string, version: string, url: string, bytes?: number }>>}
|
|
1498
|
-
*/
|
|
1499
|
-
export async function listPinned(appDir) {
|
|
1500
|
-
const file = await readPinFile(appDir);
|
|
1501
|
-
if (!file) return [];
|
|
1502
|
-
const entries = [];
|
|
1503
|
-
for (const [pkg, url] of Object.entries(file.imports)) {
|
|
1504
|
-
let version = '(unknown)';
|
|
1505
|
-
let bytes;
|
|
1506
|
-
// Order matters: try the local `/__webjs/vendor/` filename
|
|
1507
|
-
// parser first, then the CDN bare-name search. The local
|
|
1508
|
-
// filename embeds the subpath as `__plugin__utc.js`, which the
|
|
1509
|
-
// bare-name regex would match as part of the version (greedy
|
|
1510
|
-
// `[^/]+` swallows the encoded subpath). Handling the local
|
|
1511
|
-
// case explicitly preserves the cleaner version output for
|
|
1512
|
-
// `--download` mode pins.
|
|
1513
|
-
if (url.startsWith('/__webjs/vendor/')) {
|
|
1514
|
-
const filename = url.slice('/__webjs/vendor/'.length);
|
|
1515
|
-
const atIdx = filename.lastIndexOf('@');
|
|
1516
|
-
if (atIdx > 0) {
|
|
1517
|
-
// Strip trailing `.js`, split off any `__subpath` segment, keep
|
|
1518
|
-
// only the version. `dayjs@1.11.13__plugin__utc.js` parses as
|
|
1519
|
-
// version `1.11.13` (not `1.11.13__plugin__utc`).
|
|
1520
|
-
const afterAt = filename.slice(atIdx + 1, -3);
|
|
1521
|
-
const subIdx = afterAt.indexOf('__');
|
|
1522
|
-
version = subIdx < 0 ? afterAt : afterAt.slice(0, subIdx);
|
|
1523
|
-
}
|
|
1524
|
-
try {
|
|
1525
|
-
const st = await stat(join(pinDir(appDir), filename));
|
|
1526
|
-
bytes = st.size;
|
|
1527
|
-
} catch { /* file missing; bytes stays undefined */ }
|
|
1528
|
-
} else {
|
|
1529
|
-
// Derive the version from the URL by searching for the spec's
|
|
1530
|
-
// bare package name followed by `@<version>`. Works across
|
|
1531
|
-
// every CDN we support (jspm.io's `npm:dayjs@1.11.13`,
|
|
1532
|
-
// jsdelivr's `npm/dayjs@1.11.13`, unpkg's bare
|
|
1533
|
-
// `dayjs@1.11.13/`, skypack's `dayjs@1.11.13`). The bare name
|
|
1534
|
-
// lives in entries[].pkg (the import-map key), so we know it
|
|
1535
|
-
// exactly and just need to find the `<bare>@<version>`
|
|
1536
|
-
// substring. Stop at the first `/` after the version so we
|
|
1537
|
-
// don't include the entry-point path.
|
|
1538
|
-
//
|
|
1539
|
-
// Anchor the match against a non-pkg-name char (or string
|
|
1540
|
-
// start) so a short package name like `ms` doesn't false-
|
|
1541
|
-
// match inside another package's URL like `npm/terms@1.0.0/`.
|
|
1542
|
-
// npm package names use `[a-zA-Z0-9._-]` (plus `@` and `/`
|
|
1543
|
-
// for scoped names), so anything else is a safe boundary.
|
|
1544
|
-
const bare = extractPackageName(pkg) || pkg;
|
|
1545
|
-
const escapedBare = bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1546
|
-
const bareMatch = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
|
|
1547
|
-
if (bareMatch) version = bareMatch[1];
|
|
1548
|
-
}
|
|
1549
|
-
entries.push({ pkg, version, url, bytes });
|
|
1550
|
-
}
|
|
1551
|
-
return entries;
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
// ---------------------------------------------------------------------------
|
|
1555
|
-
// npm registry queries: audit + outdated + update
|
|
1556
|
-
// ---------------------------------------------------------------------------
|
|
1557
|
-
|
|
1558
|
-
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
1559
|
-
// The npm registry is reached only by `audit`, `outdated`, and `update`,
|
|
1560
|
-
// all CLI commands, so it takes the same generous budget the pin bundle
|
|
1561
|
-
// fetch does rather than the server's. importmap-rails makes these same two
|
|
1562
|
-
// calls with Ruby's 60s Net::HTTP default.
|
|
1563
|
-
const NPM_TIMEOUT_MS = 60_000;
|
|
1564
|
-
|
|
1565
|
-
/**
|
|
1566
|
-
* Fetch one URL from registry.npmjs.org with a small timeout. Returns
|
|
1567
|
-
* the parsed JSON body on 2xx, or null on any non-2xx / network /
|
|
1568
|
-
* timeout. Used by audit + outdated.
|
|
1569
|
-
*
|
|
1570
|
-
* @param {string} url
|
|
1571
|
-
* @param {RequestInit} [init]
|
|
1572
|
-
* @returns {Promise<any | null>}
|
|
1573
|
-
*/
|
|
1574
|
-
async function fetchNpmJson(url, init) {
|
|
1575
|
-
const controller = new AbortController();
|
|
1576
|
-
const timer = setTimeout(() => controller.abort(), NPM_TIMEOUT_MS);
|
|
1577
|
-
try {
|
|
1578
|
-
const resp = await fetch(url, { ...init, signal: controller.signal });
|
|
1579
|
-
if (!resp.ok) return null;
|
|
1580
|
-
return await resp.json();
|
|
1581
|
-
} catch { return null; }
|
|
1582
|
-
finally { clearTimeout(timer); }
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
/**
|
|
1586
|
-
* Group the pin file's entries by package name + the set of versions
|
|
1587
|
-
* actually pinned (a single package can be pinned at multiple versions
|
|
1588
|
-
* via subpath imports). Used by audit (npm advisories want
|
|
1589
|
-
* `{ pkgName: [versions] }`) and outdated (one query per package).
|
|
1590
|
-
*
|
|
1591
|
-
* @param {Array<{ pkg: string, version: string }>} entries
|
|
1592
|
-
* @returns {Map<string, Set<string>>}
|
|
1593
|
-
*/
|
|
1594
|
-
function groupPinnedByPackage(entries) {
|
|
1595
|
-
const out = new Map();
|
|
1596
|
-
for (const e of entries) {
|
|
1597
|
-
if (!e.version || e.version === '(unknown)') continue;
|
|
1598
|
-
// entries[].pkg can include a subpath (e.g. `dayjs/plugin/utc`).
|
|
1599
|
-
// Extract the bare package name (`dayjs` or `@scope/name`).
|
|
1600
|
-
const bare = extractPackageName(e.pkg) || e.pkg;
|
|
1601
|
-
if (!out.has(bare)) out.set(bare, new Set());
|
|
1602
|
-
out.get(bare).add(e.version);
|
|
1603
|
-
}
|
|
1604
|
-
return out;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
/**
|
|
1608
|
-
* Run a security audit against the pinned versions in the committed
|
|
1609
|
-
* pin file. POSTs to npm's bulk-advisory endpoint, the same one
|
|
1610
|
-
* `npm audit` uses internally.
|
|
1611
|
-
*
|
|
1612
|
-
* Returns `{ errored: true }` when the registry call failed (network
|
|
1613
|
-
* down, timeout, 5xx) so the CLI can surface the failure clearly
|
|
1614
|
-
* instead of misleading the user with "no vulnerabilities found".
|
|
1615
|
-
*
|
|
1616
|
-
* Mirrors importmap-rails's `bin/importmap audit`.
|
|
1617
|
-
*
|
|
1618
|
-
* @param {string} appDir
|
|
1619
|
-
* @returns {Promise<{
|
|
1620
|
-
* vulnerable: Array<{ name: string, severity: string, vulnerableVersions: string, title: string }>,
|
|
1621
|
-
* totalChecked: number,
|
|
1622
|
-
* errored?: boolean,
|
|
1623
|
-
* }>}
|
|
1624
|
-
*/
|
|
1625
|
-
export async function auditPinned(appDir) {
|
|
1626
|
-
const entries = await listPinned(appDir);
|
|
1627
|
-
if (!entries.length) return { vulnerable: [], totalChecked: 0 };
|
|
1628
|
-
const grouped = groupPinnedByPackage(entries);
|
|
1629
|
-
const body = {};
|
|
1630
|
-
for (const [pkg, versions] of grouped) body[pkg] = [...versions];
|
|
1631
|
-
const result = await fetchNpmJson(`${NPM_REGISTRY}/-/npm/v1/security/advisories/bulk`, {
|
|
1632
|
-
method: 'POST',
|
|
1633
|
-
headers: { 'content-type': 'application/json' },
|
|
1634
|
-
body: JSON.stringify(body),
|
|
1635
|
-
});
|
|
1636
|
-
const totalChecked = grouped.size;
|
|
1637
|
-
if (result === null) {
|
|
1638
|
-
// Distinguish "registry returned no advisories" (success, empty
|
|
1639
|
-
// object) from "couldn't reach registry" (null). The latter is
|
|
1640
|
-
// user-visible because a silent "no vulnerabilities" on a failed
|
|
1641
|
-
// call would falsely reassure the user.
|
|
1642
|
-
return { vulnerable: [], totalChecked, errored: true };
|
|
1643
|
-
}
|
|
1644
|
-
if (typeof result !== 'object') return { vulnerable: [], totalChecked };
|
|
1645
|
-
/** @type {Array<{ name: string, severity: string, vulnerableVersions: string, title: string }>} */
|
|
1646
|
-
const vulnerable = [];
|
|
1647
|
-
for (const [name, advisories] of Object.entries(result)) {
|
|
1648
|
-
if (!Array.isArray(advisories)) continue;
|
|
1649
|
-
for (const a of advisories) {
|
|
1650
|
-
vulnerable.push({
|
|
1651
|
-
name,
|
|
1652
|
-
severity: String(a?.severity || 'unknown'),
|
|
1653
|
-
vulnerableVersions: String(a?.vulnerable_versions || a?.range || ''),
|
|
1654
|
-
title: String(a?.title || a?.overview || ''),
|
|
1655
|
-
});
|
|
1656
|
-
}
|
|
1657
|
-
}
|
|
1658
|
-
return { vulnerable, totalChecked };
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
/**
|
|
1662
|
-
* Find pinned packages that have a newer version available on npm.
|
|
1663
|
-
* Queries `registry.npmjs.org/<pkg>` per pinned package, compares the
|
|
1664
|
-
* pinned version against `dist-tags.latest` with semver-shaped string
|
|
1665
|
-
* ordering (regex parse, then numeric compare per segment).
|
|
1666
|
-
*
|
|
1667
|
-
* Mirrors importmap-rails's `bin/importmap outdated`.
|
|
1668
|
-
*
|
|
1669
|
-
* @param {string} appDir
|
|
1670
|
-
* @returns {Promise<Array<{ pkg: string, current: string, latest: string }>>}
|
|
1671
|
-
*/
|
|
1672
|
-
export async function findOutdated(appDir) {
|
|
1673
|
-
const entries = await listPinned(appDir);
|
|
1674
|
-
if (!entries.length) return [];
|
|
1675
|
-
const grouped = groupPinnedByPackage(entries);
|
|
1676
|
-
// Fetch in parallel. With sequential awaits a 50-package project
|
|
1677
|
-
// could take 50 × 10s = 500s in the worst case (one npm registry
|
|
1678
|
-
// timeout each). Parallel `Promise.all` collapses this to one
|
|
1679
|
-
// round-trip's wall-clock, while staying well below npm registry's
|
|
1680
|
-
// unauthenticated-client soft rate limit (registry-side concern,
|
|
1681
|
-
// not ours to throttle).
|
|
1682
|
-
//
|
|
1683
|
-
// Scoped packages: the `/` between `@scope` and `name` is part of
|
|
1684
|
-
// the URL path, NOT a path separator that should be encoded.
|
|
1685
|
-
// `encodeURIComponent` would emit `%2F`, which the npm registry
|
|
1686
|
-
// accepts but other npm-compatible registries (Verdaccio, JFrog,
|
|
1687
|
-
// GitHub Packages) sometimes reject. The npm-cli uses the literal
|
|
1688
|
-
// form. npm package-name rules disallow URL-unsafe chars so this
|
|
1689
|
-
// is safe.
|
|
1690
|
-
const queries = [...grouped].map(async ([pkg, versions]) => {
|
|
1691
|
-
const meta = await fetchNpmJson(`${NPM_REGISTRY}/${pkg}`);
|
|
1692
|
-
const latest = meta?.['dist-tags']?.latest;
|
|
1693
|
-
if (typeof latest !== 'string') return null;
|
|
1694
|
-
// A package can be pinned at multiple versions (subpath imports).
|
|
1695
|
-
// Take the max pinned version as the "current" for the comparison
|
|
1696
|
-
// so we only report it as outdated when EVERY pinned version
|
|
1697
|
-
// trails latest.
|
|
1698
|
-
const current = maxSemverVersion([...versions]);
|
|
1699
|
-
if (compareSemver(current, latest) >= 0) return null;
|
|
1700
|
-
return { pkg, current, latest };
|
|
1701
|
-
});
|
|
1702
|
-
const results = await Promise.all(queries);
|
|
1703
|
-
// `return` followed by a newline triggers ASI: `return; (expr);`
|
|
1704
|
-
// returns undefined and drops the value. Keep the filter on the
|
|
1705
|
-
// same line as `return` (or pull the result into a variable
|
|
1706
|
-
// first) to avoid the trap.
|
|
1707
|
-
/** @type {Array<{ pkg: string, current: string, latest: string }>} */
|
|
1708
|
-
const out = results.filter((x) => x !== null);
|
|
1709
|
-
return out;
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
/**
|
|
1713
|
-
* Re-pin every package returned by findOutdated to its latest version.
|
|
1714
|
-
* Calls jspm.io's Generator API with `<pkg>@<latest>` for each
|
|
1715
|
-
* outdated entry, then writes the new pin file.
|
|
1716
|
-
*
|
|
1717
|
-
* Mirrors importmap-rails's `bin/importmap update`, with the same
|
|
1718
|
-
* caveat: this updates the pin file but does NOT update the user's
|
|
1719
|
-
* `package.json` / `node_modules`. The user should run `npm install
|
|
1720
|
-
* <pkg>@<latest>` afterward to keep package.json in sync.
|
|
1721
|
-
*
|
|
1722
|
-
* When `opts.from` is not passed, the existing pin file's `provider`
|
|
1723
|
-
* field is used (so a user who pinned `--from jsdelivr` originally
|
|
1724
|
-
* stays on jsdelivr after update). When the file has no provider
|
|
1725
|
-
* field, defaults to `jspm`.
|
|
1726
|
-
*
|
|
1727
|
-
* @param {string} appDir
|
|
1728
|
-
* @param {{ from?: string }} [opts]
|
|
1729
|
-
* @returns {Promise<{ updated: Array<{ pkg: string, from: string, to: string }>, noOutdated?: boolean, provider?: string }>}
|
|
1730
|
-
*/
|
|
1731
|
-
export async function updatePinned(appDir, opts = {}) {
|
|
1732
|
-
const file = await readPinFile(appDir);
|
|
1733
|
-
// Provider precedence:
|
|
1734
|
-
// 1. explicit opts.from (CLI flag wins)
|
|
1735
|
-
// 2. pin file's persisted provider
|
|
1736
|
-
// 3. default 'jspm'
|
|
1737
|
-
// Validate AFTER resolving so a stale pin file with a previously-
|
|
1738
|
-
// valid-but-now-removed provider still errors clearly.
|
|
1739
|
-
const from = opts.from || file?.provider || 'jspm';
|
|
1740
|
-
if (!SUPPORTED_PROVIDERS.has(from)) {
|
|
1741
|
-
throw new Error(
|
|
1742
|
-
`[webjs] unknown provider '${from}'. Supported: ${[...SUPPORTED_PROVIDERS].join(', ')}.`,
|
|
1743
|
-
);
|
|
1744
|
-
}
|
|
1745
|
-
const outdated = await findOutdated(appDir);
|
|
1746
|
-
if (!outdated.length) return { updated: [], noOutdated: true, provider: from };
|
|
1747
|
-
if (!file) return { updated: [], provider: from };
|
|
1748
|
-
const newImports = { ...file.imports };
|
|
1749
|
-
const newIntegrity = { ...(file.integrity || {}) };
|
|
1750
|
-
/** @type {Array<{ pkg: string, from: string, to: string }>} */
|
|
1751
|
-
const updated = [];
|
|
1752
|
-
for (const { pkg, current, latest } of outdated) {
|
|
1753
|
-
// Resolve the new version via jspm.io. The Generator API
|
|
1754
|
-
// returns URLs for `<pkg>@<latest>` (and any subpath we ask
|
|
1755
|
-
// for, but for update we just refresh the bare root pin and
|
|
1756
|
-
// any subpaths that were already pinned).
|
|
1757
|
-
let anySpecUpdated = false;
|
|
1758
|
-
for (const [spec, oldUrl] of Object.entries(file.imports)) {
|
|
1759
|
-
const specPkg = extractPackageName(spec) || spec;
|
|
1760
|
-
if (specPkg !== pkg) continue;
|
|
1761
|
-
const subpath = spec.slice(specPkg.length);
|
|
1762
|
-
const install = `${pkg}@${latest}${subpath}`;
|
|
1763
|
-
const resolved = await jspmGenerate([install], from, PIN_BUNDLE_TIMEOUT_MS);
|
|
1764
|
-
const newUrl = resolved[spec];
|
|
1765
|
-
if (!newUrl) continue;
|
|
1766
|
-
newImports[spec] = newUrl;
|
|
1767
|
-
// Recompute integrity for the new URL. Drop the stale entry
|
|
1768
|
-
// even on fetch failure so the new pin doesn't carry the
|
|
1769
|
-
// wrong hash silently.
|
|
1770
|
-
delete newIntegrity[oldUrl];
|
|
1771
|
-
const sri = await fetchIntegrity(newUrl);
|
|
1772
|
-
if (sri) newIntegrity[newUrl] = sri;
|
|
1773
|
-
anySpecUpdated = true;
|
|
1774
|
-
}
|
|
1775
|
-
// Only report `pkg` as updated when at least one spec actually
|
|
1776
|
-
// got a new URL. If every subpath failed to resolve via
|
|
1777
|
-
// jspm.io (transient outage, the new version not yet indexed),
|
|
1778
|
-
// the CLI must not lie about having updated it.
|
|
1779
|
-
if (anySpecUpdated) updated.push({ pkg, from: current, to: latest });
|
|
1780
|
-
}
|
|
1781
|
-
await writePinFile(appDir, newImports, newIntegrity, from);
|
|
1782
|
-
return { updated, provider: from };
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
/**
|
|
1786
|
-
* Lightweight semver-aware comparison (no prerelease tags). Returns
|
|
1787
|
-
* negative if a < b, zero if equal, positive if a > b. Used by
|
|
1788
|
-
* findOutdated to decide if `current` lags `latest`. Non-numeric
|
|
1789
|
-
* segments fall back to string compare so prerelease-ish strings
|
|
1790
|
-
* still sort somewhere.
|
|
1791
|
-
*
|
|
1792
|
-
* @param {string} a
|
|
1793
|
-
* @param {string} b
|
|
1794
|
-
* @returns {number}
|
|
1795
|
-
*/
|
|
1796
|
-
function compareSemver(a, b) {
|
|
1797
|
-
const aParts = a.split(/[.+-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
|
|
1798
|
-
const bParts = b.split(/[.+-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
|
|
1799
|
-
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
1800
|
-
const ai = aParts[i] ?? 0;
|
|
1801
|
-
const bi = bParts[i] ?? 0;
|
|
1802
|
-
if (typeof ai === 'number' && typeof bi === 'number') {
|
|
1803
|
-
if (ai !== bi) return ai - bi;
|
|
1804
|
-
} else if (ai !== bi) {
|
|
1805
|
-
return String(ai) < String(bi) ? -1 : 1;
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
return 0;
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
/** @param {string[]} versions */
|
|
1812
|
-
function maxSemverVersion(versions) {
|
|
1813
|
-
return versions.reduce((max, v) => compareSemver(v, max) > 0 ? v : max, versions[0]);
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
/**
|
|
1817
|
-
* Resolve the vendor importmap fragment for runtime use. Prefers the
|
|
1818
|
-
* committed pin file over a live api.jspm.io call. Called from
|
|
1819
|
-
* `ensureReady()` in dev.js on the first request, never at boot.
|
|
1820
|
-
*
|
|
1821
|
-
* Order of preference:
|
|
1822
|
-
* 1. `.webjs/vendor/importmap.json` (committed; no network needed)
|
|
1823
|
-
* 2. Live api.jspm.io/generate (fallback when no pin file exists)
|
|
1824
|
-
*
|
|
1825
|
-
* Returns both `imports` (the URL map) and `integrity` (SRI hashes
|
|
1826
|
-
* keyed by the FINAL URL). Integrity is populated on BOTH paths (#235):
|
|
1827
|
-
* the pin file supplies it directly, and the live-API path now hashes
|
|
1828
|
-
* each cross-origin bundle after resolving (bounded + fail-open, see
|
|
1829
|
-
* `computeLiveIntegrity`), so an unpinned app also serves SRI. A fetch
|
|
1830
|
-
* failure for one URL degrades to a missing hash for that URL plus a
|
|
1831
|
-
* one-time warning, never a broken resolve.
|
|
1832
|
-
*
|
|
1833
|
-
* @param {string} appDir
|
|
1834
|
-
* @param {() => Promise<Set<string>>} getBareImports lazy scan, invoked ONLY
|
|
1835
|
-
* on the unpinned path (so a pinned app never pays the whole-app walk).
|
|
1836
|
-
* @returns {Promise<{ imports: Record<string, string>, integrity: Record<string, string> }>}
|
|
1837
|
-
*/
|
|
1838
|
-
/**
|
|
1839
|
-
* Base package of a bare specifier: `dayjs` -> `dayjs`,
|
|
1840
|
-
* `dayjs/plugin/utc` -> `dayjs`, `@scope/pkg/sub` -> `@scope/pkg`.
|
|
1841
|
-
*
|
|
1842
|
-
* @param {string} spec
|
|
1843
|
-
* @returns {string}
|
|
1844
|
-
*/
|
|
1845
|
-
function basePackage(spec) {
|
|
1846
|
-
const parts = spec.split('/');
|
|
1847
|
-
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
/**
|
|
1851
|
-
* Prune a pinned import map to the vendor specifiers still reachable from
|
|
1852
|
-
* NON-elided modules. A committed pin is the whole map, but elision can make
|
|
1853
|
-
* a pinned package unreachable (its only importer is a display-only component
|
|
1854
|
-
* that ships no JS, e.g. dayjs via the blog's vendor-badge). The live-resolve
|
|
1855
|
-
* path prunes such a package by excluding elided components from the bare-
|
|
1856
|
-
* import scan; this brings the pinned path to the same result, so a pinned app
|
|
1857
|
-
* and an unpinned app serve the same import map (issue #197).
|
|
1858
|
-
*
|
|
1859
|
-
* Keeps an entry when its specifier is reachable, OR when its base package is
|
|
1860
|
-
* the base of any reachable specifier (so a pinned base entry `dayjs` survives
|
|
1861
|
-
* when code imports `dayjs/plugin/utc`, and vice versa). Integrity hashes for
|
|
1862
|
-
* dropped URLs are pruned too.
|
|
1863
|
-
*
|
|
1864
|
-
* @param {Record<string, string>} imports pin entries (specifier -> URL)
|
|
1865
|
-
* @param {Record<string, string>} integrity SRI hashes keyed by URL
|
|
1866
|
-
* @param {Set<string>} reachable bare specifiers used by non-elided modules
|
|
1867
|
-
* @returns {{ imports: Record<string, string>, integrity: Record<string, string> }}
|
|
1868
|
-
*/
|
|
1869
|
-
export function prunePinToReachable(imports, integrity, reachable) {
|
|
1870
|
-
const reachableBases = new Set([...reachable].map(basePackage));
|
|
1871
|
-
/** @type {Record<string, string>} */
|
|
1872
|
-
const keptImports = {};
|
|
1873
|
-
for (const [spec, url] of Object.entries(imports || {})) {
|
|
1874
|
-
if (reachable.has(spec) || reachableBases.has(basePackage(spec))) {
|
|
1875
|
-
keptImports[spec] = url;
|
|
1876
|
-
}
|
|
1877
|
-
}
|
|
1878
|
-
const keptUrls = new Set(Object.values(keptImports));
|
|
1879
|
-
/** @type {Record<string, string>} */
|
|
1880
|
-
const keptIntegrity = {};
|
|
1881
|
-
for (const [url, hash] of Object.entries(integrity || {})) {
|
|
1882
|
-
if (keptUrls.has(url)) keptIntegrity[url] = hash;
|
|
1883
|
-
}
|
|
1884
|
-
return { imports: keptImports, integrity: keptIntegrity };
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
// ---------------------------------------------------------------------------
|
|
1888
|
-
// Importmap coherence check (issue #450)
|
|
1889
|
-
// ---------------------------------------------------------------------------
|
|
1890
|
-
//
|
|
1891
|
-
// A produced importmap pins one URL per resolved package, each URL carrying an
|
|
1892
|
-
// `@<version>`. That pinned graph is INCOHERENT when one resolved package
|
|
1893
|
-
// declares a dependency or peer range on ANOTHER resolved package and the
|
|
1894
|
-
// version actually pinned for that other package falls OUTSIDE the range. The
|
|
1895
|
-
// motivating crash (#446): `@codemirror/view@6.39.16` pinned while
|
|
1896
|
-
// `@codemirror/lint@6.9.6` (also pinned) needs `view@^6.42.0`, so a symbol
|
|
1897
|
-
// `lint` expects is missing from the older `view` bundle at runtime.
|
|
1898
|
-
//
|
|
1899
|
-
// This is a VALIDATION over a produced importmap, NOT a re-resolution (that is
|
|
1900
|
-
// #446's job) and NOT bundling. It emits warnings; it never mutates the map.
|
|
1901
|
-
//
|
|
1902
|
-
// PARITY: `checkImportmapCoherence` is a pure function of the EXTRACTED
|
|
1903
|
-
// `{ package -> pinned version }` set plus the dependency metadata. It does not
|
|
1904
|
-
// know or care whether the importmap came from a live jspm.io resolve or from a
|
|
1905
|
-
// committed `.webjs/vendor/importmap.json`. Two importmaps that pin the same
|
|
1906
|
-
// versions for the same packages therefore always produce the same verdict,
|
|
1907
|
-
// which is exactly the runtime-vs-vendored parity the maintainer requires.
|
|
1908
|
-
|
|
1909
|
-
/**
|
|
1910
|
-
* Extract `{ basePackage -> pinned version }` from an importmap's `imports`
|
|
1911
|
-
* map. Each value is a CDN URL (jspm.io's `npm:dayjs@1.11.13/...`, jsdelivr's
|
|
1912
|
-
* `npm/dayjs@1.11.13/...`, unpkg's bare `dayjs@1.11.13/...`, skypack's
|
|
1913
|
-
* `dayjs@1.11.13`) or a local `/__webjs/vendor/<pkg>@<version>...js` path. The
|
|
1914
|
-
* key is the bare package name parsed from the importmap KEY (the specifier),
|
|
1915
|
-
* which is authoritative; the version is parsed from the URL.
|
|
1916
|
-
*
|
|
1917
|
-
* A specifier that resolves to a version we cannot parse from its URL is
|
|
1918
|
-
* skipped (it contributes nothing to the dep graph rather than a wrong pin).
|
|
1919
|
-
* When the same base package appears at several versions (subpath imports),
|
|
1920
|
-
* the LAST parsed wins; in practice every subpath of a package resolves to the
|
|
1921
|
-
* one installed version, so they agree.
|
|
1922
|
-
*
|
|
1923
|
-
* @param {Record<string, string>} imports importmap `imports` map (specifier -> URL)
|
|
1924
|
-
* @returns {Map<string, string>} base package name -> pinned version
|
|
1925
|
-
*/
|
|
1926
|
-
export function extractPinnedVersions(imports) {
|
|
1927
|
-
/** @type {Map<string, string>} */
|
|
1928
|
-
const out = new Map();
|
|
1929
|
-
for (const [spec, url] of Object.entries(imports || {})) {
|
|
1930
|
-
if (typeof url !== 'string') continue;
|
|
1931
|
-
const bare = extractPackageName(spec);
|
|
1932
|
-
if (!bare) continue;
|
|
1933
|
-
let version = null;
|
|
1934
|
-
if (url.startsWith('/__webjs/vendor/')) {
|
|
1935
|
-
// Local downloaded-pin path: `<name>@<version>[__subpath].js`. The name
|
|
1936
|
-
// is `--`-encoded for scoped packages; we only need the version, which
|
|
1937
|
-
// sits after the LAST `@` and before any `__subpath` / `.js` suffix.
|
|
1938
|
-
const filename = url.slice('/__webjs/vendor/'.length);
|
|
1939
|
-
const atIdx = filename.lastIndexOf('@');
|
|
1940
|
-
if (atIdx > 0) {
|
|
1941
|
-
const afterAt = filename.slice(atIdx + 1, filename.endsWith('.js') ? -3 : undefined);
|
|
1942
|
-
const subIdx = afterAt.indexOf('__');
|
|
1943
|
-
version = subIdx < 0 ? afterAt : afterAt.slice(0, subIdx);
|
|
1944
|
-
}
|
|
1945
|
-
} else {
|
|
1946
|
-
// CDN URL: find `<bare>@<version>` anchored on a non-name char (or the
|
|
1947
|
-
// string start) so a short name like `ms` does not false-match inside
|
|
1948
|
-
// another package's URL. Mirrors listPinned's parser.
|
|
1949
|
-
const escapedBare = bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1950
|
-
const m = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
|
|
1951
|
-
if (m) version = m[1];
|
|
1952
|
-
}
|
|
1953
|
-
if (version && /\d/.test(version)) out.set(bare, version);
|
|
1954
|
-
}
|
|
1955
|
-
return out;
|
|
1956
|
-
}
|
|
1957
|
-
|
|
1958
|
-
/**
|
|
1959
|
-
* Does `version` satisfy the npm `range`? PRAGMATIC, no semver dependency
|
|
1960
|
-
* (vendor.js stays dependency-free). Supports the shapes that appear in real
|
|
1961
|
-
* `dependencies` / `peerDependencies`: `*` / `latest` / `x` / `''` (any),
|
|
1962
|
-
* `||` alternation, a leading `>=` / `>` / `<=` / `<` / `=`, caret `^`, tilde
|
|
1963
|
-
* `~`, an `x`/`*` wildcard segment (`6.x`, `6.39.x`), and an exact `1.2.3`.
|
|
1964
|
-
*
|
|
1965
|
-
* Returns `true` / `false` when the range can be evaluated, and `null` when
|
|
1966
|
-
* the shape is one we do NOT statically understand (a URL range, a git range,
|
|
1967
|
-
* a hyphen `1.2.3 - 1.4.0` range). `null` is the "could not verify" signal: the
|
|
1968
|
-
* caller degrades to a soft "unverified" note rather than warning on a shape it
|
|
1969
|
-
* cannot judge. Failing open here is deliberate, a coherence check must never
|
|
1970
|
-
* cry wolf on a range it misread.
|
|
1971
|
-
*
|
|
1972
|
-
* Prerelease note: both the version and the range are judged on their release
|
|
1973
|
-
* line only (the `-beta` / `-rc` tag is dropped, see `parseSemver`), so a
|
|
1974
|
-
* prerelease pin is treated as its stable tuple. The worst case is a MISSED
|
|
1975
|
-
* warning when a prerelease is pinned, never a spurious one.
|
|
1976
|
-
*
|
|
1977
|
-
* @param {string} version e.g. `6.39.16`
|
|
1978
|
-
* @param {string} range e.g. `^6.42.0`
|
|
1979
|
-
* @returns {boolean | null}
|
|
1980
|
-
*/
|
|
1981
|
-
export function satisfiesSemverRange(version, range) {
|
|
1982
|
-
const v = parseSemver(version);
|
|
1983
|
-
if (!v) return null;
|
|
1984
|
-
const r = String(range == null ? '' : range).trim();
|
|
1985
|
-
if (r === '' || r === '*' || r === 'x' || r === 'X' || r === 'latest') return true;
|
|
1986
|
-
if (r.startsWith('workspace:')) return true;
|
|
1987
|
-
// Alternation: satisfied if ANY clause is satisfied. A clause we cannot
|
|
1988
|
-
// evaluate (null) must not let a non-matching clause produce a false
|
|
1989
|
-
// negative, so an unknown clause makes the whole result unknown unless a
|
|
1990
|
-
// known clause already matched.
|
|
1991
|
-
if (r.includes('||')) {
|
|
1992
|
-
let sawUnknown = false;
|
|
1993
|
-
for (const clause of r.split('||')) {
|
|
1994
|
-
const res = satisfiesSemverRange(version, clause.trim());
|
|
1995
|
-
if (res === true) return true;
|
|
1996
|
-
if (res === null) sawUnknown = true;
|
|
1997
|
-
}
|
|
1998
|
-
return sawUnknown ? null : false;
|
|
1999
|
-
}
|
|
2000
|
-
// A space-joined comparator set (`>=6.0.0 <7.0.0`) must ALL be satisfied.
|
|
2001
|
-
if (/\s/.test(r)) {
|
|
2002
|
-
// A hyphen range (`1.2.3 - 1.4.0`) is not a comparator set; we do not
|
|
2003
|
-
// parse it, so degrade to unknown rather than mis-AND its halves.
|
|
2004
|
-
if (/\s-\s/.test(r)) return null;
|
|
2005
|
-
let result = true;
|
|
2006
|
-
for (const part of r.split(/\s+/)) {
|
|
2007
|
-
if (!part) continue;
|
|
2008
|
-
const res = satisfiesSemverRange(version, part);
|
|
2009
|
-
if (res === null) return null;
|
|
2010
|
-
if (res === false) result = false;
|
|
2011
|
-
}
|
|
2012
|
-
return result;
|
|
2013
|
-
}
|
|
2014
|
-
// Comparators: >= > <= < =.
|
|
2015
|
-
const cmpMatch = /^(>=|<=|>|<|=)\s*(.+)$/.exec(r);
|
|
2016
|
-
if (cmpMatch) {
|
|
2017
|
-
const op = cmpMatch[1];
|
|
2018
|
-
const bound = parseSemver(cmpMatch[2]);
|
|
2019
|
-
if (!bound) return null;
|
|
2020
|
-
const c = cmpSemver(v, bound);
|
|
2021
|
-
switch (op) {
|
|
2022
|
-
case '>=': return c >= 0;
|
|
2023
|
-
case '>': return c > 0;
|
|
2024
|
-
case '<=': return c <= 0;
|
|
2025
|
-
case '<': return c < 0;
|
|
2026
|
-
case '=': return c === 0;
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
// Caret: same left-most non-zero segment. ^6.42.0 -> >=6.42.0 <7.0.0;
|
|
2030
|
-
// ^0.7.0 -> >=0.7.0 <0.8.0; ^0.0.3 -> >=0.0.3 <0.0.4.
|
|
2031
|
-
if (r.startsWith('^')) {
|
|
2032
|
-
const b = parseSemver(r.slice(1));
|
|
2033
|
-
if (!b) return null;
|
|
2034
|
-
if (cmpSemver(v, b) < 0) return false;
|
|
2035
|
-
if (b[0] > 0) return v[0] === b[0];
|
|
2036
|
-
if (b[1] > 0) return v[0] === 0 && v[1] === b[1];
|
|
2037
|
-
return v[0] === 0 && v[1] === 0 && v[2] === b[2];
|
|
2038
|
-
}
|
|
2039
|
-
// Tilde: ~6.42.0 -> >=6.42.0 <6.43.0; ~6 -> >=6.0.0 <7.0.0.
|
|
2040
|
-
if (r.startsWith('~')) {
|
|
2041
|
-
const raw = r.slice(1);
|
|
2042
|
-
const b = parseSemver(raw);
|
|
2043
|
-
if (!b) return null;
|
|
2044
|
-
if (cmpSemver(v, b) < 0) return false;
|
|
2045
|
-
// If the range named a minor (`~6.42` / `~6.42.0`), pin the minor; if it
|
|
2046
|
-
// named only a major (`~6`), pin the major.
|
|
2047
|
-
const namedMinor = /^\d+\.\d+/.test(raw);
|
|
2048
|
-
return namedMinor ? v[0] === b[0] && v[1] === b[1] : v[0] === b[0];
|
|
2049
|
-
}
|
|
2050
|
-
// x / * wildcard segment: 6.x, 6.39.x, 6.*.
|
|
2051
|
-
if (/^\d+(\.(\d+|[xX*])){0,2}$/.test(r) && /[xX*]/.test(r)) {
|
|
2052
|
-
const segs = r.split('.');
|
|
2053
|
-
for (let i = 0; i < segs.length; i++) {
|
|
2054
|
-
const s = segs[i];
|
|
2055
|
-
if (s === 'x' || s === 'X' || s === '*') break; // any beyond here
|
|
2056
|
-
if (Number(s) !== v[i]) return false;
|
|
2057
|
-
}
|
|
2058
|
-
return true;
|
|
2059
|
-
}
|
|
2060
|
-
// Exact `1.2.3` (or shorter `1` / `1.2`, treated as that prefix pinned). A
|
|
2061
|
-
// leading `v` (`v1.2.3`) is tolerated so a `v`-prefixed exact pin evaluates
|
|
2062
|
-
// instead of degrading to unverified.
|
|
2063
|
-
const exact = r.startsWith('v') ? r.slice(1) : r;
|
|
2064
|
-
if (/^\d+(\.\d+){0,2}$/.test(exact)) {
|
|
2065
|
-
const b = parseSemver(exact);
|
|
2066
|
-
if (!b) return null;
|
|
2067
|
-
const segs = exact.split('.').length;
|
|
2068
|
-
for (let i = 0; i < segs; i++) if (v[i] !== b[i]) return false;
|
|
2069
|
-
return true;
|
|
2070
|
-
}
|
|
2071
|
-
return null;
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
/**
|
|
2075
|
-
* Parse a version string to a `[major, minor, patch]` numeric triple, or null
|
|
2076
|
-
* when it has no parseable numeric core (a `latest`, a git URL, a `*`).
|
|
2077
|
-
*
|
|
2078
|
-
* KNOWN LIMITATION: a prerelease / build suffix (`-rc.1`, `+sha`) is dropped, so
|
|
2079
|
-
* a version is judged purely on its release line. A pinned prerelease is treated
|
|
2080
|
-
* as its stable tuple: `6.42.0-beta.1` is judged as `6.42.0`, so a stable range
|
|
2081
|
-
* like `^6.42.0` reports it as a MATCH even though npm semver excludes a
|
|
2082
|
-
* prerelease from a stable range. This is a deliberate fail-safe simplification
|
|
2083
|
-
* (we do not carry prerelease ordering): the only consequence is a MISSED
|
|
2084
|
-
* coherence warning when a prerelease is pinned, never a spurious one on a
|
|
2085
|
-
* coherent graph. Pinned prereleases are vanishingly rare in a vendored
|
|
2086
|
-
* importmap, so the missed-warning risk is negligible.
|
|
2087
|
-
*
|
|
2088
|
-
* @param {string} v
|
|
2089
|
-
* @returns {[number, number, number] | null}
|
|
2090
|
-
*/
|
|
2091
|
-
function parseSemver(v) {
|
|
2092
|
-
const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(v == null ? '' : v));
|
|
2093
|
-
if (!m) return null;
|
|
2094
|
-
return [Number(m[1]), Number(m[2] || 0), Number(m[3] || 0)];
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
/**
|
|
2098
|
-
* Compare two `[major, minor, patch]` triples. Negative if a < b, 0 if equal,
|
|
2099
|
-
* positive if a > b.
|
|
2100
|
-
* @param {[number, number, number]} a
|
|
2101
|
-
* @param {[number, number, number]} b
|
|
2102
|
-
* @returns {number}
|
|
2103
|
-
*/
|
|
2104
|
-
function cmpSemver(a, b) {
|
|
2105
|
-
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
/**
|
|
2109
|
-
* @typedef {{
|
|
2110
|
-
* pkg: string,
|
|
2111
|
-
* version: string,
|
|
2112
|
-
* dependsOn: string,
|
|
2113
|
-
* kind: 'dependency' | 'peerDependency',
|
|
2114
|
-
* requiredRange: string,
|
|
2115
|
-
* pinnedVersion: string,
|
|
2116
|
-
* }} CoherenceConflict
|
|
2117
|
-
*/
|
|
2118
|
-
|
|
2119
|
-
/**
|
|
2120
|
-
* @typedef {{
|
|
2121
|
-
* conflicts: CoherenceConflict[],
|
|
2122
|
-
* unverified: Array<{ pkg: string, reason: string }>,
|
|
2123
|
-
* checked: number,
|
|
2124
|
-
* }} CoherenceReport
|
|
2125
|
-
* `checked` counts the pinned packages whose dependency metadata was actually
|
|
2126
|
-
* read (so a clean verdict is grounded in real data); a package whose manifest
|
|
2127
|
-
* was unavailable is in `unverified` and is NOT counted as checked. `checked
|
|
2128
|
-
* === 0` with a non-empty `unverified` therefore means "could not verify
|
|
2129
|
-
* anything", which the caller surfaces as a soft degrade rather than "coherent".
|
|
2130
|
-
*/
|
|
2131
|
-
|
|
2132
|
-
/**
|
|
2133
|
-
* Validate that a produced importmap's pinned dependency graph is COHERENT
|
|
2134
|
-
* (issue #450). For each resolved package, read its declared `dependencies`
|
|
2135
|
-
* and `peerDependencies` (via the injected `getManifest`) and, for every
|
|
2136
|
-
* declared range that targets ANOTHER package ALSO pinned in this importmap,
|
|
2137
|
-
* check the pinned version satisfies the range. A miss is a conflict naming
|
|
2138
|
-
* both packages, the required range, and the pinned version.
|
|
2139
|
-
*
|
|
2140
|
-
* This is the SAME function the doctor runs over the live importmap and over
|
|
2141
|
-
* `.webjs/vendor/importmap.json`. It is pure in `(imports, getManifest)`, so
|
|
2142
|
-
* the same pinned dep set yields the same verdict regardless of which input it
|
|
2143
|
-
* came from (the runtime-vs-vendored parity invariant).
|
|
2144
|
-
*
|
|
2145
|
-
* Degrades gracefully: a package whose manifest `getManifest` cannot supply
|
|
2146
|
-
* (not installed, unreadable, network unavailable) is recorded under
|
|
2147
|
-
* `unverified` and contributes NO conflict, so the check reports "could not
|
|
2148
|
-
* verify" rather than failing closed. A declared range in a shape we cannot
|
|
2149
|
-
* statically evaluate (see `satisfiesSemverRange` -> null) is likewise skipped,
|
|
2150
|
-
* never warned on.
|
|
2151
|
-
*
|
|
2152
|
-
* @param {Record<string, string>} imports importmap `imports` map
|
|
2153
|
-
* @param {{
|
|
2154
|
-
* getManifest: (pkg: string, version: string) =>
|
|
2155
|
-
* ({ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null
|
|
2156
|
-
* | Promise<{ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null>),
|
|
2157
|
-
* }} opts `getManifest` returns the declared dep ranges for a resolved
|
|
2158
|
-
* `pkg@version`, or null when unavailable (degrade to "unverified").
|
|
2159
|
-
* @returns {Promise<CoherenceReport>}
|
|
2160
|
-
*/
|
|
2161
|
-
export async function checkImportmapCoherence(imports, opts) {
|
|
2162
|
-
const pinned = extractPinnedVersions(imports);
|
|
2163
|
-
/** @type {CoherenceConflict[]} */
|
|
2164
|
-
const conflicts = [];
|
|
2165
|
-
/** @type {Array<{ pkg: string, reason: string }>} */
|
|
2166
|
-
const unverified = [];
|
|
2167
|
-
// Sort for deterministic output: the same dep set always yields the same
|
|
2168
|
-
// ordering, which keeps the verdict (and any test asserting it) stable and
|
|
2169
|
-
// strengthens the parity guarantee end to end.
|
|
2170
|
-
const packages = [...pinned.keys()].sort();
|
|
2171
|
-
// Count of packages whose metadata we could actually read (so a conflict
|
|
2172
|
-
// verdict is grounded). A package whose manifest is unavailable lands in
|
|
2173
|
-
// `unverified` instead and does NOT count as checked, which lets the caller
|
|
2174
|
-
// distinguish "verified coherent" from "could not verify anything".
|
|
2175
|
-
let checked = 0;
|
|
2176
|
-
for (const pkg of packages) {
|
|
2177
|
-
const version = pinned.get(pkg);
|
|
2178
|
-
let manifest;
|
|
2179
|
-
try {
|
|
2180
|
-
manifest = await opts.getManifest(pkg, version);
|
|
2181
|
-
} catch {
|
|
2182
|
-
manifest = null;
|
|
2183
|
-
}
|
|
2184
|
-
if (!manifest || typeof manifest !== 'object') {
|
|
2185
|
-
unverified.push({ pkg, reason: `could not read dependency metadata for ${pkg}@${version}` });
|
|
2186
|
-
continue;
|
|
2187
|
-
}
|
|
2188
|
-
checked++;
|
|
2189
|
-
const groups = /** @type {const} */ ([
|
|
2190
|
-
['dependency', manifest.dependencies],
|
|
2191
|
-
['peerDependency', manifest.peerDependencies],
|
|
2192
|
-
]);
|
|
2193
|
-
for (const [kind, deps] of groups) {
|
|
2194
|
-
if (!deps || typeof deps !== 'object') continue;
|
|
2195
|
-
for (const [depName, range] of Object.entries(deps)) {
|
|
2196
|
-
// Only edges INTO the pinned graph matter: a dep on a package that is
|
|
2197
|
-
// not in this importmap is not the importmap's coherence problem (it
|
|
2198
|
-
// is either bundled into a CDN megabundle or simply unused on the
|
|
2199
|
-
// client). Self-edges cannot conflict.
|
|
2200
|
-
if (depName === pkg) continue;
|
|
2201
|
-
const depPinned = pinned.get(depName);
|
|
2202
|
-
if (!depPinned) continue;
|
|
2203
|
-
const ok = satisfiesSemverRange(depPinned, String(range));
|
|
2204
|
-
if (ok === false) {
|
|
2205
|
-
conflicts.push({
|
|
2206
|
-
pkg,
|
|
2207
|
-
version: String(version),
|
|
2208
|
-
dependsOn: depName,
|
|
2209
|
-
kind,
|
|
2210
|
-
requiredRange: String(range),
|
|
2211
|
-
pinnedVersion: depPinned,
|
|
2212
|
-
});
|
|
2213
|
-
}
|
|
2214
|
-
// ok === null (range shape not understood) is silently skipped: the
|
|
2215
|
-
// check never warns on a range it could not evaluate.
|
|
2216
|
-
}
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
return { conflicts, unverified, checked };
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
/**
|
|
2223
|
-
* Per-process cache of SHA-384 integrity hashes for live-resolved vendor
|
|
2224
|
-
* URLs, keyed by the FINAL cross-origin URL. A vendor bundle at a given
|
|
2225
|
-
* versioned URL is immutable, so once hashed it never needs re-fetching
|
|
2226
|
-
* within the process: a re-resolve (e.g. after a file-watcher rebuild
|
|
2227
|
-
* that did not change the dep) reuses the hash instead of re-downloading.
|
|
2228
|
-
* Cleared by `clearVendorCache` alongside the jspm fragment cache so a
|
|
2229
|
-
* version bump re-hashes. This is NOT a persistent cache (that is the pin
|
|
2230
|
-
* file's job); it only avoids redundant fetches in one running process.
|
|
2231
|
-
*
|
|
2232
|
-
* @type {Map<string, string>}
|
|
2233
|
-
*/
|
|
2234
|
-
const liveIntegrityCache = new Map();
|
|
2235
|
-
|
|
2236
|
-
// Cap concurrent bundle fetches so a large dep set does not open dozens of
|
|
2237
|
-
// sockets at once during warmup. Matches the bounded posture of the rest of
|
|
2238
|
-
// vendor.js (the jspm resolve is per-package but the network is the shared
|
|
2239
|
-
// constraint).
|
|
2240
|
-
const INTEGRITY_FETCH_CONCURRENCY = 6;
|
|
2241
|
-
// Total wall-clock budget for the whole live-integrity hashing phase. It runs
|
|
2242
|
-
// inside the readiness-gating warmup, so even a CDN that serves the importmap
|
|
2243
|
-
// then hangs on every bundle GET must not stall the first request for the sum
|
|
2244
|
-
// of per-fetch timeouts. Once the budget passes, the remaining URLs are left
|
|
2245
|
-
// without integrity (the same fail-open fallback as a fetch failure) instead of
|
|
2246
|
-
// waiting out a 10s timeout each. A healthy CDN finishes in well under this.
|
|
2247
|
-
const INTEGRITY_TOTAL_BUDGET_MS = 15_000;
|
|
2248
|
-
|
|
2249
|
-
/**
|
|
2250
|
-
* Fetch a single cross-origin URL with a bounded timeout and return its
|
|
2251
|
-
* SHA-384 SRI hash, or null on any failure (network, timeout, non-ok).
|
|
2252
|
-
* Fail-OPEN by design: a CDN hiccup must never break warmup, so a failure
|
|
2253
|
-
* is a skipped hash (the URL serves without `integrity`, the pre-#235
|
|
2254
|
-
* behavior for that one URL), not a thrown error.
|
|
2255
|
-
*
|
|
2256
|
-
* @param {string} url
|
|
2257
|
-
* @returns {Promise<string | null>}
|
|
2258
|
-
*/
|
|
2259
|
-
async function fetchLiveIntegrity(url) {
|
|
2260
|
-
const cached = liveIntegrityCache.get(url);
|
|
2261
|
-
if (cached) return cached;
|
|
2262
|
-
const controller = new AbortController();
|
|
2263
|
-
const timer = setTimeout(() => controller.abort(), INTEGRITY_FETCH_TIMEOUT_MS);
|
|
2264
|
-
try {
|
|
2265
|
-
const response = await fetch(url, { signal: controller.signal });
|
|
2266
|
-
if (!response.ok) return null;
|
|
2267
|
-
// Hash the raw response bytes (arrayBuffer -> Uint8Array), the same
|
|
2268
|
-
// primitive the browser's SRI implementation hashes. Decoding to a
|
|
2269
|
-
// string first would risk encoding round-trip drift. See the matching
|
|
2270
|
-
// comment in downloadBundle / fetchIntegrity.
|
|
2271
|
-
const buf = new Uint8Array(await response.arrayBuffer());
|
|
2272
|
-
const sri = await sha384Integrity(buf);
|
|
2273
|
-
liveIntegrityCache.set(url, sri);
|
|
2274
|
-
return sri;
|
|
2275
|
-
} catch {
|
|
2276
|
-
return null;
|
|
2277
|
-
} finally {
|
|
2278
|
-
clearTimeout(timer);
|
|
2279
|
-
}
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
/**
|
|
2283
|
-
* Compute SRI integrity for the CROSS-ORIGIN targets of a live-resolved
|
|
2284
|
-
* import map. Same-origin targets (the `@webjsdev/core/*` runtime under
|
|
2285
|
-
* `/__webjs/core/...` and any local `/__webjs/vendor/...` bundle) are
|
|
2286
|
-
* skipped: they are served by the framework and already trusted, and SRI
|
|
2287
|
-
* is a cross-origin defense.
|
|
2288
|
-
*
|
|
2289
|
-
* The returned map is keyed by the FINAL URL (the import-map target
|
|
2290
|
-
* value), matching `vendorIntegrityFor(url)`'s lookup key so ssr.js emits
|
|
2291
|
-
* the `integrity` sibling for free.
|
|
2292
|
-
*
|
|
2293
|
-
* Bounded and fail-open: cross-origin bundles are fetched in parallel with
|
|
2294
|
-
* a small concurrency cap and a per-fetch timeout, and a failed fetch is
|
|
2295
|
-
* skipped (no integrity for that one URL) rather than breaking the resolve.
|
|
2296
|
-
* A single one-time `console.warn` reports the count of URLs that could not
|
|
2297
|
-
* be hashed (no per-URL spam).
|
|
2298
|
-
*
|
|
2299
|
-
* @param {Record<string, string>} imports specifier -> final URL
|
|
2300
|
-
* @returns {Promise<Record<string, string>>} integrity keyed by final URL
|
|
2301
|
-
*/
|
|
2302
|
-
async function computeLiveIntegrity(imports) {
|
|
2303
|
-
// De-duplicate by URL: two specifiers can resolve to the same bundle URL
|
|
2304
|
-
// (a bare import and one of its subpaths), so hash each URL once.
|
|
2305
|
-
const urls = [...new Set(Object.values(imports))].filter((u) => /^https:\/\//.test(u));
|
|
2306
|
-
/** @type {Record<string, string>} */
|
|
2307
|
-
const integrity = {};
|
|
2308
|
-
if (urls.length === 0) return integrity;
|
|
2309
|
-
|
|
2310
|
-
const failed = [];
|
|
2311
|
-
let next = 0;
|
|
2312
|
-
const deadline = Date.now() + INTEGRITY_TOTAL_BUDGET_MS;
|
|
2313
|
-
async function worker() {
|
|
2314
|
-
// Stop claiming new URLs once the total budget passes; a URL already
|
|
2315
|
-
// in flight still settles under its own per-fetch timeout.
|
|
2316
|
-
while (next < urls.length && Date.now() < deadline) {
|
|
2317
|
-
const url = urls[next++];
|
|
2318
|
-
const sri = await fetchLiveIntegrity(url);
|
|
2319
|
-
if (sri) integrity[url] = sri;
|
|
2320
|
-
else failed.push(url);
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
|
-
const workerCount = Math.min(INTEGRITY_FETCH_CONCURRENCY, urls.length);
|
|
2324
|
-
await Promise.all(Array.from({ length: workerCount }, worker));
|
|
2325
|
-
// Any URL never claimed because the budget passed also fails open (served
|
|
2326
|
-
// without integrity), the same outcome as a fetch failure.
|
|
2327
|
-
while (next < urls.length) failed.push(urls[next++]);
|
|
2328
|
-
|
|
2329
|
-
if (failed.length) {
|
|
2330
|
-
// One-time, count-based warning. The app still boots and the imports
|
|
2331
|
-
// still work; only these URLs lack SRI (served as before #235). Run
|
|
2332
|
-
// `webjs vendor pin` to lock in integrity, or retry once the CDN is
|
|
2333
|
-
// healthy. Naming one example URL aids diagnosis without per-URL spam.
|
|
2334
|
-
console.warn(
|
|
2335
|
-
`[webjs] could not compute SRI for ${failed.length} live-resolved ` +
|
|
2336
|
-
`vendor URL(s) (e.g. ${failed[0]}); they will load WITHOUT integrity. ` +
|
|
2337
|
-
`This is a fail-open fallback for a CDN fetch failure or the warmup ` +
|
|
2338
|
-
`time budget; the app still works. Run \`webjs vendor pin\` to lock in ` +
|
|
2339
|
-
`SRI hashes.`,
|
|
2340
|
-
);
|
|
2341
|
-
}
|
|
2342
|
-
return integrity;
|
|
2343
|
-
}
|
|
2344
|
-
|
|
2345
|
-
export async function resolveVendorImports(appDir, getBareImports) {
|
|
2346
|
-
const file = await readPinFile(appDir);
|
|
2347
|
-
// A committed pin file IS the import map. The whole-app bare-import scan is
|
|
2348
|
-
// discarded in that case, so it must never run (runtime-first boot: no
|
|
2349
|
-
// static analysis when pinned). The scan is supplied as a thunk and invoked
|
|
2350
|
-
// solely here, only when there is no pin file.
|
|
2351
|
-
if (file) {
|
|
2352
|
-
// A pin file is a deterministic disk read: always "ok" (no live CDN call
|
|
2353
|
-
// that could partially fail). This is the recommended prod posture. The
|
|
2354
|
-
// pin's own integrity is used verbatim; the live-hash path below is NOT
|
|
2355
|
-
// taken for a pinned app.
|
|
2356
|
-
return { imports: file.imports, integrity: file.integrity || {}, ok: true };
|
|
2357
|
-
}
|
|
2358
|
-
lastLiveResolveFailed = false;
|
|
2359
|
-
const bareImports = await getBareImports();
|
|
2360
|
-
const imports = await vendorImportMapEntries(bareImports, appDir);
|
|
2361
|
-
// Fill the SRI gap for live-resolved (unpinned) apps (#235): hash each
|
|
2362
|
-
// cross-origin bundle and key the integrity by its final URL, the same
|
|
2363
|
-
// shape the pin path uses and `vendorIntegrityFor` looks up. Bounded +
|
|
2364
|
-
// fail-open, so a CDN fetch failure degrades to a missing hash for that
|
|
2365
|
-
// URL (a warning), never a broken resolve. This runs only AFTER a live
|
|
2366
|
-
// resolve produced URLs; if the resolve itself failed there is nothing to
|
|
2367
|
-
// hash.
|
|
2368
|
-
const integrity = await computeLiveIntegrity(imports);
|
|
2369
|
-
// ok=false means at least one install could not be resolved (CDN unreachable
|
|
2370
|
-
// / timeout / non-ok), so `imports` is partial. The caller must not memoize
|
|
2371
|
-
// this as done; it should retry once the CDN recovers.
|
|
2372
|
-
return { imports, integrity, ok: !lastLiveResolveFailed };
|
|
2373
|
-
}
|
|
2374
|
-
|
|
2375
|
-
/**
|
|
2376
|
-
* Serve a downloaded vendor bundle from `.webjs/vendor/<filename>`.
|
|
2377
|
-
* Called by dev.js when the importmap contains `/__webjs/vendor/`
|
|
2378
|
-
* paths (i.e. user ran `webjs vendor pin --download`).
|
|
2379
|
-
*
|
|
2380
|
-
* @param {string} filename e.g. `'dayjs@1.11.13.js'`
|
|
2381
|
-
* @param {string} appDir
|
|
2382
|
-
* @param {boolean} dev
|
|
2383
|
-
* @returns {Promise<Response>}
|
|
2384
|
-
*/
|
|
2385
|
-
export async function serveDownloadedBundle(filename, appDir, dev) {
|
|
2386
|
-
// Strict allowlist. Vendor filenames are framework-generated:
|
|
2387
|
-
// `<pkg>@<version>.js` or `<pkg>@<version>__<subpath>.js` plus the
|
|
2388
|
-
// `@scope__name` form for scoped packages. The legal charset is
|
|
2389
|
-
// alphanumeric plus `@`, `.`, `_`, `-`, `+` (`+` covers semver
|
|
2390
|
-
// build metadata like `1.0.0+build.42`). Reject anything else
|
|
2391
|
-
// (slashes / backslashes / dots-dots / null bytes / Unicode
|
|
2392
|
-
// separators / glob chars) without echoing the input.
|
|
2393
|
-
if (!/^[A-Za-z0-9@._+-]+\.js$/.test(filename) || filename.includes('..')) {
|
|
2394
|
-
return new Response(`/* invalid vendor filename */`, {
|
|
2395
|
-
status: 400,
|
|
2396
|
-
headers: { 'content-type': 'application/javascript; charset=utf-8' },
|
|
2397
|
-
});
|
|
2398
|
-
}
|
|
2399
|
-
try {
|
|
2400
|
-
// Read as raw bytes (no encoding arg). downloadBundle writes the
|
|
2401
|
-
// file from the response arrayBuffer (the same primitive the
|
|
2402
|
-
// browser's SRI implementation hashes), so the bytes on disk are
|
|
2403
|
-
// byte-identical to what jspm.io originally served. Reading with
|
|
2404
|
-
// utf8 here would decode-then-re-encode and risk dropping the SRI
|
|
2405
|
-
// match if any byte didn't round-trip exactly (e.g. invalid
|
|
2406
|
-
// surrogate replacement). Keep the I/O binary end-to-end.
|
|
2407
|
-
const body = await readFile(join(pinDir(appDir), filename));
|
|
2408
|
-
// Buffered (bytes) body, so opt into the conditional-GET funnel, which
|
|
2409
|
-
// hashes the bytes into a weak ETag (for downstream caches that strip the
|
|
2410
|
-
// `immutable` directive) and honors If-None-Match -> 304. A WEAK validator
|
|
2411
|
-
// is correct here because compression may re-encode the bytes per request
|
|
2412
|
-
// (RFC 7232 2.3.3); the funnel is the single source for that. See
|
|
2413
|
-
// conditional-get.js.
|
|
2414
|
-
return new Response(body, {
|
|
2415
|
-
headers: {
|
|
2416
|
-
'content-type': 'application/javascript; charset=utf-8',
|
|
2417
|
-
'cache-control': dev ? 'no-cache' : 'public, max-age=31536000, immutable',
|
|
2418
|
-
[BUFFERED_MARKER]: '1',
|
|
2419
|
-
},
|
|
2420
|
-
});
|
|
2421
|
-
} catch {
|
|
2422
|
-
// Don't echo `filename` (already validated by the regex above so
|
|
2423
|
-
// safe to echo, but keep the body fixed for grep-ability and to
|
|
2424
|
-
// discourage anyone copying this pattern with untrusted input).
|
|
2425
|
-
return new Response(`/* vendor bundle not found. Run webjs vendor pin --download to (re-)download. */`, {
|
|
2426
|
-
status: 404,
|
|
2427
|
-
headers: { 'content-type': 'application/javascript; charset=utf-8' },
|
|
2428
|
-
});
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
70
|
+
export { jspmGenerate, vendorImportMapEntries } from './vendor/jspm.js';
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
ensureVendorCommittable,
|
|
74
|
+
hasVendorPin,
|
|
75
|
+
readPinFile,
|
|
76
|
+
pinAll,
|
|
77
|
+
unpinPackage,
|
|
78
|
+
listPinned,
|
|
79
|
+
prunePinToReachable,
|
|
80
|
+
} from './vendor/pins.js';
|
|
81
|
+
export {
|
|
82
|
+
auditPinned,
|
|
83
|
+
findOutdated,
|
|
84
|
+
updatePinned,
|
|
85
|
+
extractPinnedVersions,
|
|
86
|
+
checkImportmapCoherence,
|
|
87
|
+
} from './vendor/audit.js';
|
|
88
|
+
export { resolveVendorImports, serveDownloadedBundle } from './vendor/resolver.js';
|