@waku/sdk
Version:
A unified SDK for easy creation and management of js-waku nodes.
625 lines (496 loc) • 18.3 kB
text/typescript
import {
type EventHandler,
type PeerId,
TypedEventEmitter
} from "@libp2p/interface";
import { FilterCore, messageHashStr } from "@waku/core";
import type {
Callback,
FilterProtocolOptions,
IDecodedMessage,
IDecoder,
IProtoMessage,
PeerIdStr
} from "@waku/interfaces";
import { Protocols } from "@waku/interfaces";
import { WakuMessage } from "@waku/proto";
import { Logger } from "@waku/utils";
import { PeerManager, PeerManagerEventNames } from "../peer_manager/index.js";
import { SubscriptionEvents, SubscriptionParams } from "./types.js";
import { TTLSet } from "./utils.js";
const log = new Logger("sdk:filter-subscription");
type AttemptSubscribeParams = {
useNewContentTopics: boolean;
useOnlyNewPeers?: boolean;
};
type AttemptUnsubscribeParams = {
useNewContentTopics: boolean;
};
type Libp2pEventHandler = (e: CustomEvent<PeerId>) => void;
export class Subscription {
private readonly pubsubTopic: string;
private readonly protocol: FilterCore;
private readonly peerManager: PeerManager;
private readonly config: FilterProtocolOptions;
private isStarted: boolean = false;
private inProgress: boolean = false;
// Map and Set cannot reliably use PeerId type as a key
private peers = new Map<PeerIdStr, PeerId>();
private peerFailures = new Map<PeerIdStr, number>();
private readonly receivedMessages = new TTLSet<string>(60_000);
private callbacks = new Map<
IDecoder<IDecodedMessage>,
EventHandler<CustomEvent<WakuMessage>>
>();
private messageEmitter = new TypedEventEmitter<SubscriptionEvents>();
private toSubscribeContentTopics = new Set<string>();
private toUnsubscribeContentTopics = new Set<string>();
private subscribeIntervalId: number | null = null;
private keepAliveIntervalId: number | null = null;
private get contentTopics(): string[] {
const allTopics = Array.from(this.callbacks.keys()).map(
(k) => k.contentTopic
);
const uniqueTopics = new Set(allTopics).values();
return Array.from(uniqueTopics);
}
public constructor(params: SubscriptionParams) {
this.config = params.config;
this.pubsubTopic = params.pubsubTopic;
this.protocol = params.protocol;
this.peerManager = params.peerManager;
this.onPeerConnected = this.onPeerConnected.bind(this);
this.onPeerDisconnected = this.onPeerDisconnected.bind(this);
}
public start(): void {
log.info(`Starting subscription for pubsubTopic: ${this.pubsubTopic}`);
if (this.isStarted || this.inProgress) {
log.info("Subscription already started or in progress, skipping start");
return;
}
this.inProgress = true;
void this.attemptSubscribe({
useNewContentTopics: false
});
this.setupSubscriptionInterval();
this.setupKeepAliveInterval();
this.setupEventListeners();
this.isStarted = true;
this.inProgress = false;
log.info(`Subscription started for pubsubTopic: ${this.pubsubTopic}`);
}
public stop(): void {
log.info(`Stopping subscription for pubsubTopic: ${this.pubsubTopic}`);
if (!this.isStarted || this.inProgress) {
log.info("Subscription not started or stop in progress, skipping stop");
return;
}
this.inProgress = true;
this.disposeEventListeners();
this.disposeIntervals();
void this.disposePeers();
this.disposeHandlers();
this.receivedMessages.dispose();
this.inProgress = false;
this.isStarted = false;
log.info(`Subscription stopped for pubsubTopic: ${this.pubsubTopic}`);
}
public isEmpty(): boolean {
return this.callbacks.size === 0;
}
public async add<T extends IDecodedMessage>(
decoder: IDecoder<T> | IDecoder<T>[],
callback: Callback<T>
): Promise<boolean> {
const decoders = Array.isArray(decoder) ? decoder : [decoder];
for (const decoder of decoders) {
this.addSingle(decoder, callback);
}
return this.toSubscribeContentTopics.size > 0
? await this.attemptSubscribe({ useNewContentTopics: true })
: true; // if content topic is not new - subscription, most likely exists
}
public async remove<T extends IDecodedMessage>(
decoder: IDecoder<T> | IDecoder<T>[]
): Promise<boolean> {
const decoders = Array.isArray(decoder) ? decoder : [decoder];
for (const decoder of decoders) {
this.removeSingle(decoder);
}
return this.toUnsubscribeContentTopics.size > 0
? await this.attemptUnsubscribe({ useNewContentTopics: true })
: true; // no need to unsubscribe if there are other decoders on the contentTopic
}
public invoke(message: WakuMessage, _peerId: string): void {
if (this.isMessageReceived(message)) {
log.info(
`Skipping invoking callbacks for already received message: pubsubTopic:${this.pubsubTopic}, peerId:${_peerId.toString()}, contentTopic:${message.contentTopic}`
);
return;
}
log.info(`Invoking message for contentTopic: ${message.contentTopic}`);
this.messageEmitter.dispatchEvent(
new CustomEvent<WakuMessage>(message.contentTopic, {
detail: message
})
);
}
private addSingle<T extends IDecodedMessage>(
decoder: IDecoder<T>,
callback: Callback<T>
): void {
log.info(`Adding subscription for contentTopic: ${decoder.contentTopic}`);
const isNewContentTopic = !this.contentTopics.includes(
decoder.contentTopic
);
if (isNewContentTopic) {
this.toSubscribeContentTopics.add(decoder.contentTopic);
}
if (this.callbacks.has(decoder)) {
log.warn(
`Replacing callback associated associated with decoder with pubsubTopic:${decoder.pubsubTopic} and contentTopic:${decoder.contentTopic}`
);
const callback = this.callbacks.get(decoder);
this.callbacks.delete(decoder);
this.messageEmitter.removeEventListener(decoder.contentTopic, callback);
}
const eventHandler = (event: CustomEvent<WakuMessage>): void => {
void (async (): Promise<void> => {
try {
const message = await decoder.fromProtoObj(
decoder.pubsubTopic,
event.detail as IProtoMessage
);
void callback(message!);
} catch (err) {
log.error("Error decoding message", err);
}
})();
};
this.callbacks.set(decoder, eventHandler);
this.messageEmitter.addEventListener(decoder.contentTopic, eventHandler);
log.info(
`Subscription added for contentTopic: ${decoder.contentTopic}, isNewContentTopic: ${isNewContentTopic}`
);
}
private removeSingle<T extends IDecodedMessage>(decoder: IDecoder<T>): void {
log.info(`Removing subscription for contentTopic: ${decoder.contentTopic}`);
const callback = this.callbacks.get(decoder);
if (!callback) {
log.warn(
`No callback associated with decoder with pubsubTopic:${decoder.pubsubTopic} and contentTopic:${decoder.contentTopic}`
);
}
this.callbacks.delete(decoder);
this.messageEmitter.removeEventListener(decoder.contentTopic, callback);
const isCompletelyRemoved = !this.contentTopics.includes(
decoder.contentTopic
);
if (isCompletelyRemoved) {
this.toUnsubscribeContentTopics.add(decoder.contentTopic);
}
log.info(
`Subscription removed for contentTopic: ${decoder.contentTopic}, isCompletelyRemoved: ${isCompletelyRemoved}`
);
}
private isMessageReceived(message: WakuMessage): boolean {
try {
const messageHash = messageHashStr(
this.pubsubTopic,
message as IProtoMessage
);
if (this.receivedMessages.has(messageHash)) {
return true;
}
this.receivedMessages.add(messageHash);
} catch (e) {
// do nothing on throw, message will be handled as not received
}
return false;
}
private setupSubscriptionInterval(): void {
const subscriptionRefreshIntervalMs = 1000;
log.info(
`Setting up subscription interval with period ${subscriptionRefreshIntervalMs}ms`
);
this.subscribeIntervalId = setInterval(() => {
const run = async (): Promise<void> => {
if (this.toSubscribeContentTopics.size > 0) {
log.info(
`Subscription interval: ${this.toSubscribeContentTopics.size} topics to subscribe`
);
void (await this.attemptSubscribe({ useNewContentTopics: true }));
}
if (this.toUnsubscribeContentTopics.size > 0) {
log.info(
`Subscription interval: ${this.toUnsubscribeContentTopics.size} topics to unsubscribe`
);
void (await this.attemptUnsubscribe({ useNewContentTopics: true }));
}
};
void run();
}, subscriptionRefreshIntervalMs) as unknown as number;
}
private setupKeepAliveInterval(): void {
log.info(
`Setting up keep-alive interval with period ${this.config.keepAliveIntervalMs}ms`
);
this.keepAliveIntervalId = setInterval(() => {
const run = async (): Promise<void> => {
log.info(`Keep-alive interval running for ${this.peers.size} peers`);
let peersToReplace = await Promise.all(
Array.from(this.peers.values()).map(
async (peer): Promise<PeerId | undefined> => {
const response = await this.protocol.ping(peer);
if (response.success) {
log.info(`Ping successful for peer: ${peer.toString()}`);
this.peerFailures.set(peer.toString(), 0);
return;
}
let failures = this.peerFailures.get(peer.toString()) || 0;
failures += 1;
this.peerFailures.set(peer.toString(), failures);
log.warn(
`Ping failed for peer: ${peer.toString()}, failures: ${failures}/${this.config.pingsBeforePeerRenewed}`
);
if (failures < this.config.pingsBeforePeerRenewed) {
return;
}
log.info(
`Peer ${peer.toString()} exceeded max failures (${this.config.pingsBeforePeerRenewed}), will be replaced`
);
return peer;
}
)
);
peersToReplace = peersToReplace.filter((p) => !!p);
await Promise.all(
peersToReplace.map((p) => {
this.peers.delete(p?.toString() as PeerIdStr);
this.peerFailures.delete(p?.toString() as PeerIdStr);
return this.requestUnsubscribe(p as PeerId, this.contentTopics);
})
);
if (peersToReplace.length > 0) {
log.info(`Replacing ${peersToReplace.length} failed peers`);
void (await this.attemptSubscribe({
useNewContentTopics: false,
useOnlyNewPeers: true
}));
}
};
void run();
}, this.config.keepAliveIntervalMs) as unknown as number;
}
private setupEventListeners(): void {
this.peerManager.events.addEventListener(
PeerManagerEventNames.Connect,
this.onPeerConnected as Libp2pEventHandler
);
this.peerManager.events.addEventListener(
PeerManagerEventNames.Disconnect,
this.onPeerDisconnected as Libp2pEventHandler
);
}
private disposeIntervals(): void {
if (this.subscribeIntervalId) {
clearInterval(this.subscribeIntervalId);
}
if (this.keepAliveIntervalId) {
clearInterval(this.keepAliveIntervalId);
}
}
private disposeHandlers(): void {
for (const [decoder, handler] of this.callbacks.entries()) {
this.messageEmitter.removeEventListener(decoder.contentTopic, handler);
}
this.callbacks.clear();
}
private async disposePeers(): Promise<void> {
await this.attemptUnsubscribe({ useNewContentTopics: false });
this.peers.clear();
this.peerFailures = new Map();
}
private disposeEventListeners(): void {
this.peerManager.events.removeEventListener(
PeerManagerEventNames.Connect,
this.onPeerConnected as Libp2pEventHandler
);
this.peerManager.events.removeEventListener(
PeerManagerEventNames.Disconnect,
this.onPeerDisconnected as Libp2pEventHandler
);
}
private async onPeerConnected(event: CustomEvent<PeerId>): Promise<void> {
const id = event.detail?.toString();
log.info(`Peer connected: ${id}`);
const usablePeer = await this.peerManager.isPeerOnPubsub(
event.detail,
this.pubsubTopic
);
if (!usablePeer) {
log.info(`Peer ${id} doesn't support pubsubTopic:${this.pubsubTopic}`);
return;
}
// skip the peer we already subscribe to
if (this.peers.has(id)) {
log.info(`Peer ${id} already subscribed, skipping`);
return;
}
await this.attemptSubscribe({
useNewContentTopics: false,
useOnlyNewPeers: true
});
}
private async onPeerDisconnected(event: CustomEvent<PeerId>): Promise<void> {
const id = event.detail?.toString();
log.info(`Peer disconnected: ${id}`);
const usablePeer = await this.peerManager.isPeerOnPubsub(
event.detail,
this.pubsubTopic
);
if (!usablePeer) {
log.info(`Peer ${id} doesn't support pubsubTopic:${this.pubsubTopic}`);
return;
}
// ignore as the peer is not the one that is in use
if (!this.peers.has(id)) {
log.info(`Disconnected peer ${id} not in use, ignoring`);
return;
}
log.info(`Active peer ${id} disconnected, removing from peers list`);
this.peers.delete(id);
void this.attemptSubscribe({
useNewContentTopics: false,
useOnlyNewPeers: true
});
}
private async attemptSubscribe(
params: AttemptSubscribeParams
): Promise<boolean> {
const { useNewContentTopics, useOnlyNewPeers = false } = params;
const contentTopics = useNewContentTopics
? Array.from(this.toSubscribeContentTopics)
: this.contentTopics;
log.info(
`Attempting to subscribe: useNewContentTopics=${useNewContentTopics}, useOnlyNewPeers=${useOnlyNewPeers}, contentTopics=${contentTopics.length}`
);
if (!contentTopics.length) {
log.warn("Requested content topics is an empty array, skipping");
return false;
}
const prevPeers = new Set<PeerIdStr>(this.peers.keys());
const peersToAdd = await this.peerManager.getPeers({
protocol: Protocols.Filter,
pubsubTopic: this.pubsubTopic
});
for (const peer of peersToAdd) {
if (this.peers.size >= this.config.numPeersToUse) {
break;
}
this.peers.set(peer.toString(), peer);
}
const peersToUse = useOnlyNewPeers
? Array.from(this.peers.values()).filter(
(p) => !prevPeers.has(p.toString())
)
: Array.from(this.peers.values());
log.info(
`Subscribing with ${peersToUse.length} peers for ${contentTopics.length} content topics`
);
if (useOnlyNewPeers && peersToUse.length === 0) {
log.warn(`Requested to use only new peers, but no peers found, skipping`);
return false;
}
const results = await Promise.all(
peersToUse.map((p) => this.requestSubscribe(p, contentTopics))
);
const successCount = results.filter((r) => r).length;
log.info(
`Subscribe attempts completed: ${successCount}/${results.length} successful`
);
if (useNewContentTopics) {
this.toSubscribeContentTopics = new Set();
}
return results.some((v) => v);
}
private async requestSubscribe(
peerId: PeerId,
contentTopics: string[]
): Promise<boolean> {
log.info(
`requestSubscribe: pubsubTopic:${this.pubsubTopic}\tcontentTopics:${contentTopics.join(",")}`
);
if (!contentTopics.length || !this.pubsubTopic) {
log.warn(
`requestSubscribe: no contentTopics or pubsubTopic provided, not sending subscribe request`
);
return false;
}
const response = await this.protocol.subscribe(
this.pubsubTopic,
peerId,
contentTopics
);
if (response.failure) {
log.warn(
`requestSubscribe: Failed to subscribe ${this.pubsubTopic} to ${peerId.toString()} with error:${response.failure.error} for contentTopics:${contentTopics}`
);
return false;
}
log.info(
`requestSubscribe: Subscribed ${this.pubsubTopic} to ${peerId.toString()} for contentTopics:${contentTopics}`
);
return true;
}
private async attemptUnsubscribe(
params: AttemptUnsubscribeParams
): Promise<boolean> {
const { useNewContentTopics } = params;
const contentTopics = useNewContentTopics
? Array.from(this.toUnsubscribeContentTopics)
: this.contentTopics;
log.info(
`Attempting to unsubscribe: useNewContentTopics=${useNewContentTopics}, contentTopics=${contentTopics.length}`
);
if (!contentTopics.length) {
log.warn("Requested content topics is an empty array, skipping");
return false;
}
const peersToUse = Array.from(this.peers.values());
const result = await Promise.all(
peersToUse.map((p) =>
this.requestUnsubscribe(
p,
useNewContentTopics ? contentTopics : undefined
)
)
);
const successCount = result.filter((r) => r).length;
log.info(
`Unsubscribe attempts completed: ${successCount}/${result.length} successful`
);
if (useNewContentTopics) {
this.toUnsubscribeContentTopics = new Set();
}
return result.some((v) => v);
}
private async requestUnsubscribe(
peerId: PeerId,
contentTopics?: string[]
): Promise<boolean> {
const response = contentTopics
? await this.protocol.unsubscribe(this.pubsubTopic, peerId, contentTopics)
: await this.protocol.unsubscribeAll(this.pubsubTopic, peerId);
if (response.failure) {
log.warn(
`requestUnsubscribe: Failed to unsubscribe for pubsubTopic:${this.pubsubTopic} from peerId:${peerId.toString()} with error:${response.failure?.error} for contentTopics:${contentTopics}`
);
return false;
}
log.info(
`requestUnsubscribe: Unsubscribed pubsubTopic:${this.pubsubTopic} from peerId:${peerId.toString()} for contentTopics:${contentTopics}`
);
return true;
}
}