320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import type {
|
|
ConnectionStatus,
|
|
InboundEvent,
|
|
Outbound,
|
|
OutboundMedia,
|
|
} from "./types";
|
|
|
|
/** WebSocket readyState constants, referenced by value to stay portable
|
|
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
|
|
const WS_OPEN = 1;
|
|
const WS_CLOSING = 2;
|
|
|
|
type Unsubscribe = () => void;
|
|
type EventHandler = (ev: InboundEvent) => void;
|
|
type StatusHandler = (status: ConnectionStatus) => void;
|
|
|
|
/** Structured connection-level errors surfaced to the UI.
|
|
*
|
|
* These are *not* InboundEvent errors from the server application layer —
|
|
* those arrive as ``{event: "error"}`` messages via ``onChat``. These are
|
|
* transport-level or protocol-level faults the UI should make visible so
|
|
* the user understands *why* their action failed (as opposed to silently
|
|
* reconnecting under the hood).
|
|
*/
|
|
export type StreamError =
|
|
/** Server rejected the inbound frame as too large (WS close code 1009).
|
|
* Typically means the user attached images whose base64 size exceeded
|
|
* ``maxMessageBytes`` on the server. */
|
|
| { kind: "message_too_big" };
|
|
|
|
type ErrorHandler = (error: StreamError) => void;
|
|
|
|
interface PendingNewChat {
|
|
resolve: (chatId: string) => void;
|
|
reject: (err: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
export interface NanobotClientOptions {
|
|
url: string;
|
|
reconnect?: boolean;
|
|
/** Called when a connection drops so the app can refresh its token. */
|
|
onReauth?: () => Promise<string | null>;
|
|
/** Inject a custom WebSocket factory (used by unit tests). */
|
|
socketFactory?: (url: string) => WebSocket;
|
|
/** Delay-cap for reconnect backoff (ms). */
|
|
maxBackoffMs?: number;
|
|
}
|
|
|
|
/**
|
|
* Singleton WebSocket client that multiplexes chat streams.
|
|
*
|
|
* One socket carries many chat_ids: the server tags every outbound event with
|
|
* ``chat_id``, and this class fans those events out to handlers registered
|
|
* per chat. Reconnects are transparent and re-attach every known chat_id.
|
|
*/
|
|
export class NanobotClient {
|
|
private socket: WebSocket | null = null;
|
|
private statusHandlers = new Set<StatusHandler>();
|
|
private errorHandlers = new Set<ErrorHandler>();
|
|
// chat_id -> handlers listening on it
|
|
private chatHandlers = new Map<string, Set<EventHandler>>();
|
|
// chat_ids we've attached to since connect; re-attached after reconnects
|
|
private knownChats = new Set<string>();
|
|
private pendingNewChat: PendingNewChat | null = null;
|
|
// Frames queued while the socket is not yet OPEN
|
|
private sendQueue: Outbound[] = [];
|
|
private reconnectAttempts = 0;
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private readonly shouldReconnect: boolean;
|
|
private readonly maxBackoffMs: number;
|
|
private readonly socketFactory: (url: string) => WebSocket;
|
|
private currentUrl: string;
|
|
private status_: ConnectionStatus = "idle";
|
|
private readyChatId: string | null = null;
|
|
// Set by ``close()`` so the onclose handler knows the drop was intentional
|
|
// and must not schedule a reconnect or flip status back to "reconnecting".
|
|
private intentionallyClosed = false;
|
|
|
|
constructor(private options: NanobotClientOptions) {
|
|
this.shouldReconnect = options.reconnect ?? true;
|
|
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
|
|
this.socketFactory =
|
|
options.socketFactory ?? ((url) => new WebSocket(url));
|
|
this.currentUrl = options.url;
|
|
}
|
|
|
|
get status(): ConnectionStatus {
|
|
return this.status_;
|
|
}
|
|
|
|
get defaultChatId(): string | null {
|
|
return this.readyChatId;
|
|
}
|
|
|
|
/** Swap the URL (e.g. after fetching a fresh token) then reconnect. */
|
|
updateUrl(url: string): void {
|
|
this.currentUrl = url;
|
|
}
|
|
|
|
onStatus(handler: StatusHandler): Unsubscribe {
|
|
this.statusHandlers.add(handler);
|
|
handler(this.status_);
|
|
return () => {
|
|
this.statusHandlers.delete(handler);
|
|
};
|
|
}
|
|
|
|
/** Subscribe to transport-level faults (see :type:`StreamError`). */
|
|
onError(handler: ErrorHandler): Unsubscribe {
|
|
this.errorHandlers.add(handler);
|
|
return () => {
|
|
this.errorHandlers.delete(handler);
|
|
};
|
|
}
|
|
|
|
/** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
|
|
onChat(chatId: string, handler: EventHandler): Unsubscribe {
|
|
let handlers = this.chatHandlers.get(chatId);
|
|
if (!handlers) {
|
|
handlers = new Set();
|
|
this.chatHandlers.set(chatId, handlers);
|
|
}
|
|
handlers.add(handler);
|
|
this.attach(chatId);
|
|
return () => {
|
|
const current = this.chatHandlers.get(chatId);
|
|
if (!current) return;
|
|
current.delete(handler);
|
|
if (current.size === 0) this.chatHandlers.delete(chatId);
|
|
};
|
|
}
|
|
|
|
connect(): void {
|
|
if (this.socket && this.socket.readyState < WS_CLOSING) return;
|
|
this.intentionallyClosed = false;
|
|
this.setStatus("connecting");
|
|
const sock = this.socketFactory(this.currentUrl);
|
|
this.socket = sock;
|
|
sock.onopen = () => this.handleOpen();
|
|
sock.onmessage = (ev) => this.handleMessage(ev);
|
|
sock.onerror = () => this.setStatus("error");
|
|
sock.onclose = (ev) => this.handleClose(ev);
|
|
}
|
|
|
|
close(): void {
|
|
this.intentionallyClosed = true;
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
const sock = this.socket;
|
|
this.socket = null;
|
|
try {
|
|
sock?.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.setStatus("closed");
|
|
}
|
|
|
|
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
|
|
newChat(timeoutMs: number = 5_000): Promise<string> {
|
|
if (this.pendingNewChat) {
|
|
return Promise.reject(new Error("newChat already in flight"));
|
|
}
|
|
return new Promise<string>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pendingNewChat = null;
|
|
reject(new Error("newChat timed out"));
|
|
}, timeoutMs);
|
|
this.pendingNewChat = { resolve, reject, timer };
|
|
this.queueSend({ type: "new_chat" });
|
|
});
|
|
}
|
|
|
|
attach(chatId: string): void {
|
|
this.knownChats.add(chatId);
|
|
if (this.socket?.readyState === WS_OPEN) {
|
|
this.queueSend({ type: "attach", chat_id: chatId });
|
|
}
|
|
}
|
|
|
|
sendMessage(chatId: string, content: string, media?: OutboundMedia[]): void {
|
|
this.knownChats.add(chatId);
|
|
const frame: Outbound =
|
|
media && media.length > 0
|
|
? { type: "message", chat_id: chatId, content, media }
|
|
: { type: "message", chat_id: chatId, content };
|
|
this.queueSend(frame);
|
|
}
|
|
|
|
// -- internals ---------------------------------------------------------
|
|
|
|
private setStatus(status: ConnectionStatus): void {
|
|
if (this.status_ === status) return;
|
|
this.status_ = status;
|
|
for (const handler of this.statusHandlers) handler(status);
|
|
}
|
|
|
|
private handleOpen(): void {
|
|
this.setStatus("open");
|
|
this.reconnectAttempts = 0;
|
|
// Re-attach every known chat_id so deliveries continue routing after a drop.
|
|
for (const chatId of this.knownChats) {
|
|
this.rawSend({ type: "attach", chat_id: chatId });
|
|
}
|
|
// Flush anything queued during reconnect.
|
|
const queued = this.sendQueue.splice(0);
|
|
for (const frame of queued) this.rawSend(frame);
|
|
}
|
|
|
|
private handleMessage(ev: MessageEvent): void {
|
|
let parsed: InboundEvent;
|
|
try {
|
|
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as InboundEvent;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (parsed.event === "ready") {
|
|
this.readyChatId = parsed.chat_id;
|
|
this.knownChats.add(parsed.chat_id);
|
|
return;
|
|
}
|
|
|
|
if (parsed.event === "attached") {
|
|
this.knownChats.add(parsed.chat_id);
|
|
if (this.pendingNewChat) {
|
|
clearTimeout(this.pendingNewChat.timer);
|
|
this.pendingNewChat.resolve(parsed.chat_id);
|
|
this.pendingNewChat = null;
|
|
}
|
|
this.dispatch(parsed.chat_id, parsed);
|
|
return;
|
|
}
|
|
|
|
const chatId = (parsed as { chat_id?: string }).chat_id;
|
|
if (chatId) this.dispatch(chatId, parsed);
|
|
}
|
|
|
|
private dispatch(chatId: string, ev: InboundEvent): void {
|
|
const handlers = this.chatHandlers.get(chatId);
|
|
if (!handlers) return;
|
|
for (const h of handlers) h(ev);
|
|
}
|
|
|
|
private handleClose(event?: { code?: number }): void {
|
|
this.socket = null;
|
|
if (this.pendingNewChat) {
|
|
clearTimeout(this.pendingNewChat.timer);
|
|
this.pendingNewChat.reject(new Error("socket closed"));
|
|
this.pendingNewChat = null;
|
|
}
|
|
// Surface structured reasons *before* reconnect logic so the UI can
|
|
// display the error even while the client transparently reconnects.
|
|
// Browsers populate ``CloseEvent.code`` with the wire-level close code;
|
|
// 1009 = Message Too Big (server's max frame guard).
|
|
if (event?.code === 1009) {
|
|
this.emitError({ kind: "message_too_big" });
|
|
}
|
|
if (this.intentionallyClosed || !this.shouldReconnect) {
|
|
this.setStatus("closed");
|
|
return;
|
|
}
|
|
this.scheduleReconnect();
|
|
}
|
|
|
|
private emitError(error: StreamError): void {
|
|
// Isolate subscribers so a throwing handler cannot abort the surrounding
|
|
// ``handleClose`` flow (which still owes us a reconnect decision + status
|
|
// update). We deliberately swallow here: error reporting is best-effort
|
|
// and must never be allowed to compound the failure it's reporting.
|
|
for (const handler of this.errorHandlers) {
|
|
try {
|
|
handler(error);
|
|
} catch {
|
|
// best-effort: subscriber fault must not stall transport bookkeeping
|
|
}
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
this.setStatus("reconnecting");
|
|
const attempt = this.reconnectAttempts++;
|
|
// Exponential backoff: 0.5s, 1s, 2s, 4s, capped.
|
|
const delay = Math.min(500 * 2 ** attempt, this.maxBackoffMs);
|
|
this.reconnectTimer = setTimeout(async () => {
|
|
this.reconnectTimer = null;
|
|
if (this.options.onReauth) {
|
|
try {
|
|
const refreshed = await this.options.onReauth();
|
|
if (refreshed) this.currentUrl = refreshed;
|
|
} catch {
|
|
// fall through to retry with current URL
|
|
}
|
|
}
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
private queueSend(frame: Outbound): void {
|
|
if (this.socket?.readyState === WS_OPEN) {
|
|
this.rawSend(frame);
|
|
} else {
|
|
this.sendQueue.push(frame);
|
|
}
|
|
}
|
|
|
|
private rawSend(frame: Outbound): void {
|
|
if (!this.socket) return;
|
|
try {
|
|
this.socket.send(JSON.stringify(frame));
|
|
} catch {
|
|
// Send failure will materialize as a close; queue the frame for retry.
|
|
this.sendQueue.push(frame);
|
|
}
|
|
}
|
|
}
|