ipfsd-ctl
Version:
Spawn IPFS Daemons, Kubo or...
276 lines • 9.28 kB
JavaScript
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