openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
350 lines (349 loc) • 10.1 kB
JavaScript
import { n as createDebugProxyWebSocketAgent, r as resolveDebugProxySettings } from "./env-ckWue_wY.js";
import { n as captureWsEvent } from "./runtime-DrwkIUiy.js";
import "./provider-registry-D7UqrGeZ.js";
import { randomUUID } from "node:crypto";
import WebSocket from "ws";
//#region src/realtime-transcription/websocket-session.ts
const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
const DEFAULT_CLOSE_TIMEOUT_MS = 5e3;
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 5;
const DEFAULT_RECONNECT_DELAY_MS = 1e3;
const DEFAULT_MAX_QUEUED_BYTES = 2 * 1024 * 1024;
function rawWsDataToBuffer(data) {
if (Buffer.isBuffer(data)) return data;
if (Array.isArray(data)) return Buffer.concat(data);
return Buffer.from(data);
}
function defaultParseMessage(payload) {
try {
return JSON.parse(payload.toString());
} catch {
throw new Error("Realtime transcription websocket received malformed JSON.");
}
}
var WebSocketRealtimeTranscriptionSession = class {
constructor(options) {
this.closed = false;
this.connected = false;
this.currentUrl = "";
this.queuedAudio = [];
this.queuedBytes = 0;
this.ready = false;
this.reconnectAttempts = 0;
this.reconnecting = false;
this.suppressReconnect = false;
this.ws = null;
this.flowId = randomUUID();
this.options = options;
this.transport = {
callbacks: options.callbacks,
closeNow: () => {
this.closed = true;
this.forceClose();
},
failConnect: (error) => this.failConnect?.(error),
isOpen: () => this.ws?.readyState === WebSocket.OPEN,
isReady: () => this.ready,
markReady: () => this.markReady?.(),
sendBinary: (payload) => this.sendBinary(payload),
sendJson: (payload) => this.sendJson(payload)
};
}
async connect() {
this.closed = false;
this.suppressReconnect = false;
this.reconnectAttempts = 0;
await this.doConnect();
}
sendAudio(audio) {
if (this.closed || audio.byteLength === 0) return;
if (this.ws?.readyState === WebSocket.OPEN && this.ready) {
this.options.sendAudio(audio, this.transport);
return;
}
this.queueAudio(audio);
}
close() {
this.closed = true;
this.connected = false;
this.ready = false;
this.queuedAudio = [];
this.queuedBytes = 0;
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.forceClose();
return;
}
try {
this.options.onClose?.(this.transport);
} catch (error) {
this.emitError(error);
}
this.closeTimer = setTimeout(() => this.forceClose(), this.closeTimeoutMs);
}
isConnected() {
return this.connected && this.ready;
}
get closeTimeoutMs() {
return this.options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
}
get connectTimeoutMs() {
return this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
}
get maxQueuedBytes() {
return this.options.maxQueuedBytes ?? DEFAULT_MAX_QUEUED_BYTES;
}
get maxReconnectAttempts() {
return this.options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
}
get reconnectDelayMs() {
return this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
}
async doConnect() {
await new Promise((resolve, reject) => {
this.ready = false;
const proxyAgent = createDebugProxyWebSocketAgent(resolveDebugProxySettings());
let settled = false;
let opened = false;
let connectTimeout;
const normalizeError = (error) => error instanceof Error ? error : new Error(String(error));
const clearConnectTimeout = () => {
if (connectTimeout) {
clearTimeout(connectTimeout);
connectTimeout = void 0;
}
};
const finishClosedConnect = () => {
if (settled) return;
settled = true;
clearConnectTimeout();
resolve();
};
const finishConnect = () => {
if (settled) return;
settled = true;
clearConnectTimeout();
this.ready = true;
this.flushQueuedAudio();
resolve();
};
const failConnect = (error) => {
if (settled) return;
settled = true;
clearConnectTimeout();
this.emitError(error);
this.suppressReconnect = true;
this.forceClose();
reject(error);
};
this.markReady = finishConnect;
this.failConnect = failConnect;
connectTimeout = setTimeout(() => {
failConnect(new Error(this.options.connectTimeoutMessage ?? `${this.options.providerId} realtime transcription connection timeout`));
}, this.connectTimeoutMs);
(async () => {
let connection;
try {
connection = await this.resolveConnection();
} catch (error) {
failConnect(normalizeError(error));
return;
}
if (settled) return;
if (this.closed) {
finishClosedConnect();
return;
}
this.currentUrl = connection.url;
try {
this.ws = new WebSocket(this.currentUrl, {
headers: connection.headers,
...proxyAgent ? { agent: proxyAgent } : {}
});
} catch (error) {
failConnect(normalizeError(error));
return;
}
this.ws.on("open", () => {
opened = true;
this.connected = true;
this.reconnectAttempts = 0;
this.captureLocalOpen();
try {
this.options.onOpen?.(this.transport);
if (this.options.readyOnOpen) finishConnect();
} catch (error) {
failConnect(normalizeError(error));
}
});
this.ws.on("message", (data) => {
const payload = rawWsDataToBuffer(data);
this.captureFrame("inbound", payload);
try {
if (!this.options.onMessage) return;
const parseMessage = this.options.parseMessage ?? defaultParseMessage;
this.options.onMessage(parseMessage(payload), this.transport);
} catch (error) {
this.emitError(error);
}
});
this.ws.on("error", (error) => {
const normalized = normalizeError(error);
this.captureError(normalized);
if (!opened || !settled) {
failConnect(normalized);
return;
}
this.emitError(normalized);
});
this.ws.on("close", (code, reasonBuffer) => {
clearConnectTimeout();
this.captureClose(code, reasonBuffer);
this.connected = false;
this.ready = false;
if (this.closeTimer) {
clearTimeout(this.closeTimer);
this.closeTimer = void 0;
}
if (this.closed) return;
if (this.suppressReconnect) {
this.suppressReconnect = false;
return;
}
if (!opened || !settled) {
failConnect(new Error(this.options.connectClosedBeforeReadyMessage ?? `${this.options.providerId} realtime transcription connection closed before ready`));
return;
}
this.attemptReconnect();
});
})();
});
}
async resolveConnection() {
return {
url: await (typeof this.options.url === "function" ? this.options.url() : this.options.url),
headers: await (typeof this.options.headers === "function" ? this.options.headers() : this.options.headers)
};
}
async attemptReconnect() {
if (this.closed || this.reconnecting) return;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.emitError(new Error(this.options.reconnectLimitMessage ?? `${this.options.providerId} realtime transcription reconnect limit reached`));
return;
}
this.reconnectAttempts += 1;
const delay = this.reconnectDelayMs * 2 ** (this.reconnectAttempts - 1);
this.reconnecting = true;
try {
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
if (!this.closed) await this.doConnect();
} catch {
if (!this.closed) {
this.reconnecting = false;
await this.attemptReconnect();
}
} finally {
this.reconnecting = false;
}
}
queueAudio(audio) {
this.queuedAudio.push(Buffer.from(audio));
this.queuedBytes += audio.byteLength;
while (this.queuedBytes > this.maxQueuedBytes && this.queuedAudio.length > 0) {
const dropped = this.queuedAudio.shift();
this.queuedBytes -= dropped?.byteLength ?? 0;
}
}
flushQueuedAudio() {
for (const audio of this.queuedAudio) this.options.sendAudio(audio, this.transport);
this.queuedAudio = [];
this.queuedBytes = 0;
}
sendBinary(payload) {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.captureFrame("outbound", payload);
this.ws.send(payload);
return true;
}
sendJson(payload) {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
const serialized = JSON.stringify(payload);
this.captureFrame("outbound", serialized);
this.ws.send(serialized);
return true;
}
forceClose() {
if (this.closeTimer) {
clearTimeout(this.closeTimer);
this.closeTimer = void 0;
}
this.connected = false;
this.ready = false;
if (this.ws) {
this.ws.close(1e3, "Transcription session closed");
this.ws = null;
}
}
emitError(error) {
this.options.callbacks.onError?.(error instanceof Error ? error : new Error(String(error)));
}
captureFrame(direction, payload) {
captureWsEvent({
url: this.currentUrl,
direction,
kind: "ws-frame",
flowId: this.flowId,
payload,
meta: {
provider: this.options.providerId,
capability: "realtime-transcription"
}
});
}
captureLocalOpen() {
captureWsEvent({
url: this.currentUrl,
direction: "local",
kind: "ws-open",
flowId: this.flowId,
meta: {
provider: this.options.providerId,
capability: "realtime-transcription"
}
});
}
captureError(error) {
captureWsEvent({
url: this.currentUrl,
direction: "local",
kind: "error",
flowId: this.flowId,
errorText: error.message,
meta: {
provider: this.options.providerId,
capability: "realtime-transcription"
}
});
}
captureClose(code, reasonBuffer) {
captureWsEvent({
url: this.currentUrl,
direction: "local",
kind: "ws-close",
flowId: this.flowId,
closeCode: code,
meta: {
provider: this.options.providerId,
capability: "realtime-transcription",
reason: reasonBuffer.length > 0 ? reasonBuffer.toString("utf8") : void 0
}
});
}
};
/** Creates a reusable websocket session wrapper for a provider implementation. */
function createRealtimeTranscriptionWebSocketSession(options) {
return new WebSocketRealtimeTranscriptionSession(options);
}
//#endregion
export { createRealtimeTranscriptionWebSocketSession as t };