@gjsify/cli 0.4.36 → 0.4.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.gjs.mjs +155 -145
- package/lib/commands/index.d.ts +2 -0
- package/lib/commands/index.js +2 -0
- package/lib/commands/login.d.ts +10 -0
- package/lib/commands/login.js +139 -0
- package/lib/commands/logout.d.ts +8 -0
- package/lib/commands/logout.js +63 -0
- package/lib/commands/publish.js +7 -7
- package/lib/index.js +3 -1
- package/lib/utils/auth-npmrc.d.ts +22 -0
- package/lib/utils/auth-npmrc.js +89 -0
- package/lib/utils/install-backend-native.js +190 -86
- package/lib/utils/install-cache-fs.d.ts +22 -0
- package/lib/utils/install-cache-fs.js +64 -0
- package/lib/utils/install-packument-cache.d.ts +18 -0
- package/lib/utils/install-packument-cache.js +98 -0
- package/lib/utils/install-tarball-cache.d.ts +8 -0
- package/lib/utils/install-tarball-cache.js +90 -38
- package/lib/utils/prompt.d.ts +8 -0
- package/lib/utils/prompt.js +92 -0
- package/lib/utils/publish-diagnose.js +9 -5
- package/package.json +16 -16
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
164
|
-
//
|
|
165
|
-
// already
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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;
|