diff --git a/packages/interop/src/ipns-libp2p.spec.ts b/packages/interop/src/ipns.spec.ts similarity index 96% rename from packages/interop/src/ipns-libp2p.spec.ts rename to packages/interop/src/ipns.spec.ts index d333371c..7bb29b19 100644 --- a/packages/interop/src/ipns-libp2p.spec.ts +++ b/packages/interop/src/ipns.spec.ts @@ -1,7 +1,6 @@ /* eslint-env mocha */ import { ipns } from '@helia/ipns' -import { libp2p } from '@helia/ipns/routing' import { peerIdFromString } from '@libp2p/peer-id' import { createEd25519PeerId, createRSAPeerId, createSecp256k1PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' @@ -24,7 +23,7 @@ import type { HeliaLibp2p } from 'helia' import type { Controller } from 'ipfsd-ctl' keyTypes.forEach(type => { - describe(`@helia/ipns - libp2p routing with ${type} keys`, () => { + describe(`@helia/ipns - default routing with ${type} keys`, () => { let helia: HeliaLibp2p let kubo: Controller let name: IPNS @@ -114,11 +113,7 @@ keyTypes.forEach(type => { message: 'Kubo could not find Helia on the DHT' }) - name = ipns(helia, { - routers: [ - libp2p(helia) - ] - }) + name = ipns(helia) } afterEach(async () => { diff --git a/packages/ipns/README.md b/packages/ipns/README.md index 2e1b2622..05962b59 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -17,20 +17,58 @@ IPNS operations using a Helia node -## Example - Using libp2p and pubsub routers +## Example - Getting started With IPNSRouting routers: ```typescript import { createHelia } from 'helia' import { ipns } from '@helia/ipns' -import { libp2p, pubsub } from '@helia/ipns/routing' +import { unixfs } from '@helia/unixfs' + +const helia = await createHelia() +const name = ipns(helia) + +// create a public key to publish as an IPNS name +const keyInfo = await helia.libp2p.services.keychain.createKey('my-key') +const peerId = await helia.libp2p.services.keychain.exportPeerId(keyInfo.name) + +// store some data to publish +const fs = unixfs(helia) +const cid = await fs.add(Uint8Array.from([0, 1, 2, 3, 4])) + +// publish the name +await name.publish(peerId, cid) + +// resolve the name +const cid = name.resolve(peerId) +``` + +## Example - Using custom PubSub router + +Additional IPNS routers can be configured - these enable alternative means to +publish and resolve IPNS names. + +One example is the PubSub router - this requires an instance of Helia with +libp2p PubSub configured. + +It works by subscribing to a pubsub topic for each IPNS name that we try to +resolve. Updated IPNS records are shared on these topics so an update must +occur before the name is resolvable. + +This router is only suitable for networks where IPNS updates are frequent +and multiple peers are listening on the topic(s), otherwise update messages +may fail to be published with "Insufficient peers" errors. + +```typescript +import { createHelia } from 'helia' +import { ipns } from '@helia/ipns' +import { pubsub } from '@helia/ipns/routing' import { unixfs } from '@helia/unixfs' const helia = await createHelia() const name = ipns(helia, { routers: [ - libp2p(helia), pubsub(helia) ] }) diff --git a/packages/ipns/package.json b/packages/ipns/package.json index b170da4b..bafa48d1 100644 --- a/packages/ipns/package.json +++ b/packages/ipns/package.json @@ -153,6 +153,7 @@ "lint": "aegir lint", "dep-check": "aegir dep-check", "build": "aegir build", + "docs": "aegir docs", "test": "aegir test", "test:chrome": "aegir test -t browser --cov", "test:chrome-webworker": "aegir test -t webworker", @@ -163,6 +164,7 @@ "release": "aegir release" }, "dependencies": { + "@helia/interface": "^3.0.1", "@libp2p/interface": "^1.1.1", "@libp2p/kad-dht": "^12.0.2", "@libp2p/logger": "^4.0.4", @@ -188,8 +190,5 @@ }, "browser": { "./dist/src/dns-resolvers/resolver.js": "./dist/src/dns-resolvers/resolver.browser.js" - }, - "typedoc": { - "entryPoint": "./src/index.ts" } } diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 16dcee31..e956c8d2 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -3,20 +3,58 @@ * * IPNS operations using a Helia node * - * @example Using libp2p and pubsub routers + * @example Getting started * * With {@link IPNSRouting} routers: * * ```typescript * import { createHelia } from 'helia' * import { ipns } from '@helia/ipns' - * import { libp2p, pubsub } from '@helia/ipns/routing' + * import { unixfs } from '@helia/unixfs' + * + * const helia = await createHelia() + * const name = ipns(helia) + * + * // create a public key to publish as an IPNS name + * const keyInfo = await helia.libp2p.services.keychain.createKey('my-key') + * const peerId = await helia.libp2p.services.keychain.exportPeerId(keyInfo.name) + * + * // store some data to publish + * const fs = unixfs(helia) + * const cid = await fs.add(Uint8Array.from([0, 1, 2, 3, 4])) + * + * // publish the name + * await name.publish(peerId, cid) + * + * // resolve the name + * const cid = name.resolve(peerId) + * ``` + * + * @example Using custom PubSub router + * + * Additional IPNS routers can be configured - these enable alternative means to + * publish and resolve IPNS names. + * + * One example is the PubSub router - this requires an instance of Helia with + * libp2p PubSub configured. + * + * It works by subscribing to a pubsub topic for each IPNS name that we try to + * resolve. Updated IPNS records are shared on these topics so an update must + * occur before the name is resolvable. + * + * This router is only suitable for networks where IPNS updates are frequent + * and multiple peers are listening on the topic(s), otherwise update messages + * may fail to be published with "Insufficient peers" errors. + * + * ```typescript + * import { createHelia } from 'helia' + * import { ipns } from '@helia/ipns' + * import { pubsub } from '@helia/ipns/routing' * import { unixfs } from '@helia/unixfs' * * const helia = await createHelia() * const name = ipns(helia, { * routers: [ - * libp2p(helia), * pubsub(helia) * ] * }) @@ -121,9 +159,11 @@ import { ipnsValidator } from 'ipns/validator' import { CID } from 'multiformats/cid' import { CustomProgressEvent } from 'progress-events' import { defaultResolver } from './dns-resolvers/default.js' +import { helia } from './routing/helia.js' import { localStore, type LocalStore } from './routing/local-store.js' import type { IPNSRouting, IPNSRoutingEvents } from './routing/index.js' import type { DNSResponse } from './utils/dns.js' +import type { Routing } from '@helia/interface' import type { AbortOptions, PeerId } from '@libp2p/interface' import type { Datastore } from 'interface-datastore' import type { IPNSRecord } from 'ipns' @@ -249,6 +289,7 @@ export type { IPNSRouting } from './routing/index.js' export interface IPNSComponents { datastore: Datastore + routing: Routing } class DefaultIPNS implements IPNS { @@ -258,7 +299,10 @@ class DefaultIPNS implements IPNS { private readonly defaultResolvers: DNSResolver[] constructor (components: IPNSComponents, routers: IPNSRouting[] = [], resolvers: DNSResolver[] = []) { - this.routers = routers + this.routers = [ + helia(components.routing), + ...routers + ] this.localStore = localStore(components.datastore) this.defaultResolvers = resolvers.length > 0 ? resolvers : [defaultResolver()] } @@ -407,7 +451,7 @@ export interface IPNSOptions { resolvers?: DNSResolver[] } -export function ipns (components: IPNSComponents, { routers = [], resolvers = [] }: IPNSOptions): IPNS { +export function ipns (components: IPNSComponents, { routers = [], resolvers = [] }: IPNSOptions = {}): IPNS { return new DefaultIPNS(components, routers, resolvers) } diff --git a/packages/ipns/src/routing/helia.ts b/packages/ipns/src/routing/helia.ts new file mode 100644 index 00000000..8960de98 --- /dev/null +++ b/packages/ipns/src/routing/helia.ts @@ -0,0 +1,45 @@ +import { CustomProgressEvent, type ProgressEvent } from 'progress-events' +import type { GetOptions, PutOptions } from './index.js' +import type { IPNSRouting } from '../index.js' +import type { Routing } from '@helia/interface' + +export interface HeliaRoutingComponents { + routing: Routing +} + +export type HeliaRoutingProgressEvents = + ProgressEvent<'ipns:routing:helia:error', Error> + +export class HeliaRouting implements IPNSRouting { + private readonly routing: Routing + + constructor (routing: Routing) { + this.routing = routing + } + + async put (routingKey: Uint8Array, marshaledRecord: Uint8Array, options: PutOptions = {}): Promise { + try { + await this.routing.put(routingKey, marshaledRecord, options) + } catch (err: any) { + options.onProgress?.(new CustomProgressEvent('ipns:routing:helia:error', err)) + } + } + + async get (routingKey: Uint8Array, options: GetOptions = {}): Promise { + try { + return await this.routing.get(routingKey, options) + } catch (err: any) { + options.onProgress?.(new CustomProgressEvent('ipns:routing:helia:error', err)) + } + + throw new Error('Not found') + } +} + +/** + * The helia routing uses any available Routers configured on the passed Helia + * node. This could be libp2p, HTTP API Delegated Routing, etc. + */ +export function helia (routing: Routing): IPNSRouting { + return new HeliaRouting(routing) +} diff --git a/packages/ipns/src/routing/index.ts b/packages/ipns/src/routing/index.ts index b29d15cc..4ce905b8 100644 --- a/packages/ipns/src/routing/index.ts +++ b/packages/ipns/src/routing/index.ts @@ -1,4 +1,4 @@ -import type { Libp2pContentRoutingProgressEvents } from './libp2p.js' +import type { HeliaRoutingProgressEvents } from './helia.js' import type { DatastoreProgressEvents } from './local-store.js' import type { PubSubProgressEvents } from './pubsub.js' import type { AbortOptions } from '@libp2p/interface' @@ -19,8 +19,8 @@ export interface IPNSRouting { export type IPNSRoutingEvents = DatastoreProgressEvents | - Libp2pContentRoutingProgressEvents | + HeliaRoutingProgressEvents | PubSubProgressEvents -export { libp2p } from './libp2p.js' +export { helia } from './helia.js' export { pubsub } from './pubsub.js' diff --git a/packages/ipns/src/routing/libp2p.ts b/packages/ipns/src/routing/libp2p.ts deleted file mode 100644 index cb015b5d..00000000 --- a/packages/ipns/src/routing/libp2p.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { CustomProgressEvent, type ProgressEvent } from 'progress-events' -import type { GetOptions, PutOptions } from './index.js' -import type { IPNSRouting } from '../index.js' -import type { ContentRouting } from '@libp2p/interface' - -export interface Libp2pContentRoutingComponents { - libp2p: { - contentRouting: ContentRouting - } -} - -export type Libp2pContentRoutingProgressEvents = - ProgressEvent<'ipns:routing:libp2p:error', Error> - -export class Libp2pContentRouting implements IPNSRouting { - private readonly contentRouting: ContentRouting - - constructor (components: Libp2pContentRoutingComponents) { - this.contentRouting = components.libp2p.contentRouting - } - - async put (routingKey: Uint8Array, marshaledRecord: Uint8Array, options: PutOptions = {}): Promise { - try { - await this.contentRouting.put(routingKey, marshaledRecord, options) - } catch (err: any) { - options.onProgress?.(new CustomProgressEvent('ipns:routing:libp2p:error', err)) - } - } - - async get (routingKey: Uint8Array, options: GetOptions = {}): Promise { - try { - return await this.contentRouting.get(routingKey, options) - } catch (err: any) { - options.onProgress?.(new CustomProgressEvent('ipns:routing:libp2p:error', err)) - } - - throw new Error('Not found') - } -} - -/** - * The libp2p routing uses any available Content Routers configured on the - * passed libp2p node. This could be KadDHT, HTTP API Delegated Routing, etc. - */ -export function libp2p (components: Libp2pContentRoutingComponents): IPNSRouting { - return new Libp2pContentRouting(components) -} diff --git a/packages/ipns/test/publish.spec.ts b/packages/ipns/test/publish.spec.ts index 82c966d6..1609cdb0 100644 --- a/packages/ipns/test/publish.spec.ts +++ b/packages/ipns/test/publish.spec.ts @@ -8,19 +8,22 @@ import Sinon from 'sinon' import { type StubbedInstance, stubInterface } from 'sinon-ts' import { ipns } from '../src/index.js' import type { IPNS, IPNSRouting } from '../src/index.js' +import type { Routing } from '@helia/interface' const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') describe('publish', () => { let name: IPNS - let routing: StubbedInstance + let customRouting: StubbedInstance + let heliaRouting: StubbedInstance beforeEach(async () => { const datastore = new MemoryDatastore() - routing = stubInterface() - routing.get.throws(new Error('Not found')) + customRouting = stubInterface() + customRouting.get.throws(new Error('Not found')) + heliaRouting = stubInterface() - name = ipns({ datastore }, { routers: [routing] }) + name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting] }) }) it('should publish an IPNS record with the default params', async function () { @@ -40,6 +43,9 @@ describe('publish', () => { expect(ipnsEntry).to.have.property('sequence', 1n) expect(ipnsEntry).to.have.property('ttl', BigInt(lifetime) * 100000n) + + expect(heliaRouting.put.called).to.be.true() + expect(customRouting.put.called).to.be.true() }) it('should publish a record offline', async () => { @@ -48,7 +54,8 @@ describe('publish', () => { offline: true }) - expect(routing.put.called).to.be.false() + expect(heliaRouting.put.called).to.be.false() + expect(customRouting.put.called).to.be.false() }) it('should emit progress events', async function () { diff --git a/packages/ipns/test/resolve-dns.spec.ts b/packages/ipns/test/resolve-dns.spec.ts index a2288d90..2ee10f16 100644 --- a/packages/ipns/test/resolve-dns.spec.ts +++ b/packages/ipns/test/resolve-dns.spec.ts @@ -6,21 +6,24 @@ import { type Datastore } from 'interface-datastore' import { stub } from 'sinon' import { type StubbedInstance, stubInterface } from 'sinon-ts' import { type IPNSRouting, ipns } from '../src/index.js' +import type { Routing } from '@helia/interface' describe('resolveDns', () => { - let routing: StubbedInstance + let customRouting: StubbedInstance let datastore: Datastore + let heliaRouting: StubbedInstance beforeEach(async () => { datastore = new MemoryDatastore() - routing = stubInterface() - routing.get.throws(new Error('Not found')) + customRouting = stubInterface() + customRouting.get.throws(new Error('Not found')) + heliaRouting = stubInterface() }) it('should use resolvers passed in constructor', async () => { const stubbedResolver1 = stub().returns('dnslink=/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') - const name = ipns({ datastore }, { routers: [routing], resolvers: [stubbedResolver1] }) + const name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting], resolvers: [stubbedResolver1] }) const result = await name.resolveDns('foobar.baz', { nocache: true, offline: true }) expect(stubbedResolver1.called).to.be.true() expect(stubbedResolver1.calledWith('foobar.baz')).to.be.true() @@ -31,7 +34,7 @@ describe('resolveDns', () => { const stubbedResolver1 = stub().returns('dnslink=/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') const stubbedResolver2 = stub().returns('dnslink=/ipfs/bafkreibm6jg3ux5qumhcn2b3flc3tyu6dmlb4xa7u5bf44yegnrjhc4yeq') - const name = ipns({ datastore }, { routers: [routing], resolvers: [stubbedResolver1] }) + const name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting], resolvers: [stubbedResolver1] }) const result = await name.resolveDns('foobar.baz', { nocache: true, offline: true, resolvers: [stubbedResolver2] }) expect(stubbedResolver1.called).to.be.false() expect(stubbedResolver2.called).to.be.true() diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index cd02d830..386b377b 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -12,20 +12,23 @@ import { type StubbedInstance, stubInterface } from 'sinon-ts' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { ipns } from '../src/index.js' import type { IPNS, IPNSRouting } from '../src/index.js' +import type { Routing } from '@helia/interface' const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') describe('resolve', () => { let name: IPNS - let routing: StubbedInstance + let customRouting: StubbedInstance let datastore: Datastore + let heliaRouting: StubbedInstance beforeEach(async () => { datastore = new MemoryDatastore() - routing = stubInterface() - routing.get.throws(new Error('Not found')) + customRouting = stubInterface() + customRouting.get.throws(new Error('Not found')) + heliaRouting = stubInterface() - name = ipns({ datastore }, { routers: [routing] }) + name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting] }) }) it('should resolve a record', async () => { @@ -39,19 +42,24 @@ describe('resolve', () => { } expect(resolvedValue.toString()).to.equal(cid.toV1().toString()) + + expect(heliaRouting.get.called).to.be.true() + expect(customRouting.get.called).to.be.true() }) it('should resolve a record offline', async () => { const key = await createEd25519PeerId() await name.publish(key, cid) - expect(routing.put.called).to.be.true() + expect(heliaRouting.put.called).to.be.true() + expect(customRouting.put.called).to.be.true() const resolvedValue = await name.resolve(key, { offline: true }) - expect(routing.get.called).to.be.false() + expect(heliaRouting.get.called).to.be.false() + expect(customRouting.get.called).to.be.false() if (resolvedValue == null) { throw new Error('Did not resolve entry') @@ -89,15 +97,15 @@ describe('resolve', () => { it('should cache a record', async function () { const peerId = await createEd25519PeerId() - const routingKey = peerIdToRoutingKey(peerId) - const dhtKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false) + const customRoutingKey = peerIdToRoutingKey(peerId) + const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) expect(datastore.has(dhtKey)).to.be.false('already had record') const record = await create(peerId, cid, 0n, 60000) const marshalledRecord = marshal(record) - routing.get.withArgs(routingKey).resolves(marshalledRecord) + customRouting.get.withArgs(customRoutingKey).resolves(marshalledRecord) const result = await name.resolve(peerId) expect(result.toString()).to.equal(cid.toV1().toString(), 'incorrect record resolved') @@ -107,8 +115,8 @@ describe('resolve', () => { it('should cache the most recent record', async function () { const peerId = await createEd25519PeerId() - const routingKey = peerIdToRoutingKey(peerId) - const dhtKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false) + const customRoutingKey = peerIdToRoutingKey(peerId) + const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) const marshalledRecordA = marshal(await create(peerId, cid, 0n, 60000)) const marshalledRecordB = marshal(await create(peerId, cid, 10n, 60000)) @@ -118,7 +126,7 @@ describe('resolve', () => { // cache has older record await datastore.put(dhtKey, marshalledRecordA) - routing.get.withArgs(routingKey).resolves(marshalledRecordB) + customRouting.get.withArgs(customRoutingKey).resolves(marshalledRecordB) const result = await name.resolve(peerId) expect(result.toString()).to.equal(cid.toV1().toString(), 'incorrect record resolved') diff --git a/packages/ipns/tsconfig.json b/packages/ipns/tsconfig.json index 13a35996..4c0bdf77 100644 --- a/packages/ipns/tsconfig.json +++ b/packages/ipns/tsconfig.json @@ -6,5 +6,10 @@ "include": [ "src", "test" + ], + "references": [ + { + "path": "../interface" + } ] }