UNPKG

phonic

Version:

[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2FPhonic-Co%2Fphonic-node) [![npm shield](htt

253 lines (252 loc) 11.4 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import * as core from "../core/index.mjs"; import { ConversationsSocket } from "../api/resources/conversations/client/Socket.mjs"; import { fromJson } from "../core/json.mjs"; /** 1006 is the only close code that indicates an unexpected disconnect * worth reconnecting for. All other codes (1000, 4000, 4800, etc.) * are intentional server-side closes. */ const ABNORMAL_CLOSURE = 1006; const BASE_RECONNECT_DELAY_MS = 500; const MAX_RECONNECT_DELAY_MS = 5000; /** Safety cap: stop retrying if the server is completely unreachable. * In normal operation the server's terminal codes (4800/4801) stop * retries much sooner (within the 10s grace period). */ const MAX_RECONNECT_ATTEMPTS = 10; /** * Wraps ConversationsSocket with automatic reconnection on 1006. * * On abnormal closure, creates a brand new ReconnectingWebSocket (via the * createReconnectSocket factory) and wraps it in a fresh ConversationsSocket, * forwarding all events to user-registered handlers. * * Retries reconnection with exponential backoff until the server responds * with a terminal close code (4800 session expired, 4801 invalid state), * the safety cap is reached, or the user calls close(). * * Uses composition rather than inheritance to avoid coupling to the parent's * private event handler registration or ReconnectingWebSocket internals. */ export class ReconnectableConversationsSocket { constructor(args) { this._conversationId = null; this._handlers = {}; this._reconnectAttempts = 0; this._isClosed = false; this._cleanupWireListeners = null; this._pendingReconnect = null; this._pendingReplacement = false; this._createReconnectSocket = args.createReconnectSocket; this._abortSignal = args.abortSignal != undefined ? args.abortSignal : null; this._inner = new ConversationsSocket({ socket: args.socket }); this._wireInner(this._inner, args.socket); } /** The conversation ID captured from the server's conversation_created message. */ get conversationId() { return this._conversationId; } get socket() { return this._inner.socket; } get readyState() { return this._inner.readyState; } on(event, callback) { this._handlers[event] = callback; } /** Drop outbound sends when we cannot talk to a live socket (no queue). */ _safeSend(op) { if (this._isClosed || this._pendingReplacement) { return; } if (this._inner.readyState !== core.ReconnectingWebSocket.ReadyState.OPEN) { return; } op(this._inner); } sendConfig(message) { this._safeSend((inner) => inner.sendConfig(message)); } sendAudioChunk(message) { this._safeSend((inner) => inner.sendAudioChunk(message)); } sendToolCallOutput(message) { this._safeSend((inner) => inner.sendToolCallOutput(message)); } sendUpdateSystemPrompt(message) { this._safeSend((inner) => inner.sendUpdateSystemPrompt(message)); } sendAddSystemMessage(message) { this._safeSend((inner) => inner.sendAddSystemMessage(message)); } sendSetExternalId(message) { this._safeSend((inner) => inner.sendSetExternalId(message)); } sendGenerateReply(message) { this._safeSend((inner) => inner.sendGenerateReply(message)); } sendSay(message) { this._safeSend((inner) => inner.sendSay(message)); } sendReset(message) { this._safeSend((inner) => inner.sendReset(message)); } sendMute(message) { this._safeSend((inner) => inner.sendMute(message)); } sendUnmute(message) { this._safeSend((inner) => inner.sendUnmute(message)); } /** * Not supported — reconnection after 1006 is handled automatically. * To start a new conversation, create a new socket via client.conversations.connect(). */ connect() { throw new Error("connect() is not supported on ReconnectableConversationsSocket. " + "Reconnection after 1006 is automatic. To start a new conversation, " + "call client.conversations.connect() again."); } close() { var _a; this._isClosed = true; this._pendingReplacement = false; if (this._pendingReconnect != null) { clearTimeout(this._pendingReconnect); this._pendingReconnect = null; } (_a = this._cleanupWireListeners) === null || _a === void 0 ? void 0 : _a.call(this); this._cleanupWireListeners = null; this._inner.close(); } waitForOpen() { return __awaiter(this, void 0, void 0, function* () { return this._inner.waitForOpen(); }); } _getReconnectDelay() { // Exponential backoff: 500ms, 1s, 2s, 4s, capped at 5s const delay = BASE_RECONNECT_DELAY_MS * Math.pow(2, this._reconnectAttempts - 1); return Math.min(delay, MAX_RECONNECT_DELAY_MS); } /** Schedule a reconnection attempt after backoff delay. */ _scheduleReconnect() { var _a, _b, _c; if (this._isClosed || this._conversationId === null || ((_a = this._abortSignal) === null || _a === void 0 ? void 0 : _a.aborted)) { return; } if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { this._pendingReplacement = false; (_c = (_b = this._handlers).error) === null || _c === void 0 ? void 0 : _c.call(_b, new Error("Max reconnect attempts reached")); return; } // Clear any existing timer to prevent orphaned timeouts if (this._pendingReconnect != null) { clearTimeout(this._pendingReconnect); } this._reconnectAttempts++; const delay = this._getReconnectDelay(); this._pendingReplacement = true; this._pendingReconnect = setTimeout(() => { this._pendingReconnect = null; if (this._isClosed) { this._pendingReplacement = false; return; } void this._doReconnect(); }, delay); } /** Perform the actual reconnection attempt. */ _doReconnect() { return __awaiter(this, void 0, void 0, function* () { var _a, _b; try { const created = this._createReconnectSocket(this._conversationId); const newRawSocket = created instanceof Promise ? yield created : created; if (this._isClosed || ((_a = this._abortSignal) === null || _a === void 0 ? void 0 : _a.aborted)) { this._pendingReplacement = false; newRawSocket.close(); return; } // Clean up the old socket: remove our custom listeners and close // the ConversationsSocket wrapper (which removes its own listeners). (_b = this._cleanupWireListeners) === null || _b === void 0 ? void 0 : _b.call(this); this._cleanupWireListeners = null; try { this._inner.close(); } catch ( /* already closed from 1006 */_c) { /* already closed from 1006 */ } const newInner = new ConversationsSocket({ socket: newRawSocket }); this._inner = newInner; this._wireInner(newInner, newRawSocket); } catch (_d) { this._pendingReplacement = false; // Connection failed — schedule another attempt. // The server's grace period (10s) is the natural limit; // once it expires, the next attempt will get 4800 and stop. this._scheduleReconnect(); } }); } _wireInner(inner, rawSocket) { // Forward events from the inner ConversationsSocket to user handlers. // Clear _pendingReplacement before calling the user's open handler // so that sends inside the handler are not silently dropped. inner.on("open", () => { var _a, _b; this._pendingReplacement = false; (_b = (_a = this._handlers).open) === null || _b === void 0 ? void 0 : _b.call(_a); }); inner.on("message", (msg) => { var _a, _b; return (_b = (_a = this._handlers).message) === null || _b === void 0 ? void 0 : _b.call(_a, msg); }); inner.on("close", (ev) => { var _a, _b; return (_b = (_a = this._handlers).close) === null || _b === void 0 ? void 0 : _b.call(_a, ev); }); inner.on("error", (err) => { var _a, _b; return (_b = (_a = this._handlers).error) === null || _b === void 0 ? void 0 : _b.call(_a, err); }); // Intercept raw messages to capture conversation_id and reset reconnect counter const onMessage = (event) => { try { const data = fromJson(event.data); if (data && typeof data === "object" && "type" in data) { const type = data.type; if (type === "conversation_created") { this._conversationId = data.conversation_id; } if (type === "conversation_reconnected") { this._reconnectAttempts = 0; } } } catch (_a) { // ignore — inner socket handles parse errors } }; const onClose = (event) => { if (this._isClosed) { return; } if (event.code === ABNORMAL_CLOSURE && this._conversationId !== null) { // We have a conversation to resume — cancel RWS's built-in // auto-reconnect and handle it ourselves with reconnect_conv_id. // _handleClose calls _connect() before notifying listeners, // but _connect() awaits _wait() which is async. Calling // close() synchronously sets _closeCalled = true, which // _connect() checks before opening the new socket. rawSocket.close(); this._scheduleReconnect(); } // 1006 without conversationId: let RWS handle transport-level // reconnect normally (starts a fresh conversation). }; rawSocket.addEventListener("message", onMessage); rawSocket.addEventListener("close", onClose); this._cleanupWireListeners = () => { rawSocket.removeEventListener("message", onMessage); rawSocket.removeEventListener("close", onClose); }; } }