@pnpm/resolving.npm-resolver 1101.3.1 → 1101.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
2
2
  import type { Registries, TrustPolicy } from '@pnpm/types';
3
3
  import type { FetchMetadataFromFromRegistryOptions } from './fetch.js';
4
4
  import { type FetchFullMetadataCachedOptions } from './fetchFullMetadataCached.js';
5
+ import type { PackageMetaCache } from './pickPackage.js';
5
6
  export interface CreateNpmResolutionVerifierOptions {
6
7
  /**
7
8
  * Minimum age (in minutes) a published version must reach before it is
@@ -56,6 +57,16 @@ export interface CreateNpmResolutionVerifierOptions {
56
57
  fetchOpts: FetchMetadataFromFromRegistryOptions;
57
58
  getAuthHeaderValueByURI: (registry: string) => string | undefined;
58
59
  cacheDir?: FetchFullMetadataCachedOptions['cacheDir'];
60
+ /**
61
+ * Per-install LRU shared with the npm resolver's `pickPackage`
62
+ * (`{ get, set }` over `PackageMeta`). When provided, the verifier
63
+ * consults it before fetching: a name the resolver already pulled
64
+ * during the same install yields the cached packument instead of a
65
+ * fresh disk/network round-trip. Optional — frozen-install paths and
66
+ * unit tests don't have a resolver running alongside, in which case
67
+ * the verifier falls back to its own fetch chain.
68
+ */
69
+ metaCache?: PackageMetaCache;
59
70
  /** Overrides Date.now() for tests. */
60
71
  now?: number;
61
72
  }
@@ -74,6 +74,7 @@ export function createNpmResolutionVerifier(opts) {
74
74
  getAuthHeaderValueByURI: opts.getAuthHeaderValueByURI,
75
75
  cacheDir: opts.cacheDir,
76
76
  cutoffMs: cutoff,
77
+ sharedMetaCache: opts.metaCache,
77
78
  abbreviatedMetaCache: new Map(),
78
79
  publishedAtCache: new Map(),
79
80
  localMetaCache: new Map(),
@@ -268,21 +269,89 @@ function fetchFullMetaForTrust(context, registry, name) {
268
269
  const cacheKey = `${registry}\x00${name}`;
269
270
  let cachedPromise = context.fullMetaForTrustCache.get(cacheKey);
270
271
  if (cachedPromise == null) {
271
- // Don't swallow the fetch rejection here `runTrustCheck` catches it
272
- // and surfaces the underlying message in the violation reason, which
273
- // is more actionable than the generic "metadata is unavailable" the
274
- // `!meta` fallback emits. The cache still holds the rejected promise
275
- // so repeat verifier calls for the same (registry, name) within one
276
- // install don't refetch a known-failing endpoint.
277
- cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
278
- registry,
279
- authHeaderValue: context.getAuthHeaderValueByURI(registry),
280
- cacheDir: context.cacheDir,
281
- });
272
+ // Fast path: if the resolver already upgraded to full meta for this
273
+ // name during the same install (e.g. minimumReleaseAge active),
274
+ // reuse that document. Abbreviated meta is rejected here it lacks
275
+ // per-version `time` and per-version trust evidence, both required
276
+ // by failIfTrustDowngraded.
277
+ //
278
+ // Limitation: the resolver's `metaCache` keys by `${name}:full`
279
+ // it doesn't include the registry (pickPackage.ts cacheKey shape).
280
+ // If two registries serve packages of the same name in one install
281
+ // the resolver itself silently keeps the first fetch; the verifier
282
+ // here inherits that scope. The name check below is a defensive
283
+ // guard against accidental cache mixups; tightening this to a
284
+ // registry-qualified read needs the resolver's `metaCache` key
285
+ // shape to change first.
286
+ const shared = readSharedMetaForTrust(context.sharedMetaCache, name);
287
+ if (shared != null) {
288
+ cachedPromise = Promise.resolve(projectTrustMeta(shared));
289
+ }
290
+ else {
291
+ // Don't swallow the fetch rejection here — `runTrustCheck` catches it
292
+ // and surfaces the underlying message in the violation reason, which
293
+ // is more actionable than the generic "metadata is unavailable" the
294
+ // `!meta` fallback emits. The cache still holds the rejected promise
295
+ // so repeat verifier calls for the same (registry, name) within one
296
+ // install don't refetch a known-failing endpoint.
297
+ //
298
+ // The fetched packument is projected down to just the trust-relevant
299
+ // fields (per-version `_npmUser.trustedPublisher` and
300
+ // `dist.attestations.provenance`, plus the package-level `time` map)
301
+ // before being stored. The full document — dependency maps, scripts,
302
+ // READMEs for every version — would otherwise stay resident in this
303
+ // map for the entire install, which on multi-thousand-entry
304
+ // workspaces OOMs CI runners with a 2GB heap (see #11860).
305
+ cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
306
+ registry,
307
+ authHeaderValue: context.getAuthHeaderValueByURI(registry),
308
+ cacheDir: context.cacheDir,
309
+ }).then(projectTrustMeta);
310
+ }
282
311
  context.fullMetaForTrustCache.set(cacheKey, cachedPromise);
283
312
  }
284
313
  return cachedPromise;
285
314
  }
315
+ // Project the full packument to a minimal `PackageMeta`-shaped view
316
+ // that exposes only the fields `failIfTrustDowngraded` reads:
317
+ // • `name` and `modified` for error messages and cache keys
318
+ // • `time` for the per-version publish-date walk
319
+ // • `versions[v]._npmUser.trustedPublisher`
320
+ // • `versions[v].dist.attestations.provenance`
321
+ // The shape is still a valid `PackageMeta` so the downstream consumer
322
+ // doesn't have to special-case it — only the bulk fields (dependency
323
+ // graph, scripts, README, etc.) are dropped.
324
+ function projectTrustMeta(meta) {
325
+ const versions = {};
326
+ for (const [version, manifest] of Object.entries(meta.versions ?? {})) {
327
+ versions[version] = projectTrustManifest(manifest);
328
+ }
329
+ return {
330
+ name: meta.name,
331
+ 'dist-tags': {},
332
+ versions,
333
+ time: meta.time,
334
+ modified: meta.modified,
335
+ etag: meta.etag,
336
+ };
337
+ }
338
+ function projectTrustManifest(manifest) {
339
+ // Drop everything except the trust-evidence fields. `PackageInRegistry.dist`
340
+ // is typed as requiring `shasum` and `tarball`, but the trust check never
341
+ // reads them; cast away the unsoundness so callers see the same nominal
342
+ // shape without the per-version dependency graph / scripts / README bulk
343
+ // carrying through. `_npmUser` is similarly narrowed to just
344
+ // `trustedPublisher` — the only sub-field the trust check inspects — so
345
+ // we don't keep maintainer name/email PII resident in the cache.
346
+ const trustedPublisher = manifest._npmUser?.trustedPublisher;
347
+ const provenance = manifest.dist?.attestations?.provenance;
348
+ return {
349
+ _npmUser: trustedPublisher != null ? { trustedPublisher } : undefined,
350
+ dist: provenance != null
351
+ ? { attestations: { provenance } }
352
+ : undefined,
353
+ };
354
+ }
286
355
  /**
287
356
  * Per-(registry, name, version) lookup with a layered fallback:
288
357
  *
@@ -360,7 +429,7 @@ async function tryAbbreviatedModifiedShortcut(context, registry, name, version)
360
429
  // publish time — but only for versions the registry currently lists.
361
430
  // An unpublished or never-published pin would otherwise pass the gate
362
431
  // on a stale package-level timestamp.
363
- if (!meta?.versions || !(version in meta.versions))
432
+ if (!meta?.versionNames?.has(version))
364
433
  return undefined;
365
434
  return modified;
366
435
  }
@@ -368,15 +437,75 @@ function fetchAbbreviatedMeta(context, registry, name) {
368
437
  const cacheKey = `${registry}\x00${name}`;
369
438
  let cachedPromise = context.abbreviatedMetaCache.get(cacheKey);
370
439
  if (cachedPromise == null) {
371
- cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
372
- registry,
373
- authHeaderValue: context.getAuthHeaderValueByURI(registry),
374
- cacheDir: context.cacheDir,
375
- }).catch(() => undefined);
440
+ // Fast path: the resolver's per-install LRU already holds this
441
+ // packument from its own pickPackage pass — abbreviated or full.
442
+ // Project it for the shortcut and skip the disk/network round-trip.
443
+ // Mismatch on `name` is the same risk the resolver carries today
444
+ // (its cache key omits the registry), so reuse is no less correct
445
+ // than the resolver's own get.
446
+ const shared = readSharedMeta(context.sharedMetaCache, name);
447
+ if (shared != null) {
448
+ cachedPromise = Promise.resolve(projectAbbreviatedMeta(shared));
449
+ }
450
+ else {
451
+ cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
452
+ registry,
453
+ authHeaderValue: context.getAuthHeaderValueByURI(registry),
454
+ cacheDir: context.cacheDir,
455
+ }).then(projectAbbreviatedMeta, () => undefined);
456
+ }
376
457
  context.abbreviatedMetaCache.set(cacheKey, cachedPromise);
377
458
  }
378
459
  return cachedPromise;
379
460
  }
461
+ function readSharedMeta(cache, name) {
462
+ if (cache == null)
463
+ return undefined;
464
+ // Prefer the full entry — a `name:full` hit subsumes the abbreviated
465
+ // hit (full meta carries every field the abbreviated form does, plus
466
+ // `time` and per-version trust evidence the trust check needs). The
467
+ // resolver only populates `name:full` when the install ran with
468
+ // `minimumReleaseAge` configured, otherwise the bare `name` key holds
469
+ // the abbreviated form.
470
+ return validateSharedMeta(cache.get(`${name}:full`), name) ??
471
+ validateSharedMeta(cache.get(name), name);
472
+ }
473
+ function readSharedMetaForTrust(cache, name) {
474
+ if (cache == null)
475
+ return undefined;
476
+ // Abbreviated meta is rejected for the trust check — it lacks
477
+ // per-version `time` and per-version trust evidence.
478
+ return validateSharedMeta(cache.get(`${name}:full`), name);
479
+ }
480
+ // Defensive guard against the resolver's `name`-only cache key
481
+ // returning something unexpected. The known correctness gap (two
482
+ // registries serving the same package name share one cache slot)
483
+ // is inherited from the resolver itself — see the `pickPackage.ts`
484
+ // cacheKey shape; the verifier can't be stricter than the resolver
485
+ // without changing both. This name check at least catches accidental
486
+ // returns of a different package (cache corruption, factory misuse)
487
+ // rather than silently feeding wrong data to the trust / age check.
488
+ function validateSharedMeta(meta, name) {
489
+ if (meta == null)
490
+ return undefined;
491
+ if (meta.name !== name)
492
+ return undefined;
493
+ return meta;
494
+ }
495
+ // Project the abbreviated packument down to the two fields the verifier
496
+ // actually reads — package-level `modified` and the set of version names
497
+ // for the existence check inside `tryAbbreviatedModifiedShortcut`. The
498
+ // resolver populates the abbreviated mirror with every version's
499
+ // dependency / engine / dist info, which can run to hundreds of KB per
500
+ // package and accumulate to many GB across a multi-thousand-entry
501
+ // lockfile (see #11860). The full document is GC-able as soon as this
502
+ // closure returns.
503
+ function projectAbbreviatedMeta(meta) {
504
+ return {
505
+ modified: meta.modified,
506
+ versionNames: meta.versions ? new Set(Object.keys(meta.versions)) : undefined,
507
+ };
508
+ }
380
509
  function readLocalMetaTime(context, registry, name) {
381
510
  if (!context.cacheDir)
382
511
  return Promise.resolve(undefined);
package/lib/index.d.ts CHANGED
@@ -118,3 +118,11 @@ export type ResolveFromNpmOptions = {
118
118
  projectDir: string;
119
119
  workspacePackages: WorkspacePackages;
120
120
  });
121
+ /**
122
+ * Construct the LRU `PackageMetaCache` instance the resolver uses by
123
+ * default. Exported so the install layer can build one cache and hand
124
+ * the same reference to both the resolver and the verifier — the
125
+ * verifier's fast path reads from it when the resolver has already
126
+ * fetched a packument during the same install.
127
+ */
128
+ export declare function createDefaultPackageMetaCache(): PackageMetaCache;
package/lib/index.js CHANGED
@@ -76,10 +76,15 @@ export function createNpmResolver(fetchFromRegistry, getAuthHeader, opts) {
76
76
  const fetch = pMemoize(fetchMetadataFromFromRegistry.bind(null, fetchOpts), {
77
77
  cacheKey: (...args) => JSON.stringify(args),
78
78
  });
79
- const metaCache = opts.metaCache ?? new LRUCache({
80
- max: 10000,
81
- ttl: 120 * 1000, // 2 minutes
82
- });
79
+ // Track ownership so `clearCache()` below only wipes the in-memory
80
+ // cache when this factory created it. A caller-supplied
81
+ // `opts.metaCache` may be shared with another resolver instance (or
82
+ // outlive this resolver entirely — e.g. a long-lived agent process
83
+ // that keeps one cache across many install requests); clearing it
84
+ // here would silently evict entries that other consumers are still
85
+ // using.
86
+ const ownsMetaCache = opts.metaCache == null;
87
+ const metaCache = opts.metaCache ?? createDefaultPackageMetaCache();
83
88
  // Create peek function if storeDir is provided
84
89
  const storeDir = opts.storeDir;
85
90
  const peekLockerForPeek = new Map();
@@ -137,7 +142,7 @@ export function createNpmResolver(fetchFromRegistry, getAuthHeader, opts) {
137
142
  resolveLatestFromJsr: createResolveLatest(boundResolveFromJsr, isJsrSpec),
138
143
  resolveLatestFromNamedRegistry: createResolveLatest(boundResolveFromNamedRegistry, (query) => isNamedRegistrySpec(query, ctx.namedRegistryNames)),
139
144
  clearCache: () => {
140
- if ('clear' in metaCache && typeof metaCache.clear === 'function') {
145
+ if (ownsMetaCache && 'clear' in metaCache && typeof metaCache.clear === 'function') {
141
146
  metaCache.clear();
142
147
  }
143
148
  pMemoizeClear(fetch);
@@ -730,4 +735,17 @@ function createVersionSpec(version, pinnedVersion) {
730
735
  throw new PnpmError('BAD_PINNED_VERSION', `Cannot pin '${pinnedVersion ?? 'undefined'}'`);
731
736
  }
732
737
  }
738
+ /**
739
+ * Construct the LRU `PackageMetaCache` instance the resolver uses by
740
+ * default. Exported so the install layer can build one cache and hand
741
+ * the same reference to both the resolver and the verifier — the
742
+ * verifier's fast path reads from it when the resolver has already
743
+ * fetched a packument during the same install.
744
+ */
745
+ export function createDefaultPackageMetaCache() {
746
+ return new LRUCache({
747
+ max: 10000,
748
+ ttl: 120 * 1000, // 2 minutes
749
+ });
750
+ }
733
751
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/resolving.npm-resolver",
3
- "version": "1101.3.1",
3
+ "version": "1101.3.2",
4
4
  "description": "Resolver for npm-hosted packages",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -41,22 +41,22 @@
41
41
  "ssri": "13.0.1",
42
42
  "version-selector-type": "^3.0.0",
43
43
  "@pnpm/config.version-policy": "1100.1.1",
44
- "@pnpm/config.pick-registry-for-package": "1100.0.5",
45
- "@pnpm/constants": "1100.0.0",
46
44
  "@pnpm/core-loggers": "1100.1.1",
47
45
  "@pnpm/crypto.hash": "1100.0.1",
48
- "@pnpm/error": "1100.0.0",
49
46
  "@pnpm/fetching.types": "1100.0.1",
50
- "@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
47
+ "@pnpm/constants": "1100.0.0",
51
48
  "@pnpm/fs.graceful-fs": "1100.1.0",
52
- "@pnpm/resolving.registry.types": "1100.0.4",
49
+ "@pnpm/error": "1100.0.0",
50
+ "@pnpm/config.pick-registry-for-package": "1100.0.5",
53
51
  "@pnpm/resolving.registry.pkg-metadata-filter": "1100.0.4",
52
+ "@pnpm/resolving.registry.types": "1100.0.4",
54
53
  "@pnpm/resolving.resolver-base": "1100.3.0",
55
- "@pnpm/store.index": "1100.1.0",
56
- "@pnpm/types": "1101.1.1",
57
- "@pnpm/workspace.range-resolver": "1100.0.1",
54
+ "@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
58
55
  "@pnpm/store.cafs": "1100.1.6",
59
- "@pnpm/workspace.spec-parser": "1100.0.0"
56
+ "@pnpm/workspace.range-resolver": "1100.0.1",
57
+ "@pnpm/workspace.spec-parser": "1100.0.0",
58
+ "@pnpm/types": "1101.1.1",
59
+ "@pnpm/store.index": "1100.1.0"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@pnpm/logger": ">=1001.0.0 <1002.0.0",
@@ -70,11 +70,11 @@
70
70
  "@types/ssri": "^7.1.5",
71
71
  "load-json-file": "^7.0.1",
72
72
  "tempy": "3.0.0",
73
- "@pnpm/network.fetch": "1100.0.6",
74
73
  "@pnpm/logger": "1100.0.0",
75
- "@pnpm/resolving.npm-resolver": "1101.3.1",
76
- "@pnpm/testing.mock-agent": "1100.0.6",
77
- "@pnpm/test-fixtures": "1100.0.0"
74
+ "@pnpm/network.fetch": "1100.0.6",
75
+ "@pnpm/resolving.npm-resolver": "1101.3.2",
76
+ "@pnpm/test-fixtures": "1100.0.0",
77
+ "@pnpm/testing.mock-agent": "1100.0.6"
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=22.13"