@webjsdev/server 0.8.20 → 0.8.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +111 -5
- package/index.js +5 -0
- package/package.json +1 -1
- package/src/auth.js +7 -0
- package/src/check.js +260 -68
- package/src/module-graph.js +16 -7
- package/src/page-action.js +5 -3
- package/src/ssr.js +6 -1
- package/src/vendor.js +839 -96
package/src/vendor.js
CHANGED
|
@@ -44,6 +44,7 @@ import { readFile, readdir, writeFile, mkdir, unlink, stat, rename } from 'node:
|
|
|
44
44
|
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
45
45
|
import { join, dirname, basename, sep } from 'node:path';
|
|
46
46
|
import { createRequire } from 'node:module';
|
|
47
|
+
import { spawnSync } from 'node:child_process';
|
|
47
48
|
import { digestBase64 } from './crypto-utils.js';
|
|
48
49
|
import { BUFFERED_MARKER } from './conditional-get.js';
|
|
49
50
|
|
|
@@ -257,13 +258,48 @@ export function getPackageVersion(pkgName, appDir) {
|
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Read the installed package's declared `dependencies` + `peerDependencies`
|
|
263
|
+
* from its `package.json`, hoist-aware (same resolution as `getPackageVersion`,
|
|
264
|
+
* so a monorepo-hoisted dep resolves from the workspace root). Returns null
|
|
265
|
+
* when the package is not installed / unreadable, which the importmap-coherence
|
|
266
|
+
* check (#450) treats as "could not verify" rather than a conflict.
|
|
267
|
+
*
|
|
268
|
+
* This is the "already-resolved metadata, no network" source the coherence
|
|
269
|
+
* check prefers: the package is on disk because the importmap pinned it, so its
|
|
270
|
+
* manifest is a local read.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} pkgName
|
|
273
|
+
* @param {string} appDir
|
|
274
|
+
* @returns {{ dependencies: Record<string,string>, peerDependencies: Record<string,string> } | null}
|
|
275
|
+
*/
|
|
276
|
+
export function getPackageManifest(pkgName, appDir) {
|
|
277
|
+
const real = resolvePackageDir(pkgName, appDir);
|
|
278
|
+
if (!real) return null;
|
|
279
|
+
try {
|
|
280
|
+
const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
|
|
281
|
+
return {
|
|
282
|
+
dependencies: pkg.dependencies || {},
|
|
283
|
+
peerDependencies: pkg.peerDependencies || {},
|
|
284
|
+
};
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
260
290
|
// ---------------------------------------------------------------------------
|
|
261
291
|
// JSPM Generator API client
|
|
262
292
|
// ---------------------------------------------------------------------------
|
|
263
293
|
|
|
264
294
|
/**
|
|
265
295
|
* In-memory cache of resolved importmap fragments from api.jspm.io.
|
|
266
|
-
*
|
|
296
|
+
* Two kinds of key share this map:
|
|
297
|
+
* - The UNIFIED key (`<provider>::unified::<sorted installs joined>`)
|
|
298
|
+
* caches the whole-set resolve produced by one `generate` call, the
|
|
299
|
+
* default path (issue #446).
|
|
300
|
+
* - The PER-INSTALL key (`<provider>::<install>`) caches a single
|
|
301
|
+
* install's isolated resolve, used only on the fallback path when the
|
|
302
|
+
* unified call fails because some install is unresolvable.
|
|
267
303
|
* Per-process; cleared by `clearVendorCache` on file-watcher rebuild
|
|
268
304
|
* so new versions get re-resolved.
|
|
269
305
|
*
|
|
@@ -309,91 +345,144 @@ export function normalizeProvider(name) {
|
|
|
309
345
|
}
|
|
310
346
|
|
|
311
347
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
348
|
+
* Outcome of one api.jspm.io/generate POST.
|
|
349
|
+
* @typedef {Object} JspmCallResult
|
|
350
|
+
* @property {boolean} ok true when jspm returned a 2xx with a usable map
|
|
351
|
+
* @property {Record<string, string>} imports the resolved imports (empty on failure)
|
|
352
|
+
* @property {boolean} transient true when the failure is worth retrying
|
|
353
|
+
* (network / timeout / 5xx / 429), false for a permanent 4xx
|
|
354
|
+
* (jspm uses 401 for "this install is unresolvable")
|
|
355
|
+
*/
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Make ONE api.jspm.io/generate POST for a list of installs and return a
|
|
359
|
+
* structured result. The single point that talks to the network; both the
|
|
360
|
+
* unified path and the per-install fallback funnel through it.
|
|
361
|
+
*
|
|
362
|
+
* jspm fails the WHOLE batch (401) when ANY one install is unresolvable, so
|
|
363
|
+
* a multi-install POST is all-or-nothing: either the entire coherent graph
|
|
364
|
+
* comes back, or nothing does. `jspmGenerate` uses that property to decide
|
|
365
|
+
* when to fall back to per-install isolation.
|
|
366
|
+
*
|
|
367
|
+
* @param {Array<string>} installs e.g. ['dayjs@1.11.13', '@codemirror/lint@6.9.6']
|
|
368
|
+
* @param {string} provider one of SUPPORTED_PROVIDERS
|
|
369
|
+
* @returns {Promise<JspmCallResult>}
|
|
370
|
+
*/
|
|
371
|
+
async function jspmCall(installs, provider) {
|
|
372
|
+
const label = installs.length === 1 ? `'${installs[0]}'` : `${installs.length} packages`;
|
|
373
|
+
const controller = new AbortController();
|
|
374
|
+
const timer = setTimeout(() => controller.abort(), JSPM_GENERATE_TIMEOUT_MS);
|
|
375
|
+
try {
|
|
376
|
+
const response = await fetch(JSPM_GENERATE_ENDPOINT, {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: { 'content-type': 'application/json' },
|
|
379
|
+
body: JSON.stringify({
|
|
380
|
+
install: installs,
|
|
381
|
+
// flattenScope:true merges transitive ESM deps into the flat
|
|
382
|
+
// `imports` map instead of a separate `scopes` field. Webjs only
|
|
383
|
+
// consumes `imports`, so without this any package with an
|
|
384
|
+
// unbundled ESM transitive (e.g. react-dom imports `scheduler`,
|
|
385
|
+
// @codemirror/lint imports @codemirror/state) would break in the
|
|
386
|
+
// browser with an unresolved-bare-specifier error. With the
|
|
387
|
+
// WHOLE-set call (issue #446) the flattened transitives are now
|
|
388
|
+
// ALSO mutually consistent: one `@codemirror/view` URL shared by
|
|
389
|
+
// the direct import and lint's transitive need, instead of two
|
|
390
|
+
// skewed versions from independent per-package calls.
|
|
391
|
+
flattenScope: true,
|
|
392
|
+
env: ['browser', 'production', 'module'],
|
|
393
|
+
provider: normalizeProvider(provider),
|
|
394
|
+
}),
|
|
395
|
+
signal: controller.signal,
|
|
396
|
+
});
|
|
397
|
+
if (!response.ok) {
|
|
398
|
+
// jspm.io returns the error reason in the body with a 401 (its
|
|
399
|
+
// quirk: 401 is what it sends for unresolvable installs, not auth
|
|
400
|
+
// failures). Surface it so the user sees WHAT failed and why.
|
|
401
|
+
let detail = '';
|
|
402
|
+
try {
|
|
403
|
+
const body = await response.json();
|
|
404
|
+
if (body && typeof body.error === 'string') detail = `: ${body.error}`;
|
|
405
|
+
} catch { /* non-JSON body */ }
|
|
406
|
+
console.error(
|
|
407
|
+
`[webjs] could not vendor ${label} via ${provider} (status ${response.status})${detail}`,
|
|
408
|
+
);
|
|
409
|
+
// A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx
|
|
410
|
+
// means at least one install is genuinely unresolvable (jspm uses
|
|
411
|
+
// 401 for that): a private / workspace / server-only package (e.g.
|
|
412
|
+
// @webjsdev/server, @prisma/client) the browser never fetches
|
|
413
|
+
// anyway. Permanent failures must NOT block readiness.
|
|
414
|
+
const transient = response.status >= 500 || response.status === 429;
|
|
415
|
+
return { ok: false, imports: {}, transient };
|
|
416
|
+
}
|
|
417
|
+
const result = await response.json();
|
|
418
|
+
const imports = (result && result.map && result.map.imports) || {};
|
|
419
|
+
return { ok: true, imports, transient: false };
|
|
420
|
+
} catch (e) {
|
|
421
|
+
const msg = e && e.name === 'AbortError'
|
|
422
|
+
? `timed out after ${JSPM_GENERATE_TIMEOUT_MS}ms`
|
|
423
|
+
: `${e && e.message}`;
|
|
424
|
+
console.error(`[webjs] could not vendor ${label} via ${provider}: ${msg}`);
|
|
425
|
+
return { ok: false, imports: {}, transient: true };
|
|
426
|
+
} finally {
|
|
427
|
+
clearTimeout(timer);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Resolve a SINGLE install in isolation, cached per install + provider.
|
|
433
|
+
* This is the FALLBACK path: it only runs when the unified whole-set call
|
|
434
|
+
* fails because some install is unresolvable. Isolating each install means
|
|
435
|
+
* one bad dep (a 401) drops out on its own instead of collapsing the map
|
|
436
|
+
* for its legitimate neighbours. The cross-package coherence the unified
|
|
437
|
+
* call provides is lost for this degraded set, which is acceptable: it is
|
|
438
|
+
* exactly the pre-#446 behaviour, reached only when the app already has an
|
|
439
|
+
* unresolvable dep.
|
|
440
|
+
*
|
|
441
|
+
* Sets `lastLiveResolveFailed` on a TRANSIENT failure (so the caller
|
|
442
|
+
* retries), never on a permanent 401 (tolerated).
|
|
325
443
|
*
|
|
326
444
|
* @param {string} install e.g. 'dayjs@1.11.13' or 'dayjs@1.11.13/plugin/utc'
|
|
327
445
|
* @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
|
|
328
446
|
* @returns {Promise<Record<string, string>>}
|
|
329
447
|
*/
|
|
330
448
|
async function jspmResolveOne(install, provider = 'jspm') {
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
449
|
+
const { ok, imports, transient } = await jspmProbeOne(install, provider);
|
|
450
|
+
// Preserve the public contract: an empty map on any failure, and the
|
|
451
|
+
// module-global retry flag set ONLY on a transient one (a permanent 401
|
|
452
|
+
// for an unresolvable private/server-only dep is tolerated).
|
|
453
|
+
if (!ok && transient) lastLiveResolveFailed = true;
|
|
454
|
+
return imports;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Probe a SINGLE install and return the FULL classification, not just the
|
|
459
|
+
* imports. Unlike `jspmResolveOne` this does NOT collapse a transient
|
|
460
|
+
* failure into the same empty map a permanent one yields, and does NOT
|
|
461
|
+
* touch `lastLiveResolveFailed`: the caller (the 401 fallback in
|
|
462
|
+
* `jspmGenerate`) needs to tell "genuinely unresolvable, safe to drop"
|
|
463
|
+
* (`ok:false, transient:false`) from "a network blip mid-probe, do NOT
|
|
464
|
+
* drop" (`ok:false, transient:true`), and owns the retry flag itself.
|
|
465
|
+
*
|
|
466
|
+
* Cached per install + provider; a successful probe's `{imports}` is the
|
|
467
|
+
* same value `jspmResolveOne` returns, so the two share the cache and the
|
|
468
|
+
* later unified re-run reuses it.
|
|
469
|
+
*
|
|
470
|
+
* @param {string} install
|
|
471
|
+
* @param {string} provider
|
|
472
|
+
* @returns {Promise<JspmCallResult>}
|
|
473
|
+
*/
|
|
474
|
+
function jspmProbeOne(install, provider) {
|
|
475
|
+
const cacheKey = `${provider}::probe::${install}`;
|
|
335
476
|
const existing = jspmCache.get(cacheKey);
|
|
336
477
|
if (existing) return existing;
|
|
337
478
|
|
|
338
479
|
const promise = (async () => {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
body: JSON.stringify({
|
|
346
|
-
install: [install],
|
|
347
|
-
// flattenScope:true merges transitive ESM deps into the
|
|
348
|
-
// flat `imports` map instead of returning them in a
|
|
349
|
-
// separate `scopes` field. Webjs only consumes `imports`
|
|
350
|
-
// (the importmap output doesn't carry `scopes`), so
|
|
351
|
-
// without this any package with an unbundled ESM
|
|
352
|
-
// transitive (e.g. react-dom imports `scheduler`)
|
|
353
|
-
// would break in the browser with an unresolved-bare-
|
|
354
|
-
// specifier error. Matches importmap-rails's posture.
|
|
355
|
-
flattenScope: true,
|
|
356
|
-
env: ['browser', 'production', 'module'],
|
|
357
|
-
provider: normalizeProvider(provider),
|
|
358
|
-
}),
|
|
359
|
-
signal: controller.signal,
|
|
360
|
-
});
|
|
361
|
-
if (!response.ok) {
|
|
362
|
-
// jspm.io returns the error reason in the body with a 401 (its
|
|
363
|
-
// quirk: 401 is what it sends for unresolvable installs, not
|
|
364
|
-
// auth failures). Surface it so the user sees WHICH dep failed
|
|
365
|
-
// and why, not just a generic "vendor pipeline broken".
|
|
366
|
-
let detail = '';
|
|
367
|
-
try {
|
|
368
|
-
const body = await response.json();
|
|
369
|
-
if (body && typeof body.error === 'string') detail = `: ${body.error}`;
|
|
370
|
-
} catch { /* non-JSON body */ }
|
|
371
|
-
console.error(
|
|
372
|
-
`[webjs] could not vendor '${install}' via ${provider} (status ${response.status})${detail}`,
|
|
373
|
-
);
|
|
374
|
-
jspmCache.delete(cacheKey);
|
|
375
|
-
// A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx means
|
|
376
|
-
// the install is genuinely unresolvable (jspm uses 401 for that): a
|
|
377
|
-
// private / workspace / server-only package (e.g. @webjsdev/server,
|
|
378
|
-
// @prisma/client) the browser never fetches anyway. That is tolerated
|
|
379
|
-
// exactly as before and must NOT block readiness, or an app with any
|
|
380
|
-
// such dep would never become ready.
|
|
381
|
-
if (response.status >= 500 || response.status === 429) lastLiveResolveFailed = true;
|
|
382
|
-
return {};
|
|
383
|
-
}
|
|
384
|
-
const result = await response.json();
|
|
385
|
-
return (result && result.map && result.map.imports) || {};
|
|
386
|
-
} catch (e) {
|
|
387
|
-
const msg = e && e.name === 'AbortError'
|
|
388
|
-
? `timed out after ${JSPM_GENERATE_TIMEOUT_MS}ms`
|
|
389
|
-
: `${e && e.message}`;
|
|
390
|
-
console.error(`[webjs] could not vendor '${install}' via ${provider}: ${msg}`);
|
|
391
|
-
jspmCache.delete(cacheKey);
|
|
392
|
-
lastLiveResolveFailed = true;
|
|
393
|
-
return {};
|
|
394
|
-
} finally {
|
|
395
|
-
clearTimeout(timer);
|
|
396
|
-
}
|
|
480
|
+
const result = await jspmCall([install], provider);
|
|
481
|
+
// Do not cache a failure: a transient one must be re-attempted on the
|
|
482
|
+
// next resolve, and a permanent one is cheap to re-confirm and must not
|
|
483
|
+
// pin a stale "unresolvable" verdict across a dependency change.
|
|
484
|
+
if (!result.ok) jspmCache.delete(cacheKey);
|
|
485
|
+
return result;
|
|
397
486
|
})();
|
|
398
487
|
|
|
399
488
|
jspmCache.set(cacheKey, promise);
|
|
@@ -401,15 +490,35 @@ async function jspmResolveOne(install, provider = 'jspm') {
|
|
|
401
490
|
}
|
|
402
491
|
|
|
403
492
|
/**
|
|
404
|
-
* Resolve a list of `pkg@version` installs to importmap entries
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
* (
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
493
|
+
* Resolve a list of `pkg@version` installs to importmap entries.
|
|
494
|
+
*
|
|
495
|
+
* Issue #446: the WHOLE set is resolved in ONE api.jspm.io/generate call
|
|
496
|
+
* (a single `install[]` array) so jspm computes one mutually-consistent
|
|
497
|
+
* dependency graph. A directly-imported package and a transitive that
|
|
498
|
+
* needs a newer version of the same package now agree on one URL, instead
|
|
499
|
+
* of the old per-package-in-isolation merge that pinned the direct dep to
|
|
500
|
+
* its local version while the transitive floated to jspm-latest, producing
|
|
501
|
+
* a missing-export crash in the browser.
|
|
502
|
+
*
|
|
503
|
+
* The per-package-isolation property is PRESERVED as a fallback only: if
|
|
504
|
+
* the unified call fails because some install is unresolvable (a 401 for a
|
|
505
|
+
* private / server-only dep), one bad install must not collapse the map
|
|
506
|
+
* for the rest. So:
|
|
507
|
+
* 1. Try the unified call. On success, return its coherent graph.
|
|
508
|
+
* 2. On a PERMANENT failure (401/4xx), probe each install in isolation
|
|
509
|
+
* to learn which ones resolve, then RE-RUN the unified call over only
|
|
510
|
+
* the resolvable subset so the survivors stay mutually consistent.
|
|
511
|
+
* Only installs whose probe fails PERMANENTLY drop out (genuinely
|
|
512
|
+
* unresolvable, the browser never fetched them anyway); if any probe
|
|
513
|
+
* fails TRANSIENTLY, no one is dropped and the resolve is flagged for
|
|
514
|
+
* retry, so a network blip mid-probe cannot evict a good package. If
|
|
515
|
+
* the re-run itself fails, fall back to the merged per-install
|
|
516
|
+
* fragments so the app is no worse off than pre-#446.
|
|
517
|
+
* 3. On a TRANSIENT failure (network / timeout / 5xx / 429), set the
|
|
518
|
+
* retry flag and serve whatever the per-install probe produced.
|
|
519
|
+
*
|
|
520
|
+
* The unified result is cached per sorted-install-set + provider; the
|
|
521
|
+
* per-install fallback reuses the per-install cache entries.
|
|
413
522
|
*
|
|
414
523
|
* @param {Array<string>} installs e.g. ['dayjs@1.11.13', 'clsx@2.1.1']
|
|
415
524
|
* @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
|
|
@@ -417,9 +526,95 @@ async function jspmResolveOne(install, provider = 'jspm') {
|
|
|
417
526
|
*/
|
|
418
527
|
export async function jspmGenerate(installs, provider = 'jspm') {
|
|
419
528
|
if (installs.length === 0) return {};
|
|
420
|
-
|
|
529
|
+
|
|
530
|
+
// A single install has no cross-package graph to reconcile, so the
|
|
531
|
+
// isolated path IS the coherent path; reuse the per-install cache.
|
|
532
|
+
if (installs.length === 1) return jspmResolveOne(installs[0], provider);
|
|
533
|
+
|
|
534
|
+
// Stable key regardless of scan order so the same dep set hits cache.
|
|
535
|
+
const unifiedKey = `${provider}::unified::${[...installs].sort().join('\n')}`;
|
|
536
|
+
const cached = jspmCache.get(unifiedKey);
|
|
537
|
+
if (cached) return cached;
|
|
538
|
+
|
|
539
|
+
const promise = (async () => {
|
|
540
|
+
const unified = await jspmCall(installs, provider);
|
|
541
|
+
if (unified.ok) return unified.imports;
|
|
542
|
+
|
|
543
|
+
// The unified call failed. Drop the cached failure so a later retry
|
|
544
|
+
// re-attempts; the per-install fallback owns the retry flag.
|
|
545
|
+
jspmCache.delete(unifiedKey);
|
|
546
|
+
|
|
547
|
+
if (unified.transient) {
|
|
548
|
+
// Network / 5xx: nothing resolved coherently. Fall back to merged
|
|
549
|
+
// per-install fragments (each may still be cached / reachable) so we
|
|
550
|
+
// serve whatever we can, and flag the transient failure for retry.
|
|
551
|
+
lastLiveResolveFailed = true;
|
|
552
|
+
return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider))));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Permanent failure: at least one install is unresolvable. Probe each
|
|
556
|
+
// in isolation to learn which ones jspm can resolve, then re-run the
|
|
557
|
+
// unified call over only those so the survivors form one consistent
|
|
558
|
+
// graph (restores #446 coherence for the resolvable subset).
|
|
559
|
+
const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider)));
|
|
560
|
+
|
|
561
|
+
// A GOOD package whose isolated probe failed TRANSIENTLY (a network blip
|
|
562
|
+
// mid-probe) must NOT be classified as unresolvable and dropped. Only a
|
|
563
|
+
// PERMANENT probe failure (401/404) means the install is genuinely
|
|
564
|
+
// unresolvable. If any probe failed transiently, we cannot safely decide
|
|
565
|
+
// the resolvable set this pass, so flag the whole resolve transient-
|
|
566
|
+
// failed and serve the merged fragments WITHOUT dropping anyone; the next
|
|
567
|
+
// ensureReady retry re-resolves once the blip clears. Conflating the two
|
|
568
|
+
// here is exactly the bug this guard prevents.
|
|
569
|
+
const transientProbe = probes.some(p => !p.ok && p.transient);
|
|
570
|
+
if (transientProbe) {
|
|
571
|
+
lastLiveResolveFailed = true;
|
|
572
|
+
return mergePerInstall(probes.map(p => p.imports));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// From here every failed probe is PERMANENT, so dropping it is safe.
|
|
576
|
+
const resolvable = installs.filter((_, idx) => probes[idx].ok);
|
|
577
|
+
|
|
578
|
+
if (resolvable.length === installs.length) {
|
|
579
|
+
// Every install resolved alone but the batch 401'd: a genuine
|
|
580
|
+
// cross-package CONFLICT jspm could not satisfy as one graph (rare).
|
|
581
|
+
// The coherent graph is unavailable, so serve the merged fragments
|
|
582
|
+
// (pre-#446 behaviour) rather than nothing. NOTE: this degraded path
|
|
583
|
+
// can REINTRODUCE the #446 skew, because last-write-wins on a shared
|
|
584
|
+
// transitive across independent fragments is exactly the merge the
|
|
585
|
+
// unified call exists to avoid. It is a deliberate degrade-not-crash
|
|
586
|
+
// fallback for an unsatisfiable graph: no coherent graph exists, so a
|
|
587
|
+
// possibly-skewed map beats no map. The common conflicting-deps case
|
|
588
|
+
// (one shared transitive needing a newer version, issue #446's repro)
|
|
589
|
+
// IS satisfiable and resolves coherently on the unified path above;
|
|
590
|
+
// only a genuinely unsatisfiable set reaches here.
|
|
591
|
+
return mergePerInstall(probes.map(p => p.imports));
|
|
592
|
+
}
|
|
593
|
+
if (resolvable.length === 0) return {};
|
|
594
|
+
if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider);
|
|
595
|
+
|
|
596
|
+
// Re-run unified over the resolvable subset. If even that fails (a
|
|
597
|
+
// conflict among the survivors), fall back to their merged fragments.
|
|
598
|
+
const retry = await jspmCall(resolvable, provider);
|
|
599
|
+
if (retry.ok) return retry.imports;
|
|
600
|
+
return mergePerInstall(resolvable.map(i => probes[installs.indexOf(i)].imports));
|
|
601
|
+
})();
|
|
602
|
+
|
|
603
|
+
jspmCache.set(unifiedKey, promise);
|
|
604
|
+
return promise;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Last-write-wins merge of per-install import fragments. Subpath installs
|
|
609
|
+
* never collide (their keys include the subpath); a shared base package
|
|
610
|
+
* resolves to the same root URL across fragments, so the merge is stable.
|
|
611
|
+
*
|
|
612
|
+
* @param {Array<Record<string, string>>} fragments
|
|
613
|
+
* @returns {Record<string, string>}
|
|
614
|
+
*/
|
|
615
|
+
function mergePerInstall(fragments) {
|
|
421
616
|
const merged = {};
|
|
422
|
-
for (const fragment of
|
|
617
|
+
for (const fragment of fragments) Object.assign(merged, fragment);
|
|
423
618
|
return merged;
|
|
424
619
|
}
|
|
425
620
|
|
|
@@ -465,6 +660,30 @@ export function clearVendorCache() {
|
|
|
465
660
|
liveIntegrityCache.clear();
|
|
466
661
|
}
|
|
467
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Recover `{ pkg, version, subpath }` for a resolved import spec that was
|
|
665
|
+
* NOT in the directly-scanned set, i.e. a flattened transitive the unified
|
|
666
|
+
* resolve added (issue #446). The bare package name and subpath come from
|
|
667
|
+
* the spec; the version is read out of the resolved CDN URL by locating
|
|
668
|
+
* `<bare>@<version>` in it (same logic `listPinned` uses, which handles
|
|
669
|
+
* every supported provider's URL shape). Returns null when the version
|
|
670
|
+
* can't be parsed, in which case the caller pins the entry by URL anyway
|
|
671
|
+
* but cannot derive a `--download` filename for it.
|
|
672
|
+
*
|
|
673
|
+
* @param {string} spec e.g. '@codemirror/state' or 'dayjs/plugin/utc'
|
|
674
|
+
* @param {string} url the resolved CDN URL for that spec
|
|
675
|
+
* @returns {{ pkg: string, version: string, subpath: string } | null}
|
|
676
|
+
*/
|
|
677
|
+
function derivePinParts(spec, url) {
|
|
678
|
+
const pkg = extractPackageName(spec);
|
|
679
|
+
if (!pkg) return null;
|
|
680
|
+
const subpath = spec.slice(pkg.length);
|
|
681
|
+
const escapedBare = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
682
|
+
const match = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
|
|
683
|
+
if (!match) return null;
|
|
684
|
+
return { pkg, version: match[1], subpath };
|
|
685
|
+
}
|
|
686
|
+
|
|
468
687
|
// ---------------------------------------------------------------------------
|
|
469
688
|
// File-based pin (.webjs/vendor/importmap.json, optional --download bundles)
|
|
470
689
|
// ---------------------------------------------------------------------------
|
|
@@ -482,6 +701,179 @@ function pinFilePath(appDir) {
|
|
|
482
701
|
return join(pinDir(appDir), PIN_FILE);
|
|
483
702
|
}
|
|
484
703
|
|
|
704
|
+
/**
|
|
705
|
+
* The three-line `.gitignore` pattern that ignores the transient
|
|
706
|
+
* `.webjs` caches at any depth while re-including the committed
|
|
707
|
+
* `.webjs/vendor/` pin output. This mirrors the scaffold template
|
|
708
|
+
* (`packages/cli/templates/.gitignore`) and the `vendor-gitignore`
|
|
709
|
+
* check in `doctor.js` verbatim, so a self-healed `.gitignore`
|
|
710
|
+
* ends up byte-identical to a freshly scaffolded one.
|
|
711
|
+
*/
|
|
712
|
+
const VENDOR_GITIGNORE_LINES = [
|
|
713
|
+
'**/.webjs/*',
|
|
714
|
+
'!**/.webjs/vendor/',
|
|
715
|
+
'!**/.webjs/vendor/**',
|
|
716
|
+
];
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Probe whether `appDir`'s `.gitignore` would swallow the vendor pin
|
|
720
|
+
* output, via `git check-ignore`. Best-effort: returns false when the
|
|
721
|
+
* directory is not a git repo, git is absent, or the spawn fails.
|
|
722
|
+
*
|
|
723
|
+
* The inherited GIT_* env vars are stripped so `cwd` is the sole
|
|
724
|
+
* authority on which repo + `.gitignore` stack is consulted. Git
|
|
725
|
+
* exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE / GIT_PREFIX into
|
|
726
|
+
* hook processes (a pre-commit hook from a linked worktree exports
|
|
727
|
+
* GIT_WORK_TREE), and those OVERRIDE cwd-based discovery; without the
|
|
728
|
+
* strip the probe would consult the outer repo instead of `appDir`.
|
|
729
|
+
* Same reasoning as the `vendor-gitignore` doctor check.
|
|
730
|
+
*
|
|
731
|
+
* @param {string} appDir
|
|
732
|
+
* @returns {boolean} true when `.webjs/vendor/importmap.json` is ignored
|
|
733
|
+
*/
|
|
734
|
+
function vendorPinIsIgnored(appDir) {
|
|
735
|
+
try {
|
|
736
|
+
const {
|
|
737
|
+
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
738
|
+
...gitEnv
|
|
739
|
+
} = process.env;
|
|
740
|
+
const probe = `.webjs/vendor/${PIN_FILE}`;
|
|
741
|
+
// `git check-ignore -q` exits 0 when ignored, 1 when not ignored,
|
|
742
|
+
// 128 on error (not a git repo, etc.). Treat anything but 0 as
|
|
743
|
+
// "not ignored" so a non-git project never gets its .gitignore
|
|
744
|
+
// touched.
|
|
745
|
+
const result = spawnSync('git', ['check-ignore', '-q', probe], {
|
|
746
|
+
cwd: appDir,
|
|
747
|
+
stdio: 'pipe',
|
|
748
|
+
env: gitEnv,
|
|
749
|
+
});
|
|
750
|
+
return result.status === 0;
|
|
751
|
+
} catch {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Make the `webjs vendor pin` output committable, idempotently.
|
|
758
|
+
*
|
|
759
|
+
* Vendoring is an OPTIONAL opt-in: the no-build default resolves bare
|
|
760
|
+
* specifiers at runtime via jspm.io and needs nothing committed. But a
|
|
761
|
+
* user who runs `webjs vendor pin` deliberately creates pins they want
|
|
762
|
+
* in source control, and a `.gitignore` that excludes `.webjs/` (the
|
|
763
|
+
* older scaffold pattern, or one an editor/agent "simplified") silently
|
|
764
|
+
* swallows that output. Fresh scaffolds already carry the vendor
|
|
765
|
+
* exception (a glob exclusion plus a re-include negation), so for them
|
|
766
|
+
* this is a no-op.
|
|
767
|
+
*
|
|
768
|
+
* Behaviour (only ever called from the opt-in `vendor pin` path):
|
|
769
|
+
* - The pin output is NOT ignored (the common, already-correct case):
|
|
770
|
+
* return `{ ignored: false, patched: false }`. Nothing is written.
|
|
771
|
+
* - The pin output IS ignored AND a `.gitignore` exists: heal it (see
|
|
772
|
+
* below), then re-probe. If the pin is committable afterwards return
|
|
773
|
+
* `{ ignored: true, patched: true, gitignorePath }`; if a broader
|
|
774
|
+
* unrelated rule still swallows it (e.g. a root `*.json`), the heal
|
|
775
|
+
* cannot help, so revert the edit and return `patched: false` so the
|
|
776
|
+
* caller prints a notice rather than claiming a fix that did not take.
|
|
777
|
+
* - The pin output IS ignored but there is NO `.gitignore` to patch
|
|
778
|
+
* (e.g. the ignore comes from a parent repo's `.gitignore`, or from
|
|
779
|
+
* `.git/info/exclude`): leave the tree untouched and return
|
|
780
|
+
* `{ ignored: true, patched: false, gitignorePath: null }` so the
|
|
781
|
+
* caller can print a notice instead of writing a file the user did
|
|
782
|
+
* not create.
|
|
783
|
+
*
|
|
784
|
+
* Healing has two parts, because a plain append is NOT enough. A bare
|
|
785
|
+
* directory exclusion (`.webjs/`, `/.webjs/`, `.webjs`, with or without a
|
|
786
|
+
* leading glob) excludes the directory itself, and git CANNOT re-include
|
|
787
|
+
* a child of an excluded directory, so any later negation is silently
|
|
788
|
+
* dead. So: (1) rewrite each such line IN PLACE to the glob form
|
|
789
|
+
* (`**` + `/.webjs/*`), which ignores the directory's CONTENTS at any
|
|
790
|
+
* depth while leaving the directory re-includable; (2) append whichever
|
|
791
|
+
* of the three exception lines are still missing. The heal is idempotent:
|
|
792
|
+
* a re-run finds the pin already committable and short-circuits.
|
|
793
|
+
*
|
|
794
|
+
* @param {string} appDir
|
|
795
|
+
* @returns {Promise<{ ignored: boolean, patched: boolean, gitignorePath: string | null }>}
|
|
796
|
+
*/
|
|
797
|
+
export async function ensureVendorCommittable(appDir) {
|
|
798
|
+
if (!vendorPinIsIgnored(appDir)) {
|
|
799
|
+
return { ignored: false, patched: false, gitignorePath: null };
|
|
800
|
+
}
|
|
801
|
+
const gitignorePath = join(appDir, '.gitignore');
|
|
802
|
+
let original;
|
|
803
|
+
try {
|
|
804
|
+
original = await readFile(gitignorePath, 'utf8');
|
|
805
|
+
} catch {
|
|
806
|
+
// No app-level .gitignore to patch. The ignore is coming from a
|
|
807
|
+
// parent repo or from .git/info/exclude; do not fabricate a
|
|
808
|
+
// .gitignore the user never had. Let the caller print a notice.
|
|
809
|
+
return { ignored: true, patched: false, gitignorePath: null };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// The exclusion glob, assembled so the literal `*` + `/` sequence never
|
|
813
|
+
// appears in this file's source comments above.
|
|
814
|
+
const exclude = VENDOR_GITIGNORE_LINES[0]; // **/.webjs/*
|
|
815
|
+
|
|
816
|
+
// Preserve the file's line ending so a CRLF .gitignore stays all-CRLF
|
|
817
|
+
// (and an LF one all-LF). Splitting on bare `\n` keeps each existing
|
|
818
|
+
// line's trailing `\r`; the lines we WRITE (the rewritten exclusion and
|
|
819
|
+
// the appended block) must use the same ending or the file goes mixed.
|
|
820
|
+
// A file with any CRLF is treated as CRLF; otherwise LF.
|
|
821
|
+
const eol = /\r\n/.test(original) ? '\r\n' : '\n';
|
|
822
|
+
|
|
823
|
+
// 1. Rewrite any bare `.webjs` DIRECTORY exclusion to the glob form. A
|
|
824
|
+
// directory exclusion blocks all child negations, so it must become
|
|
825
|
+
// `**/.webjs/*` (ignore contents, keep the dir re-includable).
|
|
826
|
+
const lines = original.split('\n');
|
|
827
|
+
let rewroteDir = false;
|
|
828
|
+
const rewritten = lines.map((line) => {
|
|
829
|
+
// Trim CR too, so a CRLF file's `.webjs/\r` still matches.
|
|
830
|
+
const t = line.replace(/\r$/, '').trim();
|
|
831
|
+
// Match the bare-directory shapes only (no `/*` suffix, not already a
|
|
832
|
+
// negation): `.webjs`, `.webjs/`, `/.webjs`, `/.webjs/`, `**/.webjs`,
|
|
833
|
+
// `**/.webjs/`. These all exclude the directory itself. Emit the
|
|
834
|
+
// replacement with the file's own ending if the original line carried
|
|
835
|
+
// one (every line but a no-trailing-newline last line does).
|
|
836
|
+
if (/^(\*\*\/|\/)?\.webjs\/?$/.test(t)) {
|
|
837
|
+
rewroteDir = true;
|
|
838
|
+
return line.endsWith('\r') ? exclude + '\r' : exclude;
|
|
839
|
+
}
|
|
840
|
+
return line;
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// 2. Append whichever exception lines are still missing.
|
|
844
|
+
const present = new Set(rewritten.map((l) => l.replace(/\r$/, '').trim()));
|
|
845
|
+
const missing = VENDOR_GITIGNORE_LINES.filter((l) => !present.has(l));
|
|
846
|
+
|
|
847
|
+
let next = rewritten.join('\n');
|
|
848
|
+
if (missing.length > 0) {
|
|
849
|
+
const block =
|
|
850
|
+
[
|
|
851
|
+
'# webjs: keep the committed vendor pin (`webjs vendor pin`) out of',
|
|
852
|
+
'# the `.webjs` cache exclusion so the pinned importmap is committable.',
|
|
853
|
+
...missing,
|
|
854
|
+
].join(eol) + eol;
|
|
855
|
+
const sep = next.endsWith('\n') || next === '' ? '' : eol;
|
|
856
|
+
next = next + sep + block;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (!rewroteDir && missing.length === 0) {
|
|
860
|
+
// Nothing to change, yet git still ignores the pin: a broader,
|
|
861
|
+
// unrelated rule is the cause and the vendor exception cannot fix it.
|
|
862
|
+
return { ignored: true, patched: false, gitignorePath };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
await writeFile(gitignorePath, next);
|
|
866
|
+
|
|
867
|
+
// 3. Re-probe. If a broader unrelated rule still swallows the pin, the
|
|
868
|
+
// edit did not achieve the goal, so revert it and report not-patched
|
|
869
|
+
// so the caller prints a notice instead of an inaccurate success.
|
|
870
|
+
if (vendorPinIsIgnored(appDir)) {
|
|
871
|
+
await writeFile(gitignorePath, original);
|
|
872
|
+
return { ignored: true, patched: false, gitignorePath };
|
|
873
|
+
}
|
|
874
|
+
return { ignored: true, patched: true, gitignorePath };
|
|
875
|
+
}
|
|
876
|
+
|
|
485
877
|
/**
|
|
486
878
|
* True when the app commits a vendor pin file (`.webjs/vendor/importmap.json`).
|
|
487
879
|
* A pinned app's importmap is deterministic and cheap to read, so `dev.js`
|
|
@@ -839,9 +1231,20 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
839
1231
|
const expected = new Set([PIN_FILE]);
|
|
840
1232
|
let downloaded = 0;
|
|
841
1233
|
|
|
1234
|
+
// Specs that were directly scanned (`partsByInstall`) AND the flattened
|
|
1235
|
+
// transitive specs the unified resolve returns (issue #446) must BOTH be
|
|
1236
|
+
// pinned, or a pinned app's importmap would be missing the transitive
|
|
1237
|
+
// entries the runtime live-resolve serves (e.g. `@codemirror/state` pulled
|
|
1238
|
+
// in by `@codemirror/lint`), breaking parity: the browser would hit an
|
|
1239
|
+
// unresolved-bare-specifier error for the transitive. For a transitive we
|
|
1240
|
+
// recover pkg + version + subpath by parsing the spec against the resolved
|
|
1241
|
+
// jspm URL (`derivePinParts`), since it has no `partsByInstall` entry.
|
|
1242
|
+
/** @type {Set<string>} */
|
|
1243
|
+
const pinnedDirectSpecs = new Set();
|
|
842
1244
|
for (const [spec, jspmUrl] of Object.entries(resolved)) {
|
|
843
|
-
const parts = partsByInstall.get(spec);
|
|
1245
|
+
const parts = partsByInstall.get(spec) || derivePinParts(spec, jspmUrl);
|
|
844
1246
|
if (!parts) continue;
|
|
1247
|
+
const direct = partsByInstall.has(spec);
|
|
845
1248
|
const { pkg, version, subpath } = parts;
|
|
846
1249
|
if (download) {
|
|
847
1250
|
const filename = bundleFilenameWithSubpath(pkg, version, subpath);
|
|
@@ -869,6 +1272,7 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
869
1272
|
);
|
|
870
1273
|
pins.push({ pkg: spec, version, url: jspmUrl, integrity: sri || undefined });
|
|
871
1274
|
}
|
|
1275
|
+
if (direct) pinnedDirectSpecs.add(spec);
|
|
872
1276
|
}
|
|
873
1277
|
|
|
874
1278
|
// If pin was attempted (installs non-empty) but resolved zero, do
|
|
@@ -878,11 +1282,16 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
878
1282
|
// every bare-specifier import. Better: surface the failure so the
|
|
879
1283
|
// user knows pin didn't take, and let the next boot fall back to
|
|
880
1284
|
// live API resolution (which may have recovered by then).
|
|
881
|
-
|
|
1285
|
+
//
|
|
1286
|
+
// Account on DIRECT specs only: pins also carries flattened transitive
|
|
1287
|
+
// entries (#446), so `pins.length === 0` would no longer mean "every
|
|
1288
|
+
// direct install failed". A resolve that returned only transitives but
|
|
1289
|
+
// no direct spec is still a total failure for the user's deps.
|
|
1290
|
+
if (installs.length > 0 && pinnedDirectSpecs.size === 0) {
|
|
882
1291
|
return { pins, pruned: [], downloaded, failed: true, attemptedInstalls: installs, provider: from };
|
|
883
1292
|
}
|
|
884
1293
|
|
|
885
|
-
// Partial-failure surface. Some installs were attempted but not
|
|
1294
|
+
// Partial-failure surface. Some DIRECT installs were attempted but not
|
|
886
1295
|
// every one made it into pins (jspm.io returned the package OK,
|
|
887
1296
|
// but downloadBundle failed mid-stream in --download mode, or the
|
|
888
1297
|
// resolver response was missing the package entirely). Write the
|
|
@@ -892,15 +1301,14 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
892
1301
|
// --download mode).
|
|
893
1302
|
//
|
|
894
1303
|
// Derive the missing set from partsByInstall (the bare-spec keys)
|
|
895
|
-
// rather than from `installs` (the versioned strings).
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
if (
|
|
899
|
-
const pinnedSpecs = new Set(pins.map(p => p.pkg));
|
|
1304
|
+
// rather than from `installs` (the versioned strings). Compare against
|
|
1305
|
+
// the DIRECT specs that pinned, NOT pins[].pkg (which now includes
|
|
1306
|
+
// transitives), so a transitive can't mask a missing direct dep.
|
|
1307
|
+
if (pinnedDirectSpecs.size < partsByInstall.size) {
|
|
900
1308
|
/** @type {string[]} */
|
|
901
1309
|
const missing = [];
|
|
902
1310
|
for (const [spec, parts] of partsByInstall.entries()) {
|
|
903
|
-
if (!
|
|
1311
|
+
if (!pinnedDirectSpecs.has(spec)) {
|
|
904
1312
|
missing.push(`${parts.pkg}@${parts.version}${parts.subpath}`);
|
|
905
1313
|
}
|
|
906
1314
|
}
|
|
@@ -1361,6 +1769,341 @@ export function prunePinToReachable(imports, integrity, reachable) {
|
|
|
1361
1769
|
return { imports: keptImports, integrity: keptIntegrity };
|
|
1362
1770
|
}
|
|
1363
1771
|
|
|
1772
|
+
// ---------------------------------------------------------------------------
|
|
1773
|
+
// Importmap coherence check (issue #450)
|
|
1774
|
+
// ---------------------------------------------------------------------------
|
|
1775
|
+
//
|
|
1776
|
+
// A produced importmap pins one URL per resolved package, each URL carrying an
|
|
1777
|
+
// `@<version>`. That pinned graph is INCOHERENT when one resolved package
|
|
1778
|
+
// declares a dependency or peer range on ANOTHER resolved package and the
|
|
1779
|
+
// version actually pinned for that other package falls OUTSIDE the range. The
|
|
1780
|
+
// motivating crash (#446): `@codemirror/view@6.39.16` pinned while
|
|
1781
|
+
// `@codemirror/lint@6.9.6` (also pinned) needs `view@^6.42.0`, so a symbol
|
|
1782
|
+
// `lint` expects is missing from the older `view` bundle at runtime.
|
|
1783
|
+
//
|
|
1784
|
+
// This is a VALIDATION over a produced importmap, NOT a re-resolution (that is
|
|
1785
|
+
// #446's job) and NOT bundling. It emits warnings; it never mutates the map.
|
|
1786
|
+
//
|
|
1787
|
+
// PARITY: `checkImportmapCoherence` is a pure function of the EXTRACTED
|
|
1788
|
+
// `{ package -> pinned version }` set plus the dependency metadata. It does not
|
|
1789
|
+
// know or care whether the importmap came from a live jspm.io resolve or from a
|
|
1790
|
+
// committed `.webjs/vendor/importmap.json`. Two importmaps that pin the same
|
|
1791
|
+
// versions for the same packages therefore always produce the same verdict,
|
|
1792
|
+
// which is exactly the runtime-vs-vendored parity the maintainer requires.
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* Extract `{ basePackage -> pinned version }` from an importmap's `imports`
|
|
1796
|
+
* map. Each value is a CDN URL (jspm.io's `npm:dayjs@1.11.13/...`, jsdelivr's
|
|
1797
|
+
* `npm/dayjs@1.11.13/...`, unpkg's bare `dayjs@1.11.13/...`, skypack's
|
|
1798
|
+
* `dayjs@1.11.13`) or a local `/__webjs/vendor/<pkg>@<version>...js` path. The
|
|
1799
|
+
* key is the bare package name parsed from the importmap KEY (the specifier),
|
|
1800
|
+
* which is authoritative; the version is parsed from the URL.
|
|
1801
|
+
*
|
|
1802
|
+
* A specifier that resolves to a version we cannot parse from its URL is
|
|
1803
|
+
* skipped (it contributes nothing to the dep graph rather than a wrong pin).
|
|
1804
|
+
* When the same base package appears at several versions (subpath imports),
|
|
1805
|
+
* the LAST parsed wins; in practice every subpath of a package resolves to the
|
|
1806
|
+
* one installed version, so they agree.
|
|
1807
|
+
*
|
|
1808
|
+
* @param {Record<string, string>} imports importmap `imports` map (specifier -> URL)
|
|
1809
|
+
* @returns {Map<string, string>} base package name -> pinned version
|
|
1810
|
+
*/
|
|
1811
|
+
export function extractPinnedVersions(imports) {
|
|
1812
|
+
/** @type {Map<string, string>} */
|
|
1813
|
+
const out = new Map();
|
|
1814
|
+
for (const [spec, url] of Object.entries(imports || {})) {
|
|
1815
|
+
if (typeof url !== 'string') continue;
|
|
1816
|
+
const bare = extractPackageName(spec);
|
|
1817
|
+
if (!bare) continue;
|
|
1818
|
+
let version = null;
|
|
1819
|
+
if (url.startsWith('/__webjs/vendor/')) {
|
|
1820
|
+
// Local downloaded-pin path: `<name>@<version>[__subpath].js`. The name
|
|
1821
|
+
// is `--`-encoded for scoped packages; we only need the version, which
|
|
1822
|
+
// sits after the LAST `@` and before any `__subpath` / `.js` suffix.
|
|
1823
|
+
const filename = url.slice('/__webjs/vendor/'.length);
|
|
1824
|
+
const atIdx = filename.lastIndexOf('@');
|
|
1825
|
+
if (atIdx > 0) {
|
|
1826
|
+
const afterAt = filename.slice(atIdx + 1, filename.endsWith('.js') ? -3 : undefined);
|
|
1827
|
+
const subIdx = afterAt.indexOf('__');
|
|
1828
|
+
version = subIdx < 0 ? afterAt : afterAt.slice(0, subIdx);
|
|
1829
|
+
}
|
|
1830
|
+
} else {
|
|
1831
|
+
// CDN URL: find `<bare>@<version>` anchored on a non-name char (or the
|
|
1832
|
+
// string start) so a short name like `ms` does not false-match inside
|
|
1833
|
+
// another package's URL. Mirrors listPinned's parser.
|
|
1834
|
+
const escapedBare = bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1835
|
+
const m = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
|
|
1836
|
+
if (m) version = m[1];
|
|
1837
|
+
}
|
|
1838
|
+
if (version && /\d/.test(version)) out.set(bare, version);
|
|
1839
|
+
}
|
|
1840
|
+
return out;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* Does `version` satisfy the npm `range`? PRAGMATIC, no semver dependency
|
|
1845
|
+
* (vendor.js stays dependency-free). Supports the shapes that appear in real
|
|
1846
|
+
* `dependencies` / `peerDependencies`: `*` / `latest` / `x` / `''` (any),
|
|
1847
|
+
* `||` alternation, a leading `>=` / `>` / `<=` / `<` / `=`, caret `^`, tilde
|
|
1848
|
+
* `~`, an `x`/`*` wildcard segment (`6.x`, `6.39.x`), and an exact `1.2.3`.
|
|
1849
|
+
*
|
|
1850
|
+
* Returns `true` / `false` when the range can be evaluated, and `null` when
|
|
1851
|
+
* the shape is one we do NOT statically understand (a URL range, a git range,
|
|
1852
|
+
* a hyphen `1.2.3 - 1.4.0` range). `null` is the "could not verify" signal: the
|
|
1853
|
+
* caller degrades to a soft "unverified" note rather than warning on a shape it
|
|
1854
|
+
* cannot judge. Failing open here is deliberate, a coherence check must never
|
|
1855
|
+
* cry wolf on a range it misread.
|
|
1856
|
+
*
|
|
1857
|
+
* Prerelease note: both the version and the range are judged on their release
|
|
1858
|
+
* line only (the `-beta` / `-rc` tag is dropped, see `parseSemver`), so a
|
|
1859
|
+
* prerelease pin is treated as its stable tuple. The worst case is a MISSED
|
|
1860
|
+
* warning when a prerelease is pinned, never a spurious one.
|
|
1861
|
+
*
|
|
1862
|
+
* @param {string} version e.g. `6.39.16`
|
|
1863
|
+
* @param {string} range e.g. `^6.42.0`
|
|
1864
|
+
* @returns {boolean | null}
|
|
1865
|
+
*/
|
|
1866
|
+
export function satisfiesSemverRange(version, range) {
|
|
1867
|
+
const v = parseSemver(version);
|
|
1868
|
+
if (!v) return null;
|
|
1869
|
+
const r = String(range == null ? '' : range).trim();
|
|
1870
|
+
if (r === '' || r === '*' || r === 'x' || r === 'X' || r === 'latest') return true;
|
|
1871
|
+
if (r.startsWith('workspace:')) return true;
|
|
1872
|
+
// Alternation: satisfied if ANY clause is satisfied. A clause we cannot
|
|
1873
|
+
// evaluate (null) must not let a non-matching clause produce a false
|
|
1874
|
+
// negative, so an unknown clause makes the whole result unknown unless a
|
|
1875
|
+
// known clause already matched.
|
|
1876
|
+
if (r.includes('||')) {
|
|
1877
|
+
let sawUnknown = false;
|
|
1878
|
+
for (const clause of r.split('||')) {
|
|
1879
|
+
const res = satisfiesSemverRange(version, clause.trim());
|
|
1880
|
+
if (res === true) return true;
|
|
1881
|
+
if (res === null) sawUnknown = true;
|
|
1882
|
+
}
|
|
1883
|
+
return sawUnknown ? null : false;
|
|
1884
|
+
}
|
|
1885
|
+
// A space-joined comparator set (`>=6.0.0 <7.0.0`) must ALL be satisfied.
|
|
1886
|
+
if (/\s/.test(r)) {
|
|
1887
|
+
// A hyphen range (`1.2.3 - 1.4.0`) is not a comparator set; we do not
|
|
1888
|
+
// parse it, so degrade to unknown rather than mis-AND its halves.
|
|
1889
|
+
if (/\s-\s/.test(r)) return null;
|
|
1890
|
+
let result = true;
|
|
1891
|
+
for (const part of r.split(/\s+/)) {
|
|
1892
|
+
if (!part) continue;
|
|
1893
|
+
const res = satisfiesSemverRange(version, part);
|
|
1894
|
+
if (res === null) return null;
|
|
1895
|
+
if (res === false) result = false;
|
|
1896
|
+
}
|
|
1897
|
+
return result;
|
|
1898
|
+
}
|
|
1899
|
+
// Comparators: >= > <= < =.
|
|
1900
|
+
const cmpMatch = /^(>=|<=|>|<|=)\s*(.+)$/.exec(r);
|
|
1901
|
+
if (cmpMatch) {
|
|
1902
|
+
const op = cmpMatch[1];
|
|
1903
|
+
const bound = parseSemver(cmpMatch[2]);
|
|
1904
|
+
if (!bound) return null;
|
|
1905
|
+
const c = cmpSemver(v, bound);
|
|
1906
|
+
switch (op) {
|
|
1907
|
+
case '>=': return c >= 0;
|
|
1908
|
+
case '>': return c > 0;
|
|
1909
|
+
case '<=': return c <= 0;
|
|
1910
|
+
case '<': return c < 0;
|
|
1911
|
+
case '=': return c === 0;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
// Caret: same left-most non-zero segment. ^6.42.0 -> >=6.42.0 <7.0.0;
|
|
1915
|
+
// ^0.7.0 -> >=0.7.0 <0.8.0; ^0.0.3 -> >=0.0.3 <0.0.4.
|
|
1916
|
+
if (r.startsWith('^')) {
|
|
1917
|
+
const b = parseSemver(r.slice(1));
|
|
1918
|
+
if (!b) return null;
|
|
1919
|
+
if (cmpSemver(v, b) < 0) return false;
|
|
1920
|
+
if (b[0] > 0) return v[0] === b[0];
|
|
1921
|
+
if (b[1] > 0) return v[0] === 0 && v[1] === b[1];
|
|
1922
|
+
return v[0] === 0 && v[1] === 0 && v[2] === b[2];
|
|
1923
|
+
}
|
|
1924
|
+
// Tilde: ~6.42.0 -> >=6.42.0 <6.43.0; ~6 -> >=6.0.0 <7.0.0.
|
|
1925
|
+
if (r.startsWith('~')) {
|
|
1926
|
+
const raw = r.slice(1);
|
|
1927
|
+
const b = parseSemver(raw);
|
|
1928
|
+
if (!b) return null;
|
|
1929
|
+
if (cmpSemver(v, b) < 0) return false;
|
|
1930
|
+
// If the range named a minor (`~6.42` / `~6.42.0`), pin the minor; if it
|
|
1931
|
+
// named only a major (`~6`), pin the major.
|
|
1932
|
+
const namedMinor = /^\d+\.\d+/.test(raw);
|
|
1933
|
+
return namedMinor ? v[0] === b[0] && v[1] === b[1] : v[0] === b[0];
|
|
1934
|
+
}
|
|
1935
|
+
// x / * wildcard segment: 6.x, 6.39.x, 6.*.
|
|
1936
|
+
if (/^\d+(\.(\d+|[xX*])){0,2}$/.test(r) && /[xX*]/.test(r)) {
|
|
1937
|
+
const segs = r.split('.');
|
|
1938
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1939
|
+
const s = segs[i];
|
|
1940
|
+
if (s === 'x' || s === 'X' || s === '*') break; // any beyond here
|
|
1941
|
+
if (Number(s) !== v[i]) return false;
|
|
1942
|
+
}
|
|
1943
|
+
return true;
|
|
1944
|
+
}
|
|
1945
|
+
// Exact `1.2.3` (or shorter `1` / `1.2`, treated as that prefix pinned). A
|
|
1946
|
+
// leading `v` (`v1.2.3`) is tolerated so a `v`-prefixed exact pin evaluates
|
|
1947
|
+
// instead of degrading to unverified.
|
|
1948
|
+
const exact = r.startsWith('v') ? r.slice(1) : r;
|
|
1949
|
+
if (/^\d+(\.\d+){0,2}$/.test(exact)) {
|
|
1950
|
+
const b = parseSemver(exact);
|
|
1951
|
+
if (!b) return null;
|
|
1952
|
+
const segs = exact.split('.').length;
|
|
1953
|
+
for (let i = 0; i < segs; i++) if (v[i] !== b[i]) return false;
|
|
1954
|
+
return true;
|
|
1955
|
+
}
|
|
1956
|
+
return null;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* Parse a version string to a `[major, minor, patch]` numeric triple, or null
|
|
1961
|
+
* when it has no parseable numeric core (a `latest`, a git URL, a `*`).
|
|
1962
|
+
*
|
|
1963
|
+
* KNOWN LIMITATION: a prerelease / build suffix (`-rc.1`, `+sha`) is dropped, so
|
|
1964
|
+
* a version is judged purely on its release line. A pinned prerelease is treated
|
|
1965
|
+
* as its stable tuple: `6.42.0-beta.1` is judged as `6.42.0`, so a stable range
|
|
1966
|
+
* like `^6.42.0` reports it as a MATCH even though npm semver excludes a
|
|
1967
|
+
* prerelease from a stable range. This is a deliberate fail-safe simplification
|
|
1968
|
+
* (we do not carry prerelease ordering): the only consequence is a MISSED
|
|
1969
|
+
* coherence warning when a prerelease is pinned, never a spurious one on a
|
|
1970
|
+
* coherent graph. Pinned prereleases are vanishingly rare in a vendored
|
|
1971
|
+
* importmap, so the missed-warning risk is negligible.
|
|
1972
|
+
*
|
|
1973
|
+
* @param {string} v
|
|
1974
|
+
* @returns {[number, number, number] | null}
|
|
1975
|
+
*/
|
|
1976
|
+
function parseSemver(v) {
|
|
1977
|
+
const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(v == null ? '' : v));
|
|
1978
|
+
if (!m) return null;
|
|
1979
|
+
return [Number(m[1]), Number(m[2] || 0), Number(m[3] || 0)];
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* Compare two `[major, minor, patch]` triples. Negative if a < b, 0 if equal,
|
|
1984
|
+
* positive if a > b.
|
|
1985
|
+
* @param {[number, number, number]} a
|
|
1986
|
+
* @param {[number, number, number]} b
|
|
1987
|
+
* @returns {number}
|
|
1988
|
+
*/
|
|
1989
|
+
function cmpSemver(a, b) {
|
|
1990
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/**
|
|
1994
|
+
* @typedef {{
|
|
1995
|
+
* pkg: string,
|
|
1996
|
+
* version: string,
|
|
1997
|
+
* dependsOn: string,
|
|
1998
|
+
* kind: 'dependency' | 'peerDependency',
|
|
1999
|
+
* requiredRange: string,
|
|
2000
|
+
* pinnedVersion: string,
|
|
2001
|
+
* }} CoherenceConflict
|
|
2002
|
+
*/
|
|
2003
|
+
|
|
2004
|
+
/**
|
|
2005
|
+
* @typedef {{
|
|
2006
|
+
* conflicts: CoherenceConflict[],
|
|
2007
|
+
* unverified: Array<{ pkg: string, reason: string }>,
|
|
2008
|
+
* checked: number,
|
|
2009
|
+
* }} CoherenceReport
|
|
2010
|
+
* `checked` counts the pinned packages whose dependency metadata was actually
|
|
2011
|
+
* read (so a clean verdict is grounded in real data); a package whose manifest
|
|
2012
|
+
* was unavailable is in `unverified` and is NOT counted as checked. `checked
|
|
2013
|
+
* === 0` with a non-empty `unverified` therefore means "could not verify
|
|
2014
|
+
* anything", which the caller surfaces as a soft degrade rather than "coherent".
|
|
2015
|
+
*/
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* Validate that a produced importmap's pinned dependency graph is COHERENT
|
|
2019
|
+
* (issue #450). For each resolved package, read its declared `dependencies`
|
|
2020
|
+
* and `peerDependencies` (via the injected `getManifest`) and, for every
|
|
2021
|
+
* declared range that targets ANOTHER package ALSO pinned in this importmap,
|
|
2022
|
+
* check the pinned version satisfies the range. A miss is a conflict naming
|
|
2023
|
+
* both packages, the required range, and the pinned version.
|
|
2024
|
+
*
|
|
2025
|
+
* This is the SAME function the doctor runs over the live importmap and over
|
|
2026
|
+
* `.webjs/vendor/importmap.json`. It is pure in `(imports, getManifest)`, so
|
|
2027
|
+
* the same pinned dep set yields the same verdict regardless of which input it
|
|
2028
|
+
* came from (the runtime-vs-vendored parity invariant).
|
|
2029
|
+
*
|
|
2030
|
+
* Degrades gracefully: a package whose manifest `getManifest` cannot supply
|
|
2031
|
+
* (not installed, unreadable, network unavailable) is recorded under
|
|
2032
|
+
* `unverified` and contributes NO conflict, so the check reports "could not
|
|
2033
|
+
* verify" rather than failing closed. A declared range in a shape we cannot
|
|
2034
|
+
* statically evaluate (see `satisfiesSemverRange` -> null) is likewise skipped,
|
|
2035
|
+
* never warned on.
|
|
2036
|
+
*
|
|
2037
|
+
* @param {Record<string, string>} imports importmap `imports` map
|
|
2038
|
+
* @param {{
|
|
2039
|
+
* getManifest: (pkg: string, version: string) =>
|
|
2040
|
+
* ({ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null
|
|
2041
|
+
* | Promise<{ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null>),
|
|
2042
|
+
* }} opts `getManifest` returns the declared dep ranges for a resolved
|
|
2043
|
+
* `pkg@version`, or null when unavailable (degrade to "unverified").
|
|
2044
|
+
* @returns {Promise<CoherenceReport>}
|
|
2045
|
+
*/
|
|
2046
|
+
export async function checkImportmapCoherence(imports, opts) {
|
|
2047
|
+
const pinned = extractPinnedVersions(imports);
|
|
2048
|
+
/** @type {CoherenceConflict[]} */
|
|
2049
|
+
const conflicts = [];
|
|
2050
|
+
/** @type {Array<{ pkg: string, reason: string }>} */
|
|
2051
|
+
const unverified = [];
|
|
2052
|
+
// Sort for deterministic output: the same dep set always yields the same
|
|
2053
|
+
// ordering, which keeps the verdict (and any test asserting it) stable and
|
|
2054
|
+
// strengthens the parity guarantee end to end.
|
|
2055
|
+
const packages = [...pinned.keys()].sort();
|
|
2056
|
+
// Count of packages whose metadata we could actually read (so a conflict
|
|
2057
|
+
// verdict is grounded). A package whose manifest is unavailable lands in
|
|
2058
|
+
// `unverified` instead and does NOT count as checked, which lets the caller
|
|
2059
|
+
// distinguish "verified coherent" from "could not verify anything".
|
|
2060
|
+
let checked = 0;
|
|
2061
|
+
for (const pkg of packages) {
|
|
2062
|
+
const version = pinned.get(pkg);
|
|
2063
|
+
let manifest;
|
|
2064
|
+
try {
|
|
2065
|
+
manifest = await opts.getManifest(pkg, version);
|
|
2066
|
+
} catch {
|
|
2067
|
+
manifest = null;
|
|
2068
|
+
}
|
|
2069
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
2070
|
+
unverified.push({ pkg, reason: `could not read dependency metadata for ${pkg}@${version}` });
|
|
2071
|
+
continue;
|
|
2072
|
+
}
|
|
2073
|
+
checked++;
|
|
2074
|
+
const groups = /** @type {const} */ ([
|
|
2075
|
+
['dependency', manifest.dependencies],
|
|
2076
|
+
['peerDependency', manifest.peerDependencies],
|
|
2077
|
+
]);
|
|
2078
|
+
for (const [kind, deps] of groups) {
|
|
2079
|
+
if (!deps || typeof deps !== 'object') continue;
|
|
2080
|
+
for (const [depName, range] of Object.entries(deps)) {
|
|
2081
|
+
// Only edges INTO the pinned graph matter: a dep on a package that is
|
|
2082
|
+
// not in this importmap is not the importmap's coherence problem (it
|
|
2083
|
+
// is either bundled into a CDN megabundle or simply unused on the
|
|
2084
|
+
// client). Self-edges cannot conflict.
|
|
2085
|
+
if (depName === pkg) continue;
|
|
2086
|
+
const depPinned = pinned.get(depName);
|
|
2087
|
+
if (!depPinned) continue;
|
|
2088
|
+
const ok = satisfiesSemverRange(depPinned, String(range));
|
|
2089
|
+
if (ok === false) {
|
|
2090
|
+
conflicts.push({
|
|
2091
|
+
pkg,
|
|
2092
|
+
version: String(version),
|
|
2093
|
+
dependsOn: depName,
|
|
2094
|
+
kind,
|
|
2095
|
+
requiredRange: String(range),
|
|
2096
|
+
pinnedVersion: depPinned,
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
// ok === null (range shape not understood) is silently skipped: the
|
|
2100
|
+
// check never warns on a range it could not evaluate.
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
return { conflicts, unverified, checked };
|
|
2105
|
+
}
|
|
2106
|
+
|
|
1364
2107
|
/**
|
|
1365
2108
|
* Per-process cache of SHA-384 integrity hashes for live-resolved vendor
|
|
1366
2109
|
* URLs, keyed by the FINAL cross-origin URL. A vendor bundle at a given
|