@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.
Files changed (48) hide show
  1. package/index.d.ts +32 -3
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. 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
- * Cached per install + provider; a successful probe's `{imports}` is the
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
- * @param {string} install
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
- const promise = (async () => {
525
- const result = await jspmCall([install], provider, timeoutMs);
526
- // Do not cache a failure: a transient one must be re-attempted on the
527
- // next resolve, and a permanent one is cheap to re-confirm and must not
528
- // pin a stale "unresolvable" verdict across a dependency change.
529
- if (!result.ok) jspmCache.delete(cacheKey);
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
- jspmCache.clear();
709
- liveIntegrityCache.clear();
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
- * Recover `{ pkg, version, subpath }` for a resolved import spec that was
720
- * NOT in the directly-scanned set, i.e. a flattened transitive the unified
721
- * resolve added (issue #446). The bare package name and subpath come from
722
- * the spec; the version is read out of the resolved CDN URL by locating
723
- * `<bare>@<version>` in it (same logic `listPinned` uses, which handles
724
- * every supported provider's URL shape). Returns null when the version
725
- * can't be parsed, in which case the caller pins the entry by URL anyway
726
- * but cannot derive a `--download` filename for it.
727
- *
728
- * @param {string} spec e.g. '@codemirror/state' or 'dayjs/plugin/utc'
729
- * @param {string} url the resolved CDN URL for that spec
730
- * @returns {{ pkg: string, version: string, subpath: string } | null}
731
- */
732
- function derivePinParts(spec, url) {
733
- const pkg = extractPackageName(spec);
734
- if (!pkg) return null;
735
- const subpath = spec.slice(pkg.length);
736
- const escapedBare = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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';