diff --git a/.aegir.js b/.aegir.js index 07040ea..01fe14f 100644 --- a/.aegir.js +++ b/.aegir.js @@ -4,19 +4,40 @@ import body from 'body-parser' export default { test: { before: async () => { + let lastRequest = { + headers: {}, + params: {}, + body: '' + } + const providers = new Map() const echoServer = new EchoServer() echoServer.polka.use(body.text()) echoServer.polka.post('/add-providers/:cid', (req, res) => { + lastRequest = { + headers: req.headers, + params: req.params, + body: req.body + } + providers.set(req.params.cid, req.body) res.end() }) echoServer.polka.get('/cid/:cid', (req, res) => { + lastRequest = { + headers: req.headers, + params: req.params, + body: req.body + } + const provs = providers.get(req.params.cid) ?? '[]' providers.delete(req.params.cid) res.end(provs) }) + echoServer.polka.get('/last-request', (req, res) => { + res.end(JSON.stringify(lastRequest)) + }) await echoServer.start() diff --git a/package.json b/package.json index 3115df8..36ba749 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,9 @@ }, "dependencies": { "@libp2p/interface-content-routing": "^2.0.2", + "@libp2p/interface-peer-id": "^2.0.2", "@libp2p/interface-peer-info": "^1.0.9", + "@libp2p/interface-peer-store": "^2.0.3", "@libp2p/interfaces": "^3.3.1", "@libp2p/logger": "^2.0.7", "@libp2p/peer-id": "^2.0.3", @@ -147,12 +149,14 @@ "iterable-ndjson": "^1.1.0", "multiformats": "^11.0.2", "p-defer": "^4.0.0", - "p-queue": "^7.3.4" + "p-queue": "^7.3.4", + "sinon-ts": "^1.0.0" }, "devDependencies": { "@libp2p/peer-id-factory": "^2.0.3", "aegir": "^39.0.7", "body-parser": "^1.20.2", - "it-all": "^3.0.1" + "it-all": "^3.0.1", + "it-drain": "^3.0.2" } } diff --git a/src/index.ts b/src/index.ts index de91ce1..7d999a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,9 @@ import ndjson from 'iterable-ndjson' import defer from 'p-defer' import PQueue from 'p-queue' import type { ContentRouting } from '@libp2p/interface-content-routing' +import type { PeerId } from '@libp2p/interface-peer-id' import type { PeerInfo } from '@libp2p/interface-peer-info' +import type { PeerStore } from '@libp2p/interface-peer-store' import type { AbortOptions } from '@libp2p/interfaces' import type { Startable } from '@libp2p/interfaces/startable' import type { Multiaddr } from '@multiformats/multiaddr' @@ -40,6 +42,11 @@ export interface IpniContentRoutingInit { timeout?: number } +export interface IpniContentRoutingComponents { + peerId: PeerId + peerStore: PeerStore +} + const defaultValues = { concurrentRequests: 4, timeout: 30e3 @@ -54,11 +61,14 @@ class IpniContentRouting implements ContentRouting, Startable { private readonly shutDownController: AbortController private readonly clientUrl: URL private readonly timeout: number + private readonly peerId: PeerId + private readonly peerStore: PeerStore + private agentVersion?: string /** * Create a new DelegatedContentRouting instance */ - constructor (url: string | URL, init: IpniContentRoutingInit = {}) { + constructor (url: string | URL, init: IpniContentRoutingInit = {}, components: IpniContentRoutingComponents) { log('enabled IPNI routing via', url) this.started = false this.shutDownController = new AbortController() @@ -67,6 +77,8 @@ class IpniContentRouting implements ContentRouting, Startable { }) this.clientUrl = url instanceof URL ? url : new URL(url) this.timeout = init.timeout ?? defaultValues.timeout + this.peerId = components.peerId + this.peerStore = components.peerStore } isStarted (): boolean { @@ -83,6 +95,21 @@ class IpniContentRouting implements ContentRouting, Startable { this.started = false } + private async getAgentVersion (): Promise { + if (this.agentVersion == null) { + const peer = await this.peerStore.get(this.peerId) + const agentVersionBuf = peer.metadata.get('AgentVersion') + + if (agentVersionBuf != null) { + this.agentVersion = new TextDecoder().decode(agentVersionBuf) + } else { + this.agentVersion = '' + } + } + + return this.agentVersion + } + async * findProviders (key: CID, options: AbortOptions = {}): AsyncIterable { log('findProviders starts: %c', key) @@ -99,7 +126,13 @@ class IpniContentRouting implements ContentRouting, Startable { await onStart.promise const resource = `${this.clientUrl}cid/${key.toString()}?cascade=ipfs-dht` - const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal } + const getOptions = { + headers: { + Accept: 'application/x-ndjson', + 'User-Agent': await this.getAgentVersion() + }, + signal + } const a = await fetch(resource, getOptions) if (a.body == null) { @@ -153,6 +186,6 @@ class IpniContentRouting implements ContentRouting, Startable { } } -export function ipniContentRouting (url: string | URL, init: IpniContentRoutingInit = {}): () => ContentRouting { - return () => new IpniContentRouting(url, init) +export function ipniContentRouting (url: string | URL, init: IpniContentRoutingInit = {}): (components: IpniContentRoutingComponents) => ContentRouting { + return (components: IpniContentRoutingComponents) => new IpniContentRouting(url, init, components) } diff --git a/test/index.spec.ts b/test/index.spec.ts index 3996dd3..5125fc3 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -3,8 +3,12 @@ import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' import all from 'it-all' +import drain from 'it-drain' import { CID } from 'multiformats/cid' +import { type StubbedInstance, stubInterface } from 'sinon-ts' import { ipniContentRouting } from '../src/index.js' +import type { PeerId } from '@libp2p/interface-peer-id' +import type { PeerStore } from '@libp2p/interface-peer-store' if (process.env.ECHO_SERVER == null) { throw new Error('Echo server not configured correctly') @@ -13,6 +17,17 @@ if (process.env.ECHO_SERVER == null) { const serverUrl = process.env.ECHO_SERVER describe('IPNIContentRouting', function () { + let peerId: PeerId + let peerStore: StubbedInstance + + beforeEach(async () => { + peerId = await createEd25519PeerId() + peerStore = stubInterface() + peerStore.get.withArgs(peerId).resolves({ + metadata: new Map() + }) + }) + it('should find providers', async () => { const providers = [{ Metadata: 'gBI=', @@ -38,7 +53,10 @@ describe('IPNIContentRouting', function () { body: providers.map(prov => JSON.stringify(prov)).join('\n') }) - const routing = ipniContentRouting(serverUrl)() + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) const provs = await all(routing.findProviders(cid)) expect(provs.map(prov => ({ @@ -59,7 +77,10 @@ describe('IPNIContentRouting', function () { body: 'not json' }) - const routing = ipniContentRouting(serverUrl)() + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) const provs = await all(routing.findProviders(cid)) expect(provs).to.be.empty() @@ -87,7 +108,10 @@ describe('IPNIContentRouting', function () { body: providers.map(prov => JSON.stringify(prov)).join('\n') }) - const routing = ipniContentRouting(serverUrl)() + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) const provs = await all(routing.findProviders(cid)) expect(provs).to.be.empty() @@ -95,9 +119,44 @@ describe('IPNIContentRouting', function () { it('should handle empty input', async () => { const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') - const routing = ipniContentRouting(serverUrl)() + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) const provs = await all(routing.findProviders(cid)) expect(provs).to.be.empty() }) + + it('should send user agent header', async () => { + const agentVersion = 'herp/1.0.0 derp/1.0.0' + + // return user agent + peerStore.get.withArgs(peerId).resolves({ + metadata: new Map([['AgentVersion', new TextEncoder().encode(agentVersion)]]) + }) + + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + + // load providers for the router to fetch + await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, { + method: 'POST', + body: '[]\n' + }) + + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) + + await drain(routing.findProviders(cid)) + + const response = await fetch(`${process.env.ECHO_SERVER}/last-request`, { + method: 'GET' + }) + const bodyText = await response.text() + const body = JSON.parse(bodyText) + + expect(body).to.have.nested.property('headers.user-agent', agentVersion) + }) })