@helia/delegated-routing-v1-http-api-client 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,266 @@
1
+ import { CodeError } from '@libp2p/interface/errors'
2
+ import { logger } from '@libp2p/logger'
3
+ import { peerIdFromString } from '@libp2p/peer-id'
4
+ import { multiaddr } from '@multiformats/multiaddr'
5
+ import { anySignal } from 'any-signal'
6
+ import toIt from 'browser-readablestream-to-it'
7
+ import { unmarshal, type IPNSRecord, marshal, peerIdToRoutingKey } from 'ipns'
8
+ import { ipnsValidator } from 'ipns/validator'
9
+ // @ts-expect-error no types
10
+ import ndjson from 'iterable-ndjson'
11
+ import defer from 'p-defer'
12
+ import PQueue from 'p-queue'
13
+ import type { DelegatedRoutingV1HttpApiClient, DelegatedRoutingV1HttpApiClientInit, PeerRecord } from './index.js'
14
+ import type { AbortOptions } from '@libp2p/interface'
15
+ import type { PeerId } from '@libp2p/interface/peer-id'
16
+ import type { CID } from 'multiformats'
17
+
18
+ const log = logger('routing-v1-http-api-client')
19
+
20
+ const defaultValues = {
21
+ concurrentRequests: 4,
22
+ timeout: 30e3
23
+ }
24
+
25
+ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpApiClient {
26
+ private started: boolean
27
+ private readonly httpQueue: PQueue
28
+ private readonly shutDownController: AbortController
29
+ private readonly clientUrl: URL
30
+ private readonly timeout: number
31
+
32
+ /**
33
+ * Create a new DelegatedContentRouting instance
34
+ */
35
+ constructor (url: string | URL, init: DelegatedRoutingV1HttpApiClientInit = {}) {
36
+ this.started = false
37
+ this.shutDownController = new AbortController()
38
+ this.httpQueue = new PQueue({
39
+ concurrency: init.concurrentRequests ?? defaultValues.concurrentRequests
40
+ })
41
+ this.clientUrl = url instanceof URL ? url : new URL(url)
42
+ this.timeout = init.timeout ?? defaultValues.timeout
43
+ }
44
+
45
+ isStarted (): boolean {
46
+ return this.started
47
+ }
48
+
49
+ start (): void {
50
+ this.started = true
51
+ }
52
+
53
+ stop (): void {
54
+ this.httpQueue.clear()
55
+ this.shutDownController.abort()
56
+ this.started = false
57
+ }
58
+
59
+ async * getProviders (cid: CID, options: AbortOptions = {}): AsyncGenerator<PeerRecord, any, unknown> {
60
+ log('getProviders starts: %c', cid)
61
+
62
+ const signal = anySignal([this.shutDownController.signal, options.signal, AbortSignal.timeout(this.timeout)])
63
+ const onStart = defer()
64
+ const onFinish = defer()
65
+
66
+ void this.httpQueue.add(async () => {
67
+ onStart.resolve()
68
+ return onFinish.promise
69
+ })
70
+
71
+ try {
72
+ await onStart.promise
73
+
74
+ // https://specs.ipfs.tech/routing/http-routing-v1/
75
+ const resource = `${this.clientUrl}routing/v1/providers/${cid.toString()}`
76
+ const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal }
77
+ const res = await fetch(resource, getOptions)
78
+
79
+ if (res.body == null) {
80
+ throw new CodeError('Routing response had no body', 'ERR_BAD_RESPONSE')
81
+ }
82
+
83
+ const contentType = res.headers.get('Content-Type')
84
+ if (contentType === 'application/json') {
85
+ const body = await res.json()
86
+
87
+ for (const provider of body.Providers) {
88
+ const record = this.#handleProviderRecords(provider)
89
+ if (record != null) {
90
+ yield record
91
+ }
92
+ }
93
+ } else {
94
+ for await (const provider of ndjson(toIt(res.body))) {
95
+ const record = this.#handleProviderRecords(provider)
96
+ if (record != null) {
97
+ yield record
98
+ }
99
+ }
100
+ }
101
+ } catch (err) {
102
+ log.error('getProviders errored:', err)
103
+ } finally {
104
+ signal.clear()
105
+ onFinish.resolve()
106
+ log('getProviders finished: %c', cid)
107
+ }
108
+ }
109
+
110
+ async * getPeerInfo (peerId: PeerId, options: AbortOptions | undefined = {}): AsyncGenerator<PeerRecord, any, unknown> {
111
+ log('getPeers starts: %c', peerId)
112
+
113
+ const signal = anySignal([this.shutDownController.signal, options.signal, AbortSignal.timeout(this.timeout)])
114
+ const onStart = defer()
115
+ const onFinish = defer()
116
+
117
+ void this.httpQueue.add(async () => {
118
+ onStart.resolve()
119
+ return onFinish.promise
120
+ })
121
+
122
+ try {
123
+ await onStart.promise
124
+
125
+ // https://specs.ipfs.tech/routing/http-routing-v1/
126
+ const resource = `${this.clientUrl}routing/v1/peers/${peerId.toCID().toString()}`
127
+ const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal }
128
+ const res = await fetch(resource, getOptions)
129
+
130
+ if (res.body == null) {
131
+ throw new CodeError('Routing response had no body', 'ERR_BAD_RESPONSE')
132
+ }
133
+
134
+ const contentType = res.headers.get('Content-Type')
135
+ if (contentType === 'application/json') {
136
+ const body = await res.json()
137
+
138
+ for (const peer of body.Peers) {
139
+ const record = this.#handlePeerRecords(peerId, peer)
140
+ if (record != null) {
141
+ yield record
142
+ }
143
+ }
144
+ } else {
145
+ for await (const peer of ndjson(toIt(res.body))) {
146
+ const record = this.#handlePeerRecords(peerId, peer)
147
+ if (record != null) {
148
+ yield record
149
+ }
150
+ }
151
+ }
152
+ } catch (err) {
153
+ log.error('getPeers errored:', err)
154
+ } finally {
155
+ signal.clear()
156
+ onFinish.resolve()
157
+ log('getPeers finished: %c', peerId)
158
+ }
159
+ }
160
+
161
+ async getIPNS (peerId: PeerId, options: AbortOptions = {}): Promise<IPNSRecord> {
162
+ log('getIPNS starts: %c', peerId)
163
+
164
+ const signal = anySignal([this.shutDownController.signal, options.signal, AbortSignal.timeout(this.timeout)])
165
+ const onStart = defer()
166
+ const onFinish = defer()
167
+
168
+ void this.httpQueue.add(async () => {
169
+ onStart.resolve()
170
+ return onFinish.promise
171
+ })
172
+
173
+ try {
174
+ await onStart.promise
175
+
176
+ // https://specs.ipfs.tech/routing/http-routing-v1/
177
+ const resource = `${this.clientUrl}routing/v1/ipns/${peerId.toCID().toString()}`
178
+ const getOptions = { headers: { Accept: 'application/vnd.ipfs.ipns-record' }, signal }
179
+ const res = await fetch(resource, getOptions)
180
+
181
+ if (res.body == null) {
182
+ throw new CodeError('GET ipns response had no body', 'ERR_BAD_RESPONSE')
183
+ }
184
+
185
+ const body = new Uint8Array(await res.arrayBuffer())
186
+ await ipnsValidator(peerIdToRoutingKey(peerId), body)
187
+ return unmarshal(body)
188
+ } finally {
189
+ signal.clear()
190
+ onFinish.resolve()
191
+ log('getIPNS finished: %c', peerId)
192
+ }
193
+ }
194
+
195
+ async putIPNS (peerId: PeerId, record: IPNSRecord, options: AbortOptions = {}): Promise<void> {
196
+ log('getIPNS starts: %c', peerId)
197
+
198
+ const signal = anySignal([this.shutDownController.signal, options.signal, AbortSignal.timeout(this.timeout)])
199
+ const onStart = defer()
200
+ const onFinish = defer()
201
+
202
+ void this.httpQueue.add(async () => {
203
+ onStart.resolve()
204
+ return onFinish.promise
205
+ })
206
+
207
+ try {
208
+ await onStart.promise
209
+
210
+ const body = marshal(record)
211
+
212
+ // https://specs.ipfs.tech/routing/http-routing-v1/
213
+ const resource = `${this.clientUrl}routing/v1/ipns/${peerId.toCID().toString()}`
214
+ const getOptions = { method: 'PUT', headers: { 'Content-Type': 'application/vnd.ipfs.ipns-record' }, body, signal }
215
+ const res = await fetch(resource, getOptions)
216
+ if (res.status !== 200) {
217
+ throw new CodeError('PUT ipns response had status other than 200', 'ERR_BAD_RESPONSE')
218
+ }
219
+ } finally {
220
+ signal.clear()
221
+ onFinish.resolve()
222
+ log('getIPNS finished: %c', peerId)
223
+ }
224
+ }
225
+
226
+ #handleProviderRecords (record: any): PeerRecord | undefined {
227
+ if (record.Schema === 'peer') {
228
+ // Peer schema can have additional, user-defined, fields.
229
+ record.ID = peerIdFromString(record.ID)
230
+ record.Addrs = record.Addrs.map(multiaddr)
231
+ return record
232
+ }
233
+
234
+ if (record.Schema === 'bitswap') {
235
+ // Bitswap schema is deprecated, was incorrectly used when server had no
236
+ // information about actual protocols, so we convert it to peer result
237
+ // without protocol information
238
+ return {
239
+ Schema: 'peer',
240
+ ID: peerIdFromString(record.ID),
241
+ Addrs: record.Addrs.map(multiaddr),
242
+ Protocols: record.Protocol != null ? [record.Protocol] : []
243
+ }
244
+ }
245
+
246
+ if (record.ID != null && Array.isArray(record.Addrs)) {
247
+ return {
248
+ Schema: 'peer',
249
+ ID: peerIdFromString(record.ID),
250
+ Addrs: record.Addrs.map(multiaddr),
251
+ Protocols: Array.isArray(record.Protocols) ? record.Protocols : []
252
+ }
253
+ }
254
+ }
255
+
256
+ #handlePeerRecords (peerId: PeerId, record: any): PeerRecord | undefined {
257
+ if (record.Schema === 'peer') {
258
+ // Peer schema can have additional, user-defined, fields.
259
+ record.ID = peerIdFromString(record.ID)
260
+ record.Addrs = record.Addrs.map(multiaddr)
261
+ if (peerId.equals(record.ID)) {
262
+ return record
263
+ }
264
+ }
265
+ }
266
+ }
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @packageDocumentation
3
+ *
4
+ * Create a client to use with a Routing V1 HTTP API server.
5
+ *
6
+ * @example
7
+ *
8
+ * ```typescript
9
+ * import { createRoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client'
10
+ * import { CID } from 'multiformats/cid'
11
+ *
12
+ * const client = createRoutingV1HttpApiClient(new URL('https://example.org'))
13
+ *
14
+ * for await (const prov of getProviders(CID.parse('QmFoo'))) {
15
+ * // ...
16
+ * }
17
+ * ```
18
+ */
19
+
20
+ import { DefaultDelegatedRoutingV1HttpApiClient } from './client.js'
21
+ import type { AbortOptions } from '@libp2p/interface'
22
+ import type { PeerId } from '@libp2p/interface/peer-id'
23
+ import type { Multiaddr } from '@multiformats/multiaddr'
24
+ import type { IPNSRecord } from 'ipns'
25
+ import type { CID } from 'multiformats/cid'
26
+
27
+ export interface PeerRecord {
28
+ Schema: 'peer'
29
+ ID: PeerId
30
+ Addrs?: Multiaddr[]
31
+ Protocols?: string[]
32
+ }
33
+
34
+ export interface DelegatedRoutingV1HttpApiClientInit {
35
+ /**
36
+ * A concurrency limit to avoid request flood in web browser (default: 4)
37
+ *
38
+ * @see https://github.com/libp2p/js-libp2p-delegated-content-routing/issues/12
39
+ */
40
+ concurrentRequests?: number
41
+
42
+ /**
43
+ * How long a request is allowed to take in ms (default: 30 seconds)
44
+ */
45
+ timeout?: number
46
+ }
47
+
48
+ export interface DelegatedRoutingV1HttpApiClient {
49
+ /**
50
+ * Returns an async generator of PeerInfos that can provide the content
51
+ * for the passed CID
52
+ */
53
+ getProviders(cid: CID, options?: AbortOptions): AsyncGenerator<PeerRecord>
54
+
55
+ /**
56
+ * Returns an async generator of PeerInfos for the provided PeerId
57
+ */
58
+ getPeerInfo(peerId: PeerId, options?: AbortOptions): AsyncGenerator<PeerRecord>
59
+
60
+ /**
61
+ * Returns a promise of a IPNSRecord for the given PeerId
62
+ */
63
+ getIPNS(peerId: PeerId, options?: AbortOptions): Promise<IPNSRecord>
64
+
65
+ /**
66
+ * Publishes the given IPNSRecorded for the provided PeerId
67
+ */
68
+ putIPNS(peerId: PeerId, record: IPNSRecord, options?: AbortOptions): Promise<void>
69
+
70
+ /**
71
+ * Shut down any currently running HTTP requests and clear up any resources
72
+ * that are in use
73
+ */
74
+ stop(): void
75
+ }
76
+
77
+ /**
78
+ * Create and return a client to use with a Routing V1 HTTP API server
79
+ */
80
+ export function createDelegatedRoutingV1HttpApiClient (url: URL, init: DelegatedRoutingV1HttpApiClientInit = {}): DelegatedRoutingV1HttpApiClient {
81
+ return new DefaultDelegatedRoutingV1HttpApiClient(url, init)
82
+ }