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(); 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; private pollingTimer?: ReturnType; private disconnectedAt = 0; private attempt = 0; private stopped = true; private cursors = new Map(); 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; } }