@graphql-hive/pubsub
Version:
139 lines (136 loc) • 4.28 kB
JavaScript
import { Repeater } from '@repeaterjs/repeater';
import { DisposableSymbols } from '@whatwg-node/disposablestack';
import { createDeferredPromise } from '@whatwg-node/promise-helpers';
class RedisPubSub {
#disposed = false;
#quitOnDispose;
#subscribers = /* @__PURE__ */ new Map();
#subscribersSetKey;
#redis;
#channelPrefix;
#boundHandleMessage;
constructor(redis, options) {
this.#redis = redis;
this.#channelPrefix = options.channelPrefix;
if (String(this.#channelPrefix || "").trim() === "") {
throw new Error("RedisPubSub requires a non-empty channelPrefix");
}
this.#subscribersSetKey = `subscribers:${this.#channelPrefix}`;
this.#quitOnDispose = !options.noQuitOnDispose;
this.#boundHandleMessage = this.#handleMessage.bind(this);
this.#redis.sub.on("message", this.#boundHandleMessage);
}
#topicToChannel(topic) {
return `${this.#channelPrefix}:${String(topic)}`;
}
#topicFromChannel(channel) {
return channel.replace(`${this.#channelPrefix}:`, "");
}
async subscribedTopics() {
if (this.#disposed) {
throw new Error("PubSub is disposed, cannot get subscribed topics");
}
const distinctTopics = Array.from(this.#subscribers.keys());
for (const otherTopic of await this.#redis.pub.smembers(
this.#subscribersSetKey
)) {
if (!distinctTopics.includes(otherTopic)) {
distinctTopics.push(otherTopic);
}
}
return distinctTopics;
}
publish(topic, data) {
if (this.#disposed) {
throw new Error("PubSub is disposed, cannot publish data");
}
return new Promise((resolve, reject) => {
this.#redis.pub.publish(
this.#topicToChannel(topic),
JSON.stringify(data),
(e) => {
if (e) reject(e);
else resolve();
}
);
});
}
#handleMessage(channel, message) {
const topic = this.#topicFromChannel(channel);
const data = JSON.parse(message);
const listeners = this.#subscribers.get(topic);
if (listeners) {
for (const l of listeners.keys()) {
l(data);
}
}
}
subscribe(topic, listener) {
if (this.#disposed) {
throw new Error("PubSub is disposed, cannot subscribe to topics");
}
let listeners = this.#subscribers.get(topic);
if (!listeners) {
listeners = /* @__PURE__ */ new Map();
this.#subscribers.set(topic, listeners);
}
const setArgs = [
this.#subscribersSetKey,
String(topic)
];
if (!listener) {
return new Repeater(async (push, stop) => {
await this.#redis.sub.subscribe(this.#topicToChannel(topic));
await this.#redis.pub.sadd(...setArgs);
const { promise: settled, resolve: done } = createDeferredPromise();
listeners.set(push, () => (stop(), settled));
await stop;
listeners.delete(push);
if (listeners.size === 0) {
this.#subscribers.delete(topic);
}
try {
await this.#redis.pub.srem(...setArgs);
await this.#redis.sub.unsubscribe(this.#topicToChannel(topic));
} finally {
done();
}
});
}
const listenerRef = new WeakRef(listener);
const unsubscribe = async () => {
const l = listenerRef.deref();
if (l) listeners.delete(l);
if (listeners.size === 0) {
this.#subscribers.delete(topic);
}
await this.#redis.pub.srem(...setArgs);
await this.#redis.sub.unsubscribe(this.#topicToChannel(topic));
};
listeners.set(listener, unsubscribe);
return (async () => {
await this.#redis.sub.subscribe(this.#topicToChannel(topic));
await this.#redis.pub.sadd(...setArgs);
return unsubscribe;
})();
}
async dispose() {
this.#disposed = true;
this.#redis.sub.off("message", this.#boundHandleMessage);
for (const sub of this.#subscribers.values()) {
await Promise.all(Array.from(sub.values()).map((stop) => stop()));
sub.clear();
}
this.#subscribers.clear();
if (this.#quitOnDispose) {
await Promise.allSettled([
this.#redis.pub.quit(),
this.#redis.sub.quit()
]);
}
}
[DisposableSymbols.asyncDispose]() {
return this.dispose();
}
}
export { RedisPubSub };