Files
han-app/codebase/backend/frontend-test-site/src/realtime.ts
T

146 lines
5.2 KiB
TypeScript

import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
import { dialogApi } from "./services";
import type { Message } from "./types";
export { reconcileMessages } from "./reconcile";
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
export type RealtimeEvent =
| { type: "message.new"; dialog_id: string; message: Message; cursor?: string }
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
const safeCursors = new Map<string, string>();
export const getRealtimeDiagnostics = () =>
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
dialog: `${dialogId.slice(0, 8)}…`,
cursor,
}));
export function websocketJwtProtocol(token: string) {
const bytes = new TextEncoder().encode(token);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return `han.jwt.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`;
}
export class RealtimeClient {
private socket?: WebSocket;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private pollingTimer?: ReturnType<typeof setTimeout>;
private disconnectedAt = 0;
private attempt = 0;
private stopped = true;
private cursors = new Map<string, string>();
constructor(
private dialogIds: string[],
private readonly onEvent: (event: RealtimeEvent) => void,
private readonly onMessages: (dialogId: string, messages: Message[]) => void,
private readonly onState: (state: RealtimeState) => void,
) {}
updateDialogs(ids: string[]) {
this.dialogIds = [...new Set(ids)];
if (this.socket?.readyState === WebSocket.OPEN) this.subscribe();
}
start() {
if (!this.stopped) return;
this.stopped = false;
this.connect();
}
stop() {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.pollingTimer) clearTimeout(this.pollingTimer);
this.socket?.close();
this.onState("idle");
}
private connect() {
if (this.stopped) return;
const token = getAccessToken();
if (!token) return;
this.onState("connecting");
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
this.socket.onopen = () => {
this.attempt = 0;
// Сначала подписываемся, затем читаем REST-gap: события в этом окне
// уже попадут в merge, а дубли устраняются по message_id.
this.subscribe();
void this.reconcileAll().then(() => {
this.stopPolling();
this.onState("websocket");
});
};
this.socket.onmessage = ({ data }) => {
try {
const event = JSON.parse(String(data)) as RealtimeEvent | { type: string };
if (event.type === "ping") return this.socket?.send(JSON.stringify({ type: "pong" }));
if (event.type === "message.new" || event.type === "message.status" || event.type === "dialog.status") {
const known = event as RealtimeEvent;
if (known.cursor) {
this.cursors.set(known.dialog_id, known.cursor);
safeCursors.set(known.dialog_id, known.cursor);
}
this.onEvent(known);
}
} catch { /* unknown and malformed events are safely ignored */ }
};
this.socket.onclose = (event) => {
if (this.stopped) return;
if (!this.disconnectedAt) this.disconnectedAt = Date.now();
if (event.code === 4401 || event.code === 1008) {
void refreshTokens().finally(() => this.scheduleReconnect());
} else {
this.scheduleReconnect();
}
};
this.socket.onerror = () => this.socket?.close();
}
private subscribe() {
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: this.dialogIds }));
}
private scheduleReconnect() {
if (this.stopped) return;
if (Date.now() - this.disconnectedAt >= 30_000) this.startPolling();
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
private async reconcileAll() {
await Promise.all(this.dialogIds.map(async (id) => {
const page = await dialogApi.messages(id, this.cursors.get(id));
if (page.items.length) this.onMessages(id, page.items);
if (page.next_cursor) {
this.cursors.set(id, page.next_cursor);
safeCursors.set(id, page.next_cursor);
}
}));
this.disconnectedAt = 0;
}
private startPolling() {
if (this.pollingTimer) return;
this.onState("polling");
const poll = async () => {
if (this.stopped) return;
await this.reconcileAll().catch(() => undefined);
const delay = typeof document !== "undefined" && document.hidden ? 15_000 : 5_000;
this.pollingTimer = setTimeout(poll, delay);
};
void poll();
}
private stopPolling() {
if (this.pollingTimer) clearTimeout(this.pollingTimer);
this.pollingTimer = undefined;
}
}