Files
js-sdk/src/_shared/websocket.ts
Tasnim Kabir Sadik 5b3f2741a3 feat: add SDK foundation layer
Implement config resolution, typed errors, HTTP and WebSocket transport
helpers, and timeout handling for the SDK foundation. Add unit and local
integration tests covering the SDK foundation behaviour and align
package exports with the tsup output.
2026-05-09 16:32:41 +06:00

141 lines
3.6 KiB
TypeScript

import WebSocket from "ws";
import { TimeoutError } from "../exceptions.js";
/** Options used to establish a Wrenn WebSocket connection. */
export interface WsConnectionOpts {
/** HTTP(S) API origin. Converted to WS(S) for the socket URL. */
baseUrl: string;
/** WebSocket path relative to the base URL. */
path: string;
/** API key sent as `X-API-Key`. */
apiKey?: string;
/** Host token sent as `X-Host-Token`. */
hostToken?: string;
/** Callback invoked for each JSON message or raw text payload. */
onMessage: (data: unknown) => void;
/** Callback invoked for socket errors after connection establishment. */
onError?: (error: Error) => void;
/** Callback invoked when the socket closes after connection establishment. */
onClose?: (code: number, reason: string) => void;
/** Connection timeout in milliseconds. Defaults to 30 seconds. */
timeoutMs?: number;
}
/** Minimal WebSocket wrapper for JSON-oriented Wrenn streaming endpoints. */
export class WsConnection {
private ws: WebSocket;
private closed = false;
private constructor(ws: WebSocket) {
this.ws = ws;
}
/** Sends a JSON-encoded message over the open WebSocket. */
send(data: unknown): void {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open");
}
this.ws.send(JSON.stringify(data));
}
/** Closes the WebSocket connection if it is still open. */
close(): void {
if (this.closed) return;
this.closed = true;
this.ws.close();
}
/** Indicates whether the connection has closed or failed. */
get isClosed(): boolean {
return this.closed;
}
/**
* Opens a WebSocket connection and resolves once the socket is ready.
*
* @param opts - Connection URL, authentication, callbacks, and timeout.
* @returns An established WebSocket connection wrapper.
* @throws TimeoutError When the connection is not established before timeout.
*/
static connect(opts: WsConnectionOpts): Promise<WsConnection> {
return new Promise((resolve, reject) => {
const url = new URL(`${opts.baseUrl}${opts.path}`);
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.protocol = protocol;
const headers: Record<string, string> = {};
if (opts.apiKey) {
headers["X-API-Key"] = opts.apiKey;
}
if (opts.hostToken) {
headers["X-Host-Token"] = opts.hostToken;
}
const ws = new WebSocket(url.toString(), {
headers,
});
const timeout = opts.timeoutMs ?? 30_000;
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
settled = true;
};
timeoutHandle = setTimeout(() => {
if (!settled) {
cleanup();
ws.terminate();
reject(
new TimeoutError(
`WebSocket connection timed out after ${timeout}ms`,
),
);
}
}, timeout);
ws.on("open", () => {
if (settled) return;
cleanup();
const conn = new WsConnection(ws);
ws.on("message", (raw) => {
try {
const data = JSON.parse(raw.toString());
opts.onMessage(data);
} catch {
opts.onMessage(raw.toString());
}
});
ws.on("error", (err) => {
conn.closed = true;
opts.onError?.(err);
});
ws.on("close", (code, reason) => {
conn.closed = true;
opts.onClose?.(code, reason.toString());
});
resolve(conn);
});
ws.on("error", (err) => {
if (settled) return;
cleanup();
reject(err);
});
ws.on("close", (code, reason) => {
if (settled) return;
cleanup();
reject(
new Error(
`WebSocket closed before opening (${code}): ${reason.toString()}`,
),
);
});
});
}
}