From 428644c788fd81d5b7ae070deaf97f7859793a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Louren=C3=A7o?= Date: Tue, 23 Sep 2025 22:47:29 -0300 Subject: [PATCH] fix: lazy load imports --- lib/dir.js | 15 ++++------ lib/fetcher.js | 59 ++++++++++++++++++------------------- lib/file.js | 6 ++-- lib/git.js | 28 +++++++++--------- lib/lazy.js | 75 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/registry.js | 27 +++++++++-------- lib/remote.js | 8 ++++-- lib/util/npm.js | 3 +- test/lazy.js | 7 +++++ test/registry.js | 23 ++++++++------- 10 files changed, 167 insertions(+), 84 deletions(-) create mode 100644 lib/lazy.js create mode 100644 test/lazy.js diff --git a/lib/dir.js b/lib/dir.js index 04846eb8..0c92f259 100644 --- a/lib/dir.js +++ b/lib/dir.js @@ -1,10 +1,6 @@ const { resolve } = require('node:path') -const packlist = require('npm-packlist') -const runScript = require('@npmcli/run-script') -const tar = require('tar') -const { Minipass } = require('minipass') +const { lazyRunScript, lazyMinipass, lazyNpmPacklist, lazyTar, lazyFileFetcher } = require('./lazy.js') const Fetcher = require('./fetcher.js') -const FileFetcher = require('./file.js') const _ = require('./util/protected.js') const tarCreateOptions = require('./util/tar-create-options.js') @@ -41,7 +37,7 @@ class DirFetcher extends Fetcher { // but this function is *also* run when installing git deps const stdio = this.opts.foregroundScripts ? 'inherit' : 'pipe' - return runScript({ + return lazyRunScript()({ // this || undefined is because runScript will be unhappy with the default null value scriptShell: this.opts.scriptShell || undefined, pkg: mani, @@ -62,6 +58,7 @@ class DirFetcher extends Fetcher { throw new Error('DirFetcher requires either a tree or an Arborist constructor to pack') } + const { Minipass } = lazyMinipass() const stream = new Minipass() stream.resolved = this.resolved stream.integrity = this.integrity @@ -76,9 +73,9 @@ class DirFetcher extends Fetcher { const arb = new this.Arborist({ path: this.resolved }) this.tree = await arb.loadActual() } - return packlist(this.tree, { path: this.resolved, prefix, workspaces }) + return lazyNpmPacklist()(this.tree, { path: this.resolved, prefix, workspaces }) }) - .then(files => tar.c(tarCreateOptions(this.package), files) + .then(files => lazyTar().c(tarCreateOptions(this.package), files) .on('error', er => stream.emit('error', er)).pipe(stream)) .catch(er => stream.emit('error', er)) return stream @@ -99,7 +96,7 @@ class DirFetcher extends Fetcher { } packument () { - return FileFetcher.prototype.packument.apply(this) + return lazyFileFetcher().prototype.packument.apply(this) } } module.exports = DirFetcher diff --git a/lib/fetcher.js b/lib/fetcher.js index f2ac9761..adc4c613 100644 --- a/lib/fetcher.js +++ b/lib/fetcher.js @@ -5,31 +5,25 @@ const { basename, dirname } = require('node:path') const { rm, mkdir } = require('node:fs/promises') -const PackageJson = require('@npmcli/package-json') -const cacache = require('cacache') -const fsm = require('fs-minipass') const getContents = require('@npmcli/installed-package-contents') -const npa = require('npm-package-arg') const retry = require('promise-retry') -const ssri = require('ssri') -const tar = require('tar') -const { Minipass } = require('minipass') const { log } = require('proc-log') const _ = require('./util/protected.js') const cacheDir = require('./util/cache-dir.js') const isPackageBin = require('./util/is-package-bin.js') const removeTrailingSlashes = require('./util/trailing-slashes.js') +const { lazyMinipass, lazyPackageJson, lazyNpa, lazySsri, lazyCacache, lazyFsm, lazyTar } = require('./lazy.js') // Pacote is only concerned with the package.json contents -const packageJsonPrepare = (p) => PackageJson.prepare(p).then(pkg => pkg.content) -const packageJsonNormalize = (p) => PackageJson.normalize(p).then(pkg => pkg.content) +const packageJsonPrepare = (p) => lazyPackageJson().prepare(p).then(pkg => pkg.content) +const packageJsonNormalize = (p) => lazyPackageJson().normalize(p).then(pkg => pkg.content) class FetcherBase { constructor (spec, opts) { if (!opts || typeof opts !== 'object') { throw new TypeError('options object is required') } - this.spec = npa(spec, opts.where) + this.spec = lazyNpa()(spec, opts.where) this.allowGitIgnore = !!opts.allowGitIgnore @@ -57,7 +51,7 @@ class FetcherBase { this.defaultIntegrityAlgorithm = opts.defaultIntegrityAlgorithm || 'sha512' if (typeof opts.integrity === 'string') { - this.opts.integrity = ssri.parse(opts.integrity) + this.opts.integrity = lazySsri().parse(opts.integrity) } this.package = null @@ -130,7 +124,7 @@ class FetcherBase { return } - i = ssri.parse(i) + i = lazySsri().parse(i) const current = this.opts.integrity // do not ever update an existing hash value, but do @@ -189,7 +183,7 @@ class FetcherBase { // Note: cacache will raise a EINTEGRITY error if the integrity doesn't match #tarballFromCache () { const startTime = Date.now() - const stream = cacache.get.stream.byDigest(this.cache, this.integrity, this.opts) + const stream = lazyCacache().get.stream.byDigest(this.cache, this.integrity, this.opts) const elapsedTime = Date.now() - startTime // cache is good, so log it as a hit in particular since there was no fetch logged log.http( @@ -213,7 +207,7 @@ class FetcherBase { return stream } - const istream = ssri.integrityStream(this.opts) + const istream = lazySsri().integrityStream(this.opts) istream.on('integrity', i => this.integrity = i) stream.on('error', err => istream.emit('error', err)) return stream.pipe(istream) @@ -226,10 +220,11 @@ class FetcherBase { // the cache AFTER we pipe into the middleStream. Since the cache stream // has an asynchronous flush to write its contents to disk, we need to // defer the middleStream end until the cache stream ends. + const { Minipass } = lazyMinipass() const middleStream = new Minipass() stream.on('error', err => middleStream.emit('error', err)) stream.pipe(middleStream, { end: false }) - const cstream = cacache.put.stream( + const cstream = lazyCacache().put.stream( this.opts.cache, `pacote:tarball:${this.from}`, this.opts @@ -340,7 +335,7 @@ class FetcherBase { } cleanupCached () { - return cacache.rm.content(this.cache, this.integrity, this.opts) + return lazyCacache().rm.content(this.cache, this.integrity, this.opts) } #empty (path) { @@ -361,6 +356,8 @@ class FetcherBase { } #toFile (dest) { + const fsm = lazyFsm() + return this.tarballStream(str => new Promise((res, rej) => { const writer = new fsm.WriteStream(dest) str.on('error', er => writer.emit('error', er)) @@ -382,6 +379,7 @@ class FetcherBase { } #extract (dest, tarball) { + const tar = lazyTar() const extractor = tar.x(this.#tarxOptions({ cwd: dest })) const p = new Promise((resolve, reject) => { extractor.on('end', () => { @@ -462,34 +460,37 @@ class FetcherBase { module.exports = FetcherBase -// Child classes -const GitFetcher = require('./git.js') -const RegistryFetcher = require('./registry.js') -const FileFetcher = require('./file.js') -const DirFetcher = require('./dir.js') -const RemoteFetcher = require('./remote.js') - // Get an appropriate fetcher object from a spec and options FetcherBase.get = (rawSpec, opts = {}) => { - const spec = npa(rawSpec, opts.where) + const spec = lazyNpa()(rawSpec, opts.where) switch (spec.type) { - case 'git': + case 'git': { + const GitFetcher = require('./git.js') return new GitFetcher(spec, opts) + } - case 'remote': + case 'remote': { + const RemoteFetcher = require('./remote.js') return new RemoteFetcher(spec, opts) + } case 'version': case 'range': case 'tag': - case 'alias': + case 'alias': { + const RegistryFetcher = require('./registry.js') return new RegistryFetcher(spec.subSpec || spec, opts) + } - case 'file': + case 'file': { + const FileFetcher = require('./file.js') return new FileFetcher(spec, opts) + } - case 'directory': + case 'directory': { + const DirFetcher = require('./dir.js') return new DirFetcher(spec, opts) + } default: throw new TypeError('Unknown spec type: ' + spec.type) diff --git a/lib/file.js b/lib/file.js index 20213250..34ccd027 100644 --- a/lib/file.js +++ b/lib/file.js @@ -1,9 +1,8 @@ const { resolve } = require('node:path') const { stat, chmod } = require('node:fs/promises') -const cacache = require('cacache') -const fsm = require('fs-minipass') const Fetcher = require('./fetcher.js') const _ = require('./util/protected.js') +const { lazyCacache, lazyFsm } = require('./lazy.js') class FileFetcher extends Fetcher { constructor (spec, opts) { @@ -22,7 +21,7 @@ class FileFetcher extends Fetcher { } // have to unpack the tarball for this. - return cacache.tmp.withTmp(this.cache, this.opts, dir => + return lazyCacache().tmp.withTmp(this.cache, this.opts, dir => this.extract(dir) .then(() => this[_.readPackageJson](dir)) .then(mani => this.package = { @@ -67,6 +66,7 @@ class FileFetcher extends Fetcher { } [_.tarballFromResolved] () { + const fsm = lazyFsm() // create a read stream and return it return new fsm.ReadStream(this.resolved) } diff --git a/lib/git.js b/lib/git.js index 077193a8..33cd3188 100644 --- a/lib/git.js +++ b/lib/git.js @@ -1,13 +1,6 @@ -const cacache = require('cacache') -const git = require('@npmcli/git') -const npa = require('npm-package-arg') -const pickManifest = require('npm-pick-manifest') -const { Minipass } = require('minipass') +const { lazyGit, lazyNpa, lazyPickManifest, lazyMinipass, lazyCacache } = require('./lazy.js') const { log } = require('proc-log') -const DirFetcher = require('./dir.js') const Fetcher = require('./fetcher.js') -const FileFetcher = require('./file.js') -const RemoteFetcher = require('./remote.js') const _ = require('./util/protected.js') const addGitSha = require('./util/add-git-sha.js') const npm = require('./util/npm.js') @@ -87,7 +80,7 @@ class GitFetcher extends Fetcher { #resolvedFromHosted (hosted) { return this.#resolvedFromRepo(hosted.https && hosted.https()).catch(er => { // Throw early since we know pathspec errors will fail again if retried - if (er instanceof git.errors.GitPathspecError) { + if (er instanceof lazyGit().errors.GitPathspecError) { throw er } const ssh = hosted.sshurl && hosted.sshurl() @@ -106,8 +99,8 @@ class GitFetcher extends Fetcher { } const gitRange = this.spec.gitRange const name = this.spec.name - return git.revs(gitRemote, this.opts).then(remoteRefs => { - return gitRange ? pickManifest({ + return lazyGit().revs(gitRemote, this.opts).then(remoteRefs => { + return gitRange ? lazyPickManifest()({ versions: remoteRefs.versions, 'dist-tags': remoteRefs['dist-tags'], name, @@ -134,7 +127,7 @@ class GitFetcher extends Fetcher { // we haven't cloned, so a tgz download is still faster // of course, if it's not a known host, we can't do that. this.resolved = !this.spec.hosted ? withSha - : repoUrl(npa(withSha).hosted, { noCommittish: false }) + : repoUrl(lazyNpa()(withSha).hosted, { noCommittish: false }) } // when we get the git sha, we affix it to our spec to build up @@ -191,10 +184,12 @@ class GitFetcher extends Fetcher { } [_.tarballFromResolved] () { + const { Minipass } = lazyMinipass() const stream = new Minipass() stream.resolved = this.resolved stream.from = this.from + const DirFetcher = require('./dir.js') // check it out and then shell out to the DirFetcher tarball packer this.#clone(dir => this.#prepareDir(dir) .then(() => new Promise((res, rej) => { @@ -235,10 +230,11 @@ class GitFetcher extends Fetcher { tarballOk = tarballOk && h && resolved === repoUrl(h, { noCommittish: false }) && h.tarball - return cacache.tmp.withTmp(this.cache, o, async tmp => { + return lazyCacache().tmp.withTmp(this.cache, o, async tmp => { // if we're resolved, and have a tarball url, shell out to RemoteFetcher if (tarballOk) { const nameat = this.spec.name ? `${this.spec.name}@` : '' + const RemoteFetcher = require('./remote.js') return new RemoteFetcher(h.tarball({ noCommittish: false }), { ...this.opts, allowGitIgnore: true, @@ -277,7 +273,7 @@ class GitFetcher extends Fetcher { return this.#cloneRepo(hosted.https({ noCommittish: true }), ref, tmp) .catch(er => { // Throw early since we know pathspec errors will fail again if retried - if (er instanceof git.errors.GitPathspecError) { + if (er instanceof lazyGit().errors.GitPathspecError) { throw er } const ssh = hosted.sshurl && hosted.sshurl({ noCommittish: true }) @@ -291,7 +287,7 @@ class GitFetcher extends Fetcher { #cloneRepo (repo, ref, tmp) { const { opts, spec } = this - return git.clone(repo, ref, tmp, { ...opts, spec }) + return lazyGit().clone(repo, ref, tmp, { ...opts, spec }) } manifest () { @@ -299,6 +295,7 @@ class GitFetcher extends Fetcher { return Promise.resolve(this.package) } + const FileFetcher = require('./file.js') return this.spec.hosted && this.resolved ? FileFetcher.prototype.manifest.apply(this) : this.#clone(dir => @@ -311,6 +308,7 @@ class GitFetcher extends Fetcher { } packument () { + const FileFetcher = require('./file.js') return FileFetcher.prototype.packument.apply(this) } } diff --git a/lib/lazy.js b/lib/lazy.js new file mode 100644 index 00000000..09519237 --- /dev/null +++ b/lib/lazy.js @@ -0,0 +1,75 @@ +let git +let npa +let pickManifest +let crypto +let runScript +let minipass +let tar +let npmPackList +let fileFetcher +let cacache +let ssri +let packageJson +let fsm +let fetchObj +let sigstore +module.exports = { + createCryptoVerify (alg) { + return (crypto ??= require('node:crypto')).createVerify(alg) + }, + /** @returns {import('@npmcli/git')} */ + lazyGit () { + return git ??= require('@npmcli/git') + }, + /** @returns {import('npm-package-arg')} */ + lazyNpa () { + return npa ??= require('npm-package-arg') + }, + /** @returns {import('npm-pick-manifest')} */ + lazyPickManifest () { + return pickManifest ??= require('npm-pick-manifest') + }, + /** @returns {import('@npmcli/run-script')} */ + lazyRunScript () { + return runScript ??= require('@npmcli/run-script') + }, + /** @returns {import('minipass')} */ + lazyMinipass () { + return minipass ??= require('minipass') + }, + /** @returns {import('tar')} */ + lazyTar () { + return tar ??= require('tar') + }, + /** @returns {import('npm-packlist')} */ + lazyNpmPacklist () { + return npmPackList ??= require('npm-packlist') + }, + /** @returns {import('./file.js')} */ + lazyFileFetcher () { + return fileFetcher ??= require('./file.js') + }, + /** @returns {import('cacache')} */ + lazyCacache () { + return cacache ??= require('cacache') + }, + /** @returns {import('ssri')} */ + lazySsri () { + return ssri ??= require('ssri') + }, + /** @returns {import('@npmcli/package-json')} */ + lazyPackageJson () { + return packageJson ??= require('@npmcli/package-json') + }, + /** @returns {import('fs-minipass')} */ + lazyFsm () { + return fsm ??= require('fs-minipass') + }, + /** @returns {import('npm-registry-fetch')} */ + lazyFetch () { + return fetchObj ??= require('npm-registry-fetch') + }, + lazySigstore () { + return sigstore ??= require('sigstore') + }, +} diff --git a/lib/registry.js b/lib/registry.js index 1ecf4ee1..41951ac9 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -1,12 +1,5 @@ -const crypto = require('node:crypto') -const PackageJson = require('@npmcli/package-json') -const pickManifest = require('npm-pick-manifest') -const ssri = require('ssri') -const npa = require('npm-package-arg') -const sigstore = require('sigstore') -const fetch = require('npm-registry-fetch') +const { lazyFetch, lazyPackageJson, lazySsri, lazyPickManifest, lazyNpa, createCryptoVerify, lazySigstore } = require('./lazy.js') const Fetcher = require('./fetcher.js') -const RemoteFetcher = require('./remote.js') const pacoteVersion = require('../package.json').version const removeTrailingSlashes = require('./util/trailing-slashes.js') const _ = require('./util/protected.js') @@ -32,7 +25,7 @@ class RegistryFetcher extends Fetcher { // already. this.packumentCache = this.opts.packumentCache || null - this.registry = fetch.pickRegistry(spec, opts) + this.registry = lazyFetch().pickRegistry(spec, opts) this.packumentUrl = `${removeTrailingSlashes(this.registry)}/${this.spec.escapedName}` this.#cacheKey = `${this.fullMetadata ? 'full' : 'corgi'}:${this.packumentUrl}` @@ -87,7 +80,7 @@ class RegistryFetcher extends Fetcher { // set the appropriate header for corgis if fullMetadata isn't set // return the res.json() promise try { - const res = await fetch(this.packumentUrl, { + const res = await lazyFetch()(this.packumentUrl, { ...this.opts, headers: this.#headers(), spec: this.spec, @@ -125,7 +118,12 @@ class RegistryFetcher extends Fetcher { this.fullMetadata = true } + const PackageJson = lazyPackageJson() + const ssri = lazySsri() + const pickManifest = lazyPickManifest() + const packument = await this.packument() + const steps = PackageJson.normalizeSteps.filter(s => s !== '_attributes') const mani = await new PackageJson().fromContent(pickManifest(packument, this.spec.fetchSpec, { ...this.opts, @@ -198,7 +196,7 @@ class RegistryFetcher extends Fetcher { `but the corresponding public key has expired ${publicKey.expires}` ), { code: 'EEXPIREDSIGNATUREKEY' }) } - const verifier = crypto.createVerify('SHA256') + const verifier = createCryptoVerify('SHA256') verifier.write(message) verifier.end() const valid = verifier.verify( @@ -230,7 +228,7 @@ class RegistryFetcher extends Fetcher { // Always fetch attestations from the current registry host const attestationsPath = new URL(dist.attestations.url).pathname const attestationsUrl = removeTrailingSlashes(this.registry) + attestationsPath - const res = await fetch(attestationsUrl, { + const res = await lazyFetch()(attestationsUrl, { ...this.opts, // disable integrity check for attestations json payload, we check the // integrity in the verification steps below @@ -294,7 +292,7 @@ class RegistryFetcher extends Fetcher { } // Only type 'version' can be turned into a PURL - const purl = this.spec.type === 'version' ? npa.toPurl(this.spec) : this.spec + const purl = this.spec.type === 'version' ? lazyNpa().toPurl(this.spec) : this.spec // Verify the statement subject matches the package, version if (subject.name !== purl) { throw Object.assign(new Error( @@ -324,7 +322,7 @@ class RegistryFetcher extends Fetcher { tufForceCache: true, keySelector: publicKey ? () => publicKey.pemkey : undefined, } - await sigstore.verify(bundle, options) + await lazySigstore().verify(bundle, options) } catch (e) { throw Object.assign(new Error( `${mani._id} failed to verify attestation: ${e.message}` @@ -350,6 +348,7 @@ class RegistryFetcher extends Fetcher { } [_.tarballFromResolved] () { + const RemoteFetcher = require('./remote.js') // we use a RemoteFetcher to get the actual tarball stream return new RemoteFetcher(this.resolved, { ...this.opts, diff --git a/lib/remote.js b/lib/remote.js index bd321e65..503d5b29 100644 --- a/lib/remote.js +++ b/lib/remote.js @@ -1,7 +1,5 @@ -const fetch = require('npm-registry-fetch') -const { Minipass } = require('minipass') const Fetcher = require('./fetcher.js') -const FileFetcher = require('./file.js') +const { lazyFetch } = require('./lazy.js') const _ = require('./util/protected.js') const pacoteVersion = require('../package.json').version @@ -28,6 +26,7 @@ class RemoteFetcher extends Fetcher { } [_.tarballFromResolved] () { + const { Minipass } = require('minipass') const stream = new Minipass() stream.hasIntegrityEmitter = true @@ -39,6 +38,7 @@ class RemoteFetcher extends Fetcher { algorithms: [this.pickIntegrityAlgorithm()], } + const fetch = lazyFetch() // eslint-disable-next-line promise/always-return fetch(this.resolved, fetchOpts).then(res => { res.body.on('error', @@ -79,10 +79,12 @@ class RemoteFetcher extends Fetcher { // getting a packument and/or manifest is the same as with a file: spec. // unpack the tarball stream, and then read from the package.json file. packument () { + const FileFetcher = require('./file.js') return FileFetcher.prototype.packument.apply(this) } manifest () { + const FileFetcher = require('./file.js') return FileFetcher.prototype.manifest.apply(this) } } diff --git a/lib/util/npm.js b/lib/util/npm.js index a3005c25..1fa74d2c 100644 --- a/lib/util/npm.js +++ b/lib/util/npm.js @@ -1,7 +1,8 @@ // run an npm command -const spawn = require('@npmcli/promise-spawn') +let spawn module.exports = (npmBin, npmCommand, cwd, env, extra) => { + spawn ??= require('@npmcli/promise-spawn') const isJS = npmBin.endsWith('.js') const cmd = isJS ? process.execPath : npmBin const args = (isJS ? [npmBin] : []).concat(npmCommand) diff --git a/test/lazy.js b/test/lazy.js new file mode 100644 index 00000000..4aa26679 --- /dev/null +++ b/test/lazy.js @@ -0,0 +1,7 @@ +const t = require('tap') +const { lazySigstore } = require('../lib/lazy') + +t.test('lazySigstore', t => { + t.strictEqual(require('sigstore'), lazySigstore()) + t.end() +}) diff --git a/test/registry.js b/test/registry.js index f80abf33..651db022 100644 --- a/test/registry.js +++ b/test/registry.js @@ -7,16 +7,19 @@ const tnock = require('./fixtures/tnock') const RegistryFetcher = require('../lib/registry.js') const MockedRegistryFetcher = t.mock('../lib/registry.js', { - sigstore: { - verify: async (bundle, options) => { - options.keySelector && options.keySelector() - if (bundle.dsseEnvelope.payloadType === 'tlog-entry-mismatch') { - throw new Error('bundle content and tlog entry do not match') - } - if (bundle.dsseEnvelope.signatures[0].sig === 'invalid-signature') { - throw new Error('artifact signature verification failed') - } - }, + '../lib/lazy.js': { + ...require('../lib/lazy.js'), + lazySigstore: () => ({ + verify: async (bundle, options) => { + options.keySelector && options.keySelector() + if (bundle.dsseEnvelope.payloadType === 'tlog-entry-mismatch') { + throw new Error('bundle content and tlog entry do not match') + } + if (bundle.dsseEnvelope.signatures[0].sig === 'invalid-signature') { + throw new Error('artifact signature verification failed') + } + }, + }), }, })