magnetar/fe_calckey/frontend/magnetar-common/src/sse-listener.ts

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