@vltpkg/package-info 1.1.1 → 1.3.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,27 @@ 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;
70
+ /**
71
+ * Resolve to the registry's Brotli (`.tar.br`) tarball when it
72
+ * advertises one in `dist.alternates`. Defaults to true; set false to
73
+ * always resolve to the gzip `.tgz`.
74
+ */
75
+ 'brotli-tarballs'?: boolean;
40
76
  };
41
77
  export type PackageInfoClientRequestOptions = PickManifestOptions & RegistryClientRequestOptions & {
42
78
  /** dir to resolve `file://` specifiers against. Defaults to projectRoot. */
43
79
  from?: string;
80
+ /**
81
+ * Fetch the full packument (readme, maintainers, `dist.integrity`)
82
+ * rather than the abbreviated one. Bypasses the disk cache, which is
83
+ * keyed by URL alone, so the two representations never mix.
84
+ */
85
+ full?: boolean;
44
86
  };
45
87
  export type PackageInfoClientExtractOptions = PackageInfoClientRequestOptions & {
46
88
  integrity?: Integrity;
@@ -48,10 +90,15 @@ export type PackageInfoClientExtractOptions = PackageInfoClientRequestOptions &
48
90
  /**
49
91
  * When true, indicates that integrity + resolved came from a
50
92
  * lockfile (i.e. they were already verified on first install).
51
- * Skips the client-side tarball integrity check.
52
- * Defaults to false — fresh installs always verify integrity.
93
+ * Skips re-hashing a refetched body of an already verified url.
94
+ * Other network bodies are always checked.
53
95
  */
54
96
  fromLockfile?: boolean;
97
+ /**
98
+ * The manifest declares install scripts: copy the package from the
99
+ * global store, never link it, even if its package.json has none.
100
+ */
101
+ installScripts?: boolean;
55
102
  };
56
103
  export declare class PackageInfoClient {
57
104
  #private;
@@ -59,9 +106,15 @@ export declare class PackageInfoClient {
59
106
  packageJson: PackageJson;
60
107
  monorepo?: Monorepo;
61
108
  getRegistryClient(): Promise<RegistryClient>;
109
+ /**
110
+ * The vlt extensions `registry` serves, from its
111
+ * `GET /-/vlt/capabilities` document. A registry that does not answer
112
+ * one reads as an empty document, so a missing key means unsupported.
113
+ */
114
+ capabilities(registry: string): Promise<Capabilities>;
62
115
  getTarPool(): Promise<Pool>;
63
116
  constructor(options?: PackageInfoClientOptions);
64
- extract(spec: Spec | string, target: string, options?: PackageInfoClientExtractOptions): Promise<Resolution>;
117
+ extract(spec: Spec | string, target: string, options?: PackageInfoClientExtractOptions): Promise<ExtractResolution>;
65
118
  /**
66
119
  * Conditionally return the path to the manifest cache file. The logic
67
120
  * to determine if caching should be skipped aligns with `pickManifest`
@@ -70,6 +123,11 @@ export declare class PackageInfoClient {
70
123
  _manifestCachePath(spec: Spec, options: PackageInfoClientRequestOptions): string | undefined;
71
124
  tarball(spec: Spec | string, options?: PackageInfoClientExtractOptions): Promise<Buffer>;
72
125
  manifest(spec: Spec | string, options?: PackageInfoClientRequestOptions): Promise<Manifest | import("@vltpkg/types").Override<Manifest, import("@vltpkg/types").NormalizedFields>>;
126
+ /**
127
+ * The packument for `spec`, with every version the registry has. Callers
128
+ * that only pick a manifest out of it go through `#packument` instead,
129
+ * which can ask for the prerelease-free one.
130
+ */
73
131
  packument(spec: Spec | string, options?: PackageInfoClientRequestOptions): Promise<Packument>;
74
132
  resolve(spec: Spec | string, options?: PackageInfoClientRequestOptions): Promise<Resolution>;
75
133
  }
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, brotliTarballUrl, integrityHex, tarballFormat, } 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;
@@ -99,6 +178,18 @@ export class PackageInfoClient {
99
178
  });
100
179
  return this.#tarPoolPromise;
101
180
  }
181
+ /**
182
+ * The absolute URL of a version's Brotli (`.tar.br`) tarball, or
183
+ * undefined when the registry advertised none, advertised one this
184
+ * client does not use (see {@link brotliTarballUrl}), or
185
+ * `--no-brotli-tarballs` is set. `dist.tarball` is already absolute
186
+ * here -- see `absolutizeTarballs`.
187
+ */
188
+ #brotliTarball(tarball, alternates) {
189
+ if (this.options['brotli-tarballs'] === false)
190
+ return undefined;
191
+ return brotliTarballUrl(tarball, alternates);
192
+ }
102
193
  constructor(options = {}) {
103
194
  this.options = options;
104
195
  this.#projectRoot = options.projectRoot || process.cwd();
@@ -116,6 +207,10 @@ export class PackageInfoClient {
116
207
  packageJson: this.packageJson,
117
208
  });
118
209
  this.#cachePath = options.cache ?? xdg.cache();
210
+ this.#storeRoot = options.storeRoot ?? storeRoot(this.#cachePath);
211
+ const linker = options['store-linker'];
212
+ this.#storeLinker =
213
+ linker && storeLinkers.has(linker) ? linker : 'unpack';
119
214
  // optionally create its cache directory if it doesn't exist
120
215
  void mkdir(pathResolve(this.#cachePath, 'package-info'), {
121
216
  recursive: true,
@@ -124,13 +219,22 @@ export class PackageInfoClient {
124
219
  async extract(spec, target, options = {}) {
125
220
  if (typeof spec === 'string')
126
221
  spec = Spec.parse(spec, this.options);
127
- const { from = this.#projectRoot, integrity, resolved, fromLockfile = false, } = options;
222
+ const { from = this.#projectRoot, integrity, resolved, fromLockfile = false, installScripts = false, } = options;
128
223
  const f = spec.final;
129
224
  // If the caller already provides both integrity and resolved
130
- // (from lockfile or prior resolution), skip re-resolving.
131
- const alreadyResolved = !!(integrity && resolved);
132
- const r = alreadyResolved ?
133
- { resolved, integrity, spec }
225
+ // (from lockfile or prior resolution), skip re-resolving. A
226
+ // `.tar.br` needs only the URL: its hash is not knowable before the
227
+ // download, so the graph can never hand one over, and `Repr-Digest`
228
+ // is what pins it -- `required`, so a registry that serves the
229
+ // alternate unlabelled is rejected rather than trusted.
230
+ const brotli = !!resolved && tarballFormat(resolved) === 'brotli';
231
+ const r = resolved && (integrity || brotli) ?
232
+ {
233
+ resolved,
234
+ integrity,
235
+ spec,
236
+ ...(brotli && !integrity ? { digestRequired: true } : {}),
237
+ }
134
238
  : await this.resolve(spec, options);
135
239
  switch (f.type) {
136
240
  case 'git': {
@@ -160,16 +264,58 @@ export class PackageInfoClient {
160
264
  // fallthrough if a remote tarball url present
161
265
  }
162
266
  case 'registry': {
267
+ const pool = await this.getTarPool();
268
+ // brotli bytes carry no signature of their own, so every unpack
269
+ // below has to be told; gzip and raw tar are sniffed.
270
+ const format = tarballFormat(r.resolved);
271
+ // git tarballs keep the unpack path
272
+ const hex = f.type === 'registry' && this.#storeLinker !== 'unpack' ?
273
+ integrityHex(r.integrity)
274
+ : undefined;
275
+ const copy = this.#storeLinker === 'copy' || installScripts;
276
+ if (hex) {
277
+ if (debug.enabled && !this.#storeHitRateLogged) {
278
+ this.#storeHitRateLogged = true;
279
+ process.once('beforeExit', this.#logStoreHitRate);
280
+ }
281
+ const linked = await pool.linkFromStore(pathResolve(this.#storeRoot, hex), target, { copy });
282
+ if (linked) {
283
+ const { how, index } = linked;
284
+ this.#storeHits[how]++;
285
+ // a copy is no link: report it as a cache hit
286
+ logRequest(r.resolved, how === 'link' ? 'store' : 'cache');
287
+ return {
288
+ ...r,
289
+ manifest: index.manifest,
290
+ // implies scripts, so most packages skip the scan
291
+ bindingGyp: index.scripts &&
292
+ index.files.some(f => f[0] === 'binding.gyp'),
293
+ };
294
+ }
295
+ this.#storeMisses++;
296
+ }
163
297
  // if the tarball is already on disk, unpack it straight from
164
298
  // the cache file: it never has to be held in the client's
165
299
  // in-memory cache. anything unexpected falls through to the
166
300
  // fetch path, which throws its own error if the body is
167
301
  // genuinely bad.
168
- const cached = (await this.getRegistryClient()).cachedBody(r.resolved, { integrity: r.integrity });
302
+ const rc = await this.getRegistryClient();
303
+ const cached = rc.cachedBody(r.resolved, {
304
+ integrity: r.integrity,
305
+ });
169
306
  if (cached) {
170
307
  try {
171
- await (await this.getTarPool()).unpack(cached.body, target);
308
+ await pool.unpack(cached.body, target, format);
172
309
  logRequest(r.resolved, 'cache');
310
+ r.integrity ??= cached.integrity;
311
+ // a warm install writes nothing to the cache, so queue the
312
+ // store miss here or an existing cache never converges,
313
+ // and a gzipped body with no store link, to unzip it.
314
+ // (a brotli body is never rewritten: cache-unzip only
315
+ // un-gzips, and leaving it compressed keeps the cache small.)
316
+ if (hex || cached.gzip) {
317
+ rc.queueForStore(cached.key, r.integrity);
318
+ }
173
319
  return r;
174
320
  }
175
321
  catch (er) {
@@ -184,6 +330,7 @@ export class PackageInfoClient {
184
330
  const response = await (await this.getRegistryClient()).request(r.resolved, {
185
331
  integrity: r.integrity,
186
332
  trustIntegrity,
333
+ verifyDigest: r.digestRequired ? 'required' : true,
187
334
  ...(useCache === false ? { useCache } : {}),
188
335
  });
189
336
  if (response.statusCode !== 200) {
@@ -195,33 +342,42 @@ export class PackageInfoClient {
195
342
  response,
196
343
  });
197
344
  }
345
+ // checkIntegrity() hashes the body unless its url is trusted
346
+ const verified = !trustIntegrity &&
347
+ response.checkIntegrity({ spec, url: resolved });
198
348
  // if it's not trusted already, but valid, start trusting
199
- if (!trustIntegrity &&
200
- response.checkIntegrity({ spec, url: resolved })) {
349
+ if (verified) {
201
350
  this.#trustedIntegrities.set(r.resolved, response.integrity);
202
351
  }
203
352
  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
- });
353
+ if (r.integrity) {
354
+ // hash only network bytes checkIntegrity() skipped (trusted
355
+ // url). cache bodies were verified when cached.
356
+ if (!verified && !fromLockfile && !response.fromCache) {
357
+ const hash = createHash('sha512');
358
+ hash.update(buf);
359
+ const computed = `sha512-${hash.digest('base64')}`;
360
+ if (computed !== r.integrity) {
361
+ throw error('Tarball integrity check failed', {
362
+ code: 'EINTEGRITY',
363
+ spec,
364
+ url: r.resolved,
365
+ wanted: r.integrity,
366
+ found: computed,
367
+ });
368
+ }
223
369
  }
224
- /* c8 ignore stop */
370
+ }
371
+ else if (response.fromCache) {
372
+ // the hash the body was stored under
373
+ r.integrity = response.integrity;
374
+ }
375
+ else {
376
+ // no dist.integrity: the registry client checked the body
377
+ // against the digest the registry sent with it (verifyDigest
378
+ // above). hand the hash back so the lockfile pins it from
379
+ // now on
380
+ r.integrity = response.integrityActual;
225
381
  }
226
382
  return buf;
227
383
  };
@@ -245,7 +401,7 @@ export class PackageInfoClient {
245
401
  }
246
402
  }
247
403
  try {
248
- await (await this.getTarPool()).unpack(buf, target);
404
+ await pool.unpack(buf, target, format);
249
405
  }
250
406
  catch (er) {
251
407
  throw this.#resolveError(spec, options, 'tar unpack failed', { cause: er });
@@ -421,6 +577,11 @@ export class PackageInfoClient {
421
577
  ...options,
422
578
  integrity,
423
579
  trustIntegrity,
580
+ // no dist.integrity: checked against the digest the registry
581
+ // sent with it instead, before the client caches it
582
+ verifyDigest: this.#vltPackuments.has(`${f.registry}${f.name}`) ?
583
+ 'required'
584
+ : true,
424
585
  ...(useCache === false ? { useCache } : {}),
425
586
  });
426
587
  if (response.statusCode !== 200) {
@@ -429,19 +590,19 @@ export class PackageInfoClient {
429
590
  `version may have been unpublished, or the registry may ` +
430
591
  `be misconfigured or unreachable.`, { response, url: tarball });
431
592
  }
593
+ const verified = !trustIntegrity &&
594
+ response.checkIntegrity({ spec, url: tarball });
432
595
  // if we don't already trust it, but it's valid, start
433
596
  // trusting it
434
- if (!trustIntegrity &&
435
- response.checkIntegrity({ spec, url: tarball })) {
597
+ if (verified) {
436
598
  this.#trustedIntegrities.set(tarball, response.integrity);
437
599
  }
438
600
  const buf = response.buffer();
439
- // Same as extract(): only hash network-delivered bodies.
440
- if (integrity && !response.fromCache) {
601
+ // Same as extract()
602
+ if (integrity && !verified && !response.fromCache) {
441
603
  const hash = createHash('sha512');
442
604
  hash.update(buf);
443
605
  const computed = `sha512-${hash.digest('base64')}`;
444
- /* c8 ignore start - defense-in-depth (see extract) */
445
606
  if (computed !== integrity) {
446
607
  throw error('Tarball integrity check failed', {
447
608
  code: 'EINTEGRITY',
@@ -451,7 +612,6 @@ export class PackageInfoClient {
451
612
  found: computed,
452
613
  });
453
614
  }
454
- /* c8 ignore stop */
455
615
  }
456
616
  return buf;
457
617
  };
@@ -545,7 +705,8 @@ export class PackageInfoClient {
545
705
  try {
546
706
  // Cache file exists, read and return it. Freshness is
547
707
  // tracked via the file's mtime, so the file content is
548
- // exactly the manifest and can be returned as parsed.
708
+ // the manifest, plus a marker when it came from a vlt
709
+ // packument, and can be returned as parsed.
549
710
  const [st, cached] = await Promise.all([
550
711
  stat(cachePath),
551
712
  readFile(cachePath, 'utf8'),
@@ -561,13 +722,18 @@ export class PackageInfoClient {
561
722
  void unlink(cachePath).catch(() => { });
562
723
  throw new Error('manifest cache expired');
563
724
  }
725
+ // the tarball digest stays required across processes
726
+ if (json.__VLT_PACKUMENT) {
727
+ this.#vltPackuments.add(`${f.registry}${f.name}`);
728
+ delete json.__VLT_PACKUMENT;
729
+ }
564
730
  return json;
565
731
  }
566
732
  catch {
567
733
  // Cache miss, fetch from packument
568
734
  }
569
735
  }
570
- const mani = pickManifest(await this.packument(f, options), spec, options);
736
+ const mani = pickManifest(await this.#packument(f, options, this.#stable(f)), spec, options);
571
737
  if (!mani)
572
738
  throw this.#resolveError(spec, options);
573
739
  // Cache the manifest data. Skip paths already written this
@@ -575,7 +741,8 @@ export class PackageInfoClient {
575
741
  // and racing writers for the same path.
576
742
  if (cachePath && !this.#manifestWritePaths.has(cachePath)) {
577
743
  this.#manifestWritePaths.add(cachePath);
578
- void this.#writeManifestCache(cachePath, JSON.stringify(mani));
744
+ const vlt = this.#vltPackuments.has(`${f.registry}${f.name}`);
745
+ void this.#writeManifestCache(cachePath, JSON.stringify(vlt ? { ...mani, __VLT_PACKUMENT: true } : mani));
579
746
  }
580
747
  return mani;
581
748
  }
@@ -647,7 +814,15 @@ export class PackageInfoClient {
647
814
  }
648
815
  }
649
816
  }
817
+ /**
818
+ * The packument for `spec`, with every version the registry has. Callers
819
+ * that only pick a manifest out of it go through `#packument` instead,
820
+ * which can ask for the prerelease-free one.
821
+ */
650
822
  async packument(spec, options = {}) {
823
+ return this.#packument(spec, options, false);
824
+ }
825
+ async #packument(spec, options, stable) {
651
826
  if (typeof spec === 'string')
652
827
  spec = Spec.parse(spec, this.options);
653
828
  const f = spec.final;
@@ -684,10 +859,21 @@ export class PackageInfoClient {
684
859
  const { registry, name } = f;
685
860
  if (!registry)
686
861
  throw noRegistryError(spec);
687
- // Coalescing key has no representation component (see #fetchPackument).
688
- const packumentKey = `${registry}${name}`;
862
+ // a full representation neither reads nor feeds the coalescing
863
+ // map, which holds the abbreviated one
864
+ if (options.full)
865
+ return this.#fetchPackument(spec, options, new URL(name, registry));
866
+ // The only representation component of the coalescing key is
867
+ // `?stable`; everything else about the request is fixed (see
868
+ // #fetchPackument).
869
+ const fullKey = `${registry}${name}`;
870
+ const packumentKey = stable ? `${fullKey}?stable` : fullKey;
689
871
  const forced = isMovingSelector(f);
690
- const inflight = this.#packumentPromises.get(packumentKey);
872
+ const inflight = this.#packumentPromises.get(packumentKey) ??
873
+ // the full packument is a superset of the stable one, so an
874
+ // in-flight full request answers a stable ask too, and a package
875
+ // wanted both ways is still fetched once
876
+ (stable ? this.#packumentPromises.get(fullKey) : undefined);
691
877
  // a moving selector must not ride along on a non-forced request:
692
878
  // that one can settle to a fresh-but-stale cache hit, which is
693
879
  // exactly what forceRevalidate exists to avoid. the other
@@ -696,7 +882,8 @@ export class PackageInfoClient {
696
882
  // when both shapes are asked for at once.
697
883
  if (inflight && (!forced || inflight.forced))
698
884
  return inflight.promise;
699
- const pakuURL = new URL(name, registry);
885
+ // `?stable` is a distinct URL, so it gets its own disk cache entry
886
+ const pakuURL = new URL(stable ? `${name}?stable` : name, registry);
700
887
  const promise = this.#fetchPackument(spec, options, pakuURL);
701
888
  const record = { promise, forced };
702
889
  this.#packumentPromises.set(packumentKey, record);
@@ -735,8 +922,12 @@ export class PackageInfoClient {
735
922
  // packument request must use this same accept header, and the SWR
736
923
  // revalidation child re-requests the representation it was given.
737
924
  const response = await (await this.getRegistryClient()).request(pakuURL, {
738
- headers: { accept: PACKUMENT_ACCEPT },
739
- ...(useCache === false ? { useCache } : {}),
925
+ headers: {
926
+ accept: options.full ? 'application/json' : PACKUMENT_ACCEPT,
927
+ },
928
+ ...(useCache === false || options.full ?
929
+ { useCache: false }
930
+ : {}),
740
931
  // costs a conditional GET per moving selector on an otherwise warm
741
932
  // cache, install included. 304s are cheap but not free; the
742
933
  // alternative is serving a dist tag that moved (#1656).
@@ -750,8 +941,9 @@ export class PackageInfoClient {
750
941
  response,
751
942
  });
752
943
  }
944
+ let paku;
753
945
  try {
754
- return response.json();
946
+ paku = response.json();
755
947
  }
756
948
  catch (er) {
757
949
  if (useCache !== false) {
@@ -759,6 +951,14 @@ export class PackageInfoClient {
759
951
  }
760
952
  throw er;
761
953
  }
954
+ const { registry, name } = spec.final;
955
+ if (response.contentType.startsWith(VLT_PACKUMENT_MIME)) {
956
+ this.#vltPackuments.add(`${registry}${name}`);
957
+ }
958
+ /* c8 ignore next - registry specs always have a registry */
959
+ if (registry)
960
+ absolutizeTarballs(paku, registry);
961
+ return paku;
762
962
  }
763
963
  async resolve(spec, options = {}) {
764
964
  const memoKey = String(spec);
@@ -798,14 +998,22 @@ export class PackageInfoClient {
798
998
  case 'registry': {
799
999
  const mani = await this.manifest(spec, options);
800
1000
  if (mani.dist) {
801
- const { integrity, tarball, signatures } = mani.dist;
1001
+ const { integrity, tarball, signatures, alternates } = mani.dist;
802
1002
  if (tarball) {
803
- const r = {
804
- resolved: tarball,
805
- integrity,
806
- signatures,
807
- spec,
808
- };
1003
+ // A `.tar.br` is a distinct artifact, not a re-encoding of the
1004
+ // `.tgz`: it hashes differently, so `dist.integrity` and the
1005
+ // signatures over it describe the wrong bytes and are dropped.
1006
+ // What pins it instead is its own `Repr-Digest`, which the
1007
+ // registry that advertised it always sends -- hence `required`.
1008
+ const brotli = this.#brotliTarball(tarball, alternates);
1009
+ const r = brotli ?
1010
+ { resolved: brotli, spec, digestRequired: true }
1011
+ : { resolved: tarball, integrity, signatures, spec };
1012
+ if (!brotli &&
1013
+ !integrity &&
1014
+ this.#vltPackuments.has(`${f.registry}${f.name}`)) {
1015
+ r.digestRequired = true;
1016
+ }
809
1017
  this.#resolutions.set(memoKey, r);
810
1018
  return r;
811
1019
  }
@@ -883,7 +1091,13 @@ export class PackageInfoClient {
883
1091
  // error resolving
884
1092
  #resolveError(spec, options = {}, message = 'Could not resolve', extra = {}) {
885
1093
  const { from = this.#projectRoot } = options;
886
- const er = error(message, {
1094
+ // Every registry failure here carries its response, so the advice is
1095
+ // attached once rather than at each throw site. The cast is because
1096
+ // `response` is typed loosely enough to include a `fetch` Response.
1097
+ const advice = spec?.final.type === 'registry' ?
1098
+ tokenRefusalAdvice(extra.response, extra.url)
1099
+ : undefined;
1100
+ const er = error(advice ? `${message}\n⚠️ ${advice}` : message, {
887
1101
  code: 'ERESOLVE',
888
1102
  spec,
889
1103
  from,
@@ -892,3 +1106,16 @@ export class PackageInfoClient {
892
1106
  return er;
893
1107
  }
894
1108
  }
1109
+ // vlt packuments carry dist.tarball relative to the registry base
1110
+ // (`foo/-/foo-1.0.0.tgz`), the form conventionalRegistryTarball builds.
1111
+ // Nothing downstream sees a relative URL: the manifest cache, the graph
1112
+ // and the lockfile all get the absolute one.
1113
+ const absolutizeTarballs = (paku, registry) => {
1114
+ const base = registry.endsWith('/') ? registry : registry + '/';
1115
+ for (const { dist } of Object.values(paku.versions)) {
1116
+ const tarball = dist?.tarball;
1117
+ if (tarball && !/^[a-z][a-z0-9+.-]*:/i.test(tarball)) {
1118
+ dist.tarball = String(new URL(tarball, base));
1119
+ }
1120
+ }
1121
+ };
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.3.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.3.0",
17
+ "@vltpkg/git": "1.3.0",
18
+ "@vltpkg/output": "1.3.0",
19
+ "@vltpkg/package-json": "1.3.0",
20
+ "@vltpkg/pick-manifest": "1.3.0",
21
+ "@vltpkg/registry-client": "1.3.0",
22
+ "@vltpkg/spec": "1.3.0",
23
+ "@vltpkg/tar": "1.3.0",
24
+ "@vltpkg/types": "1.3.0",
25
+ "@vltpkg/workspaces": "1.3.0",
26
+ "@vltpkg/xdg": "1.3.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.3.0",
36
+ "@vltpkg/vlt-json": "1.3.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"