143 lines
4.1 KiB
TypeScript
143 lines
4.1 KiB
TypeScript
import { EventEmitter } from "eventemitter3";
|
|
import { ChannelEvent } from "./types/ChannelEvent";
|
|
|
|
export type MagChannelState = "connected" | "exponentialBackoff" | "failed";
|
|
|
|
export class MagEventChannel extends EventEmitter<{
|
|
stateChange: MagChannelState;
|
|
message: ChannelEvent;
|
|
close: "cancelled";
|
|
}> {
|
|
private readonly baseUrl: string;
|
|
private attempts = 0;
|
|
private readonly maxAttempts: number;
|
|
private readonly token: string | null;
|
|
private readonly backoffFactor: number;
|
|
private readonly backoffBase: number;
|
|
private readonly closePromise: Promise<"cancelled">;
|
|
|
|
public constructor(
|
|
baseUrl: string,
|
|
token: string | null,
|
|
maxReconnectAttempts: number = 12,
|
|
backoffFactor: number = 1.618,
|
|
backoffBase: number = 500.0
|
|
) {
|
|
super();
|
|
|
|
this.baseUrl = baseUrl;
|
|
this.token = token;
|
|
this.maxAttempts = maxReconnectAttempts;
|
|
this.backoffFactor = backoffFactor;
|
|
this.backoffBase = backoffBase;
|
|
this.closePromise = new Promise((resolve) => {
|
|
this.on("close", resolve);
|
|
});
|
|
this.connect().then();
|
|
}
|
|
|
|
public useFiltered<T extends ChannelEvent["type"]>(
|
|
messageType: T,
|
|
listener: (val: (ChannelEvent & { type: T })["body"]) => void
|
|
) {
|
|
const cb = (val: ChannelEvent) => {
|
|
if (val.type != messageType) return;
|
|
|
|
listener((val as ChannelEvent & { type: T }).body);
|
|
};
|
|
this.on("message", cb);
|
|
return cb;
|
|
}
|
|
|
|
private async connect() {
|
|
if (this.attempts >= this.maxAttempts) {
|
|
this.emit("stateChange", "failed");
|
|
return;
|
|
}
|
|
|
|
const authorization = this.token ? `Bearer ${this.token}` : undefined;
|
|
const baseUrl = this.baseUrl.replace(/\/+$/, "");
|
|
|
|
const response = await fetch(`${baseUrl}/streaming`, {
|
|
method: "GET",
|
|
headers: authorization ? { authorization } : {},
|
|
credentials: "omit",
|
|
cache: "no-cache",
|
|
}).catch((e) => {
|
|
console.error(e);
|
|
return null;
|
|
});
|
|
|
|
if (
|
|
response === null ||
|
|
response.status >= 500 ||
|
|
response.body === null
|
|
) {
|
|
this.emit("stateChange", "exponentialBackoff");
|
|
setTimeout(
|
|
() => this.connect(),
|
|
this.backoffBase * Math.pow(this.backoffFactor, this.attempts)
|
|
);
|
|
this.attempts++;
|
|
return;
|
|
}
|
|
|
|
if (response.status >= 400 && response.status < 500) {
|
|
this.emit("stateChange", "failed");
|
|
return;
|
|
}
|
|
|
|
this.attempts = 0;
|
|
|
|
const decoderStream = new TextDecoderStream();
|
|
const reader = response.body.pipeThrough(decoderStream).getReader();
|
|
this.emit("stateChange", "connected");
|
|
|
|
let buf = "";
|
|
|
|
while (true) {
|
|
const res = await Promise.race([reader.read(), this.closePromise]);
|
|
|
|
if (res === "cancelled") break;
|
|
|
|
if (res.done) {
|
|
this.emit("stateChange", "exponentialBackoff");
|
|
setTimeout(
|
|
() => this.connect(),
|
|
this.backoffBase *
|
|
Math.pow(this.backoffFactor, this.attempts)
|
|
);
|
|
this.attempts++;
|
|
break;
|
|
}
|
|
|
|
buf += res.value;
|
|
|
|
const splitIndex = buf.indexOf("\n\n");
|
|
if (splitIndex === -1) {
|
|
continue;
|
|
}
|
|
|
|
const rawValue = buf.substring(0, splitIndex);
|
|
buf = buf.substring(splitIndex + 2);
|
|
|
|
if (rawValue.startsWith(":")) continue;
|
|
|
|
const text = rawValue
|
|
.split("\n")
|
|
.filter((l) => l.startsWith("data: "))
|
|
.map((l) => l.substring("data: ".length))
|
|
.join("\n");
|
|
|
|
if (!text) continue;
|
|
|
|
const data = JSON.parse(text) as ChannelEvent;
|
|
this.emit("message", data);
|
|
}
|
|
}
|
|
|
|
public async close() {
|
|
this.emit("close", "cancelled");
|
|
}
|
|
}
|