@vltpkg/package-info 1.0.2 → 1.0.3

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/dist/index.d.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { PackageJson } from '@vltpkg/package-json';
2
2
  import type { PickManifestOptions } from '@vltpkg/pick-manifest';
3
- import type { RegistryClientOptions, RegistryClientRequestOptions } from '@vltpkg/registry-client';
4
- import { RegistryClient } from '@vltpkg/registry-client';
3
+ import type { RegistryClient, RegistryClientOptions, RegistryClientRequestOptions } from '@vltpkg/registry-client';
5
4
  import type { SpecOptions } from '@vltpkg/spec';
6
5
  import { Spec } from '@vltpkg/spec';
7
- import { Pool } from '@vltpkg/tar';
6
+ import type { Pool } from '@vltpkg/tar';
8
7
  import type { Integrity, Manifest, Packument } from '@vltpkg/types';
9
8
  import { Monorepo } from '@vltpkg/workspaces';
10
9
  export declare const delimiter = "~";
@@ -45,8 +44,8 @@ export declare class PackageInfoClient {
45
44
  options: PackageInfoClientOptions;
46
45
  packageJson: PackageJson;
47
46
  monorepo?: Monorepo;
48
- get registryClient(): RegistryClient;
49
- get tarPool(): Pool;
47
+ getRegistryClient(): Promise<RegistryClient>;
48
+ getTarPool(): Promise<Pool>;
50
49
  constructor(options?: PackageInfoClientOptions);
51
50
  extract(spec: Spec | string, target: string, options?: PackageInfoClientExtractOptions): Promise<Resolution>;
52
51
  /**
package/dist/index.js CHANGED
@@ -2,9 +2,7 @@ import { error } from '@vltpkg/error-cause';
2
2
  import { clone, resolve as gitResolve, revs } from '@vltpkg/git';
3
3
  import { PackageJson } from '@vltpkg/package-json';
4
4
  import { pickManifest } from '@vltpkg/pick-manifest';
5
- import { RegistryClient } from '@vltpkg/registry-client';
6
5
  import { Spec } from '@vltpkg/spec';
7
- import { Pool } from '@vltpkg/tar';
8
6
  import { asPackument } from '@vltpkg/types';
9
7
  import ssri from 'ssri';
10
8
  import { Monorepo } from '@vltpkg/workspaces';
@@ -36,17 +34,39 @@ export class PackageInfoClient {
36
34
  #trustedIntegrities = new Map();
37
35
  #manifestCacheMinAge = Date.now() - manifestCacheMaxAge;
38
36
  #cachePath;
37
+ // In-flight coalescing key is `${registry}${name}` — no representation
38
+ // component. Safe only because every caller requests the same full
39
+ // packument (see #fetchPackument).
39
40
  #packumentPromises = new Map();
40
- get registryClient() {
41
- if (!this.#registryClient) {
42
- this.#registryClient = new RegistryClient(this.options);
43
- }
44
- return this.#registryClient;
41
+ // unique temp file names for atomic manifest cache writes
42
+ #manifestWriteRandom = randomBytes(6).toString('hex');
43
+ #manifestWriteCount = 0;
44
+ // cache paths already written this run. manifest() is called many
45
+ // times for the same cache key per install and content for a given
46
+ // path is deterministic within a run, so only the first write is
47
+ // needed. entries are removed when the on-disk file is invalidated
48
+ // so the refreshed manifest can be written again.
49
+ #manifestWritePaths = new Set();
50
+ #registryClientPromise;
51
+ #tarPoolPromise;
52
+ async getRegistryClient() {
53
+ if (this.#registryClient)
54
+ return this.#registryClient;
55
+ this.#registryClientPromise ??=
56
+ import('@vltpkg/registry-client').then(({ RegistryClient }) => {
57
+ this.#registryClient = new RegistryClient(this.options);
58
+ return this.#registryClient;
59
+ });
60
+ return this.#registryClientPromise;
45
61
  }
46
- get tarPool() {
47
- if (!this.#tarPool)
62
+ async getTarPool() {
63
+ if (this.#tarPool)
64
+ return this.#tarPool;
65
+ this.#tarPoolPromise ??= import('@vltpkg/tar').then(({ Pool }) => {
48
66
  this.#tarPool = new Pool();
49
- return this.#tarPool;
67
+ return this.#tarPool;
68
+ });
69
+ return this.#tarPoolPromise;
50
70
  }
51
71
  constructor(options = {}) {
52
72
  this.options = options;
@@ -111,7 +131,7 @@ export class PackageInfoClient {
111
131
  case 'registry': {
112
132
  const fetchTarball = async (useCache) => {
113
133
  const trustIntegrity = this.#trustedIntegrities.get(r.resolved) === r.integrity;
114
- const response = await this.registryClient.request(r.resolved, {
134
+ const response = await (await this.getRegistryClient()).request(r.resolved, {
115
135
  integrity: r.integrity,
116
136
  trustIntegrity,
117
137
  ...(useCache === false ? { useCache } : {}),
@@ -131,20 +151,17 @@ export class PackageInfoClient {
131
151
  this.#trustedIntegrities.set(r.resolved, response.integrity);
132
152
  }
133
153
  const buf = response.buffer();
134
- // Verify tarball integrity against the manifest's
135
- // dist.integrity. This is a supply-chain security measure:
136
- // the registry may not validate integrity, so we do it
137
- // client-side on every fresh download. Skip when integrity
138
- // came from lockfile/cache (it was already verified on
139
- // first install).
140
- /* c8 ignore start - defense-in-depth: registry client's
141
- * checkIntegrity() usually catches mismatches first since
142
- * it checks the same gzip bytes. This fires only when the
143
- * registry client check is skipped (trustIntegrity=true). */
144
- if (r.integrity && !fromLockfile) {
154
+ // Verify network-delivered tarball bytes against dist.integrity.
155
+ // Skip cache-served bodies: they were verified on the fetch that
156
+ // populated the cache, and cache-unzip rewrites them un-gzipped
157
+ // so the gzip-hash can never match. Skip lockfile-sourced
158
+ // integrity: it was verified on first install.
159
+ if (r.integrity && !fromLockfile && !response.fromCache) {
145
160
  const hash = createHash('sha512');
146
161
  hash.update(buf);
147
162
  const computed = `sha512-${hash.digest('base64')}`;
163
+ /* c8 ignore start - defense-in-depth: registry client's
164
+ * checkIntegrity() usually catches mismatches first. */
148
165
  if (computed !== r.integrity) {
149
166
  throw error('Tarball integrity check failed', {
150
167
  code: 'EINTEGRITY',
@@ -154,8 +171,8 @@ export class PackageInfoClient {
154
171
  found: computed,
155
172
  });
156
173
  }
174
+ /* c8 ignore stop */
157
175
  }
158
- /* c8 ignore stop */
159
176
  return buf;
160
177
  };
161
178
  let buf;
@@ -178,7 +195,7 @@ export class PackageInfoClient {
178
195
  }
179
196
  }
180
197
  try {
181
- await this.tarPool.unpack(buf, target);
198
+ await (await this.getTarPool()).unpack(buf, target);
182
199
  }
183
200
  catch (er) {
184
201
  throw this.#resolveError(spec, options, 'tar unpack failed', { cause: er });
@@ -186,7 +203,7 @@ export class PackageInfoClient {
186
203
  return r;
187
204
  }
188
205
  case 'remote': {
189
- const response = await this.registryClient.request(r.resolved);
206
+ const response = await (await this.getRegistryClient()).request(r.resolved);
190
207
  if (response.statusCode !== 200) {
191
208
  throw this.#resolveError(spec, options, 'failed to fetch remote tarball', {
192
209
  url: r.resolved,
@@ -209,7 +226,7 @@ export class PackageInfoClient {
209
226
  }
210
227
  r.integrity = computed;
211
228
  try {
212
- await this.tarPool.unpack(buf, target);
229
+ await (await this.getTarPool()).unpack(buf, target);
213
230
  }
214
231
  catch (er) {
215
232
  throw this.#resolveError(spec, options, 'remote tar unpack failed', { cause: er });
@@ -227,7 +244,7 @@ export class PackageInfoClient {
227
244
  const st = await stat(path);
228
245
  if (st.isFile()) {
229
246
  try {
230
- await this.tarPool.unpack(await this.tarball(spec, options), target);
247
+ await (await this.getTarPool()).unpack(await this.tarball(spec, options), target);
231
248
  }
232
249
  catch (er) {
233
250
  throw this.#resolveError(spec, options, 'tar unpack failed', { cause: er });
@@ -304,6 +321,35 @@ export class PackageInfoClient {
304
321
  const key = this.#manifestCacheKey(f, options);
305
322
  return pathResolve(this.#cachePath, 'package-info', key);
306
323
  }
324
+ /**
325
+ * Write a manifest cache file atomically (write to a temp file in
326
+ * the same directory, then rename over the destination) so that
327
+ * concurrent readers never observe a partially written manifest.
328
+ * Freshness is tracked via the resulting file's mtime.
329
+ */
330
+ async #writeManifestCache(cachePath, json) {
331
+ const tmp = `${cachePath}.${this.#manifestWriteRandom}.${this.#manifestWriteCount++}`;
332
+ try {
333
+ try {
334
+ await writeFile(tmp, json, 'utf8');
335
+ }
336
+ catch (err) {
337
+ // in case the cache directory doesn't exist
338
+ // just create it and retry
339
+ if (!(err instanceof Error &&
340
+ 'code' in err &&
341
+ err.code === 'ENOENT')) {
342
+ throw err;
343
+ }
344
+ await mkdir(dirname(cachePath), { recursive: true });
345
+ await writeFile(tmp, json, 'utf8');
346
+ }
347
+ await rename(tmp, cachePath);
348
+ }
349
+ catch {
350
+ // failing to write the manifest cache is not an install failure
351
+ }
352
+ }
307
353
  async tarball(spec, options = {}) {
308
354
  if (typeof spec === 'string')
309
355
  spec = Spec.parse(spec, this.options);
@@ -323,7 +369,7 @@ export class PackageInfoClient {
323
369
  }
324
370
  const fetchTarball = async (useCache) => {
325
371
  const trustIntegrity = this.#trustedIntegrities.get(tarball) === integrity;
326
- const response = await this.registryClient.request(tarball, {
372
+ const response = await (await this.getRegistryClient()).request(tarball, {
327
373
  ...options,
328
374
  integrity,
329
375
  trustIntegrity,
@@ -342,13 +388,12 @@ export class PackageInfoClient {
342
388
  this.#trustedIntegrities.set(tarball, response.integrity);
343
389
  }
344
390
  const buf = response.buffer();
345
- // Verify tarball integrity against the manifest's
346
- // dist.integrity.
347
- /* c8 ignore start - defense-in-depth (see extract) */
348
- if (integrity) {
391
+ // Same as extract(): only hash network-delivered bodies.
392
+ if (integrity && !response.fromCache) {
349
393
  const hash = createHash('sha512');
350
394
  hash.update(buf);
351
395
  const computed = `sha512-${hash.digest('base64')}`;
396
+ /* c8 ignore start - defense-in-depth (see extract) */
352
397
  if (computed !== integrity) {
353
398
  throw error('Tarball integrity check failed', {
354
399
  code: 'EINTEGRITY',
@@ -358,8 +403,8 @@ export class PackageInfoClient {
358
403
  found: computed,
359
404
  });
360
405
  }
406
+ /* c8 ignore stop */
361
407
  }
362
- /* c8 ignore stop */
363
408
  return buf;
364
409
  };
365
410
  try {
@@ -409,7 +454,7 @@ export class PackageInfoClient {
409
454
  if (!remoteURL) {
410
455
  throw this.#resolveError(spec, options);
411
456
  }
412
- const response = await this.registryClient.request(remoteURL);
457
+ const response = await (await this.getRegistryClient()).request(remoteURL);
413
458
  if (response.statusCode !== 200) {
414
459
  throw this.#resolveError(spec, options, 'failed to fetch URL', { response, url: remoteURL });
415
460
  }
@@ -450,15 +495,21 @@ export class PackageInfoClient {
450
495
  const cachePath = this._manifestCachePath(spec, options);
451
496
  if (cachePath) {
452
497
  try {
453
- // Cache file exists, read and return it
454
- const cached = await readFile(cachePath, 'utf8');
498
+ // Cache file exists, read and return it. Freshness is
499
+ // tracked via the file's mtime, so the file content is
500
+ // exactly the manifest and can be returned as parsed.
501
+ const [st, cached] = await Promise.all([
502
+ stat(cachePath),
503
+ readFile(cachePath, 'utf8'),
504
+ ]);
455
505
  const json = JSON.parse(cached);
456
- // retrieve timestamp to check if cache is still valid
457
- const timestamp = json.__VLT_MANIFEST_CACHE_TIMESTAMP;
458
- delete json.__VLT_MANIFEST_CACHE_TIMESTAMP;
459
- // removes the cache file if older than its maximum age
460
- if (timestamp != null &&
461
- timestamp < this.#manifestCacheMinAge) {
506
+ // removes the cache file if older than its maximum age.
507
+ // entries written by older clients embed a timestamp in
508
+ // the manifest itself; treat those as expired so they get
509
+ // rewritten in the mtime-tracked format.
510
+ if (st.mtimeMs < this.#manifestCacheMinAge ||
511
+ json.__VLT_MANIFEST_CACHE_TIMESTAMP !== undefined) {
512
+ this.#manifestWritePaths.delete(cachePath);
462
513
  void unlink(cachePath).catch(() => { });
463
514
  throw new Error('manifest cache expired');
464
515
  }
@@ -471,27 +522,12 @@ export class PackageInfoClient {
471
522
  const mani = pickManifest(await this.packument(f, options), spec, options);
472
523
  if (!mani)
473
524
  throw this.#resolveError(spec, options);
474
- // Cache the manifest data
475
- if (cachePath) {
476
- const json = JSON.stringify({
477
- ...mani,
478
- // append a timestamp to the manifest so that we can quickly
479
- // check if the cache is still valid when loading it
480
- __VLT_MANIFEST_CACHE_TIMESTAMP: Date.now(),
481
- });
482
- void writeFile(cachePath, json, 'utf8').catch((err) => {
483
- // in case the cache directory doesn't exist
484
- // just create it and retry
485
- if (err instanceof Error &&
486
- 'code' in err &&
487
- err.code === 'ENOENT') {
488
- void mkdir(dirname(cachePath), {
489
- recursive: true,
490
- }).then(() => {
491
- void writeFile(cachePath, json, 'utf8');
492
- });
493
- }
494
- });
525
+ // Cache the manifest data. Skip paths already written this
526
+ // run — first writer wins, avoiding duplicate serialization
527
+ // and racing writers for the same path.
528
+ if (cachePath && !this.#manifestWritePaths.has(cachePath)) {
529
+ this.#manifestWritePaths.add(cachePath);
530
+ void this.#writeManifestCache(cachePath, JSON.stringify(mani));
495
531
  }
496
532
  return mani;
497
533
  }
@@ -517,7 +553,7 @@ export class PackageInfoClient {
517
553
  }
518
554
  const s = spec;
519
555
  return await this.#tmpdir(async (dir) => {
520
- const response = await this.registryClient.request(remoteURL);
556
+ const response = await (await this.getRegistryClient()).request(remoteURL);
521
557
  if (response.statusCode !== 200) {
522
558
  throw this.#resolveError(s, options, 'failed to fetch URL', { response, url: remoteURL });
523
559
  }
@@ -527,7 +563,7 @@ export class PackageInfoClient {
527
563
  .fromData(buf, { algorithms: ['sha512'] })
528
564
  .toString();
529
565
  try {
530
- await this.tarPool.unpack(buf, dir);
566
+ await (await this.getTarPool()).unpack(buf, dir);
531
567
  }
532
568
  catch (er) {
533
569
  throw this.#resolveError(s, options, 'tar unpack failed', { cause: er });
@@ -550,7 +586,7 @@ export class PackageInfoClient {
550
586
  const s = spec;
551
587
  return await this.#tmpdir(async (dir) => {
552
588
  try {
553
- await this.tarPool.unpack(await readFile(path), dir);
589
+ await (await this.getTarPool()).unpack(await readFile(path), dir);
554
590
  }
555
591
  catch (er) {
556
592
  throw this.#resolveError(s, options, 'tar unpack failed', { cause: er });
@@ -600,6 +636,7 @@ export class PackageInfoClient {
600
636
  const { registry, name } = f;
601
637
  if (!registry)
602
638
  throw noRegistryError(spec);
639
+ // Coalescing key has no representation component (see #fetchPackument).
603
640
  const packumentKey = `${registry}${name}`;
604
641
  const inflight = this.#packumentPromises.get(packumentKey);
605
642
  if (inflight)
@@ -615,9 +652,35 @@ export class PackageInfoClient {
615
652
  }
616
653
  }
617
654
  }
618
- async #fetchPackument(spec, options, pakuURL) {
619
- const response = await this.registryClient.request(pakuURL, {
655
+ async #fetchPackument(spec, options, pakuURL, useCache) {
656
+ // Always request the full packument (`application/json`), never the
657
+ // abbreviated "corgi" form:
658
+ // accept: application/vnd.npm.install-v1+json; q=1.0,
659
+ // application/json; q=0.8, */*
660
+ //
661
+ // Corgi is smaller (~−41% packument bytes, est. 1–3s on clean installs)
662
+ // but is disabled for two load-bearing reasons:
663
+ //
664
+ // 1. Metadata loss reaches the lockfile. The version entry returned
665
+ // here is `node.manifest` and is persisted to vlt-lock.json / the
666
+ // hidden lockfile. Corgi drops `license` (also `time`, `readme`,
667
+ // `maintainers`, `_rev`, `scripts`), so graph queries like
668
+ // `[license=MPL-2.0]` cannot match a field that was never stored.
669
+ // Shipped in 1.0.0-rc.33 via #1692 (8ba2b10c); 24 false-positive
670
+ // edges in the dependency-check job on #1704
671
+ // (`@resvg/resvg-wasm@2.6.2` MPL-2.0 in package.json, blank in the
672
+ // stored graph); isolated in #1705; reverted in #1707.
673
+ //
674
+ // 2. The RegistryClient disk cache key is method + URL only — no
675
+ // accept, no Vary — so a full-packument request can be served a
676
+ // cached abbreviated body (and vice versa).
677
+ //
678
+ // To revisit: put the requested representation on both the disk-cache
679
+ // key and the in-flight coalescing key, and have the SWR child
680
+ // (cache-revalidate / revalidate) re-request the same representation.
681
+ const response = await (await this.getRegistryClient()).request(pakuURL, {
620
682
  headers: { accept: 'application/json' },
683
+ ...(useCache === false ? { useCache } : {}),
621
684
  });
622
685
  if (response.statusCode !== 200) {
623
686
  throw this.#resolveError(spec, options, 'failed to fetch packument', {
@@ -625,7 +688,15 @@ export class PackageInfoClient {
625
688
  response,
626
689
  });
627
690
  }
628
- return response.json();
691
+ try {
692
+ return response.json();
693
+ }
694
+ catch (er) {
695
+ if (useCache !== false) {
696
+ return this.#fetchPackument(spec, options, pakuURL, false);
697
+ }
698
+ throw er;
699
+ }
629
700
  }
630
701
  async resolve(spec, options = {}) {
631
702
  const memoKey = String(spec);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vltpkg/package-info",
3
3
  "description": "Resolve and fetch package metadata and tarballs",
4
- "version": "1.0.2",
4
+ "version": "1.0.3",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -13,16 +13,16 @@
13
13
  "url": "http://vlt.sh"
14
14
  },
15
15
  "dependencies": {
16
- "@vltpkg/error-cause": "1.0.2",
17
- "@vltpkg/git": "1.0.2",
18
- "@vltpkg/package-json": "1.0.2",
19
- "@vltpkg/pick-manifest": "1.0.2",
20
- "@vltpkg/registry-client": "1.0.2",
21
- "@vltpkg/spec": "1.0.2",
22
- "@vltpkg/tar": "1.0.2",
23
- "@vltpkg/types": "1.0.2",
24
- "@vltpkg/workspaces": "1.0.2",
25
- "@vltpkg/xdg": "1.0.2",
16
+ "@vltpkg/error-cause": "1.0.3",
17
+ "@vltpkg/git": "1.0.3",
18
+ "@vltpkg/package-json": "1.0.3",
19
+ "@vltpkg/pick-manifest": "1.0.3",
20
+ "@vltpkg/registry-client": "1.0.3",
21
+ "@vltpkg/spec": "1.0.3",
22
+ "@vltpkg/tar": "1.0.3",
23
+ "@vltpkg/types": "1.0.3",
24
+ "@vltpkg/workspaces": "1.0.3",
25
+ "@vltpkg/xdg": "1.0.3",
26
26
  "ssri": "^13.0.0",
27
27
  "tar": "^7.5.2"
28
28
  },
@@ -31,8 +31,8 @@
31
31
  "@types/node": "^22.19.2",
32
32
  "@types/pacote": "^11.1.8",
33
33
  "@vltpkg/benchmark": "0.0.0",
34
- "@vltpkg/cache-unzip": "1.0.2",
35
- "@vltpkg/vlt-json": "1.0.2",
34
+ "@vltpkg/cache-unzip": "1.0.3",
35
+ "@vltpkg/vlt-json": "1.0.3",
36
36
  "eslint": "^9.39.1",
37
37
  "pacote": "^21.0.4",
38
38
  "prettier": "^3.7.4",