146 lines
5.2 KiB
TypeScript
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;
|
|
}
|
|
}
|