@graphql-hive/pubsub
Version:
147 lines (143 loc) • 4.42 kB
JavaScript
;
var repeater = require('@repeaterjs/repeater');
var disposablestack = require('@whatwg-node/disposablestack');
var promiseHelpers = require('@whatwg-node/promise-helpers');
class NATSJetStreamPubSub {
#disposed = false;
#js;
#subjectPrefix;
#stream;
#activeConsumers = /* @__PURE__ */ new Map();
constructor(js, options) {
this.#js = js;
this.#subjectPrefix = options.subjectPrefix;
this.#stream = options.stream;
if (String(this.#subjectPrefix || "").trim() === "") {
throw new Error("NATSJetStreamPubSub requires a non-empty subjectPrefix");
}
if (String(this.#stream || "").trim() === "") {
throw new Error("NATSJetStreamPubSub requires a non-empty stream");
}
}
#topicToSubject(topic) {
return `${this.#subjectPrefix}:${String(topic)}`;
}
/** Cursors are opaque outside of this adapter, they're simply the stream's message sequence. */
#parseCursor(cursor) {
const seq = Number(cursor);
if (!Number.isInteger(seq) || seq <= 0) {
throw new Error(`Invalid cursor "${cursor}"`);
}
return seq;
}
async subscribedTopics() {
const distinctTopics = [];
for (const topic of this.#activeConsumers.values()) {
if (!distinctTopics.includes(topic)) {
distinctTopics.push(topic);
}
}
return distinctTopics;
}
publish(topic, data) {
if (this.#disposed) {
throw new Error("PubSub is disposed, cannot publish data");
}
return this.#js.publish(this.#topicToSubject(topic), JSON.stringify(data)).then(() => void 0);
}
/**
* An ordered consumer is a fresh ephemeral consumer, exactly what we want for a resumable
* per-subscription cursor: no shared/durable state between subscribers.
*/
#createConsumer(topic, cursor) {
return this.#js.consumers.get(this.#stream, {
filter_subjects: [this.#topicToSubject(topic)],
...cursor === void 0 ? { deliver_policy: "new" } : {
deliver_policy: "by_start_sequence",
opt_start_seq: this.#parseCursor(cursor) + 1
}
});
}
async #consume(topic, cursor, callback, finished, closed) {
const consumer = await this.#createConsumer(topic, cursor);
const messages = await consumer.consume({ callback });
const stop = async () => {
if (this.#activeConsumers.delete(stop)) {
try {
await messages.close();
} finally {
finished?.();
}
}
};
if (this.#disposed) {
await messages.close();
finished?.();
throw new Error("PubSub is disposed");
}
this.#activeConsumers.set(stop, topic);
if (closed) {
void messages.closed().then((error) => error ? closed.reject(error) : closed.resolve());
}
return stop;
}
subscribe(topic, optionsOrListener) {
if (this.#disposed) {
throw new Error("PubSub is disposed, cannot subscribe to topics");
}
if (typeof optionsOrListener === "function") {
const listenerRef = {
ref: optionsOrListener
};
const stop = this.#consume(topic, void 0, (msg) => {
try {
listenerRef.ref?.(msg.json());
} catch {
}
});
return promiseHelpers.fakePromise(stop).then((stop2) => async () => {
listenerRef.ref = null;
await stop2();
});
}
return new repeater.Repeater(
async (push, stopped) => {
const consumerClosed = promiseHelpers.createDeferredPromise();
const stop = await this.#consume(
topic,
optionsOrListener?.cursor,
(msg) => {
try {
const item = {
data: msg.json(),
cursor: String(msg.seq)
};
void push(item);
} catch (error) {
consumerClosed.reject(error);
}
},
stopped,
consumerClosed
);
try {
await Promise.race([stopped, consumerClosed.promise]);
} finally {
consumerClosed.resolve();
await stop();
}
}
);
}
async dispose() {
this.#disposed = true;
await Promise.all(
Array.from(this.#activeConsumers.keys()).map((stop) => stop())
);
this.#activeConsumers.clear();
}
[disposablestack.DisposableSymbols.asyncDispose]() {
return this.dispose();
}
}
exports.NATSJetStreamPubSub = NATSJetStreamPubSub;