@gjsify/cli 0.4.36 → 0.4.38

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.
@@ -17,10 +17,16 @@ import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
  import * as os from 'node:os';
19
19
  import { Range, SemVer, maxSatisfying, satisfies } from '@gjsify/semver';
20
- import { DEFAULT_REGISTRY, fetchPackument, fetchTarball, parseNpmrc, } from '@gjsify/npm-registry';
20
+ import { DEFAULT_REGISTRY, fetchPackument, fetchPackumentConditional, fetchTarball, parseNpmrc, registryFor, } from '@gjsify/npm-registry';
21
21
  import { extractTarball } from '@gjsify/tar';
22
- import { getCachedTarball, putCachedTarball, } from './install-tarball-cache.js';
23
- const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '8') || 8;
22
+ import { getCachedTarball, getForeignCachedTarball, putCachedTarball, } from './install-tarball-cache.js';
23
+ import { getCachedPackument, putCachedPackument } from './install-packument-cache.js';
24
+ // 16-wide download pool. Matched to the shared Soup.Session's lifted
25
+ // `max-conns-per-host` (see @gjsify/fetch `getSharedSession`) — a higher pool
26
+ // here only translates into real concurrency because that cap was raised from
27
+ // libsoup's default of 2. npm (`maxsockets` 15) and pnpm (`network-concurrency`
28
+ // 16) use the same order of magnitude. Override with GJSIFY_INSTALL_CONCURRENCY.
29
+ const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '16') || 16;
24
30
  const LOCKFILE_NAME = 'gjsify-lock.json';
25
31
  const LOCKFILE_VERSION = 2;
26
32
  export async function installPackagesNative(opts) {
@@ -139,13 +145,7 @@ async function resolveDeps(specs, npmrc, log, overrides, skipDeps, signal, progr
139
145
  const cached = packumentCache.get(name);
140
146
  if (cached)
141
147
  return cached;
142
- const fresh = fetchPackument(name, {
143
- npmrc,
144
- signal,
145
- onRetry: ({ attempt, error, delayMs }) => {
146
- log('packument %s: retry %d after %dms (%s)', name, attempt, delayMs, errMsg(error));
147
- },
148
- });
148
+ const fresh = fetchPackumentWithDiskCache(name, npmrc, log, signal);
149
149
  packumentCache.set(name, fresh);
150
150
  return fresh;
151
151
  };
@@ -159,94 +159,189 @@ async function resolveDeps(specs, npmrc, log, overrides, skipDeps, signal, progr
159
159
  range: applyOverride(s.name, s.range),
160
160
  required: true,
161
161
  }));
162
+ // Wave-based BFS. Each iteration drains the current queue level, prefetches
163
+ // every not-yet-cached packument in that level IN PARALLEL (bounded), then
164
+ // applies placement SERIALLY in the same FIFO order the single-edge loop
165
+ // used. Because newly-discovered children always append to the end of the
166
+ // queue, "the current queue contents" is exactly one BFS level — so the
167
+ // serial pass visits edges in the identical order, and `decidePlacement`'s
168
+ // order-dependent hoist/nest decisions (hence the lockfile) are byte-for-
169
+ // byte unchanged. Only the network moved: N sequential ~RTT packument
170
+ // fetches per level collapse into one bounded-parallel batch. This is the
171
+ // dominant cold-install cost — at libsoup's old `max-conns-per-host=2` it
172
+ // would have throttled anyway, so it lands alongside the connection-cap
173
+ // lift in `@gjsify/fetch`. `packumentCache` still guarantees ≤1 fetch per
174
+ // unique name across the whole resolve, so prefetching every wave name
175
+ // fetches exactly the same SET as before, just batched.
162
176
  while (queue.length > 0) {
163
- const edge = queue.shift();
164
- // Walk the ancestor chain to see whether a satisfying placement is
165
- // already visible from the requester's `node_modules` lookup. npm's
166
- // resolver does this each level of nesting acts as a fallback.
167
- const visible = findVisible(edge.from, edge.name, byPath);
168
- if (visible && satisfiesRange(visible.version, edge.range)) {
169
- // Compatible placement reachable; reuse, no new install.
170
- continue;
177
+ const wave = queue.splice(0, queue.length);
178
+ // Prefetch every not-yet-cached packument referenced in this level.
179
+ // Names already in `packumentCache` (resolved in an earlier wave, or a
180
+ // duplicate within this one) are skipped the de-dup keeps the batch
181
+ // to genuinely-new names.
182
+ const toPrefetch = [];
183
+ const queuedForFetch = new Set();
184
+ for (const edge of wave) {
185
+ if (packumentCache.has(edge.name) || queuedForFetch.has(edge.name))
186
+ continue;
187
+ queuedForFetch.add(edge.name);
188
+ toPrefetch.push(edge.name);
171
189
  }
172
- // No compatible existing placement. Resolve a fresh version.
173
- let version = null;
174
- try {
175
- const packument = await fetchPkg(edge.name);
176
- version = pickVersion(packument, edge.range);
177
- if (!version) {
178
- if (!edge.required)
179
- continue;
180
- throw new Error(`No version of ${edge.name} satisfies ${edge.range}`);
181
- }
182
- const v = packument.versions[version];
183
- if (!v) {
184
- throw new Error(`Packument for ${edge.name} promised ${version} but no entry exists`);
185
- }
186
- // Decision: hoist to root, or nest under the requester?
187
- // - Hoist iff the root has no conflicting placement (i.e. the
188
- // root slot for `name` is empty OR holds the same version).
189
- // - Otherwise nest. Top-level specs (from === null) always
190
- // hoist; the resolver guarantees they never conflict with
191
- // each other because the input set is checked once.
192
- const installPath = decidePlacement(edge.from, edge.name, version, root);
193
- const node = {
194
- name: edge.name,
195
- version,
196
- tarballUrl: v.dist.tarball,
197
- integrity: v.dist.integrity,
198
- installPath,
199
- dependencies: v.dependencies ?? {},
200
- optionalDependencies: v.optionalDependencies ?? {},
201
- bin: v.bin,
202
- };
203
- byPath.set(installPath, node);
204
- if (installPath === `node_modules/${edge.name}`) {
205
- root.set(edge.name, node);
190
+ await prefetchPackuments(toPrefetch, fetchPkg, signal);
191
+ // Serial placement pass — identical decisions and order to the original
192
+ // single-edge loop, but every `fetchPkg` now resolves from the warmed
193
+ // cache instead of blocking on a fresh round-trip.
194
+ for (let wi = 0; wi < wave.length; wi++) {
195
+ const edge = wave[wi];
196
+ // Walk the ancestor chain to see whether a satisfying placement is
197
+ // already visible from the requester's `node_modules` lookup. npm's
198
+ // resolver does this — each level of nesting acts as a fallback.
199
+ const visible = findVisible(edge.from, edge.name, byPath);
200
+ if (visible && satisfiesRange(visible.version, edge.range)) {
201
+ // Compatible placement reachable; reuse, no new install.
202
+ continue;
206
203
  }
207
- log('resolve: %s@%s %s (at %s)', edge.name, version, edge.range, installPath);
208
- // Soft-total tracks the dep graph as it grows. We use
209
- // byPath.size (resolved so far) + queue.length (still to
210
- // process) as a moving estimate that converges as work
211
- // finishes yarn/pnpm use the same pattern.
212
- progress?.update({
213
- phase: 'resolve',
214
- current: byPath.size,
215
- total: byPath.size + queue.length,
216
- name: `${edge.name}@${version}`,
217
- });
218
- if (!skipDeps) {
219
- for (const [depName, depRange] of Object.entries(node.dependencies)) {
220
- queue.push({
221
- from: installPath,
222
- name: depName,
223
- range: applyOverride(depName, depRange),
224
- required: true,
225
- });
204
+ // No compatible existing placement. Resolve a fresh version.
205
+ let version = null;
206
+ try {
207
+ const packument = await fetchPkg(edge.name);
208
+ version = pickVersion(packument, edge.range);
209
+ if (!version) {
210
+ if (!edge.required)
211
+ continue;
212
+ throw new Error(`No version of ${edge.name} satisfies ${edge.range}`);
213
+ }
214
+ const v = packument.versions[version];
215
+ if (!v) {
216
+ throw new Error(`Packument for ${edge.name} promised ${version} but no entry exists`);
217
+ }
218
+ // Decision: hoist to root, or nest under the requester?
219
+ // - Hoist iff the root has no conflicting placement (i.e. the
220
+ // root slot for `name` is empty OR holds the same version).
221
+ // - Otherwise nest. Top-level specs (from === null) always
222
+ // hoist; the resolver guarantees they never conflict with
223
+ // each other because the input set is checked once.
224
+ const installPath = decidePlacement(edge.from, edge.name, version, root);
225
+ const node = {
226
+ name: edge.name,
227
+ version,
228
+ tarballUrl: v.dist.tarball,
229
+ integrity: v.dist.integrity,
230
+ installPath,
231
+ dependencies: v.dependencies ?? {},
232
+ optionalDependencies: v.optionalDependencies ?? {},
233
+ bin: v.bin,
234
+ };
235
+ byPath.set(installPath, node);
236
+ if (installPath === `node_modules/${edge.name}`) {
237
+ root.set(edge.name, node);
226
238
  }
227
- for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
228
- queue.push({
229
- from: installPath,
230
- name: depName,
231
- range: applyOverride(depName, depRange),
232
- required: false,
233
- });
239
+ log('resolve: %s@%s ← %s (at %s)', edge.name, version, edge.range, installPath);
240
+ // Moving soft-total: resolved so far + edges still to visit in
241
+ // this wave + children already queued for the next wave. Same
242
+ // converging-estimate pattern yarn/pnpm use.
243
+ progress?.update({
244
+ phase: 'resolve',
245
+ current: byPath.size,
246
+ total: byPath.size + (wave.length - wi - 1) + queue.length,
247
+ name: `${edge.name}@${version}`,
248
+ });
249
+ if (!skipDeps) {
250
+ for (const [depName, depRange] of Object.entries(node.dependencies)) {
251
+ queue.push({
252
+ from: installPath,
253
+ name: depName,
254
+ range: applyOverride(depName, depRange),
255
+ required: true,
256
+ });
257
+ }
258
+ for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
259
+ queue.push({
260
+ from: installPath,
261
+ name: depName,
262
+ range: applyOverride(depName, depRange),
263
+ required: false,
264
+ });
265
+ }
234
266
  }
235
267
  }
236
- }
237
- catch (e) {
238
- // Optional deps that fail to resolve are skipped — yarn/npm
239
- // behavior. Required deps re-throw.
240
- if (!edge.required) {
241
- log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, e.message);
242
- continue;
268
+ catch (e) {
269
+ // Optional deps that fail to resolve are skipped — yarn/npm
270
+ // behavior. Required deps re-throw.
271
+ if (!edge.required) {
272
+ log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, e.message);
273
+ continue;
274
+ }
275
+ throw e;
243
276
  }
244
- throw e;
245
277
  }
246
278
  }
247
279
  progress?.endPhase('resolve');
248
280
  return Array.from(byPath.values());
249
281
  }
282
+ /**
283
+ * Warm `packumentCache` for a batch of package names with bounded parallelism
284
+ * (same `DEFAULT_CONCURRENCY` width as the download pool). Rejections — e.g. a
285
+ * 404 on an optional dep — are swallowed here: the promise stays cached in its
286
+ * rejected state and the caller's per-edge `await fetchPkg(name)` re-surfaces
287
+ * it, so required deps still throw and optional ones are skipped exactly as in
288
+ * the single-edge path. Swallowing also stops one bad optional dependency from
289
+ * aborting the whole wave's batch.
290
+ */
291
+ /**
292
+ * Fetch a packument with on-disk ETag revalidation. Reads the cached
293
+ * `{ etag, packument }` for `(registry, name)`, sends it as `If-None-Match`,
294
+ * and on a `304 Not Modified` returns the cached body without re-downloading
295
+ * it; on a `200` it stores the fresh body + ETag and returns it. The cache is
296
+ * keyed by the registry the name resolves to, so scope-registry overrides never
297
+ * cross-contaminate. Falls back to a plain fetch when there's no cached entry
298
+ * or the registry doesn't send an ETag (the 304 fast-path simply never fires).
299
+ */
300
+ async function fetchPackumentWithDiskCache(name, npmrc, log, signal) {
301
+ const registry = registryFor(name, npmrc);
302
+ const disk = getCachedPackument(registry, name);
303
+ const onRetry = ({ attempt, error, delayMs }) => {
304
+ log('packument %s: retry %d after %dms (%s)', name, attempt, delayMs, errMsg(error));
305
+ };
306
+ const result = await fetchPackumentConditional(name, {
307
+ npmrc,
308
+ signal,
309
+ ifNoneMatch: disk?.etag,
310
+ onRetry,
311
+ });
312
+ if (result.status === 'not-modified' && disk) {
313
+ log('packument-cache-hit: %s (304, etag %s)', name, disk.etag);
314
+ return disk.packument;
315
+ }
316
+ if (result.status === 'fresh' && result.packument) {
317
+ if (result.etag)
318
+ putCachedPackument(registry, name, result.etag, result.packument);
319
+ return result.packument;
320
+ }
321
+ // 304 with no cached body to satisfy it (a stale `If-None-Match` raced a
322
+ // cache eviction). Re-fetch unconditionally so we always return a body.
323
+ return fetchPackument(name, { npmrc, signal, onRetry });
324
+ }
325
+ async function prefetchPackuments(names, fetchPkg, signal) {
326
+ if (names.length === 0)
327
+ return;
328
+ let cursor = 0;
329
+ const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, names.length));
330
+ const worker = async () => {
331
+ while (cursor < names.length) {
332
+ if (signal?.aborted)
333
+ return;
334
+ const name = names[cursor++];
335
+ try {
336
+ await fetchPkg(name);
337
+ }
338
+ catch {
339
+ /* cached rejection — the serial placement pass handles it */
340
+ }
341
+ }
342
+ };
343
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
344
+ }
250
345
  /**
251
346
  * Walk the ancestor `node_modules` chain from `requesterPath` upward,
252
347
  * looking for a placement of `name` that the requester would resolve
@@ -532,6 +627,15 @@ async function extractOne(node, prefix, npmrc, log, signal) {
532
627
  if (bytes) {
533
628
  log('cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
534
629
  }
630
+ else if ((bytes = getForeignCachedTarball(node.integrity))) {
631
+ // Second-chance: npm's cacache content store (same SRI key). A user
632
+ // who has run `npm install` before already has the tarball on disk —
633
+ // read it instead of the network. Write it through to OUR store so
634
+ // the next `gjsify install` is a first-class hit even if npm later
635
+ // prunes its cache.
636
+ log('npm-cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
637
+ putCachedTarball(node.integrity, bytes);
638
+ }
535
639
  else {
536
640
  log('fetch: %s@%s ← %s (→ %s)', node.name, node.version, node.tarballUrl, node.installPath);
537
641
  bytes = await fetchTarball(node.tarballUrl, {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolve `$XDG_CACHE_HOME/gjsify/<kind>/<layoutVersion>` (falling back to
3
+ * `~/.cache` when `XDG_CACHE_HOME` is unset/empty). `kind` is the cache area
4
+ * (`tarballs`, `metadata`); `layoutVersion` lets one cache evolve its on-disk
5
+ * shape without invalidating its siblings.
6
+ */
7
+ export declare function gjsifyCacheRoot(kind: string, layoutVersion: string): string;
8
+ /**
9
+ * Write `bytes` to `path` atomically: write a `<path>.tmp.<pid>` sibling, then
10
+ * `rename` it into place, so a concurrent reader never observes a half-written
11
+ * file. Creates the parent directory. Best-effort — any failure (read-only /
12
+ * out-of-disk cache volume) is swallowed so a cache hiccup never breaks the
13
+ * install; the caller proceeds with its in-memory copy.
14
+ */
15
+ export declare function atomicWrite(path: string, bytes: Uint8Array | string): void;
16
+ /**
17
+ * Read a cache file, returning its bytes on a HIT or `null` on a MISS. A
18
+ * missing file, a zero-byte file (an interrupted previous write), or any read
19
+ * error are all treated as a MISS — the file is left untouched so a follow-up
20
+ * writer's atomic rename isn't disturbed.
21
+ */
22
+ export declare function readCacheFile(path: string): Buffer | null;
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Shared filesystem primitives for `gjsify install`'s on-disk caches.
3
+ //
4
+ // Both the content-addressable tarball cache (install-tarball-cache.ts) and the
5
+ // packument metadata cache (install-packument-cache.ts) need the same three
6
+ // things: an `XDG_CACHE_HOME`-honouring cache root, an atomic write (so a
7
+ // concurrent install never observes a half-written entry), and a read that
8
+ // treats a missing / zero-byte / unreadable file as a MISS. Centralising them
9
+ // here keeps the two caches byte-for-byte consistent.
10
+ //
11
+ // Out of scope: the dlx cache (`dlx-cache.ts`) has a different layout
12
+ // (`gjsify/dlx`, sha256 + symlink-swap) and its own TTL/prepare-dir concerns —
13
+ // it is a precedent for the XDG helper, not a reuse target.
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ /**
18
+ * Resolve `$XDG_CACHE_HOME/gjsify/<kind>/<layoutVersion>` (falling back to
19
+ * `~/.cache` when `XDG_CACHE_HOME` is unset/empty). `kind` is the cache area
20
+ * (`tarballs`, `metadata`); `layoutVersion` lets one cache evolve its on-disk
21
+ * shape without invalidating its siblings.
22
+ */
23
+ export function gjsifyCacheRoot(kind, layoutVersion) {
24
+ const xdg = process.env.XDG_CACHE_HOME;
25
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
26
+ return join(base, 'gjsify', kind, layoutVersion);
27
+ }
28
+ /**
29
+ * Write `bytes` to `path` atomically: write a `<path>.tmp.<pid>` sibling, then
30
+ * `rename` it into place, so a concurrent reader never observes a half-written
31
+ * file. Creates the parent directory. Best-effort — any failure (read-only /
32
+ * out-of-disk cache volume) is swallowed so a cache hiccup never breaks the
33
+ * install; the caller proceeds with its in-memory copy.
34
+ */
35
+ export function atomicWrite(path, bytes) {
36
+ try {
37
+ mkdirSync(join(path, '..'), { recursive: true });
38
+ const tmp = `${path}.tmp.${process.pid}`;
39
+ writeFileSync(tmp, bytes);
40
+ renameSync(tmp, path);
41
+ }
42
+ catch {
43
+ /* best-effort — a cache-write failure must not break the install */
44
+ }
45
+ }
46
+ /**
47
+ * Read a cache file, returning its bytes on a HIT or `null` on a MISS. A
48
+ * missing file, a zero-byte file (an interrupted previous write), or any read
49
+ * error are all treated as a MISS — the file is left untouched so a follow-up
50
+ * writer's atomic rename isn't disturbed.
51
+ */
52
+ export function readCacheFile(path) {
53
+ if (!existsSync(path))
54
+ return null;
55
+ try {
56
+ const buf = readFileSync(path);
57
+ if (buf.length === 0)
58
+ return null;
59
+ return buf;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
@@ -0,0 +1,18 @@
1
+ import type { Packument } from '@gjsify/npm-registry';
2
+ export interface CachedPackument {
3
+ etag: string;
4
+ packument: Packument;
5
+ }
6
+ /**
7
+ * Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
8
+ * a miss, when the cache is disabled, or when the entry is unreadable/corrupt
9
+ * (treated as a miss — the caller just re-fetches).
10
+ */
11
+ export declare function getCachedPackument(registry: string, name: string): CachedPackument | null;
12
+ /**
13
+ * Persist a packument + ETag for `(registry, name)`. Writes to a `<path>.tmp.<pid>`
14
+ * sibling then atomically renames so a concurrent reader never sees a partial
15
+ * write. No-op when the cache is disabled, the ETag is empty, or the write
16
+ * fails (read-only / out-of-disk cache volume must not break the install).
17
+ */
18
+ export declare function putCachedPackument(registry: string, name: string, etag: string, packument: Packument): void;
@@ -0,0 +1,98 @@
1
+ // On-disk packument metadata cache for `gjsify install`.
2
+ //
3
+ // Sibling to the content-addressable tarball cache (install-tarball-cache.ts).
4
+ // Stores each package's abbreviated packument plus the registry `ETag` it came
5
+ // with, so a re-resolve (lockfile miss — e.g. a dependency range changed) can
6
+ // send a conditional `If-None-Match` and turn the unchanged majority into empty
7
+ // `304 Not Modified` responses — a real bandwidth + parse saving on repeated /
8
+ // dep-churn installs, on top of the ~4× gzip transfer for the changed minority
9
+ // (packuments are fetched gzip-compressed and decoded by the fetch layer; see
10
+ // `@gjsify/npm-registry` `fetchPackumentConditional`).
11
+ //
12
+ // Mirrors pnpm's metadata cache + npm's make-fetch-happen HTTP-cache layer.
13
+ //
14
+ // Layout:
15
+ //
16
+ // $XDG_CACHE_HOME/gjsify/metadata/v1/<shard>/<encoded-registry>|<encoded-name>.json
17
+ //
18
+ // The cache is keyed by (registry, name) — NOT name alone — so switching the
19
+ // registry for a scope can never serve a packument from the wrong source on a
20
+ // coincidental ETag match. `<shard>` is a 2-hex FNV-1a digest of the key, a
21
+ // directory-fan-out step so the leaf dir never grows unbounded (same rationale
22
+ // as the tarball cache's hex sharding). `v1` is a layout version.
23
+ //
24
+ // Disabled with `GJSIFY_PACKUMENT_CACHE=0` (or `false`). Honours
25
+ // `XDG_CACHE_HOME` like the tarball + dlx caches.
26
+ import { join } from 'node:path';
27
+ import { atomicWrite, gjsifyCacheRoot, readCacheFile } from './install-cache-fs.js';
28
+ const CACHE_LAYOUT_VERSION = 'v1';
29
+ /** `true` unless `GJSIFY_PACKUMENT_CACHE` is `0` / `false` / empty. */
30
+ function isEnabled() {
31
+ const flag = process.env.GJSIFY_PACKUMENT_CACHE;
32
+ if (flag === undefined)
33
+ return true;
34
+ const trimmed = flag.trim();
35
+ return !(trimmed === '0' || trimmed === 'false' || trimmed === '');
36
+ }
37
+ /** Root of the packument cache: `$XDG_CACHE_HOME/gjsify/metadata/v1`. */
38
+ function cacheRoot() {
39
+ return gjsifyCacheRoot('metadata', CACHE_LAYOUT_VERSION);
40
+ }
41
+ /** FNV-1a 32-bit → 2 hex chars. Directory-fan-out only; not security-sensitive. */
42
+ function shardFor(key) {
43
+ let h = 0x811c9dc5;
44
+ for (let i = 0; i < key.length; i++) {
45
+ h ^= key.charCodeAt(i);
46
+ h = Math.imul(h, 0x01000193);
47
+ }
48
+ return ((h >>> 0) & 0xff).toString(16).padStart(2, '0');
49
+ }
50
+ /** Filesystem path for a (registry, name) pair, or `null` when disabled. */
51
+ function pathFor(registry, name) {
52
+ if (!isEnabled())
53
+ return null;
54
+ const key = `${registry}|${name}`;
55
+ // encodeURIComponent makes both halves filesystem-safe (`/` → `%2F`, etc.)
56
+ // while staying a stable, reversible, single path segment.
57
+ const file = `${encodeURIComponent(registry)}|${encodeURIComponent(name)}.json`;
58
+ return join(cacheRoot(), shardFor(key), file);
59
+ }
60
+ /**
61
+ * Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
62
+ * a miss, when the cache is disabled, or when the entry is unreadable/corrupt
63
+ * (treated as a miss — the caller just re-fetches).
64
+ */
65
+ export function getCachedPackument(registry, name) {
66
+ const path = pathFor(registry, name);
67
+ if (!path)
68
+ return null;
69
+ const buf = readCacheFile(path);
70
+ if (!buf)
71
+ return null;
72
+ try {
73
+ const parsed = JSON.parse(buf.toString('utf-8'));
74
+ if (typeof parsed.etag !== 'string' || !parsed.etag)
75
+ return null;
76
+ if (!parsed.packument || typeof parsed.packument !== 'object')
77
+ return null;
78
+ return { etag: parsed.etag, packument: parsed.packument };
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /**
85
+ * Persist a packument + ETag for `(registry, name)`. Writes to a `<path>.tmp.<pid>`
86
+ * sibling then atomically renames so a concurrent reader never sees a partial
87
+ * write. No-op when the cache is disabled, the ETag is empty, or the write
88
+ * fails (read-only / out-of-disk cache volume must not break the install).
89
+ */
90
+ export function putCachedPackument(registry, name, etag, packument) {
91
+ if (!etag)
92
+ return;
93
+ const path = pathFor(registry, name);
94
+ if (!path)
95
+ return;
96
+ const entry = { etag, packument };
97
+ atomicWrite(path, JSON.stringify(entry));
98
+ }
@@ -21,3 +21,11 @@ export declare function putCachedTarball(integrity: string | undefined, bytes: U
21
21
  */
22
22
  export declare function cacheRootForLogging(): string;
23
23
  export declare function isCacheHit(integrity: string | undefined): boolean;
24
+ /**
25
+ * Read a tarball from npm's cacache content store by SRI integrity. Returns
26
+ * the raw `.tgz` bytes on a HIT, `null` on a MISS / disabled interop / read
27
+ * failure. Like {@link getCachedTarball}, this trusts the content-addressed
28
+ * path rather than re-hashing — the extractor surfaces any genuinely corrupt
29
+ * tarball loudly, and cacache verified the bytes on write.
30
+ */
31
+ export declare function getForeignCachedTarball(integrity: string | undefined): Uint8Array | null;