From 9b958ebdc88127d433a4c9fe62f595ed54fe5bcf Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 8 Jul 2026 19:18:46 +0700 Subject: [PATCH 1/5] fix: download p2pd on first use if the postinstall did not run npm v12 makes install scripts opt-in, and --ignore-scripts, pnpm and hardened CI already skip them. Keep the postinstall as a best-effort fast path, but fall back to downloading from path() (running the installer in a child process) when the binary is missing. --- src/index.js | 85 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/src/index.js b/src/index.js index 0d609b3..9c43500 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import fs from 'node:fs' import { resolve, join } from 'node:path' import * as url from 'node:url' @@ -5,25 +6,77 @@ const isWin = process.platform === 'win32' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) +const binaryLocations = isWin + ? [ + resolve(join(__dirname, '..', 'p2pd.exe')), + resolve(join(__dirname, '..', 'bin/p2pd.exe')) + ] + : [ + resolve(join(__dirname, '..', 'p2pd')), + resolve(join(__dirname, '..', 'bin/p2pd')) + ] + +// The package ships bin/p2pd as a small JS stub that the installer replaces with +// the real binary. Treat the stub (a "#!" shebang script) as not-yet-installed +// so first use triggers the download rather than returning the placeholder. /** - * @returns {string} + * @param {string} file + * @returns {boolean} */ -export function path () { - const paths = isWin - ? [ - resolve(join(__dirname, '..', 'p2pd.exe')), - resolve(join(__dirname, '..', 'bin/p2pd.exe')) - ] - : [ - resolve(join(__dirname, '..', 'p2pd')), - resolve(join(__dirname, '..', 'bin/p2pd')) - ] - - for (const bin of paths) { - if (fs.existsSync(bin)) { - return bin +function isRealBinary (file) { + let fd + try { + fd = fs.openSync(file, 'r') + const header = Buffer.alloc(2) + fs.readSync(fd, header, 0, 2, 0) + return header.toString('latin1') !== '#!' + } catch { + return false + } finally { + if (fd != null) { + fs.closeSync(fd) } } +} + +// Returns the installed binary, or undefined if it has not been downloaded yet. +const findBinary = () => binaryLocations.find(isRealBinary) + +// Download the binary by running the installer in a child process. The installer +// is async, so running it synchronously here keeps path() synchronous while +// reusing the same download, checksum and extraction logic. npm v12 makes +// install scripts opt-in, so the binary usually arrives here on first use rather +// than from a postinstall script. +function installBinary () { + execFileSync(process.execPath, [join(__dirname, 'post-install.js')], { + cwd: resolve(join(__dirname, '..')), + // The installer logs progress to stdout; send it to this process's stderr so + // a programmatic path() keeps its own stdout clean for the value it returns. + stdio: ['ignore', 2, 2] + }) +} + +/** + * Resolve the path to the p2pd binary, downloading it on first use when it has + * not been installed yet. Pass `{ autoDownload: false }` to resolve only an + * already-installed binary and throw when it is missing. + * + * @param {{ autoDownload?: boolean }} [options] + * @returns {string} + */ +export function path (options = {}) { + const { autoDownload = true } = options + + let binary = findBinary() + + if (binary == null && autoDownload) { + installBinary() + binary = findBinary() + } + + if (binary == null) { + throw new Error('p2pd binary not found, it may not be installed or an error may have occurred during installation') + } - throw new Error('p2pd binary not found, it may not be installed or an error may have occurred during installation') + return binary } From 55ef929dfc80703fb6195ecb8ea6da5f50053a8e Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 8 Jul 2026 19:18:46 +0700 Subject: [PATCH 2/5] fix: download p2pd with got instead of the global fetch Draining a global-fetch response body stalls indefinitely on some platforms (notably GitHub's macos-26 runners); got reads it reliably and handles retries/backoff. Drops the now-unused browser-readablestream-to-it, it-to-buffer, p-retry and delay deps. --- package.json | 5 +--- src/download.js | 64 ++++++++++++------------------------------------- 2 files changed, 16 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index cedfeb9..98e65b6 100644 --- a/package.json +++ b/package.json @@ -158,15 +158,12 @@ }, "dependencies": { "blockstore-core": "^6.1.2", - "browser-readablestream-to-it": "^2.0.7", "cachedir": "^2.3.0", - "delay": "^7.0.0", + "got": "^14.4.7", "gunzip-maybe": "^1.4.2", "ipfs-unixfs-importer": "^16.1.1", "it-last": "^3.0.2", - "it-to-buffer": "^4.0.7", "multiformats": "^13.1.0", - "p-retry": "^7.0.0", "package-config": "^5.0.0", "tar-fs": "^3.0.6", "tar-stream": "^3.1.7", diff --git a/src/download.js b/src/download.js index 79ffa0b..94cf240 100644 --- a/src/download.js +++ b/src/download.js @@ -12,13 +12,10 @@ import os from 'node:os' import path from 'node:path' import * as url from 'node:url' import util from 'node:util' -import browserReadableStreamToIt from 'browser-readablestream-to-it' import cachedir from 'cachedir' -import delay from 'delay' +import got from 'got' import gunzip from 'gunzip-maybe' -import toBuffer from 'it-to-buffer' import { CID } from 'multiformats/cid' -import retry from 'p-retry' import { packageConfigSync } from 'package-config' import tarFS from 'tar-fs' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' @@ -40,13 +37,12 @@ const DOWNLOAD_TIMEOUT_MS = 60000 * * @param {string} url * @param {string} cid - * @param {{ retries?: number, retryDelay?: number }} [options] + * @param {{ retries?: number }} [options] */ async function cachingFetchAndVerify (url, cid, options = {}) { const cacheDir = process.env.NPM_GO_LIBP2P_CACHE || cachedir('npm-go-libp2p') const filename = url.split('/').pop() const retries = options.retries ?? 10 - const retryDelay = options.retryDelay ?? 5000 if (!filename) { throw new Error('Invalid URL') @@ -62,42 +58,17 @@ async function cachingFetchAndVerify (url, cid, options = {}) { console.info(`Cached file ${cachedFilePath} not found`) console.info(`Downloading ${url} to ${cacheDir}`) - const buf = await retry(async () => { - const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) - - try { - const res = await fetch(url, { - signal - }) - - console.info(`${url} ${res.status} ${res.statusText}`) - - if (!res.ok) { - throw new Error(`${res.status}: ${res.statusText}`) - } - - const body = res.body - - if (body == null) { - throw new Error('Response had no body') - } - - return await toBuffer(browserReadableStreamToIt(body)) - } catch (err) { - if (signal.aborted) { - console.error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS}ms`) - } - - throw err - } - }, { - retries, - onFailedAttempt: async (err) => { - console.error('Attempt', err.attemptNumber, 'failed. There are', err.retriesLeft, 'retries left', err) - console.info('Waiting for', retryDelay / 1000, 'seconds before retrying') - await delay(retryDelay) + // use got rather than the global fetch - draining a fetch response body + // stalls indefinitely on some platforms (e.g. GitHub's macos-26 runners), + // whereas got reads it reliably. got also handles retries and backoff. + const buf = await got(url, { + retry: { + limit: retries + }, + timeout: { + request: DOWNLOAD_TIMEOUT_MS } - }) + }).buffer() // download file fs.writeFileSync(cachedFilePath, buf) @@ -154,7 +125,6 @@ function unpack (installPath, stream) { * @param {string} [options.arch] * @param {string} [options.installPath] * @param {number} [options.retries] - * @param {number} [options.retryDelay] */ function cleanArguments (options = {}) { const conf = packageConfigSync('go-libp2p', { @@ -171,8 +141,7 @@ function cleanArguments (options = {}) { arch: process.env.TARGET_ARCH ?? options.arch ?? goenv.GOARCH, distUrl: process.env.GO_LIBP2P_DIST_URL ?? conf.distUrl, installPath: options.installPath ? path.resolve(options.installPath) : process.cwd(), - retries: Number(process.env.RETRIES ?? options.retries ?? 10), - retryDelay: Number(process.env.RETRY_DELAY ?? options.retryDelay ?? 5000) + retries: Number(process.env.RETRIES ?? options.retries ?? 10) } } @@ -225,13 +194,11 @@ async function getDownloadURL (version, platform, arch, distUrl) { * @param {string} options.installPath * @param {string} options.distUrl * @param {number} options.retries - * @param {number} options.retryDelay */ -async function downloadFile ({ version, platform, arch, installPath, distUrl, retries, retryDelay }) { +async function downloadFile ({ version, platform, arch, installPath, distUrl, retries }) { const { cid, url } = await getDownloadURL(version, platform, arch, distUrl) const data = await cachingFetchAndVerify(url, cid, { - retries, - retryDelay + retries }) await unpack(installPath, data) @@ -321,7 +288,6 @@ async function link ({ depBin }) { * @param {string} [options.arch] * @param {string} [options.installPath] * @param {number} [options.retries] - * @param {number} [options.retryDelay] * @returns {Promise} */ export async function download (options = {}) { From d0f31aba39f00d304c5743b615524f46df103da0 Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 8 Jul 2026 21:20:02 +0700 Subject: [PATCH 3/5] chore: trim comments --- src/download.js | 4 +--- src/index.js | 15 --------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/src/download.js b/src/download.js index 94cf240..c49cf2b 100644 --- a/src/download.js +++ b/src/download.js @@ -58,9 +58,7 @@ async function cachingFetchAndVerify (url, cid, options = {}) { console.info(`Cached file ${cachedFilePath} not found`) console.info(`Downloading ${url} to ${cacheDir}`) - // use got rather than the global fetch - draining a fetch response body - // stalls indefinitely on some platforms (e.g. GitHub's macos-26 runners), - // whereas got reads it reliably. got also handles retries and backoff. + // use got rather than the global fetch, which can hang reading the response body in CI const buf = await got(url, { retry: { limit: retries diff --git a/src/index.js b/src/index.js index 9c43500..e64afdb 100644 --- a/src/index.js +++ b/src/index.js @@ -16,9 +16,6 @@ const binaryLocations = isWin resolve(join(__dirname, '..', 'bin/p2pd')) ] -// The package ships bin/p2pd as a small JS stub that the installer replaces with -// the real binary. Treat the stub (a "#!" shebang script) as not-yet-installed -// so first use triggers the download rather than returning the placeholder. /** * @param {string} file * @returns {boolean} @@ -39,28 +36,16 @@ function isRealBinary (file) { } } -// Returns the installed binary, or undefined if it has not been downloaded yet. const findBinary = () => binaryLocations.find(isRealBinary) -// Download the binary by running the installer in a child process. The installer -// is async, so running it synchronously here keeps path() synchronous while -// reusing the same download, checksum and extraction logic. npm v12 makes -// install scripts opt-in, so the binary usually arrives here on first use rather -// than from a postinstall script. function installBinary () { execFileSync(process.execPath, [join(__dirname, 'post-install.js')], { cwd: resolve(join(__dirname, '..')), - // The installer logs progress to stdout; send it to this process's stderr so - // a programmatic path() keeps its own stdout clean for the value it returns. stdio: ['ignore', 2, 2] }) } /** - * Resolve the path to the p2pd binary, downloading it on first use when it has - * not been installed yet. Pass `{ autoDownload: false }` to resolve only an - * already-installed binary and throw when it is missing. - * * @param {{ autoDownload?: boolean }} [options] * @returns {string} */ From 73776539ca28b962e2c9c208edb989b0d68de04d Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 8 Jul 2026 21:20:02 +0700 Subject: [PATCH 4/5] refactor: use got's default retry instead of custom retries/retryDelay --- src/download.js | 27 +++++---------------------- test/download.spec.js | 2 +- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/download.js b/src/download.js index c49cf2b..6eedf5e 100644 --- a/src/download.js +++ b/src/download.js @@ -30,19 +30,15 @@ const { latest, versions } = JSON.parse(fs.readFileSync(path.join(__dirname, 've encoding: 'utf-8' })) -const DOWNLOAD_TIMEOUT_MS = 60000 - /** * avoid expensive fetch if file is already in cache * * @param {string} url * @param {string} cid - * @param {{ retries?: number }} [options] */ -async function cachingFetchAndVerify (url, cid, options = {}) { +async function cachingFetchAndVerify (url, cid) { const cacheDir = process.env.NPM_GO_LIBP2P_CACHE || cachedir('npm-go-libp2p') const filename = url.split('/').pop() - const retries = options.retries ?? 10 if (!filename) { throw new Error('Invalid URL') @@ -59,14 +55,7 @@ async function cachingFetchAndVerify (url, cid, options = {}) { console.info(`Downloading ${url} to ${cacheDir}`) // use got rather than the global fetch, which can hang reading the response body in CI - const buf = await got(url, { - retry: { - limit: retries - }, - timeout: { - request: DOWNLOAD_TIMEOUT_MS - } - }).buffer() + const buf = await got(url).buffer() // download file fs.writeFileSync(cachedFilePath, buf) @@ -122,7 +111,6 @@ function unpack (installPath, stream) { * @param {string} [options.platform] * @param {string} [options.arch] * @param {string} [options.installPath] - * @param {number} [options.retries] */ function cleanArguments (options = {}) { const conf = packageConfigSync('go-libp2p', { @@ -138,8 +126,7 @@ function cleanArguments (options = {}) { platform: process.env.TARGET_OS ?? options.platform ?? os.platform(), arch: process.env.TARGET_ARCH ?? options.arch ?? goenv.GOARCH, distUrl: process.env.GO_LIBP2P_DIST_URL ?? conf.distUrl, - installPath: options.installPath ? path.resolve(options.installPath) : process.cwd(), - retries: Number(process.env.RETRIES ?? options.retries ?? 10) + installPath: options.installPath ? path.resolve(options.installPath) : process.cwd() } } @@ -191,13 +178,10 @@ async function getDownloadURL (version, platform, arch, distUrl) { * @param {string} options.arch * @param {string} options.installPath * @param {string} options.distUrl - * @param {number} options.retries */ -async function downloadFile ({ version, platform, arch, installPath, distUrl, retries }) { +async function downloadFile ({ version, platform, arch, installPath, distUrl }) { const { cid, url } = await getDownloadURL(version, platform, arch, distUrl) - const data = await cachingFetchAndVerify(url, cid, { - retries - }) + const data = await cachingFetchAndVerify(url, cid) await unpack(installPath, data) console.info(`Unpacked ${installPath}`) @@ -285,7 +269,6 @@ async function link ({ depBin }) { * @param {string} [options.platform] * @param {string} [options.arch] * @param {string} [options.installPath] - * @param {number} [options.retries] * @returns {Promise} */ export async function download (options = {}) { diff --git a/test/download.spec.js b/test/download.spec.js index 2717164..5d86403 100644 --- a/test/download.spec.js +++ b/test/download.spec.js @@ -34,7 +34,7 @@ describe('download', () => { it('returns an error when dist url is 404', async () => { process.env.GO_LIBP2P_DIST_URL = 'https://dist.ipfs.io/notfound' - await expect(download({ version: 'v0.4.0', retries: 0 })).to.eventually.be.rejected + await expect(download({ version: 'v0.4.0' })).to.eventually.be.rejected .with.property('message').that.matches(/404/) delete process.env.GO_LIBP2P_DIST_URL From 6b3ef85e0a4ddd408f84342d329240b64a3696f7 Mon Sep 17 00:00:00 2001 From: tabcat Date: Thu, 9 Jul 2026 00:27:07 +0700 Subject: [PATCH 5/5] test: assert path() throws only when autoDownload is disabled --- test/download.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/download.spec.js b/test/download.spec.js index 5d86403..175a27f 100644 --- a/test/download.spec.js +++ b/test/download.spec.js @@ -40,7 +40,7 @@ describe('download', () => { delete process.env.GO_LIBP2P_DIST_URL }) - it('path returns undefined when no binary has been downloaded', async () => { - expect(detectLocation).to.throw(/not found/, 'Path did not throw when binary is not installed') + it('path throws when no binary is installed and autoDownload is disabled', () => { + expect(() => detectLocation({ autoDownload: false })).to.throw(/not found/, 'Path did not throw when binary is not installed') }) })