@libp2p/floodsub
Version:
libp2p-floodsub, also known as pubsub-flood or just dumbsub, this implementation of pubsub focused on delivering an API for Publish/Subscribe, but with no CastTree Forming (it just floods the network).
729 lines (605 loc) • 21.3 kB
text/typescript
import { InvalidMessageError, NotStartedError, InvalidParametersError, serviceCapabilities, serviceDependencies } from '@libp2p/interface'
import { PeerMap, PeerSet } from '@libp2p/peer-collections'
import { TypedEventEmitter } from 'main-event'
import Queue from 'p-queue'
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
import { SimpleTimeCache } from './cache.ts'
import { pubSubSymbol } from './constants.ts'
import { protocol, StrictNoSign, TopicValidatorResult, StrictSign } from './index.ts'
import { RPC } from './message/rpc.ts'
import { PeerStreams } from './peer-streams.ts'
import { signMessage, verifySignature } from './sign.ts'
import { toMessage, noSignMsgId, msgId, toRpcMessage, randomSeqno } from './utils.ts'
import type { FloodSubComponents, FloodSubEvents, FloodSubInit, FloodSub as FloodSubInterface, Message, PublishResult, SubscriptionChangeData, TopicValidatorFn } from './index.ts'
import type { Logger, Connection, PeerId, Stream } from '@libp2p/interface'
export interface PubSubRPCMessage {
from?: Uint8Array
topic?: string
data?: Uint8Array
sequenceNumber?: Uint8Array
signature?: Uint8Array
key?: Uint8Array
}
export interface PubSubRPCSubscription {
subscribe?: boolean
topic?: string
}
export interface PubSubRPC {
subscriptions: PubSubRPCSubscription[]
messages: PubSubRPCMessage[]
}
/**
* PubSubBaseProtocol handles the peers and connections logic for pubsub routers
* and specifies the API that pubsub routers should have.
*/
export class FloodSub extends TypedEventEmitter<FloodSubEvents> implements FloodSubInterface {
protected log: Logger
public started: boolean
/**
* Map of topics to which peers are subscribed to
*/
public topics: Map<string, PeerSet>
/**
* List of our subscriptions
*/
public subscriptions: Set<string>
/**
* Map of peer streams
*/
public peers: PeerMap<PeerStreams>
/**
* The signature policy to follow by default
*/
public globalSignaturePolicy: typeof StrictNoSign | typeof StrictSign
/**
* If router can relay received messages, even if not subscribed
*/
public canRelayMessage: boolean
/**
* if publish should emit to self, if subscribed
*/
public emitSelf: boolean
/**
* Topic validator map
*
* Keyed by topic
* Topic validators are functions with the following input:
*/
public topicValidators: Map<string, TopicValidatorFn>
public queue: Queue
public protocol: string
public components: FloodSubComponents
private _registrarTopologyId: string | undefined
private readonly maxInboundStreams: number
private readonly maxOutboundStreams: number
public seenCache: SimpleTimeCache<boolean>
constructor (components: FloodSubComponents, init: FloodSubInit) {
super()
this.log = components.logger.forComponent('libp2p:floodsub')
this.components = components
this.protocol = init.protocol ?? protocol
this.started = false
this.topics = new Map()
this.subscriptions = new Set()
this.peers = new PeerMap<PeerStreams>()
this.globalSignaturePolicy = init.globalSignaturePolicy === 'StrictNoSign' ? 'StrictNoSign' : 'StrictSign'
this.canRelayMessage = init.canRelayMessage ?? true
this.emitSelf = init.emitSelf ?? false
this.topicValidators = new Map()
this.queue = new Queue({
concurrency: init.messageProcessingConcurrency ?? 10
})
this.maxInboundStreams = init.maxInboundStreams ?? 1
this.maxOutboundStreams = init.maxOutboundStreams ?? 1
this.seenCache = new SimpleTimeCache<boolean>({
validityMs: init?.seenTTL ?? 30000
})
this._onIncomingStream = this._onIncomingStream.bind(this)
this._onPeerConnected = this._onPeerConnected.bind(this)
this._onPeerDisconnected = this._onPeerDisconnected.bind(this)
}
readonly [pubSubSymbol] = true
readonly [Symbol.toStringTag] = '@libp2p/floodsub'
readonly [serviceCapabilities]: string[] = [
'@libp2p/pubsub'
]
readonly [serviceDependencies]: string[] = [
'@libp2p/identify'
]
// LIFECYCLE METHODS
/**
* Register the pubsub protocol onto the libp2p node.
*/
async start (): Promise<void> {
if (this.started) {
return
}
this.log('starting')
// Incoming streams
// Called after a peer dials us
await this.components.registrar.handle(this.protocol, this._onIncomingStream, {
maxInboundStreams: this.maxInboundStreams,
maxOutboundStreams: this.maxOutboundStreams
})
// register protocol with topology
// Topology callbacks called after identify has run on a new connection
this._registrarTopologyId = await this.components.registrar.register(this.protocol, {
onConnect: this._onPeerConnected,
onDisconnect: this._onPeerDisconnected
})
this.log('started')
this.started = true
}
/**
* Unregister the pubsub protocol and the streams with other peers will be closed.
*/
async stop (): Promise<void> {
if (!this.started) {
return
}
const registrar = this.components.registrar
// unregister protocol and handlers
if (this._registrarTopologyId != null) {
registrar.unregister(this._registrarTopologyId)
}
await registrar.unhandle(this.protocol)
this.log('stopping')
for (const peerStreams of this.peers.values()) {
peerStreams.close()
}
this.peers.clear()
this.subscriptions = new Set()
this.started = false
this.log('stopped')
}
isStarted (): boolean {
return this.started
}
/**
* On an inbound stream opened
*/
protected _onIncomingStream (stream: Stream, connection: Connection): void {
const peerStreams = this.addPeer(connection.remotePeer, stream)
peerStreams.attachInboundStream(stream)
}
/**
* Registrar notifies an established connection with pubsub protocol
*/
protected async _onPeerConnected (peerId: PeerId, conn: Connection): Promise<void> {
this.log('connected %p', peerId)
// if this connection is already in use for pubsub, ignore it
if (conn.streams.find(stream => stream.direction === 'outbound' && stream.protocol === this.protocol)) {
this.log('outbound pubsub stream already present on connection from %p', peerId)
return
}
const stream = await conn.newStream(this.protocol)
const peerStreams = this.addPeer(peerId, stream)
peerStreams.attachOutboundStream(stream)
// Immediately send my own subscriptions to the newly established conn
this.send(peerId, {
subscriptions: Array.from(this.subscriptions).map(sub => sub.toString()),
subscribe: true
})
}
/**
* Registrar notifies a closing connection with pubsub protocol
*/
protected _onPeerDisconnected (peerId: PeerId, conn?: Connection): void {
this.log('connection ended %p', peerId)
this._removePeer(peerId)
}
/**
* Notifies the router that a peer has been connected
*/
addPeer (peerId: PeerId, stream: Stream): PeerStreams {
const existing = this.peers.get(peerId)
// If peer streams already exists, do nothing
if (existing != null) {
return existing
}
// else create a new peer streams
this.log('new peer %p', peerId)
const peerStreams = new PeerStreams(peerId)
this.peers.set(peerId, peerStreams)
peerStreams.addEventListener('message', (evt) => {
const rpcMsg = evt.detail
const messages: PubSubRPCMessage[] = []
for (const msg of (rpcMsg.messages ?? [])) {
if (msg.from == null || msg.data == null || msg.topic == null) {
this.log('message from %p was missing from, data or topic fields, dropping', peerId)
continue
}
messages.push({
from: msg.from,
data: msg.data,
topic: msg.topic,
sequenceNumber: msg.sequenceNumber ?? undefined,
signature: msg.signature ?? undefined,
key: msg.key ?? undefined
})
}
// Since processRpc may be overridden entirely in unsafe ways,
// the simplest/safest option here is to wrap in a function and capture all errors
// to prevent a top-level unhandled exception
// This processing of rpc messages should happen without awaiting full validation/execution of prior messages
this.processRpc(peerStreams, {
subscriptions: (rpcMsg.subscriptions ?? []).map(sub => ({
subscribe: Boolean(sub.subscribe),
topic: sub.topic ?? ''
})),
messages
})
.catch(err => { this.log(err) })
})
peerStreams.addEventListener('close', () => this._removePeer(peerId), {
once: true
})
return peerStreams
}
/**
* Notifies the router that a peer has been disconnected
*/
protected _removePeer (peerId: PeerId): void {
const peerStreams = this.peers.get(peerId)
if (peerStreams == null) {
return
}
// close peer streams
peerStreams.close()
// delete peer streams
this.log('delete peer %p', peerId)
this.peers.delete(peerId)
// remove peer from topics map
for (const peers of this.topics.values()) {
peers.delete(peerId)
}
}
/**
* Handles an rpc request from a peer
*/
async processRpc (peerStream: PeerStreams, rpc: PubSubRPC): Promise<boolean> {
this.log('rpc from %p', peerStream.peerId)
const { subscriptions, messages } = rpc
if (subscriptions != null && subscriptions.length > 0) {
this.log('subscription update from %p', peerStream.peerId)
// update peer subscriptions
subscriptions.forEach((subOpt) => {
this.processRpcSubOpt(peerStream.peerId, subOpt)
})
super.dispatchEvent(new CustomEvent<SubscriptionChangeData>('subscription-change', {
detail: {
peerId: peerStream.peerId,
subscriptions: subscriptions.map(({ topic, subscribe }) => ({
topic: `${topic ?? ''}`,
subscribe: Boolean(subscribe)
}))
}
}))
}
if (messages != null && messages.length > 0) {
this.log('messages from %p', peerStream.peerId)
this.queue.addAll(messages.map(message => async () => {
if (message.topic == null || (!this.subscriptions.has(message.topic) && !this.canRelayMessage)) {
this.log('received message we didn\'t subscribe to. Dropping.')
return false
}
try {
const msg = await toMessage(message)
await this.processMessage(peerStream.peerId, msg)
} catch (err: any) {
this.log.error('failed to queue messages from %p - %e', peerStream.peerId, err)
}
}))
.catch(err => { this.log(err) })
}
return true
}
/**
* Handles a subscription change from a peer
*/
processRpcSubOpt (id: PeerId, subOpt: PubSubRPCSubscription): void {
const t = subOpt.topic
if (t == null) {
return
}
let topicSet = this.topics.get(t)
if (topicSet == null) {
topicSet = new PeerSet()
this.topics.set(t, topicSet)
}
if (subOpt.subscribe === true) {
// subscribe peer to new topic
topicSet.add(id)
} else {
// unsubscribe from existing topic
topicSet.delete(id)
}
}
/**
* Handles a message from a peer
*/
async processMessage (from: PeerId, msg: Message): Promise<void> {
if (this.components.peerId.equals(from) && !this.emitSelf) {
return
}
// Check if I've seen the message, if yes, ignore
const seqno = await this.getMsgId(msg)
const msgIdStr = uint8ArrayToString(seqno, 'base64')
if (this.seenCache.has(msgIdStr)) {
return
}
this.seenCache.put(msgIdStr, true)
// Ensure the message is valid before processing it
try {
await this.validate(from, msg)
} catch (err: any) {
this.log('Message is invalid, dropping it. %O', err)
return
}
if (this.subscriptions.has(msg.topic)) {
const isFromSelf = this.components.peerId.equals(from)
if (!isFromSelf || this.emitSelf) {
super.dispatchEvent(new CustomEvent<Message>('message', {
detail: msg
}))
}
}
await this.publishMessage(from, msg)
}
/**
* The default msgID implementation
* Child class can override this.
*/
getMsgId (msg: Message): Promise<Uint8Array> | Uint8Array {
const signaturePolicy = this.globalSignaturePolicy
switch (signaturePolicy) {
case 'StrictSign':
if (msg.type !== 'signed') {
throw new InvalidMessageError('Message type should be "signed" when signature policy is StrictSign but it was not')
}
if (msg.sequenceNumber == null) {
throw new InvalidMessageError('Need sequence number when signature policy is StrictSign but it was missing')
}
if (msg.key == null) {
throw new InvalidMessageError('Need key when signature policy is StrictSign but it was missing')
}
return msgId(msg.key, msg.sequenceNumber)
case 'StrictNoSign':
return noSignMsgId(msg.data)
default:
throw new InvalidMessageError('Cannot get message id: unhandled signature policy')
}
}
/**
* Encode RPC object into a Uint8Array.
* This can be override to use a custom router protobuf.
*/
encodeMessage (rpc: PubSubRPCMessage): Uint8Array {
return RPC.Message.encode(rpc)
}
/**
* Send an rpc object to a peer
*/
send (peer: PeerId, data: { messages?: Message[], subscriptions?: string[], subscribe?: boolean }): void {
const { messages, subscriptions, subscribe } = data
this.sendRpc(peer, {
subscriptions: (subscriptions ?? []).map(str => ({ topic: str, subscribe: Boolean(subscribe) })),
messages: (messages ?? []).map(toRpcMessage)
})
}
/**
* Send an rpc object to a peer
*/
sendRpc (peer: PeerId, rpc: PubSubRPC): void {
const peerStreams = this.peers.get(peer)
if (peerStreams == null) {
this.log.error('cannot send RPC to %p as there are no streams to it available', peer)
return
}
peerStreams.write(rpc)
}
/**
* Validates the given message. The signature will be checked for authenticity.
* Throws an error on invalid messages
*/
async validate (from: PeerId, message: Message): Promise<void> {
const signaturePolicy = this.globalSignaturePolicy
switch (signaturePolicy) {
case 'StrictNoSign':
if (message.type !== 'unsigned') {
throw new InvalidMessageError('Message type should be "unsigned" when signature policy is StrictNoSign but it was not')
}
// @ts-expect-error should not be present
if (message.signature != null) {
throw new InvalidMessageError('StrictNoSigning: signature should not be present')
}
// @ts-expect-error should not be present
if (message.key != null) {
throw new InvalidMessageError('StrictNoSigning: key should not be present')
}
// @ts-expect-error should not be present
if (message.sequenceNumber != null) {
throw new InvalidMessageError('StrictNoSigning: seqno should not be present')
}
break
case 'StrictSign':
if (message.type !== 'signed') {
throw new InvalidMessageError('Message type should be "signed" when signature policy is StrictSign but it was not')
}
if (message.signature == null) {
throw new InvalidMessageError('StrictSigning: Signing required and no signature was present')
}
if (message.sequenceNumber == null) {
throw new InvalidMessageError('StrictSigning: Signing required and no sequenceNumber was present')
}
if (!(await verifySignature(message, this.encodeMessage.bind(this)))) {
throw new InvalidMessageError('StrictSigning: Invalid message signature')
}
break
default:
throw new InvalidMessageError('Cannot validate message: unhandled signature policy')
}
const validatorFn = this.topicValidators.get(message.topic)
if (validatorFn != null) {
const result = await validatorFn(from, message)
if (result === TopicValidatorResult.Reject || result === TopicValidatorResult.Ignore) {
throw new InvalidMessageError('Message validation failed')
}
}
}
/**
* Normalizes the message and signs it, if signing is enabled.
* Should be used by the routers to create the message to send.
*/
async buildMessage (message: { from: PeerId, topic: string, data: Uint8Array, sequenceNumber: bigint }): Promise<Message> {
const signaturePolicy = this.globalSignaturePolicy
switch (signaturePolicy) {
case 'StrictSign':
return signMessage(this.components.privateKey, message, this.encodeMessage.bind(this))
case 'StrictNoSign':
return Promise.resolve({
type: 'unsigned',
...message
})
default:
throw new InvalidMessageError('Cannot build message: unhandled signature policy')
}
}
// API METHODS
/**
* Get a list of the peer-ids that are subscribed to one topic.
*/
getSubscribers (topic: string): PeerId[] {
if (!this.started) {
throw new NotStartedError('not started yet')
}
if (topic == null) {
throw new InvalidParametersError('Topic is required')
}
const peersInTopic = this.topics.get(topic.toString())
if (peersInTopic == null) {
return []
}
return Array.from(peersInTopic.values())
}
/**
* Publishes messages to all subscribed peers
*/
async publish (topic: string, data?: Uint8Array): Promise<PublishResult> {
if (!this.started) {
throw new Error('Pubsub has not started')
}
const message = {
from: this.components.peerId,
topic,
data: data ?? new Uint8Array(0),
sequenceNumber: randomSeqno()
}
this.log('publish topic: %s from: %p data: %m', topic, message.from, message.data)
const rpcMessage = await this.buildMessage(message)
let emittedToSelf = false
// dispatch the event if we are interested
if (this.emitSelf) {
if (this.subscriptions.has(topic)) {
emittedToSelf = true
super.dispatchEvent(new CustomEvent<Message>('message', {
detail: rpcMessage
}))
}
}
// send to all the other peers
const result = await this.publishMessage(this.components.peerId, rpcMessage)
if (emittedToSelf) {
result.recipients = [...result.recipients, this.components.peerId]
}
return result
}
/**
* Overriding the implementation of publish should handle the appropriate algorithms for the publish/subscriber implementation.
* For example, a Floodsub implementation might simply publish each message to each topic for every peer.
*
* `sender` might be this peer, or we might be forwarding a message on behalf of another peer, in which case sender
* is the peer we received the message from, which may not be the peer the message was created by.
*/
async publishMessage (from: PeerId, message: Message): Promise<PublishResult> {
const peers = this.getSubscribers(message.topic)
const recipients: PeerId[] = []
if (peers == null || peers.length === 0) {
this.log('no peers are subscribed to topic %s', message.topic)
return { recipients }
}
peers.forEach(id => {
if (this.components.peerId.equals(id)) {
this.log('not sending message on topic %s to myself', message.topic)
return
}
if (id.equals(from)) {
this.log('not sending message on topic %s to sender %p', message.topic, id)
return
}
this.log('publish msgs on topics %s %p', message.topic, id)
recipients.push(id)
this.send(id, { messages: [message] })
})
return { recipients }
}
/**
* Subscribes to a given topic.
*/
subscribe (topic: string): void {
if (!this.started) {
throw new Error('Pubsub has not started')
}
if (this.subscriptions.has(topic)) {
// already subscribed
return
}
this.log('subscribe to topic: %s', topic)
this.subscriptions.add(topic)
for (const peerId of this.peers.keys()) {
this.send(peerId, {
subscriptions: [
topic
],
subscribe: true
})
}
}
/**
* Unsubscribe from the given topic
*/
unsubscribe (topic: string): void {
if (!this.started) {
throw new Error('Pubsub is not started')
}
if (!this.subscriptions.has(topic)) {
// not subscribed
return
}
this.log('unsubscribe from %s', topic)
this.subscriptions.delete(topic)
for (const peerId of this.peers.keys()) {
this.send(peerId, {
subscriptions: [
topic
],
subscribe: false
})
}
}
/**
* Get the list of topics which the peer is subscribed to.
*/
getTopics (): string[] {
if (!this.started) {
throw new Error('Pubsub is not started')
}
return Array.from(this.subscriptions)
}
getPeers (): PeerId[] {
if (!this.started) {
throw new Error('Pubsub is not started')
}
return Array.from(this.peers.keys())
}
}