// retoor <retoor@molodetz.nl>
export class PubSubClient {
constructor() {
this.socket = null;
this.ready = false;
this.handlers = new Map();
this.pending = [];
this.backoff = 200;
this.maxBackoff = 5000;
}
_url() {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${location.host}/pubsub/ws`;
}
_connect() {
if (this.socket) return;
const socket = new WebSocket(this._url());
this.socket = socket;
socket.addEventListener("open", () => {
this.ready = true;
this.backoff = 200;
for (const topic of this.handlers.keys()) {
socket.send(JSON.stringify({ type: "subscribe", topic }));
}
const queued = this.pending;
this.pending = [];
queued.forEach((frame) => socket.send(JSON.stringify(frame)));
});
socket.addEventListener("message", (event) => {
let frame;
try {
frame = JSON.parse(event.data);
} catch (error) {
return;
}
if (frame.type === "message") this._dispatch(frame);
});
socket.addEventListener("close", (event) => {
this.ready = false;
this.socket = null;
const delay = event.code === 4013 ? 200 : this.backoff;
this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
if (this.handlers.size || this.pending.length) {
setTimeout(() => this._connect(), delay);
}
});
}
_matches(pattern, topic) {
if (pattern === topic || pattern === "*") return true;
if (pattern.endsWith(".*")) {
return topic === pattern.slice(0, -2) || topic.startsWith(pattern.slice(0, -1));
}
return false;
}
_dispatch(frame) {
for (const [pattern, callbacks] of this.handlers.entries()) {
if (this._matches(pattern, frame.topic)) {
callbacks.forEach((callback) => callback(frame.data, frame.topic));
}
}
}
_send(frame) {
if (this.ready && this.socket) {
this.socket.send(JSON.stringify(frame));
} else {
this.pending.push(frame);
this._connect();
}
}
subscribe(topic, callback) {
let callbacks = this.handlers.get(topic);
if (!callbacks) {
callbacks = new Set();
this.handlers.set(topic, callbacks);
this._send({ type: "subscribe", topic });
}
callbacks.add(callback);
this._connect();
return () => this.unsubscribe(topic, callback);
}
unsubscribe(topic, callback) {
const callbacks = this.handlers.get(topic);
if (!callbacks) return;
callbacks.delete(callback);
if (!callbacks.size) {
this.handlers.delete(topic);
this._send({ type: "unsubscribe", topic });
}
}
publish(topic, data) {
this._send({ type: "publish", topic, data });
}
}