143 lines
4.1 KiB
TypeScript
143 lines
4.1 KiB
TypeScript
import type { ServerFrame, ClientFrame, EventFrame } from "./protocol";
|
|
|
|
export type WsState = "connecting" | "open" | "closed";
|
|
|
|
export interface WsManager {
|
|
onState(cb: (state: WsState) => void): () => void;
|
|
onFrame(cb: (frame: ServerFrame) => void): () => void;
|
|
onAuthError(cb: () => void): () => void;
|
|
subscribe(sessionId: string): void;
|
|
unsubscribe(sessionId: string): void;
|
|
/** Resubscribe all live subscriptions; call on (re)open. */
|
|
resubscribeAll(): void;
|
|
/** SessionIds subscribed right now (used by onOpen hooks to refetch). */
|
|
subscriptions(): ReadonlySet<string>;
|
|
close(): void;
|
|
}
|
|
|
|
const BACKOFF_INITIAL_MS: number = 500;
|
|
const BACKOFF_MAX_MS: number = 30_000;
|
|
const CLOSED_BY_USER: number = 4900;
|
|
|
|
export function createWsManager(url: string | (() => string)): WsManager {
|
|
const urlFn: () => string = typeof url === "string" ? () => url : url;
|
|
|
|
let ws: WebSocket | null = null;
|
|
let closed = false;
|
|
let attempt: number = 0;
|
|
let reconnectTimer: number | undefined;
|
|
let state: WsState = "connecting";
|
|
|
|
const stateCbs = new Set<(s: WsState) => void>();
|
|
const frameCbs = new Set<(f: ServerFrame) => void>();
|
|
const authErrorCbs = new Set<() => void>();
|
|
const subs = new Set<string>();
|
|
|
|
const setState = (s: WsState): void => {
|
|
state = s;
|
|
stateCbs.forEach((cb) => cb(s));
|
|
};
|
|
|
|
const connect = (): void => {
|
|
if (closed) return;
|
|
setState("connecting");
|
|
const sock = new WebSocket(urlFn());
|
|
ws = sock;
|
|
sock.onopen = () => {
|
|
attempt = 0;
|
|
setState("open");
|
|
// resubscribe current chat + sessions fan-out resumes automatically
|
|
subs.forEach((id) => {
|
|
const frame: ClientFrame = { type: "subscribe", sessionId: id };
|
|
sock.send(JSON.stringify(frame));
|
|
});
|
|
};
|
|
sock.onmessage = (ev: MessageEvent) => {
|
|
let frame: ServerFrame;
|
|
try {
|
|
frame = JSON.parse(typeof ev.data === "string" ? ev.data : "") as ServerFrame;
|
|
} catch {
|
|
return;
|
|
}
|
|
if (frame === null || typeof frame !== "object" || typeof frame.type !== "string") return;
|
|
frameCbs.forEach((cb) => cb(frame));
|
|
};
|
|
sock.onclose = (ev: CloseEvent) => {
|
|
ws = null;
|
|
setState("closed");
|
|
if (closed) return;
|
|
if (ev.code === 1008) {
|
|
// policy violation: token rejected
|
|
authErrorCbs.forEach((cb) => cb());
|
|
return;
|
|
}
|
|
const jitter: number = Math.random();
|
|
const exp: number = Math.min(BACKOFF_INITIAL_MS * 2 ** attempt, BACKOFF_MAX_MS);
|
|
attempt += 1;
|
|
reconnectTimer = window.setTimeout(connect, exp * (0.5 + 0.5 * jitter));
|
|
};
|
|
sock.onerror = () => {
|
|
/* onclose follows */
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
return {
|
|
onState(cb) {
|
|
stateCbs.add(cb);
|
|
cb(state);
|
|
return () => stateCbs.delete(cb);
|
|
},
|
|
onFrame(cb) {
|
|
frameCbs.add(cb);
|
|
return () => frameCbs.delete(cb);
|
|
},
|
|
onAuthError(cb) {
|
|
authErrorCbs.add(cb);
|
|
return () => authErrorCbs.delete(cb);
|
|
},
|
|
subscribe(sessionId) {
|
|
subs.add(sessionId);
|
|
if (ws !== null && ws.readyState === WebSocket.OPEN) {
|
|
const frame: ClientFrame = { type: "subscribe", sessionId };
|
|
ws.send(JSON.stringify(frame));
|
|
}
|
|
},
|
|
unsubscribe(sessionId) {
|
|
subs.delete(sessionId);
|
|
if (ws !== null && ws.readyState === WebSocket.OPEN) {
|
|
const frame: ClientFrame = { type: "unsubscribe", sessionId };
|
|
ws.send(JSON.stringify(frame));
|
|
}
|
|
},
|
|
resubscribeAll() {
|
|
const sock: WebSocket | null = ws;
|
|
if (sock !== null && sock.readyState === WebSocket.OPEN) {
|
|
subs.forEach((id) => {
|
|
const frame: ClientFrame = { type: "subscribe", sessionId: id };
|
|
sock.send(JSON.stringify(frame));
|
|
});
|
|
}
|
|
},
|
|
subscriptions() {
|
|
return subs;
|
|
},
|
|
close() {
|
|
closed = true;
|
|
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
|
if (ws !== null) {
|
|
ws.onclose = null;
|
|
ws.onmessage = null;
|
|
ws.onerror = null;
|
|
ws.onopen = null;
|
|
ws.close(CLOSED_BY_USER);
|
|
ws = null;
|
|
}
|
|
setState("closed");
|
|
},
|
|
};
|
|
}
|
|
|
|
export type { EventFrame };
|