@vltpkg/package-info 1.1.1 → 1.2.0

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/README.md CHANGED
@@ -36,3 +36,26 @@ const tarballBuffer = await tarball('foo@1.x')
36
36
 
37
37
  const { resolved, integrity } = await resolve('bar@latest')
38
38
  ```
39
+
40
+ ## Global store
41
+
42
+ With the `store-linker` option set to `auto`, `hardlink` or `copy`,
43
+ `extract()` places a registry package from its global store entry
44
+ (`<storeRoot>/<integrity hex>`, `storeRoot` defaults to
45
+ `<cache>/store/v1`) when there is one. On a miss it unpacks the cached
46
+ tarball as usual and queues it for the background child, so the next
47
+ install finds it in the global store. Missing or unknown
48
+ `store-linker` means `unpack`. The vlt CLI defaults to `auto`, which
49
+ it resolves to `unpack` on platforms other than Linux, so `extract()`
50
+ only ever sees `auto` from the CLI on Linux. A gzipped cached tarball
51
+ that is not store-linked (`unpack`, git or remote tarballs, no sha512
52
+ integrity) is queued too, so the child un-gzips it.
53
+
54
+ Packages with install scripts are copied, never linked: when the store
55
+ index says so, or with the `installScripts` extract option.
56
+
57
+ A store link logs its request as `store`, a copy from the store as
58
+ `cache`. Both also return `manifest` (the package.json as JSON text,
59
+ if the index has it) and `bindingGyp`, so reify need not read them
60
+ back from disk. With `NODE_DEBUG=vlt`, linked / copied / missed counts
61
+ and the store hit rate are printed at exit.
@@ -0,0 +1,32 @@
1
+ import type { RegistryClient } from '@vltpkg/registry-client';
2
+ /**
3
+ * What `GET /-/vlt/capabilities` answers: the vlt extensions a registry
4
+ * serves. Every field is optional — a registry that has never heard of the
5
+ * document, or that is down when it is asked, reads as an empty one.
6
+ */
7
+ export type Capabilities = {
8
+ /** contract version of `/-/vlt/manifests`, the batch manifest endpoint */
9
+ manifests?: string;
10
+ /** contract version of `/-/vlt/resolve`, server-side range resolution */
11
+ resolve?: string;
12
+ /** contract version of the `?stable` packument filter */
13
+ 'stable-filter'?: string;
14
+ /** packument media types the registry serves, most preferred first */
15
+ mimeTypes?: string[];
16
+ };
17
+ /** Forget every registry that has been asked. Exposed for tests. */
18
+ export declare const resetCapabilities: () => void;
19
+ /**
20
+ * What `registry` answered, if it has answered already, and `undefined`
21
+ * while the question is still open. Asking starts the request when nothing
22
+ * has asked yet, so a caller can peek now and get an answer on a later call
23
+ * without ever waiting for one.
24
+ */
25
+ export declare const peekCapabilities: (client: RegistryClient, registry: string) => Capabilities | undefined;
26
+ /**
27
+ * The vlt extensions `registry` serves. Anything short of a capability
28
+ * document — a registry that 404s it, a request that fails, a body that
29
+ * does not parse — answers an empty one, so a caller that reads a missing
30
+ * key as unsupported treats those registries as plain npm registries.
31
+ */
32
+ export declare const getCapabilities: (client: RegistryClient, registry: string) => Promise<Capabilities>;
@@ -0,0 +1,93 @@
1
+ /** What a registry with no vlt extensions supports. */
2
+ const none = Object.freeze({});
3
+ /**
4
+ * The document each registry answered, by capabilities URL. This coalesces
5
+ * the concurrent asks a single install makes; what keeps the document off
6
+ * the network is the RegistryClient disk cache behind it.
7
+ */
8
+ const asked = new Map();
9
+ /**
10
+ * The documents that have come back, by capabilities URL. A caller that
11
+ * cannot afford to wait reads this instead of the promise above.
12
+ */
13
+ const known = new Map();
14
+ /** Forget every registry that has been asked. Exposed for tests. */
15
+ export const resetCapabilities = () => {
16
+ asked.clear();
17
+ known.clear();
18
+ };
19
+ /**
20
+ * What `registry` answered, if it has answered already, and `undefined`
21
+ * while the question is still open. Asking starts the request when nothing
22
+ * has asked yet, so a caller can peek now and get an answer on a later call
23
+ * without ever waiting for one.
24
+ */
25
+ export const peekCapabilities = (client, registry) => {
26
+ const url = capabilitiesUrl(registry);
27
+ const seen = known.get(url);
28
+ if (seen)
29
+ return seen;
30
+ void getCapabilities(client, registry);
31
+ return undefined;
32
+ };
33
+ /**
34
+ * The vlt extensions `registry` serves. Anything short of a capability
35
+ * document — a registry that 404s it, a request that fails, a body that
36
+ * does not parse — answers an empty one, so a caller that reads a missing
37
+ * key as unsupported treats those registries as plain npm registries.
38
+ */
39
+ export const getCapabilities = async (client, registry) => {
40
+ const url = capabilitiesUrl(registry);
41
+ const seen = asked.get(url);
42
+ if (seen)
43
+ return seen;
44
+ const doc = fetchCapabilities(client, url).then(caps => {
45
+ known.set(url, caps);
46
+ return caps;
47
+ });
48
+ asked.set(url, doc);
49
+ return doc;
50
+ };
51
+ const capabilitiesUrl = (registry) => String(new URL('-/vlt/capabilities', registry));
52
+ const fetchCapabilities = async (client, url) => {
53
+ try {
54
+ // No `useCache: false`: the registry serves this with a day of
55
+ // max-age, so the RegistryClient disk cache answers it for the rest
56
+ // of the day, including from later processes. One GET per registry
57
+ // per day, not one per install.
58
+ const response = await client.request(url, {
59
+ headers: { accept: 'application/json' },
60
+ });
61
+ if (response.statusCode !== 200)
62
+ return none;
63
+ return asCapabilities(response.json());
64
+ }
65
+ catch {
66
+ return none;
67
+ }
68
+ };
69
+ /**
70
+ * Read a parsed body as a capability document, dropping any field that did
71
+ * not arrive in the shape the contract gives it.
72
+ */
73
+ const asCapabilities = (body) => {
74
+ if (typeof body !== 'object' || body === null)
75
+ return none;
76
+ const doc = body;
77
+ const caps = {};
78
+ for (const key of [
79
+ 'manifests',
80
+ 'resolve',
81
+ 'stable-filter',
82
+ ]) {
83
+ const version = doc[key];
84
+ if (typeof version === 'string')
85
+ caps[key] = version;
86
+ }
87
+ const { mimeTypes } = doc;
88
+ if (Array.isArray(mimeTypes) &&
89
+ mimeTypes.every(type => typeof type === 'string')) {
90
+ caps.mimeTypes = mimeTypes;
91
+ }
92
+ return caps;
93
+ };
package/dist/index.d.ts CHANGED
@@ -3,10 +3,20 @@ import type { PickManifestOptions } from '@vltpkg/pick-manifest';
3
3
  import type { RegistryClient, RegistryClientOptions, RegistryClientRequestOptions } from '@vltpkg/registry-client';
4
4
  import type { SpecOptions } from '@vltpkg/spec';
5
5
  import { Spec } from '@vltpkg/spec';
6
- import type { Pool } from '@vltpkg/tar';
6
+ import type { Pool, StoreLinker } from '@vltpkg/tar';
7
7
  import type { Integrity, Manifest, Packument } from '@vltpkg/types';
8
8
  import { Monorepo } from '@vltpkg/workspaces';
9
+ import type { Capabilities } from './capabilities.ts';
10
+ export type { Capabilities } from './capabilities.ts';
11
+ export { getCapabilities, peekCapabilities, resetCapabilities, } from './capabilities.ts';
9
12
  export declare const delimiter = "~";
13
+ /**
14
+ * vlt's abbreviated packument: corgi plus the fields the graph relies on
15
+ * (`license`, `time`, `hasInstallScript`), minus `dist.integrity` (the
16
+ * tarball response carries a `Repr-Digest` instead) and with
17
+ * `dist.tarball` relative to the registry base.
18
+ */
19
+ export declare const VLT_PACKUMENT_MIME = "application/vnd.vlt.packument-v1+json";
10
20
  /**
11
21
  * Accept header for packument requests. Prefers vlt's abbreviated
12
22
  * packument and falls back to the full one on registries that do not
@@ -26,6 +36,21 @@ export type Resolution = {
26
36
  integrity?: Integrity;
27
37
  signatures?: Exclude<Manifest['dist'], undefined>['signatures'];
28
38
  spec: Spec;
39
+ /**
40
+ * The manifest came from a vlt packument, which carries no
41
+ * `dist.integrity`: the tarball response must carry a `Repr-Digest`.
42
+ */
43
+ digestRequired?: boolean;
44
+ };
45
+ /**
46
+ * {@link PackageInfoClient.extract} result. A global store link also
47
+ * carries what its index knows, so reify need not read it from disk.
48
+ */
49
+ export type ExtractResolution = Resolution & {
50
+ /** the package.json as JSON text, if the index has it */
51
+ manifest?: string;
52
+ /** true if the package has a root binding.gyp */
53
+ bindingGyp?: boolean;
29
54
  };
30
55
  export type PackageInfoClientOptions = RegistryClientOptions & SpecOptions & {
31
56
  /** root of the project. Defaults to process.cwd() */
@@ -37,10 +62,21 @@ export type PackageInfoClientOptions = RegistryClientOptions & SpecOptions & {
37
62
  'workspace-group'?: string[];
38
63
  /** workspace paths to load, irrelevant if Monorepo provided */
39
64
  workspace?: string[];
65
+ /**
66
+ * How registry packages are placed: `unpack` (default) unpacks the
67
+ * tarball, anything else goes through the global store first.
68
+ */
69
+ 'store-linker'?: StoreLinker;
40
70
  };
41
71
  export type PackageInfoClientRequestOptions = PickManifestOptions & RegistryClientRequestOptions & {
42
72
  /** dir to resolve `file://` specifiers against. Defaults to projectRoot. */
43
73
  from?: string;
74
+ /**
75
+ * Fetch the full packument (readme, maintainers, `dist.integrity`)
76
+ * rather than the abbreviated one. Bypasses the disk cache, which is
77
+ * keyed by URL alone, so the two representations never mix.
78
+ */
79
+ full?: boolean;
44
80
  };
45
81
  export type PackageInfoClientExtractOptions = PackageInfoClientRequestOptions & {
46
82
  integrity?: Integrity;
@@ -52,6 +88,11 @@ export type PackageInfoClientExtractOptions = PackageInfoClientRequestOptions &
52
88
  * Defaults to false — fresh installs always verify integrity.
53
89
  */
54
90
  fromLockfile?: boolean;
91
+ /**
92
+ * The manifest declares install scripts: copy the package from the
93
+ * global store, never link it, even if its package.json has none.
94
+ */
95
+ installScripts?: boolean;
55
96
  };
56
97
  export declare class PackageInfoClient {
57
98
  #private;
@@ -59,9 +100,15 @@ export declare class PackageInfoClient {
59
100
  packageJson: PackageJson;
60
101
  monorepo?: Monorepo;
61
102
  getRegistryClient(): Promise<RegistryClient>;
103
+ /**
104
+ * The vlt extensions `registry` serves, from its
105
+ * `GET /-/vlt/capabilities` document. A registry that does not answer
106
+ * one reads as an empty document, so a missing key means unsupported.
107
+ */
108
+ capabilities(registry: string): Promise<Capabilities>;
62
109
  getTarPool(): Promise<Pool>;
63
110
  constructor(options?: PackageInfoClientOptions);
64
- extract(spec: Spec | string, target: string, options?: PackageInfoClientExtractOptions): Promise<Resolution>;
111
+ extract(spec: Spec | string, target: string, options?: PackageInfoClientExtractOptions): Promise<ExtractResolution>;
65
112
  /**
66
113
  * Conditionally return the path to the manifest cache file. The logic
67
114
  * to determine if caching should be skipped aligns with `pickManifest`
@@ -70,6 +117,11 @@ export declare class PackageInfoClient {
70
117
  _manifestCachePath(spec: Spec, options: PackageInfoClientRequestOptions): string | undefined;
71
118
  tarball(spec: Spec | string, options?: PackageInfoClientExtractOptions): Promise<Buffer>;
72
119
  manifest(spec: Spec | string, options?: PackageInfoClientRequestOptions): Promise<Manifest | import("@vltpkg/types").Override<Manifest, import("@vltpkg/types").NormalizedFields>>;
120
+ /**
121
+ * The packument for `spec`, with every version the registry has. Callers
122
+ * that only pick a manifest out of it go through `#packument` instead,
123
+ * which can ask for the prerelease-free one.
124
+ */
73
125
  packument(spec: Spec | string, options?: PackageInfoClientRequestOptions): Promise<Packument>;
74
126
  resolve(spec: Spec | string, options?: PackageInfoClientRequestOptions): Promise<Resolution>;
75
127
  }
package/dist/index.js CHANGED
@@ -5,9 +5,10 @@ import { PackageJson } from '@vltpkg/package-json';
5
5
  import { pickManifest } from '@vltpkg/pick-manifest';
6
6
  // subpath import: keeps the lazy `import('@vltpkg/registry-client')`
7
7
  // below from becoming an eager dependency on the whole client.
8
- import { registryErrorMessage } from '@vltpkg/registry-client/registry-error';
8
+ import { registryErrorMessage, tokenRefusalAdvice, } from '@vltpkg/registry-client/registry-error';
9
+ import { storeRoot } from '@vltpkg/registry-client/store-root';
9
10
  import { Spec } from '@vltpkg/spec';
10
- import { asPackument } from '@vltpkg/types';
11
+ import { asPackument, integrityHex } from '@vltpkg/types';
11
12
  import ssri from 'ssri';
12
13
  import { Monorepo } from '@vltpkg/workspaces';
13
14
  import { XDG } from '@vltpkg/xdg';
@@ -16,10 +17,19 @@ import { mkdir, readFile, rm, stat, symlink, unlink, writeFile, } from 'node:fs/
16
17
  import { basename, dirname, resolve as pathResolve, relative, } from 'node:path';
17
18
  import { debuglog } from 'node:util';
18
19
  import { create as tarC } from 'tar';
20
+ import { getCapabilities, peekCapabilities } from "./capabilities.js";
19
21
  import { rename } from "./rename.js";
22
+ export { getCapabilities, peekCapabilities, resetCapabilities, } from "./capabilities.js";
20
23
  const debug = debuglog('vlt');
21
24
  const xdg = new XDG('vlt');
22
25
  export const delimiter = '~';
26
+ /**
27
+ * vlt's abbreviated packument: corgi plus the fields the graph relies on
28
+ * (`license`, `time`, `hasInstallScript`), minus `dist.integrity` (the
29
+ * tarball response carries a `Repr-Digest` instead) and with
30
+ * `dist.tarball` relative to the registry base.
31
+ */
32
+ export const VLT_PACKUMENT_MIME = 'application/vnd.vlt.packument-v1+json';
23
33
  /**
24
34
  * Accept header for packument requests. Prefers vlt's abbreviated
25
35
  * packument and falls back to the full one on registries that do not
@@ -33,7 +43,7 @@ export const delimiter = '~';
33
43
  * only so a registry that rejects what it cannot satisfy exactly still
34
44
  * has something to match.
35
45
  */
36
- export const PACKUMENT_ACCEPT = 'application/vnd.vlt.packument-v1+json; q=1.0, application/json; q=0.8, */*; q=0.1';
46
+ export const PACKUMENT_ACCEPT = `${VLT_PACKUMENT_MIME}; q=1.0, application/json; q=0.8, */*; q=0.1`;
37
47
  // the maximum duration of a manifest cache file
38
48
  const manifestCacheMaxAge = 5 * 60 * 1000;
39
49
  /**
@@ -52,6 +62,29 @@ const noRegistryError = (spec) => error('No registry configured to resolve this
52
62
  * Takes a *final* spec (`spec.final`), same as `pickManifest` sees.
53
63
  */
54
64
  const isMovingSelector = (f) => !!(f.distTag || f.range?.isAny);
65
+ /**
66
+ * A selector that cannot land on a prerelease, and so can be answered from
67
+ * the registry's `?stable` packument.
68
+ *
69
+ * A dist tag has no range, and is out either way: it can point at a
70
+ * prerelease, which the stable packument drops along with the tag. `*` and
71
+ * an empty range are out for the same reason -- they resolve through
72
+ * `latest`. What is left is a range that names no prerelease of its own,
73
+ * which standard semver never matches against one.
74
+ *
75
+ * Takes a *final* spec (`spec.final`), same as `isMovingSelector`.
76
+ */
77
+ const isStableSelector = (f) => {
78
+ const { range } = f;
79
+ if (!range)
80
+ return false;
81
+ // A prerelease comparator always carries a `-`, and so does a hyphen
82
+ // range; reading that as "might be a prerelease" only ever gives up the
83
+ // smaller packument.
84
+ return !range.isAny && !range.raw.includes('-');
85
+ };
86
+ // anything else, eg an unvalidated env value, means `unpack`
87
+ const storeLinkers = new Set(['auto', 'hardlink', 'copy']);
55
88
  export class PackageInfoClient {
56
89
  #registryClient;
57
90
  #projectRoot;
@@ -61,8 +94,20 @@ export class PackageInfoClient {
61
94
  packageJson;
62
95
  monorepo;
63
96
  #trustedIntegrities = new Map();
97
+ // `${registry}${name}` of every packument served as VLT_PACKUMENT_MIME
98
+ #vltPackuments = new Set();
64
99
  #manifestCacheMinAge = Date.now() - manifestCacheMaxAge;
65
100
  #cachePath;
101
+ #storeRoot;
102
+ #storeLinker;
103
+ #storeHits = { link: 0, copy: 0 };
104
+ #storeMisses = 0;
105
+ #storeHitRateLogged = false;
106
+ #logStoreHitRate = () => {
107
+ const { link, copy } = this.#storeHits;
108
+ const n = Math.max(1, link + copy + this.#storeMisses);
109
+ debug('global store: linked=%d copied=%d missed=%d hit rate=%s%%', link, copy, this.#storeMisses, (((link + copy) / n) * 100).toFixed(1));
110
+ };
66
111
  // In-flight coalescing key is `${registry}${name}` — no representation
67
112
  // component. Safe only because every caller requests the same full
68
113
  // packument (see #fetchPackument). The one thing that does vary per
@@ -90,6 +135,40 @@ export class PackageInfoClient {
90
135
  });
91
136
  return this.#registryClientPromise;
92
137
  }
138
+ /**
139
+ * The vlt extensions `registry` serves, from its
140
+ * `GET /-/vlt/capabilities` document. A registry that does not answer
141
+ * one reads as an empty document, so a missing key means unsupported.
142
+ */
143
+ async capabilities(registry) {
144
+ return getCapabilities(await this.getRegistryClient(), registry);
145
+ }
146
+ /**
147
+ * Whether the packument for `f` can be fetched with `?stable`: the
148
+ * selector has to be one a prerelease cannot answer, and the registry
149
+ * must not have told us it does not serve the filter.
150
+ *
151
+ * Never waits on the capability document, which would put a round trip in
152
+ * front of the first packument of every cold install. Until that document
153
+ * arrives the answer is yes: a registry that does not know `?stable`
154
+ * ignores the parameter and serves the full packument, which resolution
155
+ * reads just as well. Once the document does arrive it is authoritative,
156
+ * so a registry that does not serve the filter stops being asked with it.
157
+ */
158
+ #stable(f) {
159
+ const { registry } = f;
160
+ if (!registry || !isStableSelector(f))
161
+ return false;
162
+ const client = this.#registryClient;
163
+ if (!client) {
164
+ // the registry client is built lazily, so the first caller starts it
165
+ // and the document along with it, and asks optimistically meanwhile
166
+ void this.capabilities(registry).catch(() => { });
167
+ return true;
168
+ }
169
+ const caps = peekCapabilities(client, registry);
170
+ return !caps || !!caps['stable-filter'];
171
+ }
93
172
  async getTarPool() {
94
173
  if (this.#tarPool)
95
174
  return this.#tarPool;
@@ -116,6 +195,10 @@ export class PackageInfoClient {
116
195
  packageJson: this.packageJson,
117
196
  });
118
197
  this.#cachePath = options.cache ?? xdg.cache();
198
+ this.#storeRoot = options.storeRoot ?? storeRoot(this.#cachePath);
199
+ const linker = options['store-linker'];
200
+ this.#storeLinker =
201
+ linker && storeLinkers.has(linker) ? linker : 'unpack';
119
202
  // optionally create its cache directory if it doesn't exist
120
203
  void mkdir(pathResolve(this.#cachePath, 'package-info'), {
121
204
  recursive: true,
@@ -124,7 +207,7 @@ export class PackageInfoClient {
124
207
  async extract(spec, target, options = {}) {
125
208
  if (typeof spec === 'string')
126
209
  spec = Spec.parse(spec, this.options);
127
- const { from = this.#projectRoot, integrity, resolved, fromLockfile = false, } = options;
210
+ const { from = this.#projectRoot, integrity, resolved, fromLockfile = false, installScripts = false, } = options;
128
211
  const f = spec.final;
129
212
  // If the caller already provides both integrity and resolved
130
213
  // (from lockfile or prior resolution), skip re-resolving.
@@ -160,16 +243,53 @@ export class PackageInfoClient {
160
243
  // fallthrough if a remote tarball url present
161
244
  }
162
245
  case 'registry': {
246
+ const pool = await this.getTarPool();
247
+ // git tarballs keep the unpack path
248
+ const hex = f.type === 'registry' && this.#storeLinker !== 'unpack' ?
249
+ integrityHex(r.integrity)
250
+ : undefined;
251
+ const copy = this.#storeLinker === 'copy' || installScripts;
252
+ if (hex) {
253
+ if (debug.enabled && !this.#storeHitRateLogged) {
254
+ this.#storeHitRateLogged = true;
255
+ process.once('beforeExit', this.#logStoreHitRate);
256
+ }
257
+ const linked = await pool.linkFromStore(pathResolve(this.#storeRoot, hex), target, { copy });
258
+ if (linked) {
259
+ const { how, index } = linked;
260
+ this.#storeHits[how]++;
261
+ // a copy is no link: report it as a cache hit
262
+ logRequest(r.resolved, how === 'link' ? 'store' : 'cache');
263
+ return {
264
+ ...r,
265
+ manifest: index.manifest,
266
+ // implies scripts, so most packages skip the scan
267
+ bindingGyp: index.scripts &&
268
+ index.files.some(f => f[0] === 'binding.gyp'),
269
+ };
270
+ }
271
+ this.#storeMisses++;
272
+ }
163
273
  // if the tarball is already on disk, unpack it straight from
164
274
  // the cache file: it never has to be held in the client's
165
275
  // in-memory cache. anything unexpected falls through to the
166
276
  // fetch path, which throws its own error if the body is
167
277
  // genuinely bad.
168
- const cached = (await this.getRegistryClient()).cachedBody(r.resolved, { integrity: r.integrity });
278
+ const rc = await this.getRegistryClient();
279
+ const cached = rc.cachedBody(r.resolved, {
280
+ integrity: r.integrity,
281
+ });
169
282
  if (cached) {
170
283
  try {
171
- await (await this.getTarPool()).unpack(cached.body, target);
284
+ await pool.unpack(cached.body, target);
172
285
  logRequest(r.resolved, 'cache');
286
+ r.integrity ??= cached.integrity;
287
+ // a warm install writes nothing to the cache, so queue the
288
+ // store miss here or an existing cache never converges,
289
+ // and a gzipped body with no store link, to unzip it
290
+ if (hex || cached.gzip) {
291
+ rc.queueForStore(cached.key, r.integrity);
292
+ }
173
293
  return r;
174
294
  }
175
295
  catch (er) {
@@ -184,6 +304,7 @@ export class PackageInfoClient {
184
304
  const response = await (await this.getRegistryClient()).request(r.resolved, {
185
305
  integrity: r.integrity,
186
306
  trustIntegrity,
307
+ verifyDigest: r.digestRequired ? 'required' : true,
187
308
  ...(useCache === false ? { useCache } : {}),
188
309
  });
189
310
  if (response.statusCode !== 200) {
@@ -201,27 +322,40 @@ export class PackageInfoClient {
201
322
  this.#trustedIntegrities.set(r.resolved, response.integrity);
202
323
  }
203
324
  const buf = response.buffer();
204
- // Verify network-delivered tarball bytes against dist.integrity.
205
- // Skip cache-served bodies: they were verified on the fetch that
206
- // populated the cache, and cache-unzip rewrites them un-gzipped
207
- // so the gzip-hash can never match. Skip lockfile-sourced
208
- // integrity: it was verified on first install.
209
- if (r.integrity && !fromLockfile && !response.fromCache) {
210
- const hash = createHash('sha512');
211
- hash.update(buf);
212
- const computed = `sha512-${hash.digest('base64')}`;
213
- /* c8 ignore start - defense-in-depth: registry client's
214
- * checkIntegrity() usually catches mismatches first. */
215
- if (computed !== r.integrity) {
216
- throw error('Tarball integrity check failed', {
217
- code: 'EINTEGRITY',
218
- spec,
219
- url: r.resolved,
220
- wanted: r.integrity,
221
- found: computed,
222
- });
325
+ if (r.integrity) {
326
+ // Verify network-delivered tarball bytes against dist.integrity.
327
+ // Skip cache-served bodies: they were verified on the fetch that
328
+ // populated the cache, and cache-unzip rewrites them un-gzipped
329
+ // so the gzip-hash can never match. Skip lockfile-sourced
330
+ // integrity: it was verified on first install.
331
+ if (!fromLockfile && !response.fromCache) {
332
+ const hash = createHash('sha512');
333
+ hash.update(buf);
334
+ const computed = `sha512-${hash.digest('base64')}`;
335
+ /* c8 ignore start - defense-in-depth: registry client's
336
+ * checkIntegrity() usually catches mismatches first. */
337
+ if (computed !== r.integrity) {
338
+ throw error('Tarball integrity check failed', {
339
+ code: 'EINTEGRITY',
340
+ spec,
341
+ url: r.resolved,
342
+ wanted: r.integrity,
343
+ found: computed,
344
+ });
345
+ }
346
+ /* c8 ignore stop */
223
347
  }
224
- /* c8 ignore stop */
348
+ }
349
+ else if (response.fromCache) {
350
+ // the hash the body was stored under
351
+ r.integrity = response.integrity;
352
+ }
353
+ else {
354
+ // no dist.integrity: the registry client checked the body
355
+ // against the digest the registry sent with it (verifyDigest
356
+ // above). hand the hash back so the lockfile pins it from
357
+ // now on
358
+ r.integrity = response.integrityActual;
225
359
  }
226
360
  return buf;
227
361
  };
@@ -421,6 +555,11 @@ export class PackageInfoClient {
421
555
  ...options,
422
556
  integrity,
423
557
  trustIntegrity,
558
+ // no dist.integrity: checked against the digest the registry
559
+ // sent with it instead, before the client caches it
560
+ verifyDigest: this.#vltPackuments.has(`${f.registry}${f.name}`) ?
561
+ 'required'
562
+ : true,
424
563
  ...(useCache === false ? { useCache } : {}),
425
564
  });
426
565
  if (response.statusCode !== 200) {
@@ -545,7 +684,8 @@ export class PackageInfoClient {
545
684
  try {
546
685
  // Cache file exists, read and return it. Freshness is
547
686
  // tracked via the file's mtime, so the file content is
548
- // exactly the manifest and can be returned as parsed.
687
+ // the manifest, plus a marker when it came from a vlt
688
+ // packument, and can be returned as parsed.
549
689
  const [st, cached] = await Promise.all([
550
690
  stat(cachePath),
551
691
  readFile(cachePath, 'utf8'),
@@ -561,13 +701,18 @@ export class PackageInfoClient {
561
701
  void unlink(cachePath).catch(() => { });
562
702
  throw new Error('manifest cache expired');
563
703
  }
704
+ // the tarball digest stays required across processes
705
+ if (json.__VLT_PACKUMENT) {
706
+ this.#vltPackuments.add(`${f.registry}${f.name}`);
707
+ delete json.__VLT_PACKUMENT;
708
+ }
564
709
  return json;
565
710
  }
566
711
  catch {
567
712
  // Cache miss, fetch from packument
568
713
  }
569
714
  }
570
- const mani = pickManifest(await this.packument(f, options), spec, options);
715
+ const mani = pickManifest(await this.#packument(f, options, this.#stable(f)), spec, options);
571
716
  if (!mani)
572
717
  throw this.#resolveError(spec, options);
573
718
  // Cache the manifest data. Skip paths already written this
@@ -575,7 +720,8 @@ export class PackageInfoClient {
575
720
  // and racing writers for the same path.
576
721
  if (cachePath && !this.#manifestWritePaths.has(cachePath)) {
577
722
  this.#manifestWritePaths.add(cachePath);
578
- void this.#writeManifestCache(cachePath, JSON.stringify(mani));
723
+ const vlt = this.#vltPackuments.has(`${f.registry}${f.name}`);
724
+ void this.#writeManifestCache(cachePath, JSON.stringify(vlt ? { ...mani, __VLT_PACKUMENT: true } : mani));
579
725
  }
580
726
  return mani;
581
727
  }
@@ -647,7 +793,15 @@ export class PackageInfoClient {
647
793
  }
648
794
  }
649
795
  }
796
+ /**
797
+ * The packument for `spec`, with every version the registry has. Callers
798
+ * that only pick a manifest out of it go through `#packument` instead,
799
+ * which can ask for the prerelease-free one.
800
+ */
650
801
  async packument(spec, options = {}) {
802
+ return this.#packument(spec, options, false);
803
+ }
804
+ async #packument(spec, options, stable) {
651
805
  if (typeof spec === 'string')
652
806
  spec = Spec.parse(spec, this.options);
653
807
  const f = spec.final;
@@ -684,10 +838,21 @@ export class PackageInfoClient {
684
838
  const { registry, name } = f;
685
839
  if (!registry)
686
840
  throw noRegistryError(spec);
687
- // Coalescing key has no representation component (see #fetchPackument).
688
- const packumentKey = `${registry}${name}`;
841
+ // a full representation neither reads nor feeds the coalescing
842
+ // map, which holds the abbreviated one
843
+ if (options.full)
844
+ return this.#fetchPackument(spec, options, new URL(name, registry));
845
+ // The only representation component of the coalescing key is
846
+ // `?stable`; everything else about the request is fixed (see
847
+ // #fetchPackument).
848
+ const fullKey = `${registry}${name}`;
849
+ const packumentKey = stable ? `${fullKey}?stable` : fullKey;
689
850
  const forced = isMovingSelector(f);
690
- const inflight = this.#packumentPromises.get(packumentKey);
851
+ const inflight = this.#packumentPromises.get(packumentKey) ??
852
+ // the full packument is a superset of the stable one, so an
853
+ // in-flight full request answers a stable ask too, and a package
854
+ // wanted both ways is still fetched once
855
+ (stable ? this.#packumentPromises.get(fullKey) : undefined);
691
856
  // a moving selector must not ride along on a non-forced request:
692
857
  // that one can settle to a fresh-but-stale cache hit, which is
693
858
  // exactly what forceRevalidate exists to avoid. the other
@@ -696,7 +861,8 @@ export class PackageInfoClient {
696
861
  // when both shapes are asked for at once.
697
862
  if (inflight && (!forced || inflight.forced))
698
863
  return inflight.promise;
699
- const pakuURL = new URL(name, registry);
864
+ // `?stable` is a distinct URL, so it gets its own disk cache entry
865
+ const pakuURL = new URL(stable ? `${name}?stable` : name, registry);
700
866
  const promise = this.#fetchPackument(spec, options, pakuURL);
701
867
  const record = { promise, forced };
702
868
  this.#packumentPromises.set(packumentKey, record);
@@ -735,8 +901,12 @@ export class PackageInfoClient {
735
901
  // packument request must use this same accept header, and the SWR
736
902
  // revalidation child re-requests the representation it was given.
737
903
  const response = await (await this.getRegistryClient()).request(pakuURL, {
738
- headers: { accept: PACKUMENT_ACCEPT },
739
- ...(useCache === false ? { useCache } : {}),
904
+ headers: {
905
+ accept: options.full ? 'application/json' : PACKUMENT_ACCEPT,
906
+ },
907
+ ...(useCache === false || options.full ?
908
+ { useCache: false }
909
+ : {}),
740
910
  // costs a conditional GET per moving selector on an otherwise warm
741
911
  // cache, install included. 304s are cheap but not free; the
742
912
  // alternative is serving a dist tag that moved (#1656).
@@ -750,8 +920,9 @@ export class PackageInfoClient {
750
920
  response,
751
921
  });
752
922
  }
923
+ let paku;
753
924
  try {
754
- return response.json();
925
+ paku = response.json();
755
926
  }
756
927
  catch (er) {
757
928
  if (useCache !== false) {
@@ -759,6 +930,14 @@ export class PackageInfoClient {
759
930
  }
760
931
  throw er;
761
932
  }
933
+ const { registry, name } = spec.final;
934
+ if (response.contentType.startsWith(VLT_PACKUMENT_MIME)) {
935
+ this.#vltPackuments.add(`${registry}${name}`);
936
+ }
937
+ /* c8 ignore next - registry specs always have a registry */
938
+ if (registry)
939
+ absolutizeTarballs(paku, registry);
940
+ return paku;
762
941
  }
763
942
  async resolve(spec, options = {}) {
764
943
  const memoKey = String(spec);
@@ -806,6 +985,10 @@ export class PackageInfoClient {
806
985
  signatures,
807
986
  spec,
808
987
  };
988
+ if (!integrity &&
989
+ this.#vltPackuments.has(`${f.registry}${f.name}`)) {
990
+ r.digestRequired = true;
991
+ }
809
992
  this.#resolutions.set(memoKey, r);
810
993
  return r;
811
994
  }
@@ -883,7 +1066,13 @@ export class PackageInfoClient {
883
1066
  // error resolving
884
1067
  #resolveError(spec, options = {}, message = 'Could not resolve', extra = {}) {
885
1068
  const { from = this.#projectRoot } = options;
886
- const er = error(message, {
1069
+ // Every registry failure here carries its response, so the advice is
1070
+ // attached once rather than at each throw site. The cast is because
1071
+ // `response` is typed loosely enough to include a `fetch` Response.
1072
+ const advice = spec?.final.type === 'registry' ?
1073
+ tokenRefusalAdvice(extra.response, extra.url)
1074
+ : undefined;
1075
+ const er = error(advice ? `${message}\n⚠️ ${advice}` : message, {
887
1076
  code: 'ERESOLVE',
888
1077
  spec,
889
1078
  from,
@@ -892,3 +1081,16 @@ export class PackageInfoClient {
892
1081
  return er;
893
1082
  }
894
1083
  }
1084
+ // vlt packuments carry dist.tarball relative to the registry base
1085
+ // (`foo/-/foo-1.0.0.tgz`), the form conventionalRegistryTarball builds.
1086
+ // Nothing downstream sees a relative URL: the manifest cache, the graph
1087
+ // and the lockfile all get the absolute one.
1088
+ const absolutizeTarballs = (paku, registry) => {
1089
+ const base = registry.endsWith('/') ? registry : registry + '/';
1090
+ for (const { dist } of Object.values(paku.versions)) {
1091
+ const tarball = dist?.tarball;
1092
+ if (tarball && !/^[a-z][a-z0-9+.-]*:/i.test(tarball)) {
1093
+ dist.tarball = String(new URL(tarball, base));
1094
+ }
1095
+ }
1096
+ };
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.1.1",
4
+ "version": "1.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -13,17 +13,17 @@
13
13
  "url": "http://vlt.sh"
14
14
  },
15
15
  "dependencies": {
16
- "@vltpkg/error-cause": "1.1.1",
17
- "@vltpkg/git": "1.1.1",
18
- "@vltpkg/output": "1.1.1",
19
- "@vltpkg/package-json": "1.1.1",
20
- "@vltpkg/pick-manifest": "1.1.1",
21
- "@vltpkg/registry-client": "1.1.1",
22
- "@vltpkg/spec": "1.1.1",
23
- "@vltpkg/tar": "1.1.1",
24
- "@vltpkg/types": "1.1.1",
25
- "@vltpkg/workspaces": "1.1.1",
26
- "@vltpkg/xdg": "1.1.1",
16
+ "@vltpkg/error-cause": "1.2.0",
17
+ "@vltpkg/git": "1.2.0",
18
+ "@vltpkg/output": "1.2.0",
19
+ "@vltpkg/package-json": "1.2.0",
20
+ "@vltpkg/pick-manifest": "1.2.0",
21
+ "@vltpkg/registry-client": "1.2.0",
22
+ "@vltpkg/spec": "1.2.0",
23
+ "@vltpkg/tar": "1.2.0",
24
+ "@vltpkg/types": "1.2.0",
25
+ "@vltpkg/workspaces": "1.2.0",
26
+ "@vltpkg/xdg": "1.2.0",
27
27
  "ssri": "^13.0.0",
28
28
  "tar": "^7.5.2"
29
29
  },
@@ -32,12 +32,12 @@
32
32
  "@types/node": "^22.19.2",
33
33
  "@types/pacote": "^11.1.8",
34
34
  "@vltpkg/benchmark": "0.0.0",
35
- "@vltpkg/cache-unzip": "1.1.1",
36
- "@vltpkg/vlt-json": "1.1.1",
35
+ "@vltpkg/cache-unzip": "1.2.0",
36
+ "@vltpkg/vlt-json": "1.2.0",
37
37
  "eslint": "^9.39.1",
38
38
  "pacote": "^21.0.4",
39
39
  "prettier": "^3.7.4",
40
- "tap": "^21.5.0",
40
+ "tap": "^21.8.0",
41
41
  "typedoc": "~0.27.9",
42
42
  "typescript": "5.7.3",
43
43
  "typescript-eslint": "^8.49.0"