|
// 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 });
|
|
}
|
|
}
|