diff --git a/.aegir.js b/.aegir.js index 9aa1d126..af6f716d 100644 --- a/.aegir.js +++ b/.aegir.js @@ -1,28 +1,19 @@ -import * as ipfsModule from 'ipfs' -import * as ipfsHttpModule from 'ipfs-http-client' -import * as kuboRpcModule from 'kubo-rpc-client' -import * as goIpfsModule from 'go-ipfs' +import { create } from 'kubo-rpc-client' +import { path } from 'kubo' -/** @type {import('aegir').Options["build"]["config"]} */ +/** @type {import('aegir').PartialOptions} */ const config = { - bundlesize: { - maxSize: '35kB' + build: { + bundlesizeMax: '2.5kB', }, test: { before: async () => { const { createServer } = await import('./dist/src/index.js') const server = createServer(undefined, { - ipfsModule, - }, { - go: { - ipfsBin: goIpfsModule.path(), - kuboRpcModule - }, - js: { - ipfsBin: ipfsModule.path(), - ipfsHttpModule - } + type: 'kubo', + bin: path(), + rpc: create } ) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0bc3b42d..d401a774 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ updates: schedule: interval: daily time: "10:00" - open-pull-requests-limit: 10 + open-pull-requests-limit: 20 commit-message: prefix: "deps" prefix-development: "deps(dev)" diff --git a/.github/workflows/js-test-and-release.yml b/.github/workflows/js-test-and-release.yml index d31e0580..f7c04de0 100644 --- a/.github/workflows/js-test-and-release.yml +++ b/.github/workflows/js-test-and-release.yml @@ -19,10 +19,9 @@ concurrency: jobs: js-test-and-release: - uses: ipdxco/unified-github-workflows/.github/workflows/js-test-and-release.yml@v1.0 + uses: ipdxco/unified-github-workflows/.github/workflows/js-test-and-release.yml@v0.0 secrets: DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} UCI_GITHUB_TOKEN: ${{ secrets.UCI_GITHUB_TOKEN }} - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml new file mode 100644 index 00000000..bd00f090 --- /dev/null +++ b/.github/workflows/semantic-pull-request.yml @@ -0,0 +1,12 @@ +name: Semantic PR + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + main: + uses: pl-strflt/.github/.github/workflows/reusable-semantic-pull-request.yml@v0.3 diff --git a/.gitignore b/.gitignore index 1531bdf9..9baf0602 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules +build dist .docs .coverage package-lock.json yarn.lock +.vscode diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..3662b370 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/README.md b/README.md index 8d835ead..578dcd5c 100644 --- a/README.md +++ b/README.md @@ -1,395 +1,173 @@ -# ipfsd-ctl +# ipfsd-ctl [![ipfs.tech](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](https://ipfs.tech) [![Discuss](https://img.shields.io/discourse/https/discuss.ipfs.tech/posts.svg?style=flat-square)](https://discuss.ipfs.tech) [![codecov](https://img.shields.io/codecov/c/github/ipfs/js-ipfsd-ctl.svg?style=flat-square)](https://codecov.io/gh/ipfs/js-ipfsd-ctl) [![CI](https://img.shields.io/github/actions/workflow/status/ipfs/js-ipfsd-ctl/js-test-and-release.yml?branch=master\&style=flat-square)](https://github.com/ipfs/js-ipfsd-ctl/actions/workflows/js-test-and-release.yml?query=branch%3Amaster) -> Spawn IPFS Daemons, JS or Go - -## Table of contents - -- [Install](#install) - - [Browser ` -``` + -If you are only using the `proc` type in-process IPFS node, you can skip installing `go-ipfs` and `ipfs-http-client`. (e.g. `npm i --save ipfs`) +This module allows you to spawn long-lived IPFS implementations from any JS environment and interact with the as is they were in the local process. -> You also need to explicitly defined the options `ipfsBin`, `ipfsModule` and `ipfsHttpModule` according to your needs. Check [ControllerOptions](#controlleroptions) and [ControllerOptionsOverrides](#controlleroptionsoverrides) for more information. +It is designed mostly for testing interoperability and is not suitable for production use. -## Usage +## Spawning a single noder: `createNode` -### Spawning a single IPFS controller: `createController` +## Example - Spawning a Kubo node -This is a shorthand for simpler use cases where factory is not needed. +```TypeScript +import { createNode } from 'ipfsd-ctl' +import { path } from 'kubo' +import { create } from 'kubo-rpc-client' -```js -// No need to create a factory when only a single controller is needed. -// Use createController to spawn it instead. -const Ctl = require('ipfsd-ctl') -const ipfsd = await Ctl.createController({ - ipfsHttpModule, - ipfsBin: goIpfsModule.path() +const node = await createNode({ + type: 'kubo', + rpc: create, + bin: path() }) -const id = await ipfsd.api.id() - -console.log(id) -await ipfsd.stop() +console.info(await node.api.id()) ``` -### Manage multiple IPFS controllers: `createFactory` - -Use a factory to spawn multiple controllers based on some common template. - -**Spawn an IPFS daemon from Node.js** - -```js -// Create a factory to spawn two test disposable controllers, get access to an IPFS api -// print node ids and clean all the controllers from the factory. -const Ctl = require('ipfsd-ctl') - -const factory = Ctl.createFactory( - { - type: 'js', - test: true, - disposable: true, - ipfsHttpModule, - ipfsModule: (await import('ipfs')) // only if you gonna spawn 'proc' controllers - }, - { // overrides per type - js: { - ipfsBin: ipfsModule.path() - }, - go: { - ipfsBin: goIpfsModule.path() - } - } -) -const ipfsd1 = await factory.spawn() // Spawns using options from `createFactory` -const ipfsd2 = await factory.spawn({ type: 'go' }) // Spawns using options from `createFactory` but overrides `type` to spawn a `go` controller - -console.log(await ipfsd1.api.id()) -console.log(await ipfsd2.api.id()) - -await factory.clean() // Clean all the controllers created by the factory calling `stop` on all of them. -``` +## Manage multiple nodes: `createFactory` -**Spawn an IPFS daemon from the Browser using the provided remote endpoint** +Use a factory to spawn multiple nodes based on some common template. -```js -// Start a remote disposable node, and get access to the api -// print the node id, and stop the temporary daemon +## Example - Spawning multiple Kubo nodes -const Ctl = require('ipfsd-ctl') +```TypeScript +import { createFactory } from 'ipfsd-ctl' +import { path } from 'kubo' +import { create } from 'kubo-rpc-client' -const port = 9090 -const server = Ctl.createServer(port, { - ipfsModule, - ipfsHttpModule -}, -{ - js: { - ipfsBin: ipfsModule.path() - }, - go: { - ipfsBin: goIpfsModule.path() - }, -}) -const factory = Ctl.createFactory({ - ipfsHttpModule, - remote: true, - endpoint: `http://localhost:${port}` // or you can set process.env.IPFSD_CTL_SERVER to http://localhost:9090 +const factory = createFactory({ + type: 'kubo', + rpc: create, + bin: path() }) -await server.start() -const ipfsd = await factory.spawn() -const id = await ipfsd.api.id() +const node1 = await factory.spawn() +const node2 = await factory.spawn() +//...etc -console.log(id) - -await ipfsd.stop() -await server.stop() +// later stop all nodes +await factory.clean() ``` -## Disposable vs non Disposable nodes - -`ipfsd-ctl` can spawn `disposable` and `non-disposable` nodes. - -- `disposable`- Disposable nodes are useful for tests or other temporary use cases, by default they create a temporary repo and automatically initialise and start the node, plus they cleanup everything when stopped. -- `non-disposable` - Non disposable nodes will by default attach to any nodes running on the default or the supplied repo. Requires the user to initialize and start the node, as well as stop and cleanup afterwards. - -## API - -### `createFactory([options], [overrides])` - -Creates a factory that can spawn multiple controllers and pre-define options for them. - -- `options` **[ControllerOptions](#controlleroptions)** Controllers options. -- `overrides` **[ControllerOptionsOverrides](#controlleroptionsoverrides)** Pre-defined options overrides per controller type. - -Returns a **[Factory](#factory)** - -### `createController([options])` - -Creates a controller. - -- `options` **[ControllerOptions](#controlleroptions)** Factory options. - -Returns **Promise<[Controller](#controller)>** - -### `createServer([options])` - -Create an Endpoint Server. This server is used by a client node to control a remote node. Example: Spawning a go-ipfs node from a browser. - -- `options` **\[Object]** Factory options. Defaults to: `{ port: 43134 }` - - `port` **number** Port to start the server on. - -Returns a **Server** - -### Factory - -#### `controllers` - -**Controller\[]** List of all the controllers spawned. - -#### `tmpDir()` - -Create a temporary repo to create controllers manually. - -Returns **Promise\** - Path to the repo. - -#### `spawn([options])` - -Creates a controller for a IPFS node. - -- `options` **[ControllerOptions](#controlleroptions)** Factory options. - -Returns **Promise<[Controller](#controller)>** - -#### `clean()` - -Cleans all controllers spawned. - -Returns **Promise<[Factory](#factory)>** - -### Controller - -Class controller for a IPFS node. - -#### `new Controller(options)` - -- `options` **[ControllerOptions](#controlleroptions)** - -#### `path` - -**String** Repo path. +## Override config based on implementation type -#### `exec` +`createFactory` takes a second argument that can be used to pass default options to an implementation based on the `type` field. -**String** Executable path. +```TypeScript +import { createFactory } from 'ipfsd-ctl' +import { path } from 'kubo' +import { create } from 'kubo-rpc-client' -#### `env` - -**Object** ENV object. - -#### `initialized` - -**Boolean** Flag with the current init state. - -#### `started` - -**Boolean** Flag with the current start state. - -#### `clean` - -**Boolean** Flag with the current clean state. - -#### `apiAddr` - -**Multiaddr** API address - -#### `gatewayAddr` - -**Multiaddr** Gateway address - -#### `api` - -**Object** IPFS core interface - -#### `init([initOptions])` - -Initialises controlled node - -- `initOptions` **\[Object]** IPFS init options - -Returns **Promise<[Controller](#controller)>** - -#### `start()` - -Starts controlled node. - -Returns **Promise\** - -#### `stop()` - -Stops controlled node. - -Returns **Promise<[Controller](#controller)>** - -#### `cleanup()` - -Cleans controlled node, a disposable controller calls this automatically. - -Returns **Promise<[Controller](#controller)>** - -#### `pid()` - -Get the pid of the controlled node process if aplicable. - -Returns **Promise\** - -#### `version()` - -Get the version of the controlled node. +const factory = createFactory({ + type: 'kubo', + test: true +}, { + otherImpl: { + //...other impl args + } +}) -Returns **Promise\** +const kuboNode = await factory.spawn() +const otherImplNode = await factory.spawn({ + type: 'otherImpl' +}) +``` -### ControllerOptionsOverrides +## Spawning nodes from browsers -Type: \[Object] +To spawn nodes from browsers, first start an ipfsd-ctl server from node.js and make the address known to the browser (the default way is to set `process.env.IPFSD_CTL_SERVER` in your bundle): -#### Properties +## Example - Create server -- `js` **\[[ControllerOptions](#controlleroptions)]** Pre-defined defaults options for **JS** controllers these are deep merged with options passed to `Factory.spawn(options)`. -- `go` **\[[ControllerOptions](#controlleroptions)]** Pre-defined defaults options for **Go** controllers these are deep merged with options passed to `Factory.spawn(options)`. -- `proc` **\[[ControllerOptions](#controlleroptions)]** Pre-defined defaults options for **Proc** controllers these are deep merged with options passed to `Factory.spawn(options)`. +In node.js: -### ControllerOptions +```TypeScript +// Start a remote disposable node, and get access to the api +// print the node id, and stop the temporary daemon -Type: \[Object] +import { createServer } from 'ipfsd-ctl' -#### Properties +const port = 9090 +const server = Ctl.createServer(port, { + type: 'kubo', + test: true +}, { + // overrides +}) +await server.start() +``` -- `test` **\[boolean]** Flag to activate custom config for tests. -- `remote` **\[boolean]** Use remote endpoint to spawn the nodes. Defaults to `true` when not in node. -- `endpoint` **\[string]** Endpoint URL to manage remote Controllers. (Defaults: ''). -- `disposable` **\[boolean]** A new repo is created and initialized for each invocation, as well as cleaned up automatically once the process exits. -- `type` **\[string]** The daemon type, see below the options: - - go - spawn go-ipfs daemon - - js - spawn js-ipfs daemon - - proc - spawn in-process js-ipfs node -- `env` **\[Object]** Additional environment variables, passed to executing shell. Only applies for Daemon controllers. -- `args` **\[Array]** Custom cli args. -- `ipfsHttpModule` **\[Object]** Reference to a IPFS HTTP Client object. -- `ipfsModule` **\[Object]** Reference to a IPFS API object. -- `ipfsBin` **\[string]** Path to a IPFS exectutable. -- `ipfsOptions` **\[IpfsOptions]** Options for the IPFS instance same as . `proc` nodes receive these options as is, daemon nodes translate the options as far as possible to cli arguments. -- `forceKill` **\[boolean]** - Whether to use SIGKILL to quit a daemon that does not stop after `.stop()` is called. (default `true`) -- `forceKillTimeout` **\[Number]** - How long to wait before force killing a daemon in ms. (default `5000`) +In a browser: -## ipfsd-ctl environment variables +```TypeScript +import { createFactory } from 'ipfsd-ctl' -In additional to the API described in previous sections, `ipfsd-ctl` also supports several environment variables. This are often very useful when running in different environments, such as CI or when doing integration/interop testing. +const factory = createFactory({ + // or you can set process.env.IPFSD_CTL_SERVER to http://localhost:9090 + endpoint: `http://localhost:${port}` +}) -*Environment variables precedence order is as follows. Top to bottom, top entry has highest precedence:* +const node = await factory.createNode({ + type: 'kubo' +}) +console.info(await node.api.id()) +``` -- command line options/method arguments -- env variables -- default values +## Disposable vs non Disposable nodes -Meaning that, environment variables override defaults in the configuration file but are superseded by options to `df.spawn({...})` +`ipfsd-ctl` can spawn `disposable` and `non-disposable` nodes. -#### IPFS\_JS\_EXEC and IPFS\_GO\_EXEC +- `disposable`- Disposable nodes are useful for tests or other temporary use cases, they create a temporary repo which is deleted automatically when the node is stopped +- `non-disposable` - Disposable nodes will not delete their repo when stopped -An alternative way of specifying the executable path for the `js-ipfs` or `go-ipfs` executable, respectively. +# Install -## Contribute +```console +$ npm i ipfsd-ctl +``` -Feel free to join in. All welcome. Open an [issue](https://github.com/ipfs/js-ipfsd-ctl/issues)! +## Browser ` +``` -## API Docs +# API Docs - -## License +# License Licensed under either of - Apache 2.0, ([LICENSE-APACHE](LICENSE-APACHE) / ) - MIT ([LICENSE-MIT](LICENSE-MIT) / ) -## Contribute +# Contribute Contributions welcome! Please check out [the issues](https://github.com/ipfs/js-ipfsd-ctl/issues). diff --git a/package.json b/package.json index 8395172f..b72f6d28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ipfsd-ctl", "version": "13.0.0", - "description": "Spawn IPFS Daemons, JS or Go", + "description": "Spawn IPFS Daemons, Kubo or...", "license": "Apache-2.0 OR MIT", "homepage": "https://github.com/ipfs/js-ipfsd-ctl#readme", "repository": { @@ -11,15 +11,15 @@ "bugs": { "url": "https://github.com/ipfs/js-ipfsd-ctl/issues" }, + "publishConfig": { + "access": "public", + "provenance": true + }, "keywords": [ "daemon", "ipfs", "node" ], - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - }, "type": "module", "types": "./dist/src/index.d.ts", "files": [ @@ -37,6 +37,7 @@ "eslintConfig": { "extends": "ipfs", "parserOptions": { + "project": true, "sourceType": "module" } }, @@ -140,34 +141,26 @@ "dependencies": { "@hapi/boom": "^10.0.0", "@hapi/hapi": "^21.1.0", - "@libp2p/interface-peer-id": "^2.0.0", - "@libp2p/logger": "^2.0.0", - "@multiformats/multiaddr": "^11.0.0", - "execa": "^6.1.0", - "ipfs-utils": "^9.0.1", + "@libp2p/interface": "^1.2.0", + "@libp2p/logger": "^4.0.10", + "execa": "^8.0.1", "joi": "^17.2.1", + "kubo-rpc-client": "^4.0.0", "merge-options": "^3.0.1", - "nanoid": "^4.0.0", + "nanoid": "^5.0.7", + "p-defer": "^4.0.1", "p-wait-for": "^5.0.0", - "temp-write": "^5.0.0", "wherearewe": "^2.0.1" }, "devDependencies": { - "aegir": "^37.0.15", - "go-ipfs": "^0.17.0", - "ipfs": "^0.66.0", - "ipfs-client": "^0.10.0", - "ipfs-core-types": "^0.14.0", - "ipfs-http-client": "^60.0.0", - "kubo-rpc-client": "^3.0.0", - "util": "^0.12.4" + "aegir": "^42.2.5", + "kubo": "^0.28.0" }, "browser": { "./dist/src/endpoint/server.js": "./dist/src/endpoint/server.browser.js", - "./dist/src/utils.js": "./dist/src/utils.browser.js", - "./dist/src/ipfsd-daemon.js": "./dist/src/ipfsd-client.js", - "go-ipfs": false + "./dist/src/kubo/utils.js": false, + "./dist/src/kubo/daemon.js": "./dist/src/kubo/client.js", + "kubo": false }, - "jsdelivr": "dist/index.min.js", - "unpkg": "dist/index.min.js" + "sidEffects": false } diff --git a/src/config.ts b/src/config.ts index d0672e73..d05037f8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,24 +1,10 @@ -import { isBrowser, isWebWorker } from 'wherearewe' -import type { ControllerType } from './index.js' +import type { NodeType } from './index.js' export interface ConfigInit { - type?: ControllerType + type?: NodeType } -export default (init: ConfigInit) => { - const { type } = init - let swarm: string[] - - // from the browser tell remote nodes to listen over WS - if (type !== 'proc' && (isBrowser || isWebWorker)) { - swarm = ['/ip4/127.0.0.1/tcp/0/ws'] - // from the browser, in process nodes cannot listen on _any_ addrs - } else if (type === 'proc' && (isBrowser || isWebWorker)) { - swarm = [] - } else { - swarm = ['/ip4/127.0.0.1/tcp/0'] - } - +export default (init: ConfigInit): any => { return { API: { HTTPHeaders: { @@ -31,7 +17,10 @@ export default (init: ConfigInit) => { } }, Addresses: { - Swarm: swarm, + Swarm: [ + '/ip4/127.0.0.1/tcp/0/ws', + '/ip4/127.0.0.1/tcp/0' + ], API: '/ip4/127.0.0.1/tcp/0', Gateway: '/ip4/127.0.0.1/tcp/0', RPC: '/ip4/127.0.0.1/tcp/0' diff --git a/src/endpoint/routes.ts b/src/endpoint/routes.ts index ffa608fe..38d80e1f 100644 --- a/src/endpoint/routes.ts +++ b/src/endpoint/routes.ts @@ -1,10 +1,9 @@ -import { nanoid } from 'nanoid' -import Joi from 'joi' import boom from '@hapi/boom' import { logger } from '@libp2p/logger' -import { tmpDir } from '../utils.js' +import Joi from 'joi' +import { nanoid } from 'nanoid' +import type { Node, Factory } from '../index.js' import type { Server } from '@hapi/hapi' -import type { Factory } from '../index.js' const debug = logger('ipfsd-ctl:routes') @@ -16,7 +15,7 @@ const routeOptions = { } } -const badRequest = (err: Error & { stdout?: string }) => { +const badRequest = (err: Error & { stdout?: string }): void => { let msg if (err.stdout != null) { msg = err.stdout + ' - ' + err.message @@ -27,30 +26,49 @@ const badRequest = (err: Error & { stdout?: string }) => { throw boom.badRequest(msg) } -const nodes: Record = {} +const nodes: Record = {} export default (server: Server, createFactory: () => Factory | Promise): void => { + /** + * Spawn a controller + */ server.route({ - method: 'GET', - path: '/util/tmp-dir', + method: 'POST', + path: '/spawn', handler: async (request) => { - const type = request.query.type ?? 'go' + const options: any = request.payload ?? {} try { - return { tmpDir: await tmpDir(type) } + const ipfsd = await createFactory() + const id = nanoid() + nodes[id] = await ipfsd.spawn({ + ...options, + // init/start will be invoked by the client + init: false, + start: false + }) + + return { + id, + options, + info: await nodes[id].info() + } } catch (err: any) { badRequest(err) } } }) + /** + * Return node info + */ server.route({ method: 'GET', - path: '/version', + path: '/info', handler: async (request) => { const id = request.query.id try { - return { version: await nodes[id].version() } + return await nodes[id].info() } catch (err: any) { badRequest(err) } @@ -58,36 +76,8 @@ export default (server: Server, createFactory: () => Factory | Promise) options: routeOptions }) - server.route({ - method: 'POST', - path: '/spawn', - handler: async (request) => { - const opts = request.payload ?? {} - try { - const ipfsd = await createFactory() - const id = nanoid() - // @ts-expect-error opts is a json object - nodes[id] = await ipfsd.spawn(opts) - return { - id: id, - apiAddr: nodes[id].apiAddr?.toString(), - gatewayAddr: nodes[id].gatewayAddr?.toString(), - grpcAddr: nodes[id].grpcAddr?.toString(), - initialized: nodes[id].initialized, - started: nodes[id].started, - disposable: nodes[id].disposable, - env: nodes[id].env, - path: nodes[id].path, - clean: nodes[id].clean - } - } catch (err: any) { - badRequest(err) - } - } - }) - /* - * Initialize a repo. + * Initialize a repo */ server.route({ method: 'POST', @@ -99,9 +89,7 @@ export default (server: Server, createFactory: () => Factory | Promise) try { await nodes[id].init(payload) - return { - initialized: nodes[id].initialized - } + return await nodes[id].info() } catch (err: any) { badRequest(err) } @@ -110,22 +98,19 @@ export default (server: Server, createFactory: () => Factory | Promise) }) /* - * Start the daemon. + * Start the daemon */ server.route({ method: 'POST', path: '/start', handler: async (request) => { const id = request.query.id + const payload = request.payload ?? {} try { - await nodes[id].start() + await nodes[id].start(payload) - return { - apiAddr: nodes[id].apiAddr?.toString(), - gatewayAddr: nodes[id].gatewayAddr?.toString(), - grpcAddr: nodes[id].grpcAddr?.toString() - } + return await nodes[id].info() } catch (err: any) { badRequest(err) } @@ -134,18 +119,17 @@ export default (server: Server, createFactory: () => Factory | Promise) }) /* - * Delete the repo that was being used. - * If the node was marked as `disposable` this will be called - * automatically when the process is exited. + * Stop the daemon */ server.route({ method: 'POST', - path: '/cleanup', + path: '/stop', handler: async (request, h) => { const id = request.query.id + const payload = request.payload ?? {} try { - await nodes[id].cleanup() + await nodes[id].stop(payload) return h.response().code(200) } catch (err: any) { @@ -156,16 +140,19 @@ export default (server: Server, createFactory: () => Factory | Promise) }) /* - * Stop the daemon. + * Delete the repo that was being used. + * If the node was marked as `disposable` this will be called + * automatically when the process is exited. */ server.route({ method: 'POST', - path: '/stop', + path: '/cleanup', handler: async (request, h) => { const id = request.query.id + const payload = request.payload ?? {} try { - await nodes[id].stop() + await nodes[id].cleanup(payload) return h.response().code(200) } catch (err: any) { @@ -174,18 +161,4 @@ export default (server: Server, createFactory: () => Factory | Promise) }, options: routeOptions }) - - /* - * Get the pid of the `ipfs daemon` process. - */ - server.route({ - method: 'GET', - path: '/pid', - handler: async (request) => { - const id = request.query.id - - return { pid: await nodes[id].pid() } - }, - options: routeOptions - }) } diff --git a/src/endpoint/server.browser.ts b/src/endpoint/server.browser.ts index 3592fe8e..be5dfd5a 100644 --- a/src/endpoint/server.browser.ts +++ b/src/endpoint/server.browser.ts @@ -20,10 +20,8 @@ class Server { /** * Start the server - * - * @returns {Promise} */ - async start () { + async start (): Promise { console.warn('Server not implemented in the browser') return this @@ -31,10 +29,8 @@ class Server { /** * Stop the server - * - * @returns {Promise} */ - async stop () { + async stop (): Promise { console.warn('Server not implemented in the browser') } } diff --git a/src/endpoint/server.ts b/src/endpoint/server.ts index af406400..61c52358 100644 --- a/src/endpoint/server.ts +++ b/src/endpoint/server.ts @@ -1,6 +1,10 @@ import Hapi from '@hapi/hapi' -import type { CreateFactory } from '../index.js' import routes from './routes.js' +import type { Factory } from '../index.js' + +interface CreateFactory { + (): Factory +} export interface ServerInit { port?: number @@ -31,7 +35,7 @@ class Server { async start (port = this.port): Promise { this.port = port this.server = new Hapi.Server({ - port: port, + port, host: this.host, routes: { cors: true @@ -48,7 +52,7 @@ class Server { /** * Stop the server */ - async stop (options: { timeout: number }): Promise { + async stop (options?: { timeout: number }): Promise { if (this.server != null) { await this.server.stop(options) } diff --git a/src/factory.ts b/src/factory.ts index 1b98bf13..8800342b 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -1,149 +1,97 @@ import mergeOptions from 'merge-options' -import { tmpDir } from './utils.js' import { isNode, isElectronMain } from 'wherearewe' -import http from 'ipfs-utils/src/http.js' -import ControllerDaemon from './ipfsd-daemon.js' -import ControllerRemote from './ipfsd-client.js' -import ControllerProc from './ipfsd-in-proc.js' -import testsConfig from './config.js' -import type { Controller, ControllerOptions, ControllerOptionsOverrides, Factory } from './index.js' +import KuboClient from './kubo/client.js' +import KuboDaemon from './kubo/daemon.js' +import type { Node, NodeOptions, NodeOptionsOverrides, NodeType, Factory, KuboNode, KuboOptions, SpawnOptions } from './index.js' const merge = mergeOptions.bind({ ignoreUndefined: true }) const defaults = { remote: !isNode && !isElectronMain, - endpoint: process.env.IPFSD_CTL_SERVER ?? 'http://localhost:43134', disposable: true, test: false, - type: 'go', + type: 'kubo', env: {}, args: [], - ipfsOptions: {}, forceKill: true, forceKillTimeout: 5000 } -export interface ControllerOptionsOverridesWithEndpoint { - js?: ControllerOptionsWithEndpoint - go?: ControllerOptionsWithEndpoint - proc?: ControllerOptionsWithEndpoint -} - -export interface ControllerOptionsWithEndpoint extends ControllerOptions { - endpoint: string +export interface FactoryInit extends NodeOptions { + /** + * Endpoint URL to manage remote Nodes. (Defaults: 'http://127.0.0.1:43134') + */ + endpoint?: string } /** * Factory class to spawn ipfsd controllers */ -class DefaultFactory implements Factory { - public opts: ControllerOptionsWithEndpoint - public controllers: Controller[] +class DefaultFactory implements Factory { + public options: NodeOptions + public controllers: Node[] + public readonly overrides: NodeOptionsOverrides - private readonly overrides: ControllerOptionsOverridesWithEndpoint + private readonly endpoint: string - constructor (options: ControllerOptions = {}, overrides: ControllerOptionsOverrides = {}) { - this.opts = merge(defaults, options) + constructor (options: FactoryInit = {}, overrides: NodeOptionsOverrides = {}) { + this.endpoint = options.endpoint ?? process.env.IPFSD_CTL_SERVER ?? 'http://localhost:43134' + this.options = merge(defaults, options) this.overrides = merge({ - js: merge(this.opts, { type: 'js' }), - go: merge(this.opts, { type: 'go' }), - proc: merge(this.opts, { type: 'proc' }) + kubo: this.options }, overrides) this.controllers = [] } /** - * Utility method to get a temporary directory - * useful in browsers to be able to generate temp - * repos manually + * Spawn an IPFSd Node */ - async tmpDir (options: ControllerOptions = {}): Promise { - const opts: ControllerOptions = merge(this.opts, options) - - if (opts.remote === true) { - const res = await http.get( - `${opts.endpoint ?? ''}/util/tmp-dir`, - { searchParams: new URLSearchParams({ type: opts.type ?? '' }) } - ) - const out = await res.json() - - return out.tmpDir - } - - return await Promise.resolve(tmpDir(opts.type)) - } - - async _spawnRemote (options: ControllerOptionsWithEndpoint) { - const opts = { - json: { - ...options, - // avoid recursive spawning - remote: false, - ipfsBin: undefined, - ipfsModule: undefined, - ipfsHttpModule: undefined, - kuboRpcModule: undefined + async spawn (options?: KuboOptions & SpawnOptions): Promise + async spawn (options?: NodeOptions & SpawnOptions): Promise { + const type: NodeType = options?.type ?? this.options.type ?? 'kubo' + const opts = merge({}, this.options, this.overrides[type], options) + let ctl: any + + if (type === 'kubo') { + if (opts.remote === true) { + const req = await fetch(`${this.endpoint}/spawn`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + ...opts, + remote: false + }) + }) + const result = await req.json() + + ctl = new KuboClient({ + endpoint: this.endpoint, + ...opts, + ...result + }) + } else { + ctl = new KuboDaemon(opts) } } - const res = await http.post( - `${options.endpoint}/spawn`, - opts - ) - return new ControllerRemote( - options.endpoint, - await res.json(), - options - ) - } - - /** - * Spawn an IPFSd Controller - */ - async spawn (options: ControllerOptions = { }): Promise { - const type = options.type ?? this.opts.type ?? 'go' - const opts: ControllerOptionsWithEndpoint = merge( - this.overrides[type], - options - ) - - // IPFS options defaults - const ipfsOptions = merge( - { - start: false, - init: false - }, - opts.test === true - ? { - config: testsConfig(opts), - preload: { enabled: false } - } - : {}, - opts.ipfsOptions - ) - - let ctl: Controller - if (opts.type === 'proc') { - // spawn in-proc controller - ctl = new ControllerProc({ ...opts, ipfsOptions }) - } else if (opts.remote === true) { - // spawn remote controller - ctl = await this._spawnRemote({ ...opts, ipfsOptions }) - } else { - // spawn daemon controller - ctl = new ControllerDaemon({ ...opts, ipfsOptions }) + if (ctl == null) { + throw new Error('Unsupported type') } // Save the controller this.controllers.push(ctl) - // Auto init and start controller - if (opts.disposable === true && (options.ipfsOptions == null || options.ipfsOptions?.init !== false)) { - await ctl.init(ipfsOptions.init) + // Auto start controller + if (opts.init !== false) { + await ctl.init(opts.init) } - if (opts.disposable === true && (options.ipfsOptions == null || options.ipfsOptions?.start !== false)) { - await ctl.start() + + // Auto start controller + if (opts.start !== false) { + await ctl.start(opts.start) } return ctl @@ -153,7 +101,10 @@ class DefaultFactory implements Factory { * Stop all controllers */ async clean (): Promise { - await Promise.all(this.controllers.map(async n => await n.stop())) + await Promise.all( + this.controllers.map(async n => n.stop()) + ) + this.controllers = [] } } diff --git a/src/index.ts b/src/index.ts index 6cde7e7e..87151be2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,70 +1,161 @@ -import DefaultFactory from './factory.js' +/** + * @packageDocumentation + * + * This module allows you to spawn long-lived IPFS implementations from any JS environment and interact with the as is they were in the local process. + * + * It is designed mostly for testing interoperability and is not suitable for production use. + * + * ## Spawning a single noder: `createNode` + * + * @example Spawning a Kubo node + * + * ```TypeScript + * import { createNode } from 'ipfsd-ctl' + * import { path } from 'kubo' + * import { create } from 'kubo-rpc-client' + * + * const node = await createNode({ + * type: 'kubo', + * rpc: create, + * bin: path() + * }) + * + * console.info(await node.api.id()) + * ``` + * + * ## Manage multiple nodes: `createFactory` + * + * Use a factory to spawn multiple nodes based on some common template. + * + * @example Spawning multiple Kubo nodes + * + * ```TypeScript + * import { createFactory } from 'ipfsd-ctl' + * import { path } from 'kubo' + * import { create } from 'kubo-rpc-client' + * + * const factory = createFactory({ + * type: 'kubo', + * rpc: create, + * bin: path() + * }) + * + * const node1 = await factory.spawn() + * const node2 = await factory.spawn() + * //...etc + * + * // later stop all nodes + * await factory.clean() + * ``` + * + * ## Override config based on implementation type + * + * `createFactory` takes a second argument that can be used to pass default options to an implementation based on the `type` field. + * + * ```TypeScript + * import { createFactory } from 'ipfsd-ctl' + * import { path } from 'kubo' + * import { create } from 'kubo-rpc-client' + * + * const factory = createFactory({ + * type: 'kubo', + * test: true + * }, { + * otherImpl: { + * //...other impl args + * } + * }) + * + * const kuboNode = await factory.spawn() + * const otherImplNode = await factory.spawn({ + * type: 'otherImpl' + * }) + * ``` + * + * ## Spawning nodes from browsers + * + * To spawn nodes from browsers, first start an ipfsd-ctl server from node.js and make the address known to the browser (the default way is to set `process.env.IPFSD_CTL_SERVER` in your bundle): + * + * @example Create server + * + * In node.js: + * + * ```TypeScript + * // Start a remote disposable node, and get access to the api + * // print the node id, and stop the temporary daemon + * + * import { createServer } from 'ipfsd-ctl' + * + * const port = 9090 + * const server = Ctl.createServer(port, { + * type: 'kubo', + * test: true + * }, { + * // overrides + * }) + * await server.start() + * ``` + * + * In a browser: + * + * ```TypeScript + * import { createFactory } from 'ipfsd-ctl' + * + * const factory = createFactory({ + * // or you can set process.env.IPFSD_CTL_SERVER to http://localhost:9090 + * endpoint: `http://localhost:${port}` + * }) + * + * const node = await factory.createNode({ + * type: 'kubo' + * }) + * console.info(await node.api.id()) + * ``` + * + * ## Disposable vs non Disposable nodes + * + * `ipfsd-ctl` can spawn `disposable` and `non-disposable` nodes. + * + * - `disposable`- Disposable nodes are useful for tests or other temporary use cases, they create a temporary repo which is deleted automatically when the node is stopped + * - `non-disposable` - Disposable nodes will not delete their repo when stopped + */ + import Server from './endpoint/server.js' -import type { IPFS } from 'ipfs-core-types' -import type { Multiaddr } from '@multiformats/multiaddr' -import type { PeerId } from '@libp2p/interface-peer-id' -import type { ExecaChildProcess } from 'execa' - -export interface PeerData { - id: PeerId - addresses: Multiaddr[] -} +import DefaultFactory from './factory.js' +import type { KuboNode, KuboOptions } from './kubo/index.js' -export type ControllerType = 'js' | 'go' | 'proc' +export * from './kubo/index.js' +export type NodeType = 'kubo' -export interface Controller { - /** - * Initialize a repo - */ - init: (options?: InitOptions) => Promise> +export interface Node = Record, InitArgs = unknown, StartArgs = unknown, StopArgs = unknown, CleanupArgs = unknown> { + api: API + options: Options /** - * Start the daemon + * Return information about a node */ - start: () => Promise> + info(): Promise /** - * Stop the daemon + * Perform any pre-start tasks such as creating a repo, generating a peer id, + * etc */ - stop: () => Promise> + init(args?: InitArgs): Promise /** - * Delete the repo that was being used. - * If the node was marked as `disposable` this will be called - * automatically when the process is exited. + * Start the node */ - cleanup: () => Promise> + start(args?: StartArgs): Promise /** - * Get the pid of the `ipfs daemon` process + * Stop a node that has previously been started */ - pid: () => Promise + stop(args?: StopArgs): Promise /** - * Get the version of ipfs + * Perform any resource cleanup after stopping a disposable node */ - version: () => Promise - path: string - started: boolean - initialized: boolean - clean: boolean - api: IPFSAPI - subprocess?: ExecaChildProcess | null - opts: ControllerOptions - apiAddr: Multiaddr - peer: PeerData -} - -export interface RemoteState { - id: string - path: string - initialized: boolean - started: boolean - disposable: boolean - clean: boolean - apiAddr: string - gatewayAddr: string - grpcAddr: string + cleanup(args?: CleanupArgs): Promise } export interface InitOptions { @@ -97,180 +188,113 @@ export interface CircuitRelayOptions { hop: CircuitRelayHopOptions } -export interface IPFSOptions { - /** - * The file path at which to store the IPFS node’s data. Alternatively, you can set up a customized storage system by providing an ipfs.Repo instance. - */ - repo?: string | any - /** - * Initialize the repo when creating the IPFS node. Instead of a boolean, you may provide an object with custom initialization options. https://github.com/ipfs/js-ipfs/blob/master/README.md#optionsinit - */ - init?: boolean | InitOptions - /** - * If false, do not automatically start the IPFS node. Instead, you’ll need to manually call node.start() yourself. - */ - start?: boolean - /** - * A passphrase to encrypt/decrypt your keys. - */ - pass?: string - /** - * Prevents all logging output from the IPFS node. - */ - silent?: boolean - /** - * Configure circuit relay. https://github.com/ipfs/js-ipfs/blob/master/README.md#optionsrelay - */ - relay?: any - /** - * Configure remote preload nodes. The remote will preload content added on this node, and also attempt to preload objects requested by this node. https://github.com/ipfs/js-ipfs/blob/master/README.md#optionspreload - */ - preload?: boolean | PreloadOptions - /** - * Enable and configure experimental features. https://github.com/ipfs/js-ipfs/blob/master/README.md#optionsexperimental - */ - EXPERIMENTAL?: ExperimentalOptions +export interface NodeOptions { /** - * Modify the default IPFS node config. This object will be merged with the default config; it will not replace it. The default config is documented in the js-ipfs config file docs. https://github.com/ipfs/js-ipfs/blob/master/README.md#optionsconfig + * The type of controller */ - config?: any - /** - * Modify the default IPLD config. This object will be merged with the default config; it will not replace it. Check IPLD docs for more information on the available options. https://github.com/ipfs/js-ipfs/blob/master/README.md#optionsipld - */ - ipld?: any - /** - * The libp2p option allows you to build your libp2p node by configuration, or via a bundle function. https://github.com/ipfs/js-ipfs/blob/master/README.md#optionslibp2p - */ - libp2p?: any - /** - * Configure the libp2p connection manager. https://github.com/ipfs/js-ipfs/blob/master/README.md#optionsconnectionmanager - */ - connectionManager?: any - /** - * Run the node offline - */ - offline?: boolean - /** - * Perform any required repo migrations - */ - repoAutoMigrate?: boolean -} + type?: NodeType -export interface ControllerOptions { /** * Flag to activate custom config for tests */ test?: boolean + /** - * Use remote endpoint to spawn the controllers. Defaults to `true` when not in node - */ - remote?: boolean - /** - * Endpoint URL to manage remote Controllers. (Defaults: 'http://localhost:43134') - */ - endpoint?: string - /** - * A new repo is created and initialized for each invocation, as well as cleaned up automatically once the process exits + * A new repo is created and initialized for each invocation, as well as + * cleaned up automatically once the process exits */ disposable?: boolean + /** - * The daemon type - */ - type?: Type - /** - * Additional environment variables, passed to executing shell. Only applies for Daemon controllers + * Additional environment variables, passed to executing shell. Only applies + * for Daemon controllers */ env?: Record + /** * Custom cli args */ args?: string[] + /** - * Reference to an ipfs-http-client module + * How long to wait before force killing a daemon in ms + * + * @default 5000 */ - ipfsHttpModule?: any + forceKillTimeout?: number + /** - * Reference to a kubo-rpc-client module + * Init options */ - kuboRpcModule?: any + init?: InitOptions + /** - * Reference to an ipfs or ipfs-core module + * Start options */ - ipfsModule?: any + start?: StartOptions +} + +export interface NodeOptionsOverrides { + kubo?: KuboOptions +} + +export interface SpawnOptions { /** - * Reference to an ipfs-core module + * Use remote endpoint to spawn the controllers. Defaults to `true` when not in node */ - ipfsClientModule?: any + remote?: true +} + +export interface Factory { /** - * Path to a IPFS executable + * Create a node */ - ipfsBin?: string + spawn(options?: KuboOptions & SpawnOptions): Promise + spawn(options?: NodeOptions & SpawnOptions): Promise + /** - * Options for the IPFS node + * Shut down all previously created nodes that are still running */ - ipfsOptions?: IPFSOptions + clean(): Promise + /** - * Whether to use SIGKILL to quit a daemon that does not stop after `.stop()` is called. (default true) + * The previously created nodes that are still running */ - forceKill?: boolean + controllers: Node[] + /** - * How long to wait before force killing a daemon in ms. (default 5000) + * The default options that will be applied to all nodes */ - forceKillTimeout?: number -} + options: NodeOptions -export interface ControllerOptionsOverrides { - js?: ControllerOptions<'js'> - go?: ControllerOptions<'go'> - proc?: ControllerOptions<'proc'> -} - -export interface Factory { - tmpDir: (options?: ControllerOptions) => Promise - spawn: (options?: ControllerOptions) => Promise> - clean: () => Promise - controllers: Array> - opts: ControllerOptions + /** + * Config overrides that will be applied to specific node types + */ + overrides: NodeOptionsOverrides } -export interface CreateFactory { (): Factory | Promise } - /** * Creates a factory - * - * @param {ControllerOptions} [options] - * @param {ControllerOptionsOverrides} [overrides] - * @returns {Factory} */ -export const createFactory = (options?: ControllerOptions, overrides?: ControllerOptionsOverrides): Factory => { +export function createFactory (options: KuboOptions, overrides?: NodeOptionsOverrides): Factory +export function createFactory (options?: NodeOptions, overrides?: NodeOptionsOverrides): Factory +export function createFactory (options?: NodeOptions, overrides?: NodeOptionsOverrides): Factory { return new DefaultFactory(options, overrides) } /** * Creates a node */ -export const createController = async (options?: ControllerOptions): Promise => { +export async function createNode (options: KuboOptions & SpawnOptions): Promise +export async function createNode (options?: any): Promise { const f = new DefaultFactory() - return await f.spawn(options) -} - -export interface IPFSAPI extends IPFS { - apiHost?: string - apiPort?: number - gatewayHost?: string - gatewayPort?: number - grpcHost?: string - grpcPort?: number + return f.spawn(options) } /** * Create a Endpoint Server - * - * @param {number | { port: number }} [options] - Configuration options or just the port. - * @param {ControllerOptions} [factoryOptions] - * @param {ControllerOptionsOverrides} [factoryOverrides] */ -export const createServer = (options?: number | { port: number }, factoryOptions: ControllerOptions = {}, factoryOverrides: ControllerOptionsOverrides = {}) => { +export const createServer = (options?: number | { port: number }, factoryOptions: NodeOptions = {}, factoryOverrides: NodeOptionsOverrides = {}): Server => { let port: number | undefined if (typeof options === 'number') { diff --git a/src/ipfsd-client.ts b/src/ipfsd-client.ts deleted file mode 100644 index 7f75ced3..00000000 --- a/src/ipfsd-client.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { Multiaddr, multiaddr } from '@multiformats/multiaddr' -import http from 'ipfs-utils/src/http.js' -import mergeOptions from 'merge-options' -import { logger } from '@libp2p/logger' -import type { Controller, ControllerOptions, InitOptions, IPFSAPI, PeerData, RemoteState } from './index.js' - -const merge = mergeOptions.bind({ ignoreUndefined: true }) - -const daemonLog = { - info: logger('ipfsd-ctl:client:stdout'), - err: logger('ipfsd-ctl:client:stderr') -} -const rpcModuleLogger = logger('ipfsd-ctl:client') - -/** - * Controller for remote nodes - */ -class Client implements Controller { - public path: string - // @ts-expect-error set during startup - public api: IPFSAPI - public subprocess: null - public opts: ControllerOptions - public initialized: boolean - public started: boolean - public clean: boolean - // @ts-expect-error set during startup - public apiAddr: Multiaddr - - private readonly baseUrl: string - private readonly id: string - private readonly disposable: boolean - private gatewayAddr?: Multiaddr - private grpcAddr?: Multiaddr - private _peerId: PeerData | null - - constructor (baseUrl: string, remoteState: RemoteState, options: ControllerOptions) { - this.opts = options - this.baseUrl = baseUrl - this.id = remoteState.id - this.path = remoteState.path - this.initialized = remoteState.initialized - this.started = remoteState.started - this.disposable = remoteState.disposable - this.clean = remoteState.clean - this.subprocess = null - - this._setApi(remoteState.apiAddr) - this._setGateway(remoteState.gatewayAddr) - this._setGrpc(remoteState.grpcAddr) - this._createApi() - this._peerId = null - } - - get peer () { - if (this._peerId == null) { - throw new Error('Not started') - } - - return this._peerId - } - - private _setApi (addr: string): void { - if (addr != null) { - this.apiAddr = multiaddr(addr) - } - } - - private _setGateway (addr: string): void { - if (addr != null) { - this.gatewayAddr = multiaddr(addr) - } - } - - private _setGrpc (addr: string): void { - if (addr != null) { - this.grpcAddr = multiaddr(addr) - } - } - - private _createApi (): void { - if (this.opts.ipfsClientModule != null && this.grpcAddr != null && this.apiAddr != 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) { - 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: InitOptions = {}): Promise { - if (this.initialized) { - return this - } - - let ipfsOptions = {} - - if (this.opts.ipfsOptions?.init != null && !(typeof this.opts.ipfsOptions.init === 'boolean')) { - ipfsOptions = this.opts.ipfsOptions.init - } - - const opts = merge( - { - emptyRepo: false, - profiles: this.opts.test === true ? ['test'] : [] - }, - ipfsOptions, - typeof initOptions === 'boolean' ? {} : initOptions - ) - - const req = await http.post( - `${this.baseUrl}/init`, - { - searchParams: new URLSearchParams({ id: this.id }), - json: opts - } - ) - const rsp = await req.json() - this.initialized = rsp.initialized - this.clean = false - return this - } - - async cleanup (): Promise { - if (this.clean) { - return this - } - - await http.post( - `${this.baseUrl}/cleanup`, - { searchParams: new URLSearchParams({ id: this.id }) } - ) - this.clean = true - return this - } - - async start (): Promise { - if (!this.started) { - const req = await http.post( - `${this.baseUrl}/start`, - { searchParams: new URLSearchParams({ id: this.id }) } - ) - const res = await req.json() - - this._setApi(res.apiAddr) - this._setGateway(res.gatewayAddr) - this._setGrpc(res.grpcAddr) - this._createApi() - - this.started = true - } - - if (this.api == null) { - throw new Error('api was not set') - } - - // Add `peerId` - const id = await this.api.id() - this._peerId = id - daemonLog.info(id) - return this - } - - async stop (): Promise { - if (!this.started) { - return this - } - - await http.post( - `${this.baseUrl}/stop`, - { searchParams: new URLSearchParams({ id: this.id }) } - ) - this.started = false - - if (this.disposable) { - await this.cleanup() - } - - return this - } - - async pid (): Promise { - const req = await http.get( - `${this.baseUrl}/pid`, - { searchParams: new URLSearchParams({ id: this.id }) } - ) - const res = await req.json() - - return res.pid - } - - async version (): Promise { - const req = await http.get( - `${this.baseUrl}/version`, - { searchParams: new URLSearchParams({ id: this.id }) } - ) - const res = await req.json() - return res.version - } -} - -export default Client diff --git a/src/ipfsd-daemon.ts b/src/ipfsd-daemon.ts deleted file mode 100644 index bea3fced..00000000 --- a/src/ipfsd-daemon.ts +++ /dev/null @@ -1,441 +0,0 @@ -import { Multiaddr, multiaddr } from '@multiformats/multiaddr' -import fs from 'fs/promises' -import mergeOptions from 'merge-options' -import { logger } from '@libp2p/logger' -import { execa, ExecaChildProcess } 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' -import type { Controller, ControllerOptions, InitOptions, IPFSAPI, PeerData } from './index.js' - -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: Error & { stdout: string, stderr: string }) { - // 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 -} - -interface TranslateUnknownErrorArgs { - err: Error | unknown - stdout: string - stderr: string - nameFallback?: string - messageFallback?: string -} - -function translateUnknownError ({ err, stdout, stderr, nameFallback = 'Unknown Error', messageFallback = 'Unknown Error Message' }: TranslateUnknownErrorArgs) { - const error: Error = err as Error - const name = error?.name ?? nameFallback - const message = error?.message ?? messageFallback - return translateError({ - name, - message, - stdout, - stderr - }) -} - -/** - * Controller for daemon nodes - */ -class Daemon implements Controller { - public path: string - // @ts-expect-error set during startup - public api: IPFSAPI - public subprocess?: ExecaChildProcess - public opts: ControllerOptions - public initialized: boolean - public started: boolean - public clean: boolean - // @ts-expect-error set during startup - public apiAddr: Multiaddr - - private gatewayAddr?: Multiaddr - private grpcAddr?: Multiaddr - private readonly exec?: string - private readonly env: Record - private readonly disposable: boolean - private _peerId: PeerData | null - - constructor (opts: ControllerOptions) { - 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 - } - - private _setApi (addr: string): void { - this.apiAddr = multiaddr(addr) - } - - private _setGrpc (addr: string): void { - this.grpcAddr = multiaddr(addr) - } - - private _setGateway (addr: string): void { - 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: InitOptions = {}): Promise { - 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} - */ - async cleanup () { - if (!this.clean) { - await fs.rm(this.path, { - recursive: true - }) - this.clean = true - } - return this - } - - /** - * Start the daemon. - * - * @returns {Promise} - */ - 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: Buffer) => { - 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: { timeout?: number } = {}): Promise { - const timeout = options.timeout ?? 60000 - - if (!this.started) { - return this - } - - if (this.subprocess != null) { - /** @type {ReturnType | 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} - */ - 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} - */ - 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 - */ - private async _replaceConfig (config: any): Promise { - 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 (): Promise { - 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 diff --git a/src/ipfsd-in-proc.ts b/src/ipfsd-in-proc.ts deleted file mode 100644 index f60d93e3..00000000 --- a/src/ipfsd-in-proc.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Multiaddr, multiaddr } from '@multiformats/multiaddr' -import mergeOptions from 'merge-options' -import { repoExists, removeRepo, checkForRunningApi, tmpDir, defaultRepo } from './utils.js' -import { logger } from '@libp2p/logger' -import type { Controller, ControllerOptions, InitOptions, IPFSAPI, PeerData } from './index.js' - -const merge = mergeOptions.bind({ ignoreUndefined: true }) - -const daemonLog = { - info: logger('ipfsd-ctl:proc:stdout'), - err: logger('ipfsd-ctl:proc:stderr') -} -const rpcModuleLogger = logger('ipfsd-ctl:proc') - -/** - * Controller for in process nodes - */ -class InProc implements Controller { - public path: string - // @ts-expect-error set during startup - public api: IPFSAPI - public subprocess: null - public opts: ControllerOptions - public initialized: boolean - public started: boolean - public clean: boolean - // @ts-expect-error set during startup - public apiAddr: Multiaddr - - private initOptions: InitOptions - private readonly disposable: boolean - private _peerId: PeerData | null - - constructor (opts: ControllerOptions) { - this.opts = opts - this.path = this.opts.ipfsOptions?.repo ?? (opts.disposable === true ? tmpDir(opts.type) : defaultRepo(opts.type)) - this.initOptions = toInitOptions(opts.ipfsOptions?.init) - this.disposable = Boolean(opts.disposable) - this.initialized = false - this.started = false - this.clean = true - this.subprocess = null - this._peerId = null - } - - get peer () { - if (this._peerId == null) { - throw new Error('Not started') - } - - return this._peerId - } - - async setExec () { - if (this.api != null) { - return - } - - const IPFS = this.opts.ipfsModule - - this.api = await IPFS.create({ - ...this.opts.ipfsOptions, - silent: true, - repo: this.path, - init: this.initOptions - }) - } - - private _setApi (addr: string): void { - this.apiAddr = multiaddr(addr) - - if (this.opts.kuboRpcModule != null) { - rpcModuleLogger('Using kubo-rpc-client') - this.api = this.opts.kuboRpcModule.create(addr) - } else if (this.opts.ipfsHttpModule != null) { - rpcModuleLogger('Using ipfs-http-client') - this.api = this.opts.ipfsHttpModule.create(addr) - } else { - throw new Error('You must pass either a kuboRpcModule or ipfsHttpModule') - } - - this.api.apiHost = this.apiAddr.nodeAddress().address - this.api.apiPort = this.apiAddr.nodeAddress().port - } - - async init (initOptions: InitOptions = {}): Promise { - this.initialized = await repoExists(this.path) - if (this.initialized) { - this.clean = false - return this - } - - // Repo not initialized - this.initOptions = merge( - { - emptyRepo: false, - profiles: this.opts.test === true ? ['test'] : [] - }, - this.initOptions, - toInitOptions(initOptions) - ) - - await this.setExec() - this.clean = false - this.initialized = true - return this - } - - async cleanup (): Promise { - if (!this.clean) { - await removeRepo(this.path) - this.clean = true - } - return this - } - - async start (): Promise { - // Check if a daemon is already running - const api = checkForRunningApi(this.path) - if (api != null) { - this._setApi(api) - } else { - await this.setExec() - await this.api.start() - } - - this.started = true - // Add `peerId` - const id = await this.api.id() - this._peerId = id - daemonLog.info(id) - return this - } - - async stop (): Promise { - if (!this.started) { - return this - } - - await this.api.stop() - this.started = false - - if (this.disposable) { - await this.cleanup() - } - return this - } - - /** - * Get the pid of the `ipfs daemon` process - */ - async pid (): Promise { - return await Promise.reject(new Error('not implemented')) - } - - async version (): Promise { - await this.setExec() - - const { version } = await this.api.version() - - return version - } -} - -const toInitOptions = (init: boolean | InitOptions = {}): InitOptions => - typeof init === 'boolean' ? {} : init - -export default InProc diff --git a/src/kubo/client.ts b/src/kubo/client.ts new file mode 100644 index 00000000..548d6586 --- /dev/null +++ b/src/kubo/client.ts @@ -0,0 +1,128 @@ +import type { KuboNode, KuboInfo, KuboInitOptions, KuboOptions, KuboStartOptions } from './index.js' +import type { PeerInfo } from '@libp2p/interface' +import type { KuboRPCClient } from 'kubo-rpc-client' + +export interface KuboClientInit extends KuboOptions { + endpoint: string + id: string + disposable: boolean + repo: string +} + +/** + * Node for remote nodes + */ +export default class KuboClient implements KuboNode { + public options: KuboOptions & Required> + public peerInfo?: PeerInfo + public id: string + public disposable: boolean + public repo: string + + private readonly endpoint: string + private _api?: KuboRPCClient + private readonly initArgs?: KuboInitOptions + private readonly startArgs?: KuboStartOptions + + constructor (options: KuboClientInit) { + if (options.rpc == null) { + throw new Error('Please pass an rpc option') + } + + // @ts-expect-error cannot detect rpc is present + this.options = options + this.endpoint = options.endpoint + this.disposable = options.disposable + this.id = options.id + this.repo = options.repo + + if (options.init != null && typeof options.init !== 'boolean') { + this.initArgs = options.init + } + + if (options.start != null && typeof options.start !== 'boolean') { + this.startArgs = options.start + } + } + + get api (): KuboRPCClient { + if (this._api == null) { + throw new Error('Not started') + } + + return this._api + } + + async info (): Promise { + const response = await fetch(`${this.endpoint}/info?${new URLSearchParams({ id: this.id })}`, { + method: 'GET' + }) + + if (!response.ok) { + throw new Error(`Error getting remote kubo info - ${await response.text()}`) + } + + return response.json() + } + + async init (args?: KuboInitOptions): Promise { + const response = await fetch(`${this.endpoint}/init?${new URLSearchParams({ id: this.id })}`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + ...(this.initArgs ?? {}), + ...(args ?? {}) + }) + }) + + if (!response.ok) { + throw new Error(`Error initializing remote kubo - ${await response.text()}`) + } + } + + async start (args?: KuboStartOptions): Promise { + const response = await fetch(`${this.endpoint}/start?${new URLSearchParams({ id: this.id })}`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + ...(this.startArgs ?? {}), + ...(args ?? {}) + }) + }) + + if (!response.ok) { + throw new Error(`Error starting remote kubo - ${await response.text()}`) + } + + const info = await response.json() + this._api = this.options.rpc(info.api) + } + + async stop (): Promise { + const response = await fetch(`${this.endpoint}/stop?${new URLSearchParams({ id: this.id })}`, { + method: 'POST' + }) + + if (!response.ok) { + throw new Error(`Error stopping remote kubo - ${await response.text()}`) + } + + if (this.disposable) { + await this.cleanup() + } + } + + async cleanup (): Promise { + const response = await fetch(`${this.endpoint}/cleanup?${new URLSearchParams({ id: this.id })}`, { + method: 'POST' + }) + + if (!response.ok) { + throw new Error(`Error cleaning up remote kubo - ${await response.text()}`) + } + } +} diff --git a/src/kubo/daemon.ts b/src/kubo/daemon.ts new file mode 100644 index 00000000..db745b82 --- /dev/null +++ b/src/kubo/daemon.ts @@ -0,0 +1,301 @@ +import fs from 'node:fs/promises' +import { logger } from '@libp2p/logger' +import { execa, type ExecaChildProcess } from 'execa' +import mergeOptions from 'merge-options' +import pDefer from 'p-defer' +import waitFor from 'p-wait-for' +import { checkForRunningApi, tmpDir, buildStartArgs, repoExists, buildInitArgs } from './utils.js' +import type { KuboNode, KuboInfo, KuboInitOptions, KuboOptions, KuboStartOptions } from './index.js' +import type { Logger } from '@libp2p/interface' +import type { KuboRPCClient } from 'kubo-rpc-client' + +const merge = mergeOptions.bind({ ignoreUndefined: true }) + +function translateError (err: Error & { stdout: string, stderr: string }): Error { + // 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 implements KuboNode { + public options: KuboOptions & Required> + + private readonly disposable: boolean + private subprocess?: ExecaChildProcess + private _api?: KuboRPCClient + private readonly repo: string + private readonly stdout: Logger + private readonly stderr: Logger + private readonly _exec?: string + private readonly env: Record + private readonly initArgs?: KuboInitOptions + private readonly startArgs?: KuboStartOptions + + constructor (options: KuboOptions) { + if (options.rpc == null) { + throw new Error('Please pass an rcp 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 + } + } + + get api (): KuboRPCClient { + if (this._api == null) { + throw new Error('Not started') + } + + return this._api + } + + get exec (): string { + if (this._exec == null) { + throw new Error('No executable specified') + } + + return this._exec + } + + async info (): Promise { + const id = await this._api?.id() + + return { + 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 + } + } + + /** + * 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 (): Promise { + await fs.rm(this.repo, { + recursive: true, + force: true + }) + } + + async init (args?: KuboInitOptions): Promise { + // check if already initialized + if (await repoExists(this.repo)) { + 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) + + const out = await execa(this.exec, cliArgs, { + env: this.env + }) + .catch(translateError) + + if (out instanceof Error) { + throw out + } + + const { stdout, stderr } = out + + this.stdout(stdout) + this.stderr(stderr) + + await this._replaceConfig(merge( + await this._getConfig(), + initOptions.config + )) + } + + /** + * Start the daemon + */ + async start (args?: KuboStartOptions): Promise { + // 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() + + const out = this.subprocess = execa(this.exec, cliArgs, { + env: this.env + }) + + if (out instanceof Error) { + 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: Buffer): void => { + 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: { timeout?: number } = {}): Promise { + const timeout = options.timeout ?? 60000 + const subprocess = this.subprocess + + if (subprocess == null || subprocess.exitCode != null || this._api == null) { + return + } + + await this.api.stop() + + // wait for the subprocess to exit and declare ourselves stopped + await waitFor(() => subprocess.exitCode != null, { + timeout + }) + + 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 (): Promise { + 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 + */ + public async _replaceConfig (config: any): Promise { + await fs.writeFile(`${this.repo}/config`, JSON.stringify(config, null, 2), { + encoding: 'utf-8' + }) + } + + private async getVersion (): Promise { + if (this.exec == null) { + throw new Error('No executable specified') + } + + const out = await execa(this.exec, ['version'], { + env: this.env + }) + .catch(translateError) + + if (out instanceof Error) { + throw out + } + + const { stdout } = out + + return stdout.trim() + } +} diff --git a/src/kubo/index.ts b/src/kubo/index.ts new file mode 100644 index 00000000..4930ff10 --- /dev/null +++ b/src/kubo/index.ts @@ -0,0 +1,72 @@ +import type { Node, NodeOptions } from '../index.js' +import type { KuboRPCClient } from 'kubo-rpc-client' + +export interface KuboInit { + emptyRepo?: boolean + profiles?: string[] + + /** + * JSON config directives to patch the config file with + */ + config?: Record + + /** + * Extra CLI args used to invoke `kubo init` + */ + args?: string[] +} + +export interface KuboEd25519Init extends KuboInit { + algorithm?: 'ed25519' +} + +export interface KuboRSAInit extends KuboInit { + algorithm: 'rsa' + bits?: number +} + +export type KuboInitOptions = KuboEd25519Init | KuboRSAInit + +export interface KuboStartOptions { + offline?: boolean + ipnsPubsub?: boolean + repoAutoMigrate?: boolean + + /** + * Extra CLI args used to invoke `kubo daemon` + */ + args?: string[] +} + +export interface KuboOptions extends NodeOptions { + type: 'kubo' + + /** + * A function that creates an instance of `KuboRPCClient` + */ + rpc?(...args: any[]): KuboRPCClient + + /** + * Path to a Kubo executable + */ + bin?: string + + /** + * The path to a repo directory. It will be created during init if it does not + * already exist. + */ + repo?: string +} + +export interface KuboInfo { + pid?: number + version?: string + peerId?: string + multiaddrs: string[] + api?: string + repo: string +} + +export interface KuboNode extends Node { + +} diff --git a/src/kubo/utils.ts b/src/kubo/utils.ts new file mode 100644 index 00000000..1b9610a3 --- /dev/null +++ b/src/kubo/utils.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { logger } from '@libp2p/logger' +import { nanoid } from 'nanoid' +import type { KuboInitOptions, KuboStartOptions } from '../index.js' + +const log = logger('ipfsd-ctl:utils') + +export const removeRepo = async (repoPath: string): Promise => { + try { + await fs.promises.rm(repoPath, { + recursive: true + }) + } catch (err: any) { + // ignore + } +} + +export const repoExists = async (repoPath: string): Promise => { + return Promise.resolve(fs.existsSync(path.join(repoPath, 'config'))) +} + +export const checkForRunningApi = (repoPath = ''): string | undefined => { + let api + try { + api = fs.readFileSync(path.join(repoPath, 'api')) + } catch (err: any) { + log('Unable to open api file') + } + + return (api != null) ? api.toString() : undefined +} + +export const tmpDir = (type = ''): string => { + return path.join(os.tmpdir(), `${type}_ipfs_${nanoid()}`) +} + +export function buildInitArgs (options: KuboInitOptions): string[] { + const args: string[] = ['init'].concat(options.args ?? []) + + // Translate IPFS options to cli args + if (options.algorithm === 'rsa' && options.bits != null) { + args.push('--bits', `${options.bits}`) + } + + if (options.algorithm != null) { + args.push('--algorithm', options.algorithm) + } + + if (options.emptyRepo === true) { + args.push('--empty-repo') + } + + if (Array.isArray(options.profiles) && options.profiles.length > 0) { + args.push('--profile', options.profiles.join(',')) + } + + return args +} + +export function buildStartArgs (options: KuboStartOptions): string[] { + const args = ['daemon'].concat(options.args ?? []) + + if (options.offline === true) { + args.push('--offline') + } + + if (options.ipnsPubsub === true) { + args.push('--enable-namesys-pubsub') + } + + if (options.repoAutoMigrate === true) { + args.push('--migrate') + } + + return args +} diff --git a/src/utils.browser.ts b/src/utils.browser.ts deleted file mode 100644 index ab4086e2..00000000 --- a/src/utils.browser.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { nanoid } from 'nanoid' - -const deleteDb = async (path: string): Promise => { - return await new Promise((resolve, reject) => { - const keys = self.indexedDB.deleteDatabase(path) - keys.onerror = (err) => reject(err) - keys.onsuccess = () => resolve() - }) -} - -/** - * close repoPath , repoPath/keys, repoPath/blocks and repoPath/datastore - */ -export const removeRepo = async (repoPath: string): Promise => { - await deleteDb(repoPath) - await deleteDb(repoPath + '/keys') - await deleteDb(repoPath + '/blocks') - await deleteDb(repoPath + '/datastore') -} - -/** - * @param {string} repoPath - */ -export const repoExists = async (repoPath: string): Promise => { - return await new Promise((resolve, reject) => { - const req = self.indexedDB.open(repoPath) - let existed = true - req.onerror = () => reject(req.error) - req.onsuccess = function () { - req.result.close() - if (!existed) { self.indexedDB.deleteDatabase(repoPath) } - resolve(existed) - } - req.onupgradeneeded = function () { - existed = false - } - }) -} - -export const defaultRepo = (): string => { - return 'ipfs' -} - -export const checkForRunningApi = (): string | null => { - return null -} - -export const tmpDir = (type = ''): string => { - return `${type}_ipfs_${nanoid()}` -} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 0e23ea51..00000000 --- a/src/utils.ts +++ /dev/null @@ -1,119 +0,0 @@ -import os from 'os' -import path from 'path' -import fs from 'fs' -import { logger } from '@libp2p/logger' -import { nanoid } from 'nanoid' -import tempWrite from 'temp-write' -import type { ControllerOptions, IPFSOptions, ControllerType } from './index.js' - -const log = logger('ipfsd-ctl:utils') - -export const removeRepo = async (repoPath: string): Promise => { - try { - await fs.promises.rm(repoPath, { - recursive: true - }) - } catch (err: any) { - // ignore - } -} - -export const repoExists = async (repoPath: string): Promise => { - return await Promise.resolve(fs.existsSync(path.join(repoPath, 'config'))) -} - -export const defaultRepo = (type?: ControllerType): string => { - if (process.env.IPFS_PATH !== undefined) { - return process.env.IPFS_PATH - } - return path.join( - os.homedir(), - type === 'js' || type === 'proc' ? '.jsipfs' : '.ipfs' - ) -} - -export const checkForRunningApi = (repoPath = ''): string | null => { - let api - try { - api = fs.readFileSync(path.join(repoPath, 'api')) - } catch (err: any) { - log('Unable to open api file') - } - - return (api != null) ? api.toString() : null -} - -export const tmpDir = (type = ''): string => { - return path.join(os.tmpdir(), `${type}_ipfs_${nanoid()}`) -} - -export function buildInitArgs (opts: ControllerOptions = {}): string[] { - const args = ['init'] - const ipfsOptions: IPFSOptions = opts.ipfsOptions ?? {} - const initOptions = ipfsOptions.init != null && typeof ipfsOptions.init !== 'boolean' ? ipfsOptions.init : {} - - // default-config only for JS - if (opts.type === 'js') { - if (ipfsOptions.config != null) { - args.push(tempWrite.sync(JSON.stringify(ipfsOptions.config))) - } - - if (initOptions.pass != null) { - args.push('--pass', `"${initOptions.pass}"`) - } - } - - // Translate IPFS options to cli args - if (initOptions.bits != null) { - args.push('--bits', `${initOptions.bits}`) - } - - if (initOptions.algorithm != null) { - args.push('--algorithm', initOptions.algorithm) - } - - if (initOptions.emptyRepo === true) { - args.push('--empty-repo') - } - - if (Array.isArray(initOptions.profiles) && initOptions.profiles.length > 0) { - args.push('--profile', initOptions.profiles.join(',')) - } - - return args -} - -export function buildStartArgs (opts: ControllerOptions = {}): string[] { - const ipfsOptions: IPFSOptions = opts.ipfsOptions ?? {} - const customArgs: string[] = opts.args ?? [] - - const args = ['daemon'].concat(customArgs) - - if (opts.type === 'js') { - if (ipfsOptions.pass != null) { - args.push('--pass', '"' + ipfsOptions.pass + '"') - } - - if (ipfsOptions.preload != null) { - args.push('--enable-preload', Boolean(typeof ipfsOptions.preload === 'boolean' ? ipfsOptions.preload : ipfsOptions.preload.enabled).toString()) - } - - if (ipfsOptions.EXPERIMENTAL?.sharding === true) { - args.push('--enable-sharding-experiment') - } - } - - if (ipfsOptions.offline === true) { - args.push('--offline') - } - - if ((ipfsOptions.EXPERIMENTAL != null) && ipfsOptions.EXPERIMENTAL.ipnsPubsub === true) { - args.push('--enable-namesys-pubsub') - } - - if (ipfsOptions.repoAutoMigrate === true) { - args.push('--migrate') - } - - return args -} diff --git a/test/browser.ts b/test/browser.ts deleted file mode 100644 index 6149fd43..00000000 --- a/test/browser.ts +++ /dev/null @@ -1 +0,0 @@ -import './browser.utils.js' diff --git a/test/browser.utils.ts b/test/browser.utils.ts deleted file mode 100644 index 8b5dbbdd..00000000 --- a/test/browser.utils.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-env mocha */ - -import { expect } from 'aegir/chai' -import { isEnvWithDom } from 'wherearewe' -import { tmpDir, checkForRunningApi, defaultRepo, repoExists, removeRepo } from '../src/utils.js' -import { createFactory, createController } from '../src/index.js' - -describe('utils browser version', function () { - if (isEnvWithDom) { - it('tmpDir should return correct path', () => { - expect(tmpDir('js')).to.be.contain('js_ipfs_') - expect(tmpDir('go')).to.be.contain('go_ipfs_') - expect(tmpDir()).to.be.contain('_ipfs_') - }) - - it('checkForRunningApi should return null', () => { - expect(checkForRunningApi()).to.be.null() - }) - - it('defaultRepo should return ipfs', () => { - expect(defaultRepo()).to.be.eq('ipfs') - }) - - it('removeRepo should work', async () => { - const ctl = await createController({ - test: true, - type: 'proc', - disposable: false, - ipfsOptions: { repo: 'ipfs_test_remove' }, - ipfsModule: (await import('ipfs')) - }) - await ctl.init() - await ctl.start() - await ctl.stop() - await removeRepo('ipfs_test_remove') - expect(await repoExists('ipfs_test_remove')).to.be.false() - expect(await repoExists('ipfs_test_remove/keys')).to.be.false() - expect(await repoExists('ipfs_test_remove/blocks')).to.be.false() - expect(await repoExists('ipfs_test_remove/datastore')).to.be.false() - }) - - it('removeRepo should wait for db to be closed', async () => { - const ctl = await createController({ - test: true, - type: 'proc', - disposable: false, - ipfsOptions: { repo: 'ipfs_test_remove' }, - ipfsModule: (await import('ipfs')) - }) - await ctl.init() - await ctl.start() - - await Promise.all([ - removeRepo('ipfs_test_remove'), - ctl.stop() - ]) - - expect(await repoExists('ipfs_test_remove')).to.be.false() - expect(await repoExists('ipfs_test_remove/keys')).to.be.false() - expect(await repoExists('ipfs_test_remove/blocks')).to.be.false() - expect(await repoExists('ipfs_test_remove/datastore')).to.be.false() - }) - - describe('repoExists', () => { - it('should resolve true when repo exists', async () => { - const f = createFactory({ test: true }) - const node = await f.spawn({ - type: 'proc', - ipfsOptions: { - repo: 'ipfs_test' - }, - ipfsModule: (await import('ipfs')) - }) - expect(await repoExists('ipfs_test')).to.be.true() - await node.stop() - }) - it('should resolve false for random path', async () => { - expect(await repoExists('random')).to.be.false() - }) - }) - } -}) diff --git a/test/controller.spec.ts b/test/controller.spec.ts index 3de78576..87b70c52 100644 --- a/test/controller.spec.ts +++ b/test/controller.spec.ts @@ -3,103 +3,51 @@ /* eslint-disable no-loop-func */ import { expect } from 'aegir/chai' +import * as kubo from 'kubo' +import { create as createKuboRPCClient } from 'kubo-rpc-client' import merge from 'merge-options' -import { createFactory, createController, ControllerOptions, Factory } from '../src/index.js' -import { repoExists } from '../src/utils.js' -import { isBrowser, isWebWorker, isNode } from 'wherearewe' -import waitFor from 'p-wait-for' -import * as ipfsModule from 'ipfs' -import * as ipfsHttpModule from 'ipfs-http-client' -// @ts-expect-error no types -import * as goIpfsModule from 'go-ipfs' -import * as kuboRpcModule from 'kubo-rpc-client' - -const types: ControllerOptions[] = [{ - type: 'js', - ipfsOptions: { - init: false, - start: false - } -}, { - type: 'go', - kuboRpcModule, - ipfsOptions: { - init: false, - start: false - } -}, { - type: 'proc', - ipfsOptions: { - init: false, - start: false - } -}, { - type: 'js', - remote: true, - ipfsOptions: { - init: false, - start: false - } +import { isBrowser, isWebWorker, isNode, isElectronMain } from 'wherearewe' +import { createFactory } from '../src/index.js' +import { repoExists } from '../src/kubo/utils.js' +import type { Factory, KuboOptions, SpawnOptions, KuboNode } from '../src/index.js' + +const types: Array = [{ + type: 'kubo' }, { - type: 'go', - kuboRpcModule, - remote: true, - ipfsOptions: { - init: false, - start: false - } + type: 'kubo', + remote: true }] -/** - * Set the options object with the correct RPC module depending on the type - */ -function addCorrectRpcModule (opts: ControllerOptions, additionalOpts: ControllerOptions) { - if (opts.type === 'go') { - additionalOpts.kuboRpcModule = kuboRpcModule - } else { - additionalOpts.ipfsHttpModule = ipfsHttpModule - } - - return additionalOpts -} - -describe('Controller API', function () { +describe('Node API', function () { this.timeout(60000) - let factory: Factory + let factory: Factory before(async () => { factory = createFactory({ + type: 'kubo', test: true, - ipfsModule: (await import('ipfs')) - }, { - js: { - ipfsBin: isNode ? ipfsModule.path() : undefined, - ipfsHttpModule - }, - go: { - ipfsBin: isNode ? goIpfsModule.path() : undefined, - kuboRpcModule - } + bin: isNode || isElectronMain ? kubo.path() : undefined, + rpc: createKuboRPCClient, + disposable: true }) - await factory.spawn({ type: 'js' }) + await factory.spawn({ type: 'kubo' }) }) - after(async () => await factory.clean()) + afterEach(async () => { + await factory.clean() + }) describe('init', () => { describe('should work with defaults', () => { for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) + const node = await factory.spawn(opts) - await ctl.init() - expect(ctl.initialized).to.be.true() - expect(ctl.clean).to.be.false() - expect(ctl.started).to.be.false() - if (!(isBrowser || isWebWorker) || opts.type === 'proc') { - expect(await repoExists(ctl.path)).to.be.true() + if (!(isBrowser || isWebWorker)) { + const info = await node.info() + await expect(repoExists(info.repo)).to.eventually.be.true() } }) } @@ -107,41 +55,31 @@ describe('Controller API', function () { describe('should work with a initialized repo', () => { for (const opts of types) { - it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(merge(opts, { - ipfsOptions: { - repo: factory.controllers[0].path, - init: false - } - })) + let repo: string - await ctl.init() - expect(ctl.initialized).to.be.true() - expect(ctl.clean).to.be.false() - expect(ctl.started).to.be.false() - if (!(isBrowser || isWebWorker) || opts.type === 'proc') { - expect(await repoExists(factory.controllers[0].path)).to.be.true() + beforeEach(async () => { + const existingNode = await factory.spawn({ + disposable: false + }) + const existingNodeInfo = await existingNode.info() + await existingNode.stop() + + if (!(isBrowser || isWebWorker)) { + await expect(repoExists(existingNodeInfo.repo)).to.eventually.be.true() } + + repo = existingNodeInfo.repo }) - } - }) - describe('should work with all the options', () => { - for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) + const node = await factory.spawn(merge(opts, { + repo, + init: false + })) - if (opts.type === 'js') { - await expect(ctl.init({ - emptyRepo: true, - profiles: ['test'], - pass: 'QmfPjo1bKmpcdWxpQnGAKjeae9F9aCxTDiS61t9a3hmvRi' - })).to.be.fulfilled() - } else { - await expect(ctl.init({ - emptyRepo: true, - profiles: ['test'] - })).to.be.fulfilled() + if (!(isBrowser || isWebWorker)) { + const info = await node.info() + await expect(repoExists(info.repo)).to.eventually.be.true() } }) } @@ -150,126 +88,57 @@ describe('Controller API', function () { describe('should apply config', () => { for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(merge( - opts, - { - ipfsOptions: { - config: { - Addresses: { - API: '/ip4/127.0.0.1/tcp/1111' - } + const node = await factory.spawn(merge(opts, { + init: { + config: { + Addresses: { + API: '/ip4/127.0.0.1/tcp/1111' } } - } - )) - await ctl.init() - await ctl.start() - const config = await ctl.api.config.get('Addresses.API') + }, + start: true + })) + + const config = await node.api.config.get('Addresses.API') expect(config).to.be.eq('/ip4/127.0.0.1/tcp/1111') - await ctl.stop() + await node.stop() }) } }) - describe('should return a version', () => { + describe('should return version in info', () => { for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) + const node = await factory.spawn(opts) + const info = await node.info() - const version = await ctl.version() - - expect(version).to.be.a('string') + expect(info.version).to.be.a('string') + await node.stop() }) } }) - }) - describe('start', () => { - describe('should work with defaults', () => { + describe('should return pid in info', () => { for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) - - await ctl.init() - await ctl.start() - expect(ctl.started).to.be.true() - await ctl.stop() - }) - } - }) - - describe('should attach to a running node', () => { - for (const opts of types) { - it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async function () { - if ((isBrowser || isWebWorker) && opts.type === 'proc') { - return this.skip() // browser in proc can't attach to running node - } + const node = await factory.spawn(opts) + const info = await node.info() - // have to use createController so we don't try to shut down - // the node twice during test cleanup - const ctl = await createController(merge( - opts, addCorrectRpcModule(opts, { - ipfsModule, - ipfsOptions: { - repo: factory.controllers[0].path - } - }) - )) - - await ctl.init() - await ctl.start() - expect(ctl.started).to.be.true() + expect(info.pid).to.be.a('number') + await node.stop() }) } }) + }) - describe('should stop a running node that we have joined', () => { + describe('start', () => { + describe('should work with defaults', () => { for (const opts of types) { - it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async function () { - if (isBrowser || isWebWorker) { - return this.skip() // browser can't attach to running node - } - - // have to use createController so we don't try to shut down - // the node twice during test cleanup - const ctl1 = await createController(merge( - { - type: 'go', - kuboRpcModule, - ipfsBin: goIpfsModule.path(), - test: true, - disposable: true, - remote: false, - ipfsOptions: { - init: true, - start: true - } - })) - expect(ctl1.started).to.be.true() - - const ctl2 = await createController(merge( - opts, addCorrectRpcModule(opts, { - ipfsModule, - test: true, - disposable: true, - ipfsOptions: { - repo: ctl1.path, - start: true - } - }) - )) - expect(ctl2.started).to.be.true() - - await ctl2.stop() - expect(ctl2.started).to.be.false() - - // wait for the other subprocess to exit - await waitFor(() => !ctl1.started, { // eslint-disable-line max-nested-callbacks - timeout: 10000, - interval: 100 - }) + it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { + const ctl = await factory.spawn(opts) - expect(ctl1.started).to.be.false() + await expect(ctl.api.isOnline()).to.eventually.be.true() + await ctl.stop() }) } }) @@ -279,32 +148,29 @@ describe('Controller API', function () { describe('should delete the repo', () => { for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) + const node = await factory.spawn(opts) + const info = await node.info() - await ctl.init() - await ctl.start() - expect(ctl.started).to.be.true() - await ctl.stop() - await ctl.cleanup() - if (!(isBrowser || isWebWorker) || opts.type === 'proc') { - expect(await repoExists(ctl.path)).to.be.false() + await node.stop() + await node.cleanup() + + if (!(isBrowser || isWebWorker)) { + expect(await repoExists(info.repo)).to.be.false() } - expect(ctl.clean).to.be.true() }) } }) }) describe('stop', () => { - describe('should stop the node', () => { + // https://github.com/ipfs/js-kubo-rpc-client/pull/222 + describe.skip('should stop the node', () => { for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) + const node = await factory.spawn(opts) - await ctl.init() - await ctl.start() - await ctl.stop() - expect(ctl.started).to.be.false() + await node.stop() + await expect(node.api.isOnline()).to.eventually.be.false() }) } }) @@ -312,19 +178,16 @@ describe('Controller API', function () { describe('should not clean with disposable false', () => { for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(merge(opts, { - disposable: false, - test: true, - ipfsOptions: { - repo: await factory.tmpDir() - } + const node = await factory.spawn(merge(opts, { + disposable: false })) + const info = await node.info() - await ctl.init() - await ctl.start() - await ctl.stop() - expect(ctl.started).to.be.false() - expect(ctl.clean).to.be.false() + await node.stop() + + if (!(isBrowser || isWebWorker)) { + expect(await repoExists(info.repo)).to.be.true() + } }) } }) @@ -332,45 +195,18 @@ describe('Controller API', function () { describe('should clean with disposable true', () => { for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) - await ctl.init() - await ctl.start() - await ctl.stop() - expect(ctl.started).to.be.false() - expect(ctl.clean).to.be.true() - }) - } - }) + const node = await factory.spawn(merge(opts, { + disposable: true + })) + const info = await node.info() - describe('should clean listeners', () => { - for (const opts of types) { - it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) - await ctl.init() - await ctl.start() - await ctl.stop() - if (ctl.subprocess?.stderr != null) { - expect(ctl.subprocess.stderr.listeners('data')).to.be.empty() - } - if (ctl.subprocess?.stdout != null) { - expect(ctl.subprocess.stdout.listeners('data')).to.be.empty() + await node.stop() + + if (!(isBrowser || isWebWorker)) { + expect(await repoExists(info.repo)).to.be.false() } }) } }) }) - describe('pid should return pid', () => { - for (const opts of types) { - it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const ctl = await factory.spawn(opts) - await ctl.init() - await ctl.start() - if (opts.type !== 'proc') { - const pid = await ctl.pid() - expect(typeof pid === 'number').to.be.true() - } - await ctl.stop() - }) - } - }) }) diff --git a/test/create.spec.ts b/test/create.spec.ts index 0716f05a..b4c39862 100644 --- a/test/create.spec.ts +++ b/test/create.spec.ts @@ -1,181 +1,95 @@ +/* eslint-disable no-loop-func */ /* eslint-env mocha */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ import { expect } from 'aegir/chai' -import { isNode, isBrowser, isWebWorker } from 'wherearewe' -import { createFactory, createController, createServer, ControllerOptions } from '../src/index.js' -import Client from '../src/ipfsd-client.js' -import Daemon from '../src/ipfsd-daemon.js' -import Proc from '../src/ipfsd-in-proc.js' -import * as ipfsModule from 'ipfs' -import * as ipfsHttpModule from 'ipfs-http-client' -// @ts-expect-error no types -import * as goIpfsModule from 'go-ipfs' -import * as ipfsClientModule from 'ipfs-client' -import * as kuboRpcModule from 'kubo-rpc-client' - -describe('`createController` should return the correct class', () => { - it('for type `js` ', async () => { - const f = await createController({ - type: 'js', - disposable: false, - ipfsModule, - ipfsHttpModule, - ipfsBin: isNode ? ipfsModule.path() : undefined - }) - - if (!isNode) { - expect(f).to.be.instanceOf(Client) - } else { - expect(f).to.be.instanceOf(Daemon) - } +import * as kubo from 'kubo' +import { create as createKuboRPCClient } from 'kubo-rpc-client' +import { isNode, isElectronMain } from 'wherearewe' +import { createFactory, createNode, createServer, type KuboOptions, type SpawnOptions, type KuboNode, type Factory } from '../src/index.js' +import KuboClient from '../src/kubo/client.js' +import KuboDaemon from '../src/kubo/daemon.js' +import type Server from '../src/endpoint/server.js' + +describe('`createNode` should return the correct class', () => { + let node: KuboNode + + afterEach(async () => { + await node?.stop() }) - it('for type `go` ', async () => { - const f = await createController({ - type: 'go', + + it('for type `kubo` ', async () => { + node = await createNode({ + type: 'kubo', + test: true, disposable: false, - kuboRpcModule, - ipfsBin: isNode ? goIpfsModule.path() : undefined + rpc: createKuboRPCClient, + bin: isNode ? kubo.path() : undefined }) - if (!isNode) { - expect(f).to.be.instanceOf(Client) + if (!isNode && !isElectronMain) { + expect(node).to.be.instanceOf(KuboClient) } else { - expect(f).to.be.instanceOf(Daemon) + expect(node).to.be.instanceOf(KuboDaemon) } }) - it('for type `proc` ', async () => { - const f = await createController({ type: 'proc', disposable: false }) - - expect(f).to.be.instanceOf(Proc) - }) it('for remote', async () => { - const f = await createController({ + node = await createNode({ + type: 'kubo', + test: true, remote: true, disposable: false, - ipfsModule, - ipfsHttpModule, - ipfsBin: isNode ? ipfsModule.path() : undefined - }) - - expect(f).to.be.instanceOf(Client) - }) - - it.skip('should use ipfs-client if passed', async () => { - let clientCreated = false - let httpCreated = false - - await createController({ - type: 'js', - disposable: false, - ipfsModule, - ipfsClientModule: { - create: (opts: any) => { - clientCreated = true - - return ipfsClientModule.create(opts) - } - }, - ipfsHttpModule: { - create: async (opts: any) => { - httpCreated = true - - return ipfsHttpModule.create(opts) - } - }, - ipfsBin: isNode ? ipfsModule.path() : undefined + rpc: createKuboRPCClient }) - expect(clientCreated).to.be.true() - expect(httpCreated).to.be.false() - }) - - it.skip('should use ipfs-client for remote if passed', async () => { - let clientCreated = false - let httpCreated = false - - const f = await createController({ - remote: true, - disposable: false, - ipfsModule, - ipfsClientModule: { - create: (opts: any) => { - clientCreated = true - - return ipfsClientModule.create(opts) - } - }, - ipfsHttpModule: { - create: async (opts: any) => { - httpCreated = true - - return ipfsHttpModule.create(opts) - } - }, - ipfsBin: isNode ? ipfsModule.path() : undefined - }) - - expect(f).to.be.instanceOf(Client) - expect(clientCreated).to.be.true() - expect(httpCreated).to.be.false() + expect(node).to.be.instanceOf(KuboClient) }) }) -const types: ControllerOptions[] = [{ - type: 'js', - ipfsHttpModule, - test: true, - ipfsModule, - ipfsBin: isNode ? ipfsModule.path() : undefined -}, { - ipfsBin: isNode ? goIpfsModule.path() : undefined, - type: 'go', - kuboRpcModule, - test: true -}, { - type: 'proc', - ipfsHttpModule, +const types: Array = [{ + type: 'kubo', test: true, - ipfsModule + rpc: createKuboRPCClient, + bin: isNode ? kubo.path() : undefined }, { - type: 'js', - ipfsHttpModule, + type: 'kubo', test: true, remote: true, - ipfsModule, - ipfsBin: isNode ? ipfsModule.path() : undefined -}, { - ipfsBin: isNode ? goIpfsModule.path() : undefined, - type: 'go', - kuboRpcModule, - test: true, - remote: true + rpc: createKuboRPCClient, + bin: isNode ? kubo.path() : undefined }] -describe('`createController({test: true})` should return daemon with test profile', () => { +describe('`createNode({test: true})` should return daemon with test profile', () => { + let node: KuboNode + + afterEach(async () => { + await node?.stop() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const node = await createController(opts) + node = await createNode(opts) expect(await node.api.config.get('Bootstrap')).to.be.empty() await node.stop() }) } }) -describe('`createController({test: true})` should return daemon with correct config', () => { +describe('`createNode({test: true})` should return daemon with correct config', () => { + let node: KuboNode + + afterEach(async () => { + await node?.stop() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const node = await createController(opts) + node = await createNode(opts) const swarm = await node.api.config.get('Addresses.Swarm') - if ((isBrowser || isWebWorker) && opts.type !== 'proc') { - expect(swarm).to.be.deep.eq(['/ip4/127.0.0.1/tcp/0/ws']) - } else if ((isBrowser || isWebWorker) && opts.type === 'proc') { - expect(swarm).to.be.deep.eq([]) - } else { - expect(swarm).to.be.deep.eq(['/ip4/127.0.0.1/tcp/0']) - } + expect(swarm).to.include('/ip4/127.0.0.1/tcp/0') + expect(swarm).to.include('/ip4/127.0.0.1/tcp/0/ws') const expectedAPI = { HTTPHeaders: { @@ -194,29 +108,40 @@ describe('`createController({test: true})` should return daemon with correct con }) describe('`createFactory({test: true})` should return daemon with test profile', () => { + let factory: Factory + + afterEach(async () => { + await factory.clean() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const factory = createFactory({ + factory = createFactory({ test: true }) const node = await factory.spawn(opts) expect(await node.api.config.get('Bootstrap')).to.be.empty() - await factory.clean() }) } }) describe('`createServer`', () => { + let server: Server + + afterEach(async () => { + await server?.stop() + }) + it('should return a Server with port 43134 by default', () => { - const s = createServer() - expect(s.port).to.be.eq(43134) + server = createServer() + expect(server.port).to.be.eq(43134) }) it('should return a Server with port 11111 when passed number directly', () => { - const s = createServer(11111) - expect(s.port).to.be.eq(11111) + server = createServer(11111) + expect(server.port).to.be.eq(11111) }) it('should return a Server with port 22222 when passed {port: 22222}', () => { - const s = createServer({ port: 22222 }) - expect(s.port).to.be.eq(22222) + server = createServer({ port: 22222 }) + expect(server.port).to.be.eq(22222) }) }) diff --git a/test/node.routes.ts b/test/endpoint/routes.node.ts similarity index 68% rename from test/node.routes.ts rename to test/endpoint/routes.node.ts index fef9ef3d..efd802d7 100644 --- a/test/node.routes.ts +++ b/test/endpoint/routes.node.ts @@ -1,11 +1,13 @@ /* eslint-env mocha */ -import { expect } from 'aegir/chai' import Hapi from '@hapi/hapi' -import routes from '../src/endpoint/routes.js' -import { createFactory } from '../src/index.js' -import * as ipfsModule from 'ipfs' -import * as ipfsHttpModule from 'ipfs-http-client' +import { expect } from 'aegir/chai' +import * as kubo from 'kubo' +import { create as createKuboRPCClient } from 'kubo-rpc-client' +import { isNode } from 'wherearewe' +import routes from '../../src/endpoint/routes.js' +import { createFactory } from '../../src/index.js' +import type { KuboInfo } from '../../src/index.js' describe('routes', function () { this.timeout(60000) @@ -17,9 +19,9 @@ describe('routes', function () { server = new Hapi.Server({ port: 43134 }) routes(server, async () => { return createFactory({ - ipfsModule, - ipfsHttpModule, - ipfsBin: ipfsModule.path() + type: 'kubo', + rpc: createKuboRPCClient, + bin: isNode ? kubo.path() : undefined }) }) }) @@ -30,23 +32,27 @@ describe('routes', function () { describe('POST /spawn', () => { it('should return 200', async () => { - const res = await server.inject({ - method: 'POST', - url: '/spawn', - payload: { - test: true, - ipfsOptions: { - init: false, - start: false + const options = { + test: true, + init: { + config: { + foo: 'bar' } } + } + + const res = await server.inject({ + method: 'POST', + url: '/spawn', + payload: options }) expect(res).to.have.property('statusCode', 200) expect(res).to.have.nested.property('result.id') - expect(res).to.have.nested.property('result.apiAddr') - expect(res).to.have.nested.property('result.gatewayAddr') - // @ts-expect-error res.result is an object + // should return passed options with the id added + expect(res).to.have.deep.nested.property('result.options', options) + expect(res).to.have.nested.property('result.info') + id = res.result.id }) }) @@ -91,20 +97,26 @@ describe('routes', function () { }) }) - describe('GET /pid', () => { + describe('GET /info', () => { it('should return 200', async () => { - const res = await server.inject({ + const res = await server.inject({ method: 'GET', - url: `/pid?id=${id}` + url: `/info?id=${id}` }) expect(res.statusCode).to.equal(200) + + expect(res.result).to.have.property('version').that.is.a('string') + expect(res.result).to.have.property('pid').that.is.a('number') + expect(res.result).to.have.property('api').that.is.a('string') + expect(res.result).to.have.property('repo').that.is.a('string') + expect(res.result).to.have.property('multiaddrs').that.is.an('array') }) it('should return 400', async () => { const res = await server.inject({ method: 'GET', - url: '/pid' + url: '/info' }) expect(res.statusCode).to.equal(400) diff --git a/test/factory.spec.ts b/test/factory.spec.ts index b96ff9c0..dcf85f8b 100644 --- a/test/factory.spec.ts +++ b/test/factory.spec.ts @@ -1,76 +1,40 @@ +/* eslint-disable no-loop-func */ /* eslint-env mocha */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ import { expect } from 'aegir/chai' +import * as kubo from 'kubo' +import { create as createKuboRPCClient } from 'kubo-rpc-client' import { isNode } from 'wherearewe' -import { ControllerOptions, createFactory } from '../src/index.js' -import * as ipfsModule from 'ipfs' -// @ts-expect-error no types -import * as goIpfsModule from 'go-ipfs' -import * as ipfsHttpModule from 'ipfs-http-client' -import * as kuboRpcModule from 'kubo-rpc-client' - -const types: ControllerOptions[] = [{ - ipfsHttpModule, - type: 'js', +import { createFactory } from '../src/index.js' +import type { Factory, KuboOptions, SpawnOptions } from '../src/index.js' + +const types: Array = [{ + type: 'kubo', test: true, - ipfsModule, - ipfsBin: isNode ? ipfsModule.path() : undefined -}, { - kuboRpcModule, - ipfsBin: isNode ? goIpfsModule.path() : undefined, - type: 'go', - test: true + rpc: createKuboRPCClient, + bin: isNode ? kubo.path() : undefined }, { - ipfsHttpModule, - type: 'proc', + type: 'kubo', test: true, - ipfsModule -}, { - ipfsHttpModule, - type: 'js', remote: true, - test: true, - ipfsModule, - ipfsBin: isNode ? ipfsModule.path() : undefined -}, { - kuboRpcModule, - ipfsBin: isNode ? goIpfsModule.path() : undefined, - type: 'go', - remote: true, - test: true + rpc: createKuboRPCClient, + bin: isNode ? kubo.path() : undefined }] -describe('`Factory tmpDir()` should return correct temporary dir', () => { - for (const opts of types) { - it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const factory = createFactory() - const dir = await factory.tmpDir(opts) - expect(dir).to.exist() - - if (opts.type === 'go' && isNode) { - expect(dir).to.contain('go_ipfs_') - } - if (opts.type === 'js' && isNode) { - expect(dir).to.contain('js_ipfs_') - } - if (opts.type === 'proc' && isNode) { - expect(dir).to.contain('proc_ipfs_') - } - if (opts.type === 'proc' && !isNode) { - expect(dir).to.contain('proc_ipfs_') - } - }) - } -}) - describe('`Factory spawn()` ', function () { this.timeout(60000) describe('should return a node with api', () => { + let factory: Factory + + afterEach(async () => { + await factory?.clean() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const factory = await createFactory() + factory = createFactory() const node = await factory.spawn(opts) expect(node).to.exist() expect(node.api).to.exist() @@ -80,79 +44,106 @@ describe('`Factory spawn()` ', function () { } }) - describe('should return ctl for tests when factory initialized with test === true', () => { + describe('should return node for tests when factory initialized with test === true', () => { + let factory: Factory + + afterEach(async () => { + await factory?.clean() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const factory = await createFactory({ test: true }) - const ctl = await factory.spawn({ + factory = createFactory({ test: true }) + const node = await factory.spawn({ type: opts.type, remote: opts.remote, - ipfsModule, - ipfsHttpModule, - ipfsBin: isNode ? ipfsModule.path() : undefined + rpc: createKuboRPCClient, + bin: isNode ? kubo.path() : undefined }) - expect(ctl).to.exist() - expect(ctl.opts.test).to.be.true() - await ctl.stop() + expect(node).to.exist() + expect(node.options.test).to.be.true() + await node.stop() }) } }) describe('should return a disposable node by default', () => { + let factory: Factory + + afterEach(async () => { + await factory?.clean() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const factory = await createFactory() - const node = await factory.spawn(opts) - - expect(node.started).to.be.true() - expect(node.initialized).to.be.true() - expect(node.path).to.not.include('.jsipfs') - expect(node.path).to.not.include('.ipfs') + factory = createFactory() + const node = await factory.spawn({ + ...opts, + disposable: undefined + }) await node.stop() + expect(node.options.disposable).to.be.true() }) } }) describe('should return a non disposable node', () => { + let factory: Factory + + afterEach(async () => { + await factory?.clean() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const factory = await createFactory() - const tmpDir = await factory.tmpDir(opts) - const node = await factory.spawn({ ...opts, disposable: false, ipfsOptions: { repo: tmpDir } }) - expect(node.started).to.be.false() - expect(node.initialized).to.be.false() - expect(node.path).to.be.eq(tmpDir) + factory = createFactory() + const node = await factory.spawn({ + ...opts, + disposable: false + }) + await node.stop() + expect(node.options.disposable).to.be.false() }) } }) - describe('`Factory.clean()` should stop all nodes', () => { + // https://github.com/ipfs/js-kubo-rpc-client/pull/222 + describe.skip('`Factory.clean()` should stop all nodes', () => { + let factory: Factory + + afterEach(async () => { + await factory?.clean() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const factory = createFactory(opts) - const ctl1 = await factory.spawn(opts) - const ctl2 = await factory.spawn(opts) + factory = createFactory(opts) + const node1 = await factory.spawn(opts) + const node2 = await factory.spawn(opts) await factory.clean() - expect(ctl1.started).to.be.false() - expect(ctl2.started).to.be.false() + await expect(node1.api.isOnline()).to.eventually.be.false() + await expect(node2.api.isOnline()).to.eventually.be.false() }) } }) - describe('`Factory.clean()` should not error when controller already stopped', () => { + // https://github.com/ipfs/js-kubo-rpc-client/pull/222 + describe.skip('`Factory.clean()` should not error when controller already stopped', () => { + let factory: Factory + + afterEach(async () => { + await factory?.clean() + }) + for (const opts of types) { it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async () => { - const factory = createFactory(opts) - const ctl1 = await factory.spawn(opts) - const ctl2 = await factory.spawn(opts) - await ctl2.stop() - try { - await factory.clean() - } catch (/** @type {any} */ error) { - expect(error).to.not.exist() - } - expect(ctl1.started).to.be.false() - expect(ctl2.started).to.be.false() + factory = createFactory(opts) + const node1 = await factory.spawn(opts) + const node2 = await factory.spawn(opts) + await node2.stop() + await factory.clean() + await expect(node1.api.isOnline()).to.eventually.be.false() + await expect(node2.api.isOnline()).to.eventually.be.false() }) } }) diff --git a/test/kubo/utils.node.ts b/test/kubo/utils.node.ts new file mode 100644 index 00000000..5e509296 --- /dev/null +++ b/test/kubo/utils.node.ts @@ -0,0 +1,147 @@ +/* eslint-env mocha */ +/* eslint max-nested-callbacks: ["error", 8] */ + +import os from 'os' +import path from 'path' +import { expect } from 'aegir/chai' +import * as kubo from 'kubo' +import { create as createKuboRPCClient } from 'kubo-rpc-client' +import { createFactory, createNode } from '../../src/index.js' +import { tmpDir, checkForRunningApi, repoExists, removeRepo, buildStartArgs, buildInitArgs } from '../../src/kubo/utils.js' + +describe('utils', function () { + this.timeout(60000) + + it('tmpDir should return correct path', () => { + expect(tmpDir('js')).to.be.contain(path.join(os.tmpdir(), 'js_ipfs_')) + expect(tmpDir('go')).to.be.contain(path.join(os.tmpdir(), 'go_ipfs_')) + expect(tmpDir()).to.be.contain(path.join(os.tmpdir(), '_ipfs_')) + }) + + describe('checkForRunningApi', () => { + it('should return undefined with path', () => { + expect(checkForRunningApi()).to.be.undefined() + }) + + it('should return path to api with running node', async () => { + const node = await createNode({ + test: true, + type: 'kubo', + rpc: createKuboRPCClient, + bin: kubo.path() + }) + + const info = await node.info() + + if (info.repo == null) { + throw new Error('info.repo was undefined') + } + + const apiPath = checkForRunningApi(info.repo) + + expect(apiPath).to.contain('/ip4/127.0.0.1/tcp/') + await node.stop() + }) + }) + + it('removeRepo should work', async () => { + const f = createFactory({ + test: true, + type: 'kubo', + rpc: createKuboRPCClient, + bin: kubo.path() + }) + const node = await f.spawn({ + disposable: false + }) + const info = await node.info() + + if (info.repo == null) { + throw new Error('info.repo was undefined') + } + + await node.stop() + await removeRepo(info.repo) + + expect(await repoExists(info.repo)).to.be.false() + }) + + describe('repoExists', () => { + it('should resolve true when repo exists', async () => { + const node = await createNode({ + type: 'kubo', + test: true, + rpc: createKuboRPCClient, + bin: kubo.path() + }) + const info = await node.info() + + if (info.repo == null) { + throw new Error('info.repo was undefined') + } + + expect(await repoExists(info.repo)).to.be.true() + + await node.stop() + }) + + it('should resolve false for random path', async () => { + expect(await repoExists('random')).to.be.false() + }) + }) + + describe('buildStartArgs', function () { + it('custom args', () => { + expect(buildStartArgs({ args: ['--foo=bar'] }).join(' ')).to.include('--foo=bar') + }) + + it('offline', () => { + expect(buildStartArgs({ + offline: true + }).join(' ')).to.include('--offline') + }) + + it('ipns pubsub', () => { + expect(buildStartArgs({ + ipnsPubsub: true + }).join(' ')).to.include('--enable-namesys-pubsub') + }) + + it('migrate', () => { + expect(buildStartArgs({ + repoAutoMigrate: true + }).join(' ')).to.include('--migrate') + }) + }) + + describe('buildInitArgs', function () { + it('custom args', () => { + expect(buildInitArgs({ args: ['--foo=bar'] }).join(' ')).to.include('--foo=bar') + }) + + it('bits', () => { + expect(buildInitArgs({ + algorithm: 'rsa', + bits: 512 + }).join(' ')).to.include('--bits 512') + }) + + it('algorithm', () => { + expect(buildInitArgs({ + algorithm: 'rsa' + }).join(' ')).to.include('--algorithm rsa') + }) + + it('empty repo', () => { + expect(buildInitArgs({ + emptyRepo: true + }).join(' ')).to.include('--empty-repo') + }) + + it('profiles', () => { + expect(buildInitArgs({ + profiles: ['foo', 'bar'] + }).join(' ')).to.include('--profile foo,bar') + }) + }) +}) diff --git a/test/node.ts b/test/node.ts index 618ea9c5..a168f08e 100644 --- a/test/node.ts +++ b/test/node.ts @@ -1,39 +1,2 @@ -/* eslint-env mocha */ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ - -import { expect } from 'aegir/chai' -import { createFactory } from '../src/index.js' -import * as ipfsModule from 'ipfs' -import * as ipfsHttpModule from 'ipfs-http-client' -// @ts-expect-error no types -import * as goIpfsModule from 'go-ipfs' - -import './node.routes.js' -import './node.utils.js' - -describe('Node specific tests', function () { - this.timeout(60000) - - const factory = createFactory({ - test: true, - ipfsHttpModule, - ipfsModule, - ipfsBin: goIpfsModule.path() - }) - - it('should use process.IPFS_PATH', async () => { - const repoPath = await factory.tmpDir() - process.env.IPFS_PATH = repoPath - const ctl = await factory.spawn({ - type: 'go', - disposable: false, - ipfsOptions: { - init: false, - start: false - } - }) - - expect(ctl.path).to.equal(repoPath) - delete process.env.IPFS_PATH - }) -}) +import './endpoint/routes.node.js' +import './kubo/utils.node.js' diff --git a/test/node.utils.ts b/test/node.utils.ts deleted file mode 100644 index 8253d919..00000000 --- a/test/node.utils.ts +++ /dev/null @@ -1,205 +0,0 @@ -/* eslint-env mocha */ -/* eslint max-nested-callbacks: ["error", 8] */ - -import { expect } from 'aegir/chai' -import os from 'os' -import path from 'path' -import { tmpDir, checkForRunningApi, defaultRepo, repoExists, removeRepo, buildInitArgs, buildStartArgs } from '../src/utils.js' -import { createFactory, createController } from '../src/index.js' -import * as ipfsModule from 'ipfs' -import * as ipfsHttpModule from 'ipfs-http-client' - -describe('utils node version', function () { - this.timeout(60000) - - it('tmpDir should return correct path', () => { - expect(tmpDir('js')).to.be.contain(path.join(os.tmpdir(), 'js_ipfs_')) - expect(tmpDir('go')).to.be.contain(path.join(os.tmpdir(), 'go_ipfs_')) - expect(tmpDir()).to.be.contain(path.join(os.tmpdir(), '_ipfs_')) - }) - - describe('checkForRunningApi', () => { - it('should return null with no node running', () => { - expect(checkForRunningApi()).to.be.null() - }) - it('should return path to api with running node', async () => { - const node = await createController({ - test: true, - ipfsModule, - ipfsHttpModule, - ipfsBin: ipfsModule.path() - }) - expect(checkForRunningApi(node.path)).to.be.contain('/ip4/127.0.0.1/tcp/') - await node.stop() - }) - }) - - it('defaultRepo should return path', () => { - expect(defaultRepo('js')).to.be.eq(path.join(os.homedir(), '.jsipfs')) - expect(defaultRepo('go')).to.be.eq(path.join(os.homedir(), '.ipfs')) - // @ts-expect-error arg is not a node type - expect(defaultRepo('kjkjdsk')).to.be.eq(path.join(os.homedir(), '.ipfs')) - }) - - it('removeRepo should work', async () => { - const f = createFactory({ - test: true, - ipfsModule, - ipfsHttpModule, - ipfsBin: ipfsModule.path() - }) - const dir = await f.tmpDir() - const node = await f.spawn({ - type: 'proc', - disposable: false, - ipfsOptions: { repo: dir } - }) - await node.init() - await node.start() - await node.stop() - await removeRepo(dir) - expect(await repoExists(dir)).to.be.false() - }) - - describe('repoExists', () => { - it('should resolve true when repo exists', async () => { - const node = await createController({ - type: 'proc', - test: true, - ipfsModule, - ipfsHttpModule, - ipfsBin: ipfsModule.path() - }) - expect(await repoExists(node.path)).to.be.true() - await node.stop() - }) - it('should resolve false for random path', async () => { - expect(await repoExists('random')).to.be.false() - }) - }) - - describe('buildStartArgs', function () { - it('custom args', () => { - expect(buildStartArgs({ - args: ['--foo=bar'] - }).join(' ')).to.include('--foo=bar') - }) - - it('pass', () => { - expect(buildStartArgs({ - type: 'js', - ipfsOptions: { - pass: 'baz' - } - }).join(' ')).to.include('--pass "baz"') - }) - - it('preload', () => { - expect(buildStartArgs({ - type: 'js', - ipfsOptions: { - preload: true - } - }).join(' ')).to.include('--enable-preload') - }) - - it('preload disabled', () => { - expect(buildStartArgs({ - type: 'js', - ipfsOptions: { - preload: false - } - }).join(' ')).to.include('--enable-preload false') - }) - - it('sharding', () => { - expect(buildStartArgs({ - type: 'js', - ipfsOptions: { - EXPERIMENTAL: { - sharding: true - } - } - }).join(' ')).to.include('--enable-sharding-experiment') - }) - - it('offline', () => { - expect(buildStartArgs({ - type: 'js', - ipfsOptions: { - offline: true - } - }).join(' ')).to.include('--offline') - }) - - it('ipns pubsub', () => { - expect(buildStartArgs({ - type: 'js', - ipfsOptions: { - EXPERIMENTAL: { - ipnsPubsub: true - } - } - }).join(' ')).to.include('--enable-namesys-pubsub') - }) - - it('migrate', () => { - expect(buildStartArgs({ - ipfsOptions: { - repoAutoMigrate: true - } - }).join(' ')).to.include('--migrate') - }) - }) - - describe('buildInitArgs', function () { - it('pass', () => { - expect(buildStartArgs({ - type: 'js', - ipfsOptions: { - pass: 'baz' - } - }).join(' ')).to.include('--pass "baz"') - }) - - it('bits', () => { - expect(buildInitArgs({ - ipfsOptions: { - init: { - bits: 512 - } - } - }).join(' ')).to.include('--bits 512') - }) - - it('algorithm', () => { - expect(buildInitArgs({ - ipfsOptions: { - init: { - algorithm: 'rsa' - } - } - }).join(' ')).to.include('--algorithm rsa') - }) - - it('empty repo', () => { - expect(buildInitArgs({ - ipfsOptions: { - init: { - emptyRepo: true - } - } - }).join(' ')).to.include('--empty-repo') - }) - - it('profiles', () => { - expect(buildInitArgs({ - ipfsOptions: { - init: { - profiles: ['foo', 'bar'] - } - } - }).join(' ')).to.include('--profile foo,bar') - }) - }) -}) diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 00000000..f599dc72 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,5 @@ +{ + "entryPoints": [ + "./src/index.ts" + ] +}