@waku/sdk
Version:
A unified SDK for easy creation and management of js-waku nodes.
359 lines • 16.9 kB
JavaScript
import { TypedEventEmitter } from "@libp2p/interface";
import { messageHashStr } from "@waku/core";
import { Protocols } from "@waku/interfaces";
import { Logger } from "@waku/utils";
import { PeerManagerEventNames } from "../peer_manager/index.js";
import { TTLSet } from "./utils.js";
const log = new Logger("sdk:filter-subscription");
export class Subscription {
pubsubTopic;
protocol;
peerManager;
config;
isStarted = false;
inProgress = false;
// Map and Set cannot reliably use PeerId type as a key
peers = new Map();
peerFailures = new Map();
receivedMessages = new TTLSet(60_000);
callbacks = new Map();
messageEmitter = new TypedEventEmitter();
toSubscribeContentTopics = new Set();
toUnsubscribeContentTopics = new Set();
subscribeIntervalId = null;
keepAliveIntervalId = null;
get contentTopics() {
const allTopics = Array.from(this.callbacks.keys()).map((k) => k.contentTopic);
const uniqueTopics = new Set(allTopics).values();
return Array.from(uniqueTopics);
}
constructor(params) {
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);
}
start() {
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}`);
}
stop() {
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}`);
}
isEmpty() {
return this.callbacks.size === 0;
}
async add(decoder, callback) {
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
}
async remove(decoder) {
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
}
invoke(message, _peerId) {
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(message.contentTopic, {
detail: message
}));
}
addSingle(decoder, callback) {
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) => {
void (async () => {
try {
const message = await decoder.fromProtoObj(decoder.pubsubTopic, event.detail);
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}`);
}
removeSingle(decoder) {
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}`);
}
isMessageReceived(message) {
try {
const messageHash = messageHashStr(this.pubsubTopic, message);
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;
}
setupSubscriptionInterval() {
const subscriptionRefreshIntervalMs = 1000;
log.info(`Setting up subscription interval with period ${subscriptionRefreshIntervalMs}ms`);
this.subscribeIntervalId = setInterval(() => {
const run = async () => {
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);
}
setupKeepAliveInterval() {
log.info(`Setting up keep-alive interval with period ${this.config.keepAliveIntervalMs}ms`);
this.keepAliveIntervalId = setInterval(() => {
const run = async () => {
log.info(`Keep-alive interval running for ${this.peers.size} peers`);
let peersToReplace = await Promise.all(Array.from(this.peers.values()).map(async (peer) => {
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());
this.peerFailures.delete(p?.toString());
return this.requestUnsubscribe(p, 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);
}
setupEventListeners() {
this.peerManager.events.addEventListener(PeerManagerEventNames.Connect, this.onPeerConnected);
this.peerManager.events.addEventListener(PeerManagerEventNames.Disconnect, this.onPeerDisconnected);
}
disposeIntervals() {
if (this.subscribeIntervalId) {
clearInterval(this.subscribeIntervalId);
}
if (this.keepAliveIntervalId) {
clearInterval(this.keepAliveIntervalId);
}
}
disposeHandlers() {
for (const [decoder, handler] of this.callbacks.entries()) {
this.messageEmitter.removeEventListener(decoder.contentTopic, handler);
}
this.callbacks.clear();
}
async disposePeers() {
await this.attemptUnsubscribe({ useNewContentTopics: false });
this.peers.clear();
this.peerFailures = new Map();
}
disposeEventListeners() {
this.peerManager.events.removeEventListener(PeerManagerEventNames.Connect, this.onPeerConnected);
this.peerManager.events.removeEventListener(PeerManagerEventNames.Disconnect, this.onPeerDisconnected);
}
async onPeerConnected(event) {
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
});
}
async onPeerDisconnected(event) {
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
});
}
async attemptSubscribe(params) {
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(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);
}
async requestSubscribe(peerId, contentTopics) {
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;
}
async attemptUnsubscribe(params) {
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);
}
async requestUnsubscribe(peerId, contentTopics) {
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;
}
}
//# sourceMappingURL=subscription.js.map