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) [ • 13 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
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());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReconnectableConversationsSocket = void 0;
const core = __importStar(require("../core/index.js"));
const Socket_js_1 = require("../api/resources/conversations/client/Socket.js");
const json_js_1 = require("../core/json.js");
/** 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.
*/
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 Socket_js_1.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 Socket_js_1.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 = (0, json_js_1.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);
};
}
}
exports.ReconnectableConversationsSocket = ReconnectableConversationsSocket;