Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for libp2p ContentRouting and PeerRouting #44

Merged
merged 7 commits into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/client/.aegir.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import body from 'body-parser'
const options = {
test: {
before: async () => {
let callCount = 0
const providers = new Map()
const peers = new Map()
const ipnsGet = new Map()
Expand All @@ -13,45 +14,60 @@ const options = {
echo.polka.use(body.raw({ type: 'application/vnd.ipfs.ipns-record'}))
echo.polka.use(body.text())
echo.polka.post('/add-providers/:cid', (req, res) => {
callCount++
providers.set(req.params.cid, req.body)
res.end()
})
echo.polka.get('/routing/v1/providers/:cid', (req, res) => {
callCount++
const records = providers.get(req.params.cid) ?? '[]'
providers.delete(req.params.cid)

res.end(records)
})
echo.polka.post('/add-peers/:peerId', (req, res) => {
callCount++
peers.set(req.params.peerId, req.body)
res.end()
})
echo.polka.get('/routing/v1/peers/:peerId', (req, res) => {
callCount++
const records = peers.get(req.params.peerId) ?? '[]'
peers.delete(req.params.peerId)

res.end(records)
})
echo.polka.post('/add-ipns/:peerId', (req, res) => {
callCount++
ipnsGet.set(req.params.peerId, req.body)
res.end()
})
echo.polka.get('/routing/v1/ipns/:peerId', (req, res) => {
callCount++
const record = ipnsGet.get(req.params.peerId) ?? ''
ipnsGet.delete(req.params.peerId)

res.end(record)
})
echo.polka.put('/routing/v1/ipns/:peerId', (req, res) => {
callCount++
ipnsPut.set(req.params.peerId, req.body)
res.end()
})
echo.polka.get('/get-ipns/:peerId', (req, res) => {
callCount++
const record = ipnsPut.get(req.params.peerId) ?? ''
ipnsPut.delete(req.params.peerId)

res.end(record)
})
echo.polka.get('/get-call-count', (req, res) => {
res.end(callCount.toString())
})
echo.polka.get('/reset-call-count', (req, res) => {
callCount = 0
res.end()
})

await echo.start()

Expand Down
30 changes: 26 additions & 4 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,44 @@

## About

A client implementation of the IPFS [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/)
that can be used to interact with any compliant server implementation.
A client implementation of the IPFS [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/) that can be used to interact with any compliant server implementation.

### Example

```typescript
import { createRoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client'
import { createDelegatedRoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client'
import { CID } from 'multiformats/cid'

const client = createRoutingV1HttpApiClient(new URL('https://example.org'))
const client = createDelegatedRoutingV1HttpApiClient('https://example.org')

for await (const prov of getProviders(CID.parse('QmFoo'))) {
// ...
}
```

### How to use with libp2p

The client can be configured as a libp2p service, this will enable it as both a ContentRouting and a PeerRouting implementation

### Example

```typescript
import { createDelegatedRoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client'
import { createLibp2p } from 'libp2p'
import { peerIdFromString } from '@libp2p/peer-id'

const client = createDelegatedRoutingV1HttpApiClient('https://example.org')
const libp2p = await createLibp2p({
// other config here
services: {
delegatedRouting: client
}
})

// later this will use the configured HTTP gateway
await libp2p.peerRouting.findPeer(peerIdFromString('QmFoo'))
```

## Install

```console
Expand Down
9 changes: 6 additions & 3 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,18 @@
"any-signal": "^4.1.1",
"browser-readablestream-to-it": "^2.0.3",
"ipns": "^7.0.1",
"it-all": "^3.0.2",
"it-first": "^3.0.3",
"it-map": "^3.0.4",
"it-ndjson": "^1.0.4",
"multiformats": "^12.1.1",
"p-defer": "^4.0.0",
"p-queue": "^7.3.4"
"p-queue": "^7.3.4",
"uint8arrays": "^4.0.6"
},
"devDependencies": {
"@libp2p/peer-id-factory": "^3.0.5",
"aegir": "^41.0.0",
"body-parser": "^1.20.2"
"body-parser": "^1.20.2",
"it-all": "^3.0.2"
}
}
15 changes: 15 additions & 0 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type ContentRouting, contentRouting } from '@libp2p/interface/content-routing'
import { CodeError } from '@libp2p/interface/errors'
import { type PeerRouting, peerRouting } from '@libp2p/interface/peer-routing'
import { logger } from '@libp2p/logger'
import { peerIdFromString } from '@libp2p/peer-id'
import { multiaddr } from '@multiformats/multiaddr'
Expand All @@ -9,6 +11,7 @@ import { ipnsValidator } from 'ipns/validator'
import { parse as ndjson } from 'it-ndjson'
import defer from 'p-defer'
import PQueue from 'p-queue'
import { DelegatedRoutingV1HttpApiClientContentRouting, DelegatedRoutingV1HttpApiClientPeerRouting } from './routings.js'
import type { DelegatedRoutingV1HttpApiClient, DelegatedRoutingV1HttpApiClientInit, PeerRecord } from './index.js'
import type { AbortOptions } from '@libp2p/interface'
import type { PeerId } from '@libp2p/interface/peer-id'
Expand All @@ -27,6 +30,8 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
private readonly shutDownController: AbortController
private readonly clientUrl: URL
private readonly timeout: number
private readonly contentRouting: ContentRouting
private readonly peerRouting: PeerRouting

/**
* Create a new DelegatedContentRouting instance
Expand All @@ -39,6 +44,16 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
})
this.clientUrl = url instanceof URL ? url : new URL(url)
this.timeout = init.timeout ?? defaultValues.timeout
this.contentRouting = new DelegatedRoutingV1HttpApiClientContentRouting(this)
this.peerRouting = new DelegatedRoutingV1HttpApiClientPeerRouting(this)
}

get [contentRouting] (): ContentRouting {
return this.contentRouting
}

get [peerRouting] (): PeerRouting {
return this.peerRouting
}

isStarted (): boolean {
Expand Down
34 changes: 28 additions & 6 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,43 @@
/**
* @packageDocumentation
*
* A client implementation of the IPFS [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/)
* that can be used to interact with any compliant server implementation.
* A client implementation of the IPFS [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/) that can be used to interact with any compliant server implementation.
*
* @example
*
* ```typescript
* import { createRoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client'
* import { createDelegatedRoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client'
* import { CID } from 'multiformats/cid'
*
* const client = createRoutingV1HttpApiClient(new URL('https://example.org'))
* const client = createDelegatedRoutingV1HttpApiClient('https://example.org')
*
* for await (const prov of getProviders(CID.parse('QmFoo'))) {
* // ...
* }
* ```
*
* ### How to use with libp2p
*
* The client can be configured as a libp2p service, this will enable it as both a {@link https://libp2p.github.io/js-libp2p/interfaces/_libp2p_interface.content_routing.ContentRouting.html | ContentRouting} and a {@link https://libp2p.github.io/js-libp2p/interfaces/_libp2p_interface.peer_routing.PeerRouting.html | PeerRouting} implementation
*
* @example
*
* ```typescript
* import { createDelegatedRoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client'
* import { createLibp2p } from 'libp2p'
* import { peerIdFromString } from '@libp2p/peer-id'
*
* const client = createDelegatedRoutingV1HttpApiClient('https://example.org')
* const libp2p = await createLibp2p({
* // other config here
* services: {
* delegatedRouting: client
* }
* })
*
* // later this will use the configured HTTP gateway
* await libp2p.peerRouting.findPeer(peerIdFromString('QmFoo'))
* ```
*/

import { DefaultDelegatedRoutingV1HttpApiClient } from './client.js'
Expand Down Expand Up @@ -79,6 +101,6 @@ export interface DelegatedRoutingV1HttpApiClient {
/**
* Create and return a client to use with a Routing V1 HTTP API server
*/
export function createDelegatedRoutingV1HttpApiClient (url: URL, init: DelegatedRoutingV1HttpApiClientInit = {}): DelegatedRoutingV1HttpApiClient {
return new DefaultDelegatedRoutingV1HttpApiClient(url, init)
export function createDelegatedRoutingV1HttpApiClient (url: URL | string, init: DelegatedRoutingV1HttpApiClientInit = {}): DelegatedRoutingV1HttpApiClient {
return new DefaultDelegatedRoutingV1HttpApiClient(new URL(url), init)
Comment on lines +104 to +105
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for supporting strings

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general if we can push validation of an argument type out of a consumer it's a good thing. Otherwise this can snowball quickly and we have to guess what the contents of a string are, usually at several points in a module if we have multiple entry points.

We suffered from this previously with "what's a CID?" is it "QmFoo", is it "/ipfs/QMFoo", etc. Pushing all that into the CID class vastly simplified the codebase.

Of course for a user it's nicer to be able to pass 'http://example.org' instead of new URL('http://example.org') so it's probably worth the tradeoff for the create... factory functions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be a straightforward one where it will fail quickly if an invalid string is passed.

}
111 changes: 111 additions & 0 deletions packages/client/src/routings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { type ContentRouting } from '@libp2p/interface/content-routing'
import { CodeError } from '@libp2p/interface/errors'
import { type PeerRouting } from '@libp2p/interface/peer-routing'
import { peerIdFromBytes } from '@libp2p/peer-id'
import { marshal, unmarshal } from 'ipns'
import first from 'it-first'
import map from 'it-map'
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import type { DelegatedRoutingV1HttpApiClient } from './index.js'
import type { AbortOptions } from '@libp2p/interface'
import type { PeerId } from '@libp2p/interface/peer-id'
import type { PeerInfo } from '@libp2p/interface/peer-info'
import type { CID } from 'multiformats/cid'

const IPNS_PREFIX = uint8ArrayFromString('/ipns/')

function isIPNSKey (key: Uint8Array): boolean {
return uint8ArrayEquals(key.subarray(0, IPNS_PREFIX.byteLength), IPNS_PREFIX)
}

const peerIdFromRoutingKey = (key: Uint8Array): PeerId => {
return peerIdFromBytes(key.slice(IPNS_PREFIX.length))
}

/**
* Wrapper class to convert [http-routing-v1 content events](https://specs.ipfs.tech/routing/http-routing-v1/#response-body) into returned values
*/
export class DelegatedRoutingV1HttpApiClientContentRouting implements ContentRouting {
private readonly client: DelegatedRoutingV1HttpApiClient

constructor (client: DelegatedRoutingV1HttpApiClient) {
this.client = client
}

async * findProviders (cid: CID, options: AbortOptions = {}): AsyncIterable<PeerInfo> {
yield * map(this.client.getProviders(cid, options), (record) => {
return {
id: record.ID,
multiaddrs: record.Addrs ?? [],
protocols: []
}
})
}

async provide (): Promise<void> {
// noop
}

async put (key: Uint8Array, value: Uint8Array, options?: AbortOptions): Promise<void> {
if (!isIPNSKey(key)) {
return
}

const peerId = peerIdFromRoutingKey(key)
const record = unmarshal(value)

await this.client.putIPNS(peerId, record, options)
}

async get (key: Uint8Array, options?: AbortOptions): Promise<Uint8Array> {
if (!isIPNSKey(key)) {
throw new CodeError('Not found', 'ERR_NOT_FOUND')
}

const peerId = peerIdFromRoutingKey(key)

try {
const record = await this.client.getIPNS(peerId, options)

return marshal(record)
} catch (err: any) {
// ERR_BAD_RESPONSE is thrown when the response had no body, which means
// the record couldn't be found
if (err.code === 'ERR_BAD_RESPONSE') {
throw new CodeError('Not found', 'ERR_NOT_FOUND')
}

throw err
}

Check warning on line 80 in packages/client/src/routings.ts

View check run for this annotation

Codecov / codecov/patch

packages/client/src/routings.ts#L73-L80

Added lines #L73 - L80 were not covered by tests
}
}

/**
* Wrapper class to convert [http-routing-v1](https://specs.ipfs.tech/routing/http-routing-v1/#response-body-0) events into expected libp2p values
*/
export class DelegatedRoutingV1HttpApiClientPeerRouting implements PeerRouting {
private readonly client: DelegatedRoutingV1HttpApiClient

constructor (client: DelegatedRoutingV1HttpApiClient) {
this.client = client
}

async findPeer (peerId: PeerId, options: AbortOptions = {}): Promise<PeerInfo> {
const peer = await first(this.client.getPeers(peerId, options))

if (peer != null) {
return {
id: peer.ID,
multiaddrs: peer.Addrs,
protocols: []
}
}

throw new CodeError('Not found', 'ERR_NOT_FOUND')
}

Check warning on line 106 in packages/client/src/routings.ts

View check run for this annotation

Codecov / codecov/patch

packages/client/src/routings.ts#L104-L106

Added lines #L104 - L106 were not covered by tests

async * getClosestPeers (key: Uint8Array, options: AbortOptions = {}): AsyncIterable<PeerInfo> {
// noop
}
}
Loading