UNPKG

ipfsd-ctl

Version:

Spawn IPFS Daemons, Kubo or...

276 lines 9.28 kB
import fs from 'node:fs/promises'; import { logger } from '@libp2p/logger'; import { execa } from 'execa'; // @ts-expect-error needs https://github.com/schnittstabil/merge-options/pull/28 import mergeOptions from 'merge-options'; import pDefer from 'p-defer'; import waitFor from 'p-wait-for'; import { checkForRunningApi, tmpDir, buildStartArgs, repoExists, buildInitArgs, getGatewayAddress } from './utils.js'; const log = logger('ipfsd-ctl:kubo:daemon'); const merge = mergeOptions.bind({ ignoreUndefined: true }); function translateError(err) { // get the actual error message to be the err.message err.message = `${err.stdout} \n\n ${err.stderr} \n\n ${err.message} \n\n`; return err; } /** * Node for daemon nodes */ export default class KuboDaemon { options; disposable; subprocess; _api; repo; stdout; stderr; _exec; env; initArgs; startArgs; stopArgs; constructor(options) { if (options.rpc == null) { throw new Error('Please pass an rpc option'); } // @ts-expect-error cannot detect rpc is present this.options = options; this.repo = options.repo ?? tmpDir(options.type); this._exec = this.options.bin; this.env = merge({ IPFS_PATH: this.repo }, this.options.env); this.disposable = Boolean(this.options.disposable); this.stdout = logger('ipfsd-ctl:kubo:stdout'); this.stderr = logger('ipfsd-ctl:kubo:stderr'); if (options.init != null && typeof options.init !== 'boolean') { this.initArgs = options.init; } if (options.start != null && typeof options.start !== 'boolean') { this.startArgs = options.start; } if (options.stop != null) { this.stopArgs = options.stop; } } get api() { if (this._api == null) { throw new Error('Not started'); } return this._api; } get exec() { if (this._exec == null) { throw new Error('No executable specified'); } return this._exec; } async info() { const id = await this._api?.id(); const info = { version: await this.getVersion(), pid: this.subprocess?.pid, peerId: id?.id.toString(), multiaddrs: (id?.addresses ?? []).map(ma => ma.toString()), api: checkForRunningApi(this.repo), repo: this.repo, gateway: getGatewayAddress(this.repo) }; log('info %s %s %p %s', info.version, info.pid, info.peerId, info.repo); return info; } /** * Delete the repo that was being used. If the node was marked as disposable * this will be called automatically when the process is exited. */ async cleanup() { try { await fs.rm(this.repo, { recursive: true, force: true, maxRetries: 10 }); } catch (err) { if (err.code !== 'EPERM') { throw err; } } } async init(args) { // check if already initialized if (await repoExists(this.repo)) { log('repo already exists'); return; } const initOptions = { ...(this.initArgs ?? {}), ...(args ?? {}) }; if (this.options.test === true) { if (initOptions.profiles == null) { initOptions.profiles = []; } if (!initOptions.profiles.includes('test')) { initOptions.profiles.push('test'); } } const cliArgs = buildInitArgs(initOptions); log('init exec %s %s', this.exec, cliArgs.join(' ')); const out = await execa(this.exec, cliArgs, { env: this.env }) .catch(translateError); if (out instanceof Error) { log('error initting %s - %e', this.exec, out); throw out; } const { stdout, stderr } = out; this.stdout(stdout); this.stderr(stderr); log('replace config'); await this._replaceConfig(merge(await this._getConfig(), initOptions.config)); } /** * Start the daemon */ async start(args) { // Check if a daemon is already running const api = checkForRunningApi(this.repo); if (api != null) { this._api = this.options.rpc(api); return; } const startOptions = { ...(this.startArgs ?? {}), ...(args ?? {}) }; const cliArgs = buildStartArgs(startOptions); let output = ''; const deferred = pDefer(); log('start exec %s %s', this.exec, cliArgs.join(' ')); const out = this.subprocess = execa(this.exec, cliArgs, { env: this.env }); if (out instanceof Error) { log('error starting %s - %e', this.exec, out); throw out; } const { stdout, stderr } = out; if (stderr == null || stdout == null) { throw new Error('stdout/stderr was not defined on subprocess'); } stderr.on('data', data => { this.stderr(data.toString()); }); stdout.on('data', data => { this.stdout(data.toString()); }); const readyHandler = (data) => { output += data.toString(); const apiMatch = output.trim().match(/API .*listening on:? (.*)/); if ((apiMatch != null) && apiMatch.length > 0) { this._api = this.options.rpc(apiMatch[1]); } if (output.match(/(?:daemon is running|Daemon is ready)/) != null) { // we're good stdout.off('data', readyHandler); deferred.resolve(); } }; stdout.on('data', readyHandler); this.subprocess.catch(err => { deferred.reject(translateError(err)); }); // remove listeners and clean up on process exit void this.subprocess.on('exit', () => { stderr.removeAllListeners(); stdout.removeAllListeners(); if (this.disposable) { this.cleanup().catch(() => { }); } }); await deferred.promise; } async stop(options) { const stopOptions = { ...(this.stopArgs ?? {}), ...(options ?? {}) }; const timeout = stopOptions.forceKillTimeout ?? 1000; const subprocess = this.subprocess; if (subprocess == null || subprocess.exitCode != null || this._api == null) { return; } try { log('stop node'); await this.api.stop(); // wait for the subprocess to exit and declare ourselves stopped await waitFor(() => subprocess.exitCode != null, { timeout }); } catch (err) { log('error stopping %s - %e', this.exec, err); subprocess.kill('SIGKILL'); } if (this.disposable) { // wait for the cleanup routine to run after the subprocess has exited await this.cleanup(); } } /** * Call `ipfs config` * * If no `key` is passed, the whole config is returned as an object. */ async _getConfig() { const contents = await fs.readFile(`${this.repo}/config`, { encoding: 'utf-8' }); const config = JSON.parse(contents); if (this.options.test === true) { // use random ports for all addresses config.Addresses.Swarm = [ '/ip4/127.0.0.1/tcp/0', '/ip4/127.0.0.1/tcp/0/ws', '/ip4/127.0.0.1/udp/0/quic-v1', '/ip4/127.0.0.1/udp/0/quic-v1/webtransport', '/ip4/127.0.0.1/tcp/0/webrtc-direct' ]; config.Addresses.API = '/ip4/127.0.0.1/tcp/0'; config.Addresses.Gateway = '/ip4/127.0.0.1/tcp/0'; // configure CORS access for the http api config.API.HTTPHeaders = { 'Access-Control-Allow-Origin': ['*'], 'Access-Control-Allow-Methods': ['PUT', 'POST', 'GET'] }; } return config; } /** * Replace the current config with the provided one */ async _replaceConfig(config) { await fs.writeFile(`${this.repo}/config`, JSON.stringify(config, null, 2), { encoding: 'utf-8' }); } async getVersion() { if (this.exec == null) { throw new Error('No executable specified'); } log('getVersion exec %s version', this.exec); const out = await execa(this.exec, ['version'], { env: this.env }) .catch(translateError); if (out instanceof Error) { log('error getting version %s - %e', this.exec, out); throw out; } const { stdout } = out; const version = stdout.trim(); log('getVersion version %s', version); return version; } } //# sourceMappingURL=daemon.js.map