Разработана первая версия приложений
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user