@sayna-ai/node-sdk
Version:
Node.js SDK for Sayna.ai server-side WebSocket connections
655 lines (645 loc) • 22.5 kB
JavaScript
// src/errors.ts
class SaynaError extends Error {
constructor(message) {
super(message);
this.name = "SaynaError";
Object.setPrototypeOf(this, SaynaError.prototype);
}
}
class SaynaNotConnectedError extends SaynaError {
constructor(message = "Not connected to Sayna WebSocket") {
super(message);
this.name = "SaynaNotConnectedError";
Object.setPrototypeOf(this, SaynaNotConnectedError.prototype);
}
}
class SaynaNotReadyError extends SaynaError {
constructor(message = "Sayna voice providers are not ready. Wait for the connection to be established.") {
super(message);
this.name = "SaynaNotReadyError";
Object.setPrototypeOf(this, SaynaNotReadyError.prototype);
}
}
class SaynaConnectionError extends SaynaError {
cause;
constructor(message, cause) {
super(message);
this.name = "SaynaConnectionError";
this.cause = cause;
Object.setPrototypeOf(this, SaynaConnectionError.prototype);
}
}
class SaynaValidationError extends SaynaError {
constructor(message) {
super(message);
this.name = "SaynaValidationError";
Object.setPrototypeOf(this, SaynaValidationError.prototype);
}
}
class SaynaServerError extends SaynaError {
constructor(message) {
super(message);
this.name = "SaynaServerError";
Object.setPrototypeOf(this, SaynaServerError.prototype);
}
}
// src/sayna-client.ts
class SaynaClient {
url;
sttConfig;
ttsConfig;
livekitConfig;
withoutAudio;
apiKey;
websocket;
isConnected = false;
isReady = false;
_livekitRoomName;
_livekitUrl;
_saynaParticipantIdentity;
_saynaParticipantName;
_streamId;
inputStreamId;
sttCallback;
ttsCallback;
errorCallback;
messageCallback;
participantDisconnectedCallback;
ttsPlaybackCompleteCallback;
readyPromiseResolve;
readyPromiseReject;
constructor(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false, apiKey, streamId) {
if (!url || typeof url !== "string") {
throw new SaynaValidationError("URL must be a non-empty string");
}
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("ws://") && !url.startsWith("wss://")) {
throw new SaynaValidationError("URL must start with http://, https://, ws://, or wss://");
}
if (!withoutAudio) {
if (!sttConfig || !ttsConfig) {
throw new SaynaValidationError("sttConfig and ttsConfig are required when withoutAudio=false (audio streaming enabled). " + "Either provide both configs or set withoutAudio=true for non-audio use cases.");
}
}
this.url = url;
this.sttConfig = sttConfig;
this.ttsConfig = ttsConfig;
this.livekitConfig = livekitConfig;
this.withoutAudio = withoutAudio;
this.apiKey = apiKey ?? process.env.SAYNA_API_KEY;
this.inputStreamId = streamId;
}
async connect() {
if (this.isConnected) {
return;
}
const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + (this.url.endsWith("/") ? "ws" : "/ws");
return new Promise((resolve, reject) => {
this.readyPromiseResolve = resolve;
this.readyPromiseReject = reject;
try {
const headers = this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : undefined;
const WebSocketConstructor = WebSocket;
this.websocket = headers ? new WebSocketConstructor(wsUrl, undefined, { headers }) : new WebSocket(wsUrl);
this.websocket.onopen = () => {
this.isConnected = true;
const configMessage = {
type: "config",
stream_id: this.inputStreamId,
stt_config: this.sttConfig,
tts_config: this.ttsConfig,
livekit: this.livekitConfig,
audio: !this.withoutAudio
};
try {
if (this.websocket) {
this.websocket.send(JSON.stringify(configMessage));
}
} catch (error) {
this.cleanup();
const err = new SaynaConnectionError("Failed to send configuration", error);
if (this.readyPromiseReject) {
this.readyPromiseReject(err);
}
}
};
this.websocket.onmessage = async (event) => {
try {
if (event.data instanceof Blob || event.data instanceof ArrayBuffer) {
const buffer = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
if (this.ttsCallback) {
await this.ttsCallback(buffer);
}
} else {
if (typeof event.data !== "string") {
throw new Error("Expected string data for JSON messages");
}
const data = JSON.parse(event.data);
await this.handleJsonMessage(data);
}
} catch (error) {
if (this.errorCallback) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.errorCallback({
type: "error",
message: `Failed to process message: ${errorMessage}`
});
}
}
};
this.websocket.onerror = () => {
const error = new SaynaConnectionError("WebSocket connection error");
if (this.readyPromiseReject && !this.isReady) {
this.readyPromiseReject(error);
}
};
this.websocket.onclose = (event) => {
const wasReady = this.isReady;
this.cleanup();
if (!wasReady && this.readyPromiseReject) {
this.readyPromiseReject(new SaynaConnectionError(`WebSocket closed before ready (code: ${event.code}, reason: ${event.reason || "none"})`));
}
};
} catch (error) {
reject(new SaynaConnectionError("Failed to create WebSocket", error));
}
});
}
async handleJsonMessage(data) {
const messageType = data.type;
try {
switch (messageType) {
case "ready": {
const readyMsg = data;
this.isReady = true;
this._livekitRoomName = readyMsg.livekit_room_name;
this._livekitUrl = readyMsg.livekit_url;
this._saynaParticipantIdentity = readyMsg.sayna_participant_identity;
this._saynaParticipantName = readyMsg.sayna_participant_name;
this._streamId = readyMsg.stream_id;
if (this.readyPromiseResolve) {
this.readyPromiseResolve();
}
break;
}
case "stt_result": {
const sttResult = data;
if (this.sttCallback) {
await this.sttCallback(sttResult);
}
break;
}
case "error": {
const errorMsg = data;
if (this.errorCallback) {
await this.errorCallback(errorMsg);
}
if (this.readyPromiseReject && !this.isReady) {
this.readyPromiseReject(new SaynaServerError(errorMsg.message));
}
break;
}
case "message": {
const messageData = data;
if (this.messageCallback) {
await this.messageCallback(messageData.message);
}
break;
}
case "participant_disconnected": {
const participantMsg = data;
if (this.participantDisconnectedCallback) {
await this.participantDisconnectedCallback(participantMsg.participant);
}
break;
}
case "tts_playback_complete": {
const ttsPlaybackCompleteMsg = data;
if (this.ttsPlaybackCompleteCallback) {
await this.ttsPlaybackCompleteCallback(ttsPlaybackCompleteMsg.timestamp);
}
break;
}
}
} catch (error) {
if (this.errorCallback) {
await this.errorCallback({
type: "error",
message: `Handler error: ${error instanceof Error ? error.message : String(error)}`
});
}
}
}
cleanup() {
this.isConnected = false;
this.isReady = false;
this._livekitRoomName = undefined;
this._livekitUrl = undefined;
this._saynaParticipantIdentity = undefined;
this._saynaParticipantName = undefined;
this._streamId = undefined;
}
getHttpUrl() {
return this.url.replace(/^ws:\/\//, "http://").replace(/^wss:\/\//, "https://");
}
async fetchFromSayna(endpoint, options = {}, responseType = "json") {
const httpUrl = this.getHttpUrl();
const url = `${httpUrl}${httpUrl.endsWith("/") ? "" : "/"}${endpoint.startsWith("/") ? endpoint.slice(1) : endpoint}`;
const headers = {
...options.headers
};
const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
if (this.apiKey && !hasAuthHeader) {
headers["Authorization"] = `Bearer ${this.apiKey}`;
}
if (options.method === "POST" && options.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
try {
const response = await fetch(url, {
...options,
headers
});
if (!response.ok) {
let errorMessage;
try {
const errorData = await response.json();
errorMessage = errorData && typeof errorData === "object" && "error" in errorData && typeof errorData.error === "string" ? errorData.error : `Request failed: ${response.status} ${response.statusText}`;
} catch {
errorMessage = `Request failed: ${response.status} ${response.statusText}`;
}
throw new SaynaServerError(errorMessage);
}
if (responseType === "arrayBuffer") {
return await response.arrayBuffer();
} else {
return await response.json();
}
} catch (error) {
if (error instanceof SaynaServerError) {
throw error;
}
throw new SaynaConnectionError(`Failed to fetch from ${endpoint}`, error);
}
}
disconnect() {
if (this.websocket) {
this.websocket.onopen = null;
this.websocket.onmessage = null;
this.websocket.onerror = null;
this.websocket.onclose = null;
if (this.websocket.readyState === WebSocket.OPEN) {
this.websocket.close(1000, "Client disconnect");
}
this.websocket = undefined;
}
this.cleanup();
}
onAudioInput(audioData) {
if (!this.isConnected || !this.websocket) {
throw new SaynaNotConnectedError;
}
if (!this.isReady) {
throw new SaynaNotReadyError;
}
if (!(audioData instanceof ArrayBuffer)) {
throw new SaynaValidationError("audioData must be an ArrayBuffer");
}
if (audioData.byteLength === 0) {
throw new SaynaValidationError("audioData cannot be empty");
}
try {
this.websocket.send(audioData);
} catch (error) {
throw new SaynaConnectionError("Failed to send audio data", error);
}
}
registerOnSttResult(callback) {
this.sttCallback = callback;
}
registerOnTtsAudio(callback) {
this.ttsCallback = callback;
}
registerOnError(callback) {
this.errorCallback = callback;
}
registerOnMessage(callback) {
this.messageCallback = callback;
}
registerOnParticipantDisconnected(callback) {
this.participantDisconnectedCallback = callback;
}
registerOnTtsPlaybackComplete(callback) {
this.ttsPlaybackCompleteCallback = callback;
}
speak(text, flush = true, allowInterruption = true) {
if (!this.isConnected || !this.websocket) {
throw new SaynaNotConnectedError;
}
if (!this.isReady) {
throw new SaynaNotReadyError;
}
if (typeof text !== "string") {
throw new SaynaValidationError("text must be a string");
}
try {
const speakMessage = {
type: "speak",
text,
flush,
allow_interruption: allowInterruption
};
this.websocket.send(JSON.stringify(speakMessage));
} catch (error) {
throw new SaynaConnectionError("Failed to send speak command", error);
}
}
clear() {
if (!this.isConnected || !this.websocket) {
throw new SaynaNotConnectedError;
}
if (!this.isReady) {
throw new SaynaNotReadyError;
}
try {
const clearMessage = {
type: "clear"
};
this.websocket.send(JSON.stringify(clearMessage));
} catch (error) {
throw new SaynaConnectionError("Failed to send clear command", error);
}
}
ttsFlush(allowInterruption = true) {
this.speak("", true, allowInterruption);
}
sendMessage(message, role, topic, debug) {
if (!this.isConnected || !this.websocket) {
throw new SaynaNotConnectedError;
}
if (!this.isReady) {
throw new SaynaNotReadyError;
}
if (typeof message !== "string") {
throw new SaynaValidationError("message must be a string");
}
if (typeof role !== "string") {
throw new SaynaValidationError("role must be a string");
}
try {
const sendMsg = {
type: "send_message",
message,
role,
topic,
debug
};
this.websocket.send(JSON.stringify(sendMsg));
} catch (error) {
throw new SaynaConnectionError("Failed to send message", error);
}
}
async health() {
return this.fetchFromSayna("");
}
async getVoices() {
return this.fetchFromSayna("voices");
}
async speakRest(text, ttsConfig) {
if (!text || text.trim().length === 0) {
throw new SaynaValidationError("Text cannot be empty");
}
return this.fetchFromSayna("speak", {
method: "POST",
body: JSON.stringify({
text,
tts_config: ttsConfig
})
}, "arrayBuffer");
}
async getLiveKitToken(roomName, participantName, participantIdentity) {
if (!roomName || roomName.trim().length === 0) {
throw new SaynaValidationError("room_name cannot be empty");
}
if (!participantName || participantName.trim().length === 0) {
throw new SaynaValidationError("participant_name cannot be empty");
}
if (!participantIdentity || participantIdentity.trim().length === 0) {
throw new SaynaValidationError("participant_identity cannot be empty");
}
return this.fetchFromSayna("livekit/token", {
method: "POST",
body: JSON.stringify({
room_name: roomName,
participant_name: participantName,
participant_identity: participantIdentity
})
});
}
async getRecording(streamId) {
if (!streamId || streamId.trim().length === 0) {
throw new SaynaValidationError("streamId cannot be empty");
}
return this.fetchFromSayna(`recording/${encodeURIComponent(streamId)}`, { method: "GET" }, "arrayBuffer");
}
async getSipHooks() {
return this.fetchFromSayna("sip/hooks");
}
async setSipHooks(hooks) {
if (!Array.isArray(hooks)) {
throw new SaynaValidationError("hooks must be an array");
}
if (hooks.length === 0) {
throw new SaynaValidationError("hooks array cannot be empty");
}
for (const [i, hook] of hooks.entries()) {
if (!hook.host || typeof hook.host !== "string" || hook.host.trim().length === 0) {
throw new SaynaValidationError(`hooks[${i}].host must be a non-empty string`);
}
if (!hook.url || typeof hook.url !== "string" || hook.url.trim().length === 0) {
throw new SaynaValidationError(`hooks[${i}].url must be a non-empty string`);
}
}
return this.fetchFromSayna("sip/hooks", {
method: "POST",
body: JSON.stringify({ hooks })
});
}
async deleteSipHooks(hosts) {
if (!Array.isArray(hosts)) {
throw new SaynaValidationError("hosts must be an array");
}
if (hosts.length === 0) {
throw new SaynaValidationError("hosts array cannot be empty");
}
for (const [i, host] of hosts.entries()) {
if (!host || typeof host !== "string" || host.trim().length === 0) {
throw new SaynaValidationError(`hosts[${i}] must be a non-empty string`);
}
}
return this.fetchFromSayna("sip/hooks", {
method: "DELETE",
body: JSON.stringify({ hosts })
});
}
get ready() {
return this.isReady;
}
get connected() {
return this.isConnected;
}
get livekitRoomName() {
return this._livekitRoomName;
}
get livekitUrl() {
return this._livekitUrl;
}
get saynaParticipantIdentity() {
return this._saynaParticipantIdentity;
}
get saynaParticipantName() {
return this._saynaParticipantName;
}
get streamId() {
return this._streamId;
}
}
// src/webhook-receiver.ts
import { createHmac, timingSafeEqual } from "crypto";
var MIN_SECRET_LENGTH = 16;
var TIMESTAMP_TOLERANCE_SECONDS = 300;
class WebhookReceiver {
secret;
constructor(secret) {
const effectiveSecret = secret ?? process.env.SAYNA_WEBHOOK_SECRET;
if (!effectiveSecret) {
throw new SaynaValidationError("Webhook secret is required. Provide it as a constructor parameter or set SAYNA_WEBHOOK_SECRET environment variable.");
}
const trimmedSecret = effectiveSecret.trim();
if (trimmedSecret.length < MIN_SECRET_LENGTH) {
throw new SaynaValidationError(`Webhook secret must be at least ${MIN_SECRET_LENGTH} characters long. ` + `Received ${trimmedSecret.length} characters. ` + `Generate a secure secret with: openssl rand -hex 32`);
}
this.secret = trimmedSecret;
}
receive(headers, body) {
const normalizedHeaders = this.normalizeHeaders(headers);
const signature = this.getRequiredHeader(normalizedHeaders, "x-sayna-signature");
const timestamp = this.getRequiredHeader(normalizedHeaders, "x-sayna-timestamp");
const eventId = this.getRequiredHeader(normalizedHeaders, "x-sayna-event-id");
if (!signature.startsWith("v1=")) {
throw new SaynaValidationError("Invalid signature format. Expected 'v1=<hex>' but got: " + signature.substring(0, 10) + "...");
}
const signatureHex = signature.substring(3);
if (!/^[0-9a-f]{64}$/i.test(signatureHex)) {
throw new SaynaValidationError("Invalid signature: must be 64 hex characters (HMAC-SHA256)");
}
this.validateTimestamp(timestamp);
const canonical = `v1:${timestamp}:${eventId}:${body}`;
const hmac = createHmac("sha256", this.secret);
hmac.update(canonical, "utf8");
const expectedSignature = hmac.digest("hex");
if (!this.constantTimeEqual(signatureHex, expectedSignature)) {
throw new SaynaValidationError("Signature verification failed. The webhook may have been tampered with or the secret is incorrect.");
}
return this.parseAndValidatePayload(body);
}
normalizeHeaders(headers) {
const normalized = {};
for (const [key, value] of Object.entries(headers)) {
if (value !== undefined) {
const stringValue = Array.isArray(value) ? value[0] : value;
if (stringValue) {
normalized[key.toLowerCase()] = stringValue;
}
}
}
return normalized;
}
getRequiredHeader(headers, name) {
const value = headers[name.toLowerCase()];
if (!value) {
throw new SaynaValidationError(`Missing required header: ${name}`);
}
return value;
}
validateTimestamp(timestampStr) {
const timestamp = Number(timestampStr);
if (isNaN(timestamp)) {
throw new SaynaValidationError(`Invalid timestamp format: expected Unix seconds but got '${timestampStr}'`);
}
const now = Math.floor(Date.now() / 1000);
const diff = Math.abs(now - timestamp);
if (diff > TIMESTAMP_TOLERANCE_SECONDS) {
throw new SaynaValidationError(`Timestamp outside replay protection window. ` + `Difference: ${diff} seconds (max allowed: ${TIMESTAMP_TOLERANCE_SECONDS}). ` + `This webhook may be a replay attack or there may be significant clock skew.`);
}
}
constantTimeEqual(a, b) {
if (a.length !== b.length) {
return false;
}
const bufA = Buffer.from(a, "utf8");
const bufB = Buffer.from(b, "utf8");
return timingSafeEqual(bufA, bufB);
}
parseAndValidatePayload(body) {
let payload;
try {
payload = JSON.parse(body);
} catch (error) {
throw new SaynaValidationError(`Invalid JSON payload: ${error instanceof Error ? error.message : String(error)}`);
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new SaynaValidationError("Webhook payload must be a JSON object");
}
const data = payload;
this.validateParticipant(data.participant);
this.validateRoom(data.room);
this.validateStringField(data, "from_phone_number", "from_phone_number");
this.validateStringField(data, "to_phone_number", "to_phone_number");
this.validateStringField(data, "room_prefix", "room_prefix");
this.validateStringField(data, "sip_host", "sip_host");
return data;
}
validateParticipant(participant) {
if (!participant || typeof participant !== "object" || Array.isArray(participant)) {
throw new SaynaValidationError("Webhook payload missing required field 'participant' (must be an object)");
}
const p = participant;
this.validateStringField(p, "identity", "participant.identity");
this.validateStringField(p, "sid", "participant.sid");
if (p.name !== undefined && typeof p.name !== "string") {
throw new SaynaValidationError("Field 'participant.name' must be a string if present");
}
}
validateRoom(room) {
if (!room || typeof room !== "object" || Array.isArray(room)) {
throw new SaynaValidationError("Webhook payload missing required field 'room' (must be an object)");
}
const r = room;
this.validateStringField(r, "name", "room.name");
this.validateStringField(r, "sid", "room.sid");
}
validateStringField(obj, field, displayName) {
const value = obj[field];
if (typeof value !== "string" || value.length === 0) {
throw new SaynaValidationError(`Webhook payload missing required field '${displayName}' (must be a non-empty string)`);
}
}
}
// src/index.ts
async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false, apiKey) {
const client = new SaynaClient(url, sttConfig, ttsConfig, livekitConfig, withoutAudio, apiKey);
await client.connect();
return client;
}
export {
saynaConnect,
WebhookReceiver,
SaynaValidationError,
SaynaServerError,
SaynaNotReadyError,
SaynaNotConnectedError,
SaynaError,
SaynaConnectionError,
SaynaClient
};
//# debugId=439FAA862268544264756E2164756E21