@helia/ipns 9.1.9 → 9.2.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.
@@ -1,17 +1,24 @@
1
- import { isPublicKey } from '@libp2p/interface'
1
+ import { isPublicKey, NotFoundError, setMaxListeners } from '@libp2p/interface'
2
2
  import { logger } from '@libp2p/logger'
3
+ import { PeerSet } from '@libp2p/peer-collections'
4
+ import { Queue } from '@libp2p/utils'
5
+ import { anySignal } from 'any-signal'
6
+ import delay from 'delay'
3
7
  import { multihashToIPNSRoutingKey } from 'ipns'
4
8
  import { ipnsSelector } from 'ipns/selector'
5
9
  import { ipnsValidator } from 'ipns/validator'
6
10
  import { CustomProgressEvent } from 'progress-events'
11
+ import { raceSignal } from 'race-signal'
7
12
  import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
8
13
  import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
9
14
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
10
- import { InvalidTopicError } from '../errors.js'
11
- import { localStore } from '../local-store.js'
12
- import type { GetOptions, IPNSRouting, PutOptions } from './index.js'
13
- import type { LocalStore } from '../local-store.js'
14
- import type { PeerId, PublicKey, TypedEventTarget, ComponentLogger } from '@libp2p/interface'
15
+ import { InvalidTopicError } from '../errors.ts'
16
+ import { localStore } from '../local-store.ts'
17
+ import { IPNS_STRING_PREFIX } from '../utils.ts'
18
+ import type { GetOptions, IPNSRouting, PutOptions } from './index.ts'
19
+ import type { LocalStore } from '../local-store.ts'
20
+ import type { Fetch } from '@libp2p/fetch'
21
+ import type { PeerId, PublicKey, TypedEventTarget, ComponentLogger, Startable, AbortOptions, Metrics, Libp2p } from '@libp2p/interface'
15
22
  import type { Datastore } from 'interface-datastore'
16
23
  import type { MultihashDigest } from 'multiformats/hashes/interface'
17
24
  import type { ProgressEvent } from 'progress-events'
@@ -25,7 +32,18 @@ export interface Message {
25
32
  data: Uint8Array
26
33
  }
27
34
 
35
+ export interface Subscription {
36
+ topic: string
37
+ subscribe: boolean
38
+ }
39
+
40
+ export interface SubscriptionChangeData {
41
+ peerId: PeerId
42
+ subscriptions: Subscription[]
43
+ }
44
+
28
45
  export interface PubSubEvents {
46
+ 'subscription-change': CustomEvent<SubscriptionChangeData>
29
47
  message: CustomEvent<Message>
30
48
  }
31
49
 
@@ -44,12 +62,31 @@ export interface PubSub extends TypedEventTarget<PubSubEvents> {
44
62
  export interface PubsubRoutingComponents {
45
63
  datastore: Datastore
46
64
  logger: ComponentLogger
47
- libp2p: {
48
- peerId: PeerId
49
- services: {
50
- pubsub: PubSub
51
- }
52
- }
65
+ metrics?: Metrics
66
+ libp2p: Pick<Libp2p<{ pubsub: PubSub, fetch?: Fetch }>, 'peerId' | 'register' | 'unregister' | 'services'>
67
+ }
68
+
69
+ export interface PubsubRoutingInit {
70
+ /**
71
+ * How many fetch requests to run concurrently
72
+ *
73
+ * @default 8
74
+ */
75
+ fetchConcurrency?: number
76
+
77
+ /**
78
+ * How long to allow a fetch request to run for in ms
79
+ *
80
+ * @default 2_500
81
+ */
82
+ fetchTimeout?: number
83
+
84
+ /**
85
+ * How many ms to wait before sending a fetch request to a topic peer
86
+ *
87
+ * @default 0
88
+ */
89
+ fetchDelay?: number
53
90
  }
54
91
 
55
92
  export type PubSubProgressEvents =
@@ -57,32 +94,105 @@ export type PubSubProgressEvents =
57
94
  ProgressEvent<'ipns:pubsub:subscribe', { topic: string }> |
58
95
  ProgressEvent<'ipns:pubsub:error', Error>
59
96
 
60
- class PubSubRouting implements IPNSRouting {
61
- private subscriptions: string[]
97
+ export class PubSubRouting implements IPNSRouting, Startable {
98
+ private readonly subscriptions: Set<string>
62
99
  private readonly localStore: LocalStore
63
- private readonly peerId: PeerId
64
- private readonly pubsub: PubSub
65
-
66
- constructor (components: PubsubRoutingComponents) {
67
- this.subscriptions = []
100
+ private readonly libp2p: Pick<Libp2p<{ pubsub: PubSub, fetch?: Fetch }>, 'peerId' | 'register' | 'unregister' | 'services'>
101
+ private readonly fetchConcurrency: number
102
+ private readonly fetchTimeout: number
103
+ private readonly fetchDelay: number
104
+ private readonly fetchQueue: Queue<Uint8Array | undefined>
105
+ private readonly fetchPeers: PeerSet
106
+ private shutdownController: AbortController
107
+ private fetchTopologyId?: string
108
+
109
+ constructor (components: PubsubRoutingComponents, init: PubsubRoutingInit = {}) {
110
+ this.subscriptions = new Set()
111
+ this.shutdownController = new AbortController()
112
+ setMaxListeners(Infinity, this.shutdownController.signal)
113
+ this.fetchPeers = new PeerSet()
68
114
  this.localStore = localStore(components.datastore, components.logger.forComponent('helia:ipns:local-store'))
69
- this.peerId = components.libp2p.peerId
70
- this.pubsub = components.libp2p.services.pubsub
115
+ this.libp2p = components.libp2p
116
+ this.fetchConcurrency = init.fetchConcurrency ?? 8
117
+ this.fetchDelay = init.fetchDelay ?? 0
118
+
119
+ // default libp2p-fetch timeout is 10 seconds - we should have an existing
120
+ // connection to the peer so this can be shortened
121
+ this.fetchTimeout = init.fetchTimeout ?? 2_500
122
+ this.fetchQueue = new Queue<Uint8Array | undefined>({
123
+ concurrency: this.fetchConcurrency,
124
+ metrics: components.metrics,
125
+ metricName: 'helia_ipns_pubsub_fetch_queue'
126
+ })
71
127
 
72
- this.pubsub.addEventListener('message', (evt) => {
128
+ this.libp2p.services.pubsub.addEventListener('message', (evt) => {
73
129
  const message = evt.detail
74
130
 
75
- if (!this.subscriptions.includes(message.topic)) {
131
+ if (!this.subscriptions.has(message.topic)) {
76
132
  return
77
133
  }
78
134
 
79
- this.#processPubSubMessage(message).catch(err => {
135
+ this.#processPubSubMessage(message, {
136
+ signal: this.shutdownController.signal
137
+ }).catch(err => {
80
138
  log.error('Error processing message - %e', err)
81
139
  })
82
140
  })
141
+
142
+ // ipns over libp2p-fetch feature
143
+ if (this.libp2p.services.fetch != null) {
144
+ try {
145
+ this.libp2p.services.pubsub.addEventListener('subscription-change', (evt) => {
146
+ const { peerId, subscriptions } = evt.detail
147
+
148
+ if (!this.fetchPeers.has(peerId)) {
149
+ return
150
+ }
151
+
152
+ for (const sub of subscriptions) {
153
+ if (!this.subscriptions.has(sub.topic)) {
154
+ continue
155
+ }
156
+
157
+ if (sub.subscribe === false) {
158
+ continue
159
+ }
160
+
161
+ log('peer %s joined topic %s', peerId, sub.topic)
162
+
163
+ const routingKey = topicToKey(sub.topic)
164
+ this.#fetchFromPeer(sub.topic, routingKey, peerId, {
165
+ signal: this.shutdownController.signal
166
+ })
167
+ .catch(err => {
168
+ log.error('failed to fetch IPNS record for %m from peer %s - %e', routingKey, peerId, err)
169
+ })
170
+ }
171
+ })
172
+
173
+ this.libp2p.services.fetch.registerLookupFunction(IPNS_STRING_PREFIX, async (key) => {
174
+ try {
175
+ const { record } = await this.localStore.get(key, {
176
+ signal: this.shutdownController.signal
177
+ })
178
+
179
+ return record
180
+ } catch (err: any) {
181
+ if (err.name !== 'NotFoundError') {
182
+ throw err
183
+ }
184
+ }
185
+ })
186
+ log('registered lookup function for IPNS with libp2p/fetch service')
187
+ } catch (e) {
188
+ log('unable to register lookup function for IPNS with libp2p/fetch service - %e', e)
189
+ }
190
+ } else {
191
+ log('no libp2p/fetch service found. Skipping registration of lookup function for IPNS.')
192
+ }
83
193
  }
84
194
 
85
- async #processPubSubMessage (message: Message): Promise<void> {
195
+ async #processPubSubMessage (message: Message, options?: AbortOptions): Promise<void> {
86
196
  log('message received for topic', message.topic)
87
197
 
88
198
  if (message.type !== 'signed') {
@@ -90,33 +200,77 @@ class PubSubRouting implements IPNSRouting {
90
200
  return
91
201
  }
92
202
 
93
- if (message.from.equals(this.peerId)) {
203
+ if (message.from.equals(this.libp2p.peerId)) {
94
204
  log('not storing record from self')
95
205
  return
96
206
  }
97
207
 
98
- const routingKey = topicToKey(message.topic)
208
+ await this.#handleRecord(message.topic, topicToKey(message.topic), message.data, false, options)
209
+ }
99
210
 
100
- await ipnsValidator(routingKey, message.data)
211
+ async #fetchFromPeer (topic: string, routingKey: Uint8Array, peerId: PeerId, options?: AbortOptions): Promise<Uint8Array> {
212
+ const marshalledRecord = await this.fetchQueue.add(async ({ signal }) => {
213
+ log('fetching ipns record for %m from peer %s', routingKey, peerId)
214
+
215
+ const sig = anySignal([
216
+ signal,
217
+ AbortSignal.timeout(this.fetchTimeout)
218
+ ])
219
+
220
+ try {
221
+ return await this.libp2p.services.fetch?.fetch(peerId, routingKey, {
222
+ signal: sig
223
+ })
224
+ } finally {
225
+ sig.clear()
226
+ }
227
+ }, options)
228
+
229
+ if (marshalledRecord == null) {
230
+ throw new NotFoundError(`Peer ${peerId} did not have record for routing key ${uint8ArrayToString(routingKey, 'base64')}`)
231
+ }
232
+
233
+ log('fetched ipns record for %m from peer %s', routingKey, peerId)
234
+ return this.#handleRecord(topic, routingKey, marshalledRecord, true, options)
235
+ }
236
+
237
+ async #handleRecord (topic: string, routingKey: Uint8Array, marshalledRecord: Uint8Array, publish: boolean, options?: AbortOptions): Promise<Uint8Array> {
238
+ await ipnsValidator(routingKey, marshalledRecord)
239
+ this.shutdownController.signal.throwIfAborted()
101
240
 
102
241
  if (await this.localStore.has(routingKey)) {
103
- const { record: currentRecord } = await this.localStore.get(routingKey)
242
+ const { record: currentRecord } = await this.localStore.get(routingKey, options)
104
243
 
105
- if (uint8ArrayEquals(currentRecord, message.data)) {
106
- log('not storing record as we already have it')
107
- return
244
+ if (uint8ArrayEquals(currentRecord, marshalledRecord)) {
245
+ log.trace('found identical record for %m', routingKey)
246
+ return currentRecord
108
247
  }
109
248
 
110
- const records = [currentRecord, message.data]
249
+ const records = [currentRecord, marshalledRecord]
111
250
  const index = ipnsSelector(routingKey, records)
112
251
 
113
252
  if (index === 0) {
114
- log('not storing record as the one we have is better')
115
- return
253
+ log.trace('found old record for %m', routingKey)
254
+ return currentRecord
255
+ }
256
+ }
257
+
258
+ log('found new record for %m', routingKey)
259
+ await this.localStore.put(routingKey, marshalledRecord, options)
260
+
261
+ // if the record was received via fetch, republish it
262
+ if (publish) {
263
+ log('publish value for topic %s', topic)
264
+
265
+ try {
266
+ const result = await this.libp2p.services.pubsub.publish(topic, marshalledRecord)
267
+ log('published record on topic %s to %d recipients', topic, result.recipients)
268
+ } catch (err) {
269
+ log.error('could not publish record on topic %s - %e', err)
116
270
  }
117
271
  }
118
272
 
119
- await this.localStore.put(routingKey, message.data)
273
+ return marshalledRecord
120
274
  }
121
275
 
122
276
  /**
@@ -127,7 +281,8 @@ class PubSubRouting implements IPNSRouting {
127
281
  const topic = keyToTopic(routingKey)
128
282
 
129
283
  log('publish value for topic %s', topic)
130
- const result = await this.pubsub.publish(topic, marshaledRecord)
284
+ const result = await this.libp2p.services.pubsub.publish(topic, marshaledRecord)
285
+ options?.signal?.throwIfAborted()
131
286
 
132
287
  log('published record on topic %s to %d recipients', topic, result.recipients)
133
288
  options.onProgress?.(new CustomProgressEvent('ipns:pubsub:publish', { topic, result }))
@@ -143,33 +298,65 @@ class PubSubRouting implements IPNSRouting {
143
298
  * updated once new publishes occur
144
299
  */
145
300
  async get (routingKey: Uint8Array, options: GetOptions = {}): Promise<Uint8Array> {
146
- try {
147
- const topic = keyToTopic(routingKey)
301
+ const topic = keyToTopic(routingKey)
148
302
 
303
+ try {
149
304
  // ensure we are subscribed to topic
150
- if (!this.pubsub.getTopics().includes(topic)) {
305
+ if (!this.libp2p.services.pubsub.getTopics().includes(topic)) {
151
306
  log('add subscription for topic', topic)
152
- this.pubsub.subscribe(topic)
153
- this.subscriptions.push(topic)
307
+ this.libp2p.services.pubsub.subscribe(topic)
308
+ this.subscriptions.add(topic)
154
309
 
155
310
  options.onProgress?.(new CustomProgressEvent('ipns:pubsub:subscribe', { topic }))
156
311
  }
157
-
158
- // chain through to local store
159
- const { record } = await this.localStore.get(routingKey, options)
160
-
161
- return record
162
312
  } catch (err: any) {
163
313
  options.onProgress?.(new CustomProgressEvent<Error>('ipns:pubsub:error', err))
164
314
  throw err
165
315
  }
316
+
317
+ // delay before fetch to allow pubsub to resolve
318
+ await raceSignal(delay(this.fetchDelay), this.shutdownController.signal)
319
+
320
+ const fetchController = new AbortController()
321
+ const promises: Array<Promise<Uint8Array>> = []
322
+
323
+ for (const peerId of this.libp2p.services.pubsub.getSubscribers(topic)) {
324
+ const signal = anySignal([
325
+ options?.signal,
326
+ fetchController.signal
327
+ ])
328
+
329
+ promises.push(
330
+ this.#fetchFromPeer(topic, routingKey, peerId, {
331
+ ...options,
332
+ signal
333
+ })
334
+ .finally(() => {
335
+ signal.clear()
336
+ })
337
+ )
338
+ }
339
+
340
+ if (promises.length > 0) {
341
+ // fetch record from topic peers
342
+ const record = await Promise.any(promises)
343
+
344
+ // cancel any in-flight requests
345
+ fetchController.abort()
346
+
347
+ if (record != null) {
348
+ return record
349
+ }
350
+ }
351
+
352
+ throw new NotFoundError('Pubsub routing does not actively query peers')
166
353
  }
167
354
 
168
355
  /**
169
356
  * Get pubsub subscriptions related to ipns
170
357
  */
171
358
  getSubscriptions (): string[] {
172
- return this.subscriptions
359
+ return [...this.subscriptions]
173
360
  }
174
361
 
175
362
  /**
@@ -181,17 +368,44 @@ class PubSubRouting implements IPNSRouting {
181
368
  const topic = keyToTopic(routingKey)
182
369
 
183
370
  // Not found topic
184
- if (!this.subscriptions.includes(topic)) {
371
+ if (!this.subscriptions.has(topic)) {
185
372
  return
186
373
  }
187
374
 
188
- this.pubsub.unsubscribe(topic)
189
- this.subscriptions = this.subscriptions.filter(t => t !== topic)
375
+ this.libp2p.services.pubsub.unsubscribe(topic)
376
+ this.subscriptions.delete(topic)
190
377
  }
191
378
 
192
379
  toString (): string {
193
380
  return 'PubSubRouting()'
194
381
  }
382
+
383
+ async start (): Promise<void> {
384
+ this.shutdownController = new AbortController()
385
+ setMaxListeners(Infinity, this.shutdownController.signal)
386
+
387
+ if (this.libp2p.services.fetch != null) {
388
+ this.fetchTopologyId = await this.libp2p.register(this.libp2p.services.fetch.protocol, {
389
+ onConnect: (peerId) => {
390
+ this.fetchPeers.add(peerId)
391
+ },
392
+ onDisconnect: (peerId) => {
393
+ this.fetchPeers.delete(peerId)
394
+ }
395
+ }, {
396
+ signal: this.shutdownController.signal
397
+ })
398
+ }
399
+ }
400
+
401
+ stop (): void {
402
+ this.fetchQueue.abort()
403
+ this.shutdownController.abort()
404
+
405
+ if (this.fetchTopologyId != null) {
406
+ this.libp2p.unregister(this.fetchTopologyId)
407
+ }
408
+ }
195
409
  }
196
410
 
197
411
  const PUBSUB_NAMESPACE = '/record/'
@@ -226,6 +440,6 @@ function topicToKey (topic: string): Uint8Array {
226
440
  * updated records, so the first call to `.get` should be expected
227
441
  * to fail!
228
442
  */
229
- export function pubsub (components: PubsubRoutingComponents): IPNSRouting {
230
- return new PubSubRouting(components)
443
+ export function pubsub (components: PubsubRoutingComponents, init: PubsubRoutingInit = {}): IPNSRouting {
444
+ return new PubSubRouting(components, init)
231
445
  }