UNPKG

@helia/bitswap

Version:

JavaScript implementation of the Bitswap data exchange protocol used by Helia

381 lines (321 loc) 13 kB
import { CodeError, TypedEventEmitter, setMaxListeners } from '@libp2p/interface' import { PeerQueue, type PeerQueueJobOptions } from '@libp2p/utils/peer-queue' import drain from 'it-drain' import * as lp from 'it-length-prefixed' import map from 'it-map' import { pipe } from 'it-pipe' import take from 'it-take' import { CustomProgressEvent } from 'progress-events' import { raceEvent } from 'race-event' import { BITSWAP_120, DEFAULT_MAX_INBOUND_STREAMS, DEFAULT_MAX_INCOMING_MESSAGE_SIZE, DEFAULT_MAX_OUTBOUND_STREAMS, DEFAULT_MAX_OUTGOING_MESSAGE_SIZE, DEFAULT_MAX_PROVIDERS_PER_REQUEST, DEFAULT_MESSAGE_RECEIVE_TIMEOUT, DEFAULT_MESSAGE_SEND_CONCURRENCY, DEFAULT_RUN_ON_TRANSIENT_CONNECTIONS } from './constants.js' import { BitswapMessage } from './pb/message.js' import { mergeMessages } from './utils/merge-messages.js' import { splitMessage } from './utils/split-message.js' import type { WantOptions } from './bitswap.js' import type { MultihashHasherLoader } from './index.js' import type { Block } from './pb/message.js' import type { QueuedBitswapMessage } from './utils/bitswap-message.js' import type { Provider, Routing } from '@helia/interface/routing' import type { Libp2p, AbortOptions, Connection, PeerId, IncomingStreamData, Topology, ComponentLogger, IdentifyResult, Counter, Metrics } from '@libp2p/interface' import type { Logger } from '@libp2p/logger' import type { CID } from 'multiformats/cid' import type { ProgressEvent, ProgressOptions } from 'progress-events' export type BitswapNetworkProgressEvents = ProgressEvent<'bitswap:network:dial', PeerId> export type BitswapNetworkWantProgressEvents = ProgressEvent<'bitswap:network:send-wantlist', PeerId> | ProgressEvent<'bitswap:network:send-wantlist:error', { peer: PeerId, error: Error }> | ProgressEvent<'bitswap:network:find-providers', CID> | BitswapNetworkProgressEvents export type BitswapNetworkNotifyProgressEvents = BitswapNetworkProgressEvents | ProgressEvent<'bitswap:network:send-block', PeerId> export interface NetworkInit { hashLoader?: MultihashHasherLoader maxInboundStreams?: number maxOutboundStreams?: number messageReceiveTimeout?: number messageSendConcurrency?: number protocols?: string[] runOnTransientConnections?: boolean maxOutgoingMessageSize?: number maxIncomingMessageSize?: number } export interface NetworkComponents { routing: Routing logger: ComponentLogger libp2p: Libp2p metrics?: Metrics } export interface BitswapMessageEventDetail { peer: PeerId message: BitswapMessage } export interface NetworkEvents { 'bitswap:message': CustomEvent<{ peer: PeerId, message: BitswapMessage }> 'peer:connected': CustomEvent<PeerId> 'peer:disconnected': CustomEvent<PeerId> } interface SendMessageJobOptions extends AbortOptions, ProgressOptions, PeerQueueJobOptions { message: QueuedBitswapMessage } export class Network extends TypedEventEmitter<NetworkEvents> { private readonly log: Logger private readonly libp2p: Libp2p private readonly routing: Routing private readonly protocols: string[] private running: boolean private readonly maxInboundStreams: number private readonly maxOutboundStreams: number private readonly messageReceiveTimeout: number private registrarIds: string[] private readonly metrics: { blocksSent?: Counter, dataSent?: Counter } private readonly sendQueue: PeerQueue<void, SendMessageJobOptions> private readonly runOnTransientConnections: boolean private readonly maxOutgoingMessageSize: number private readonly maxIncomingMessageSize: number constructor (components: NetworkComponents, init: NetworkInit = {}) { super() this.log = components.logger.forComponent('helia:bitswap:network') this.libp2p = components.libp2p this.routing = components.routing this.protocols = init.protocols ?? [BITSWAP_120] this.registrarIds = [] this.running = false // bind event listeners this._onStream = this._onStream.bind(this) this.maxInboundStreams = init.maxInboundStreams ?? DEFAULT_MAX_INBOUND_STREAMS this.maxOutboundStreams = init.maxOutboundStreams ?? DEFAULT_MAX_OUTBOUND_STREAMS this.messageReceiveTimeout = init.messageReceiveTimeout ?? DEFAULT_MESSAGE_RECEIVE_TIMEOUT this.runOnTransientConnections = init.runOnTransientConnections ?? DEFAULT_RUN_ON_TRANSIENT_CONNECTIONS this.maxIncomingMessageSize = init.maxIncomingMessageSize ?? DEFAULT_MAX_OUTGOING_MESSAGE_SIZE this.maxOutgoingMessageSize = init.maxOutgoingMessageSize ?? init.maxIncomingMessageSize ?? DEFAULT_MAX_INCOMING_MESSAGE_SIZE this.metrics = { blocksSent: components.metrics?.registerCounter('helia_bitswap_sent_blocks_total'), dataSent: components.metrics?.registerCounter('helia_bitswap_sent_data_bytes_total') } this.sendQueue = new PeerQueue({ concurrency: init.messageSendConcurrency ?? DEFAULT_MESSAGE_SEND_CONCURRENCY, metrics: components.metrics, metricName: 'helia_bitswap_message_send_queue' }) this.sendQueue.addEventListener('error', (evt) => { this.log.error('error sending wantlist to peer', evt.detail) }) } async start (): Promise<void> { if (this.running) { return } this.running = true await this.libp2p.handle(this.protocols, this._onStream, { maxInboundStreams: this.maxInboundStreams, maxOutboundStreams: this.maxOutboundStreams, runOnTransientConnection: this.runOnTransientConnections }) // register protocol with topology const topology: Topology = { onConnect: (peerId: PeerId) => { this.safeDispatchEvent('peer:connected', { detail: peerId }) }, onDisconnect: (peerId: PeerId) => { this.safeDispatchEvent('peer:disconnected', { detail: peerId }) } } this.registrarIds = [] for (const protocol of this.protocols) { this.registrarIds.push(await this.libp2p.register(protocol, topology)) } // All existing connections are like new ones for us this.libp2p.getConnections().forEach(conn => { this.safeDispatchEvent('peer:connected', { detail: conn.remotePeer }) }) } async stop (): Promise<void> { this.running = false // Unhandle both, libp2p doesn't care if it's not already handled await this.libp2p.unhandle(this.protocols) // unregister protocol and handlers if (this.registrarIds != null) { for (const id of this.registrarIds) { this.libp2p.unregister(id) } this.registrarIds = [] } } /** * Handles incoming bitswap messages */ _onStream (info: IncomingStreamData): void { if (!this.running) { return } const { stream, connection } = info Promise.resolve().then(async () => { this.log('incoming new bitswap %s stream from %p', stream.protocol, connection.remotePeer) const abortListener = (): void => { if (stream.status === 'open') { stream.abort(new CodeError(`Incoming Bitswap stream timed out after ${this.messageReceiveTimeout}ms`, 'ERR_TIMEOUT')) } else { this.log('stream aborted with status %s', stream.status) } } let signal = AbortSignal.timeout(this.messageReceiveTimeout) setMaxListeners(Infinity, signal) signal.addEventListener('abort', abortListener) await stream.closeWrite() await pipe( stream, (source) => lp.decode(source, { maxDataLength: this.maxIncomingMessageSize }), async (source) => { for await (const data of source) { try { const message = BitswapMessage.decode(data) this.log('incoming new bitswap %s message from %p on stream', stream.protocol, connection.remotePeer, stream.id) this.safeDispatchEvent('bitswap:message', { detail: { peer: connection.remotePeer, message } }) // we have received some data so reset the timeout controller signal.removeEventListener('abort', abortListener) signal = AbortSignal.timeout(this.messageReceiveTimeout) setMaxListeners(Infinity, signal) signal.addEventListener('abort', abortListener) } catch (err: any) { this.log.error('error reading incoming bitswap message from %p on stream', connection.remotePeer, stream.id, err) stream.abort(err) break } } } ) }) .catch(err => { this.log.error('error handling incoming stream from %p', connection.remotePeer, err) stream.abort(err) }) } /** * Find bitswap providers for a given `cid`. */ async * findProviders (cid: CID, options?: AbortOptions & ProgressOptions<BitswapNetworkWantProgressEvents>): AsyncIterable<Provider> { options?.onProgress?.(new CustomProgressEvent<CID>('bitswap:network:find-providers', cid)) for await (const provider of this.routing.findProviders(cid, options)) { // make sure we can dial the provider const dialable = await this.libp2p.isDialable(provider.multiaddrs, { runOnTransientConnection: this.runOnTransientConnections }) if (!dialable) { continue } yield provider } } /** * Find the providers of a given `cid` and connect to them. */ async findAndConnect (cid: CID, options?: WantOptions): Promise<void> { await drain( map( take(this.findProviders(cid, options), options?.maxProviders ?? DEFAULT_MAX_PROVIDERS_PER_REQUEST), async provider => this.connectTo(provider.id, options) ) ) .catch(err => { this.log.error(err) }) } /** * Connect to the given peer * Send the given msg (instance of Message) to the given peer */ async sendMessage (peerId: PeerId, message: QueuedBitswapMessage, options?: AbortOptions & ProgressOptions<BitswapNetworkWantProgressEvents>): Promise<void> { if (!this.running) { throw new Error('network isn\'t running') } const existingJob = this.sendQueue.queue.find(job => { return peerId.equals(job.options.peerId) && job.status === 'queued' }) if (existingJob != null) { existingJob.options.message = mergeMessages(existingJob.options.message, message) await existingJob.join({ signal: options?.signal }) return } await this.sendQueue.add(async (options) => { const message = options?.message if (message == null) { throw new CodeError('No message to send', 'ERR_NO_MESSAGE') } this.log('sendMessage to %p', peerId) options?.onProgress?.(new CustomProgressEvent<PeerId>('bitswap:network:send-wantlist', peerId)) const stream = await this.libp2p.dialProtocol(peerId, BITSWAP_120, options) await stream.closeRead() try { await pipe( splitMessage(message, this.maxOutgoingMessageSize), (source) => lp.encode(source), stream ) await stream.close(options) } catch (err: any) { options?.onProgress?.(new CustomProgressEvent<{ peer: PeerId, error: Error }>('bitswap:network:send-wantlist:error', { peer: peerId, error: err })) this.log.error('error sending message to %p', peerId, err) stream.abort(err) } this._updateSentStats(message.blocks) }, { peerId, signal: options?.signal, message }) } /** * Connects to another peer */ async connectTo (peer: PeerId, options?: AbortOptions & ProgressOptions<BitswapNetworkProgressEvents>): Promise<Connection> { // eslint-disable-line require-await if (!this.running) { throw new CodeError('Network isn\'t running', 'ERR_NOT_STARTED') } options?.onProgress?.(new CustomProgressEvent<PeerId>('bitswap:network:dial', peer)) // dial and wait for identify - this is to avoid opening a protocol stream // that we are not going to use but depends on the remote node running the // identitfy protocol const [ connection ] = await Promise.all([ this.libp2p.dial(peer, options), raceEvent(this.libp2p, 'peer:identify', options?.signal, { filter: (evt: CustomEvent<IdentifyResult>): boolean => { if (!evt.detail.peerId.equals(peer)) { return false } if (evt.detail.protocols.includes(BITSWAP_120)) { return true } throw new CodeError(`${peer} did not support ${BITSWAP_120}`, 'ERR_BITSWAP_UNSUPPORTED_BY_PEER') } }) ]) return connection } _updateSentStats (blocks: Map<string, Block>): void { let bytes = 0 for (const block of blocks.values()) { bytes += block.data.byteLength } this.metrics.dataSent?.increment(bytes) this.metrics.blocksSent?.increment(blocks.size) } }