ipfsd-ctl
Version:
Spawn IPFS Daemons, JS or Go
335 lines • 12.3 kB
JavaScript
import { multiaddr } from '@multiformats/multiaddr';
import fs from 'fs/promises';
import mergeOptions from 'merge-options';
import { logger } from '@libp2p/logger';
import { execa } from 'execa';
import { nanoid } from 'nanoid';
import path from 'path';
import os from 'os';
import { checkForRunningApi, repoExists, tmpDir, defaultRepo, buildInitArgs, buildStartArgs } from './utils.js';
import waitFor from 'p-wait-for';
const merge = mergeOptions.bind({ ignoreUndefined: true });
const daemonLog = {
info: logger('ipfsd-ctl:daemon:stdout'),
err: logger('ipfsd-ctl:daemon:stderr')
};
const rpcModuleLogger = logger('ipfsd-ctl:daemon');
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;
}
function translateUnknownError({ err, stdout, stderr, nameFallback = 'Unknown Error', messageFallback = 'Unknown Error Message' }) {
const error = err;
const name = error?.name ?? nameFallback;
const message = error?.message ?? messageFallback;
return translateError({
name,
message,
stdout,
stderr
});
}
/**
* Controller for daemon nodes
*/
class Daemon {
constructor(opts) {
this.opts = opts;
this.path = this.opts.ipfsOptions?.repo ?? (opts.disposable === true ? tmpDir(opts.type) : defaultRepo(opts.type));
this.exec = this.opts.ipfsBin;
this.env = merge({ IPFS_PATH: this.path }, this.opts.env);
this.disposable = Boolean(this.opts.disposable);
this.initialized = false;
this.started = false;
this.clean = true;
this._peerId = null;
}
get peer() {
if (this._peerId == null) {
throw new Error('Not started');
}
return this._peerId;
}
_setApi(addr) {
this.apiAddr = multiaddr(addr);
}
_setGrpc(addr) {
this.grpcAddr = multiaddr(addr);
}
_setGateway(addr) {
this.gatewayAddr = multiaddr(addr);
}
_createApi() {
if (this.opts.ipfsClientModule != null && this.grpcAddr != null) {
this.api = this.opts.ipfsClientModule.create({
grpc: this.grpcAddr,
http: this.apiAddr
});
}
else if (this.apiAddr != null) {
if (this.opts.kuboRpcModule != null) {
rpcModuleLogger('Using kubo-rpc-client');
this.api = this.opts.kuboRpcModule.create(this.apiAddr);
}
else if (this.opts.ipfsHttpModule != null) {
rpcModuleLogger('Using ipfs-http-client');
this.api = this.opts.ipfsHttpModule.create(this.apiAddr);
}
else {
throw new Error('You must pass either a kuboRpcModule or ipfsHttpModule');
}
}
if (this.api == null) {
throw new Error(`Could not create API from http '${this.apiAddr.toString()}' and/or gRPC '${this.grpcAddr?.toString() ?? 'undefined'}'`);
}
if (this.apiAddr != null) {
this.api.apiHost = this.apiAddr.nodeAddress().address;
this.api.apiPort = this.apiAddr.nodeAddress().port;
}
if (this.gatewayAddr != null) {
this.api.gatewayHost = this.gatewayAddr.nodeAddress().address;
this.api.gatewayPort = this.gatewayAddr.nodeAddress().port;
}
if (this.grpcAddr != null) {
this.api.grpcHost = this.grpcAddr.nodeAddress().address;
this.api.grpcPort = this.grpcAddr.nodeAddress().port;
}
}
async init(initOptions = {}) {
this.initialized = await repoExists(this.path);
if (this.initialized) {
this.clean = false;
return this;
}
initOptions = merge({
emptyRepo: false,
profiles: this.opts.test === true ? ['test'] : []
}, typeof this.opts.ipfsOptions?.init === 'boolean' ? {} : this.opts.ipfsOptions?.init, typeof initOptions === 'boolean' ? {} : initOptions);
const opts = merge(this.opts, {
ipfsOptions: {
init: initOptions
}
});
const args = buildInitArgs(opts);
if (this.exec == null) {
throw new Error('No executable specified');
}
const { stdout, stderr } = await execa(this.exec, args, {
env: this.env
})
.catch(translateError);
daemonLog.info(stdout);
daemonLog.err(stderr);
// default-config only for Go
if (this.opts.type === 'go') {
await this._replaceConfig(merge(await this._getConfig(), this.opts.ipfsOptions?.config));
}
this.clean = false;
this.initialized = true;
return this;
}
/**
* Delete the repo that was being used. If the node was marked as disposable this will be called automatically when the process is exited.
*
* @returns {Promise<Daemon>}
*/
async cleanup() {
if (!this.clean) {
await fs.rm(this.path, {
recursive: true
});
this.clean = true;
}
return this;
}
/**
* Start the daemon.
*
* @returns {Promise<Daemon>}
*/
async start() {
// Check if a daemon is already running
const api = checkForRunningApi(this.path);
if (api != null) {
this._setApi(api);
this._createApi();
}
else if (this.exec == null) {
throw new Error('No executable specified');
}
else {
const args = buildStartArgs(this.opts);
let output = '';
const ready = new Promise((resolve, reject) => {
if (this.exec == null) {
return reject(new Error('No executable specified'));
}
this.subprocess = execa(this.exec, args, {
env: this.env
});
const { stdout, stderr } = this.subprocess;
if (stderr == null) {
throw new Error('stderr was not defined on subprocess');
}
if (stdout == null) {
throw new Error('stderr was not defined on subprocess');
}
stderr.on('data', data => daemonLog.err(data.toString()));
stdout.on('data', data => daemonLog.info(data.toString()));
const readyHandler = (data) => {
output += data.toString();
const apiMatch = output.trim().match(/API .*listening on:? (.*)/);
const gwMatch = output.trim().match(/Gateway .*listening on:? (.*)/);
const grpcMatch = output.trim().match(/gRPC .*listening on:? (.*)/);
if ((apiMatch != null) && apiMatch.length > 0) {
this._setApi(apiMatch[1]);
}
if ((gwMatch != null) && gwMatch.length > 0) {
this._setGateway(gwMatch[1]);
}
if ((grpcMatch != null) && grpcMatch.length > 0) {
this._setGrpc(grpcMatch[1]);
}
if (output.match(/(?:daemon is running|Daemon is ready)/) != null) {
// we're good
this._createApi();
this.started = true;
stdout.off('data', readyHandler);
resolve(this.api);
}
};
stdout.on('data', readyHandler);
this.subprocess.catch(err => reject(translateError(err)));
void this.subprocess.on('exit', () => {
this.started = false;
stderr.removeAllListeners();
stdout.removeAllListeners();
if (this.disposable) {
this.cleanup().catch(() => { });
}
});
});
await ready;
}
this.started = true;
// Add `peerId`
const id = await this.api.id();
this._peerId = id;
return this;
}
async stop(options = {}) {
const timeout = options.timeout ?? 60000;
if (!this.started) {
return this;
}
if (this.subprocess != null) {
/** @type {ReturnType<setTimeout> | undefined} */
let killTimeout;
const subprocess = this.subprocess;
if (this.disposable) {
// we're done with this node and will remove it's repo when we are done
// so don't wait for graceful exit, just terminate the process
this.subprocess.kill('SIGKILL');
}
else {
if (this.opts.forceKill !== false) {
killTimeout = setTimeout(() => {
// eslint-disable-next-line no-console
console.error(new Error(`Timeout stopping ${this.opts.type ?? 'unknown'} node after ${this.opts.forceKillTimeout ?? 'unknown'}ms. Process ${subprocess.pid ?? 'unknown'} will be force killed now.`));
this.subprocess?.kill('SIGKILL');
}, this.opts.forceKillTimeout);
}
this.subprocess.cancel();
}
// wait for the subprocess to exit and declare ourselves stopped
await waitFor(() => !this.started, {
timeout
});
if (killTimeout != null) {
clearTimeout(killTimeout);
}
if (this.disposable) {
// wait for the cleanup routine to run after the subprocess has exited
await waitFor(() => this.clean, {
timeout
});
}
}
else {
await this.api.stop();
this.started = false;
}
return this;
}
/**
* Get the pid of the `ipfs daemon` process.
*
* @returns {Promise<number>}
*/
async pid() {
if (this.subprocess?.pid != null) {
return await Promise.resolve(this.subprocess?.pid);
}
throw new Error('Daemon process is not running.');
}
/**
* Call `ipfs config`
*
* If no `key` is passed, the whole config is returned as an object.
*
* @private
* @param {string} [key] - A specific config to retrieve.
* @returns {Promise<object | string>}
*/
async _getConfig(key = 'show') {
if (this.exec == null) {
throw new Error('No executable specified');
}
const { stdout, stderr } = await execa(this.exec, ['config', key], {
env: this.env
})
.catch(translateError);
if (key === 'show') {
try {
return JSON.parse(stdout);
}
catch (err) {
throw translateUnknownError({
err,
stderr,
stdout,
nameFallback: 'JSON.parse error',
messageFallback: 'Failed to parse stdout as JSON'
});
}
}
return stdout.trim();
}
/**
* Replace the current config with the provided one
*/
async _replaceConfig(config) {
if (this.exec == null) {
throw new Error('No executable specified');
}
const tmpFile = path.join(os.tmpdir(), nanoid());
await fs.writeFile(tmpFile, JSON.stringify(config));
await execa(this.exec, ['config', 'replace', `${tmpFile}`], { env: this.env })
.catch(translateError);
await fs.unlink(tmpFile);
return this;
}
async version() {
if (this.exec == null) {
throw new Error('No executable specified');
}
const { stdout } = await execa(this.exec, ['version'], {
env: this.env
})
.catch(translateError);
return stdout.trim();
}
}
export default Daemon;
//# sourceMappingURL=ipfsd-daemon.js.map