phonic
Version:
[](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2FPhonic-Co%2Fphonic-node) [ • 11.4 kB
JavaScript
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);
};
}
}