@sayna-ai/node-sdk 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -58,9 +58,11 @@ class SaynaServerError extends SaynaError {
58
58
 
59
59
  // src/sayna-client.ts
60
60
  var isBun = typeof process !== "undefined" && typeof process.versions?.bun === "string";
61
- var hasNativeWebSocket = typeof globalThis.WebSocket !== "undefined";
61
+ var isNode = typeof process !== "undefined" && typeof process.versions?.node === "string" && !isBun;
62
62
  var WS;
63
- if (hasNativeWebSocket) {
63
+ if (isNode) {
64
+ WS = __require("ws");
65
+ } else if (typeof globalThis.WebSocket !== "undefined") {
64
66
  WS = globalThis.WebSocket;
65
67
  } else {
66
68
  WS = __require("ws");
@@ -86,7 +88,9 @@ class SaynaClient {
86
88
  ttsCallback;
87
89
  errorCallback;
88
90
  messageCallback;
91
+ participantConnectedCallback;
89
92
  participantDisconnectedCallback;
93
+ trackSubscribedCallback;
90
94
  ttsPlaybackCompleteCallback;
91
95
  sipTransferErrorCallback;
92
96
  readyPromiseResolve;
@@ -115,7 +119,7 @@ class SaynaClient {
115
119
  if (this.isConnected) {
116
120
  return;
117
121
  }
118
- const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + (this.url.endsWith("/") ? "ws" : "/ws");
122
+ const wsUrl = this.getWebSocketUrl();
119
123
  return new Promise((resolve, reject) => {
120
124
  this.readyPromiseResolve = resolve;
121
125
  this.readyPromiseReject = reject;
@@ -157,7 +161,7 @@ class SaynaClient {
157
161
  const wasReady = this.isReady;
158
162
  this.cleanup();
159
163
  if (!wasReady && this.readyPromiseReject) {
160
- const reason = event.reason.length > 0 ? event.reason : "none";
164
+ const reason = event.reason && event.reason.length > 0 ? event.reason : "none";
161
165
  this.readyPromiseReject(new SaynaConnectionError(`WebSocket closed before ready (code: ${event.code}, reason: ${reason})`));
162
166
  }
163
167
  };
@@ -170,27 +174,30 @@ class SaynaClient {
170
174
  try {
171
175
  if (event.data instanceof Blob || event.data instanceof ArrayBuffer) {
172
176
  const buffer = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
173
- if (this.ttsCallback) {
174
- await this.ttsCallback(buffer);
175
- }
177
+ await this.invokeCallback(this.ttsCallback, buffer, "TTS audio");
176
178
  } else {
177
179
  if (typeof event.data !== "string") {
178
- throw new Error("Expected string data for JSON messages");
180
+ this.logProtocolWarning("Ignoring websocket message with unsupported non-binary payload type", event.data);
181
+ return;
182
+ }
183
+ let data;
184
+ try {
185
+ data = JSON.parse(event.data);
186
+ } catch (error) {
187
+ this.logProtocolWarning(`Ignoring invalid websocket JSON: ${this.describeError(error)}`, event.data);
188
+ return;
179
189
  }
180
- const data = JSON.parse(event.data);
181
190
  await this.handleJsonMessage(data);
182
191
  }
183
192
  } catch (error) {
184
- if (this.errorCallback) {
185
- const errorMessage = error instanceof Error ? error.message : String(error);
186
- await this.errorCallback({
187
- type: "error",
188
- message: `Failed to process message: ${errorMessage}`
189
- });
190
- }
193
+ await this.reportHandlerError("WebSocket message", error);
191
194
  }
192
195
  }
193
- async handleJsonMessage(data) {
196
+ async handleJsonMessage(raw) {
197
+ const data = this.parseIncomingMessage(raw);
198
+ if (!data) {
199
+ return;
200
+ }
194
201
  const messageType = data.type;
195
202
  try {
196
203
  switch (messageType) {
@@ -208,72 +215,246 @@ class SaynaClient {
208
215
  break;
209
216
  }
210
217
  case "stt_result": {
211
- const sttResult = data;
212
- if (this.sttCallback) {
213
- await this.sttCallback(sttResult);
214
- }
218
+ await this.invokeCallback(this.sttCallback, data, "STT result");
215
219
  break;
216
220
  }
217
221
  case "error": {
218
- const errorMsg = data;
219
- if (this.errorCallback) {
220
- await this.errorCallback(errorMsg);
221
- }
222
+ await this.invokeCallback(this.errorCallback, data, "error");
222
223
  if (this.readyPromiseReject && !this.isReady) {
223
- this.readyPromiseReject(new SaynaServerError(errorMsg.message));
224
+ this.readyPromiseReject(new SaynaServerError(data.message));
224
225
  }
225
226
  break;
226
227
  }
227
228
  case "sip_transfer_error": {
228
- const sipTransferError = data;
229
229
  if (this.sipTransferErrorCallback) {
230
- await this.sipTransferErrorCallback(sipTransferError);
230
+ await this.invokeCallback(this.sipTransferErrorCallback, data, "SIP transfer error");
231
231
  } else if (this.errorCallback) {
232
232
  await this.errorCallback({
233
233
  type: "error",
234
- message: sipTransferError.message
234
+ message: data.message
235
235
  });
236
236
  }
237
237
  break;
238
238
  }
239
239
  case "message": {
240
- const messageData = data;
241
- if (this.messageCallback) {
242
- await this.messageCallback(messageData.message);
243
- }
240
+ await this.invokeCallback(this.messageCallback, data.message, "message");
241
+ break;
242
+ }
243
+ case "participant_connected": {
244
+ await this.invokeCallback(this.participantConnectedCallback, data.participant, "participant connected");
244
245
  break;
245
246
  }
246
247
  case "participant_disconnected": {
247
- const participantMsg = data;
248
- if (this.participantDisconnectedCallback) {
249
- await this.participantDisconnectedCallback(participantMsg.participant);
250
- }
248
+ await this.invokeCallback(this.participantDisconnectedCallback, data.participant, "participant disconnected");
251
249
  break;
252
250
  }
253
- case "tts_playback_complete": {
254
- const ttsPlaybackCompleteMsg = data;
255
- if (this.ttsPlaybackCompleteCallback) {
256
- await this.ttsPlaybackCompleteCallback(ttsPlaybackCompleteMsg.timestamp);
257
- }
251
+ case "track_subscribed": {
252
+ await this.invokeCallback(this.trackSubscribedCallback, data.track, "track subscribed");
258
253
  break;
259
254
  }
260
- default: {
261
- const unknownMessage = data;
262
- const errorMessage = `Unknown message type received: ${unknownMessage.type}`;
263
- if (this.errorCallback) {
264
- await this.errorCallback({ type: "error", message: errorMessage });
265
- } else {
266
- console.warn(errorMessage);
267
- }
255
+ case "tts_playback_complete": {
256
+ await this.invokeCallback(this.ttsPlaybackCompleteCallback, data.timestamp, "TTS playback complete");
257
+ break;
268
258
  }
269
259
  }
270
260
  } catch (error) {
271
- if (this.errorCallback) {
272
- await this.errorCallback({
273
- type: "error",
274
- message: `Handler error: ${error instanceof Error ? error.message : String(error)}`
275
- });
261
+ await this.reportHandlerError(`"${messageType}"`, error);
262
+ }
263
+ }
264
+ getWebSocketUrl() {
265
+ const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
266
+ const parsedUrl = new URL(wsUrl);
267
+ if (!parsedUrl.pathname.endsWith("/ws")) {
268
+ parsedUrl.pathname = parsedUrl.pathname.endsWith("/") ? `${parsedUrl.pathname}ws` : `${parsedUrl.pathname}/ws`;
269
+ }
270
+ return parsedUrl.toString();
271
+ }
272
+ parseIncomingMessage(raw) {
273
+ if (!this.isJsonObject(raw)) {
274
+ this.logProtocolWarning("Ignoring websocket payload because it is not a JSON object", raw);
275
+ return;
276
+ }
277
+ const messageType = raw.type;
278
+ if (typeof messageType !== "string") {
279
+ this.logProtocolWarning('Ignoring websocket payload without a string "type" field', raw);
280
+ return;
281
+ }
282
+ try {
283
+ switch (messageType) {
284
+ case "ready":
285
+ return {
286
+ type: "ready",
287
+ stream_id: this.getOptionalString(raw, "stream_id"),
288
+ livekit_room_name: this.getOptionalString(raw, "livekit_room_name"),
289
+ livekit_url: this.getOptionalString(raw, "livekit_url"),
290
+ sayna_participant_identity: this.getOptionalString(raw, "sayna_participant_identity"),
291
+ sayna_participant_name: this.getOptionalString(raw, "sayna_participant_name")
292
+ };
293
+ case "stt_result":
294
+ return {
295
+ type: "stt_result",
296
+ transcript: this.getRequiredString(raw, "transcript"),
297
+ is_final: this.getRequiredBoolean(raw, "is_final"),
298
+ is_speech_final: this.getRequiredBoolean(raw, "is_speech_final"),
299
+ confidence: this.getRequiredNumber(raw, "confidence")
300
+ };
301
+ case "error":
302
+ return {
303
+ type: "error",
304
+ message: this.getRequiredString(raw, "message")
305
+ };
306
+ case "sip_transfer_error":
307
+ return {
308
+ type: "sip_transfer_error",
309
+ message: this.getRequiredString(raw, "message")
310
+ };
311
+ case "message":
312
+ return {
313
+ type: "message",
314
+ message: this.parseSaynaMessage(raw, "message")
315
+ };
316
+ case "participant_connected":
317
+ return {
318
+ type: "participant_connected",
319
+ participant: this.parseParticipant(raw, "participant")
320
+ };
321
+ case "participant_disconnected":
322
+ return {
323
+ type: "participant_disconnected",
324
+ participant: this.parseParticipant(raw, "participant")
325
+ };
326
+ case "track_subscribed":
327
+ return {
328
+ type: "track_subscribed",
329
+ track: this.parseTrack(raw, "track")
330
+ };
331
+ case "tts_playback_complete":
332
+ return {
333
+ type: "tts_playback_complete",
334
+ timestamp: this.getRequiredNumber(raw, "timestamp")
335
+ };
336
+ default:
337
+ this.logProtocolWarning(`Ignoring unknown websocket message type "${messageType}"`, raw);
338
+ return;
276
339
  }
340
+ } catch (error) {
341
+ this.logProtocolWarning(`Ignoring malformed websocket message "${messageType}": ${this.describeError(error)}`, raw);
342
+ return;
343
+ }
344
+ }
345
+ parseSaynaMessage(container, fieldName) {
346
+ const message = this.getRequiredObject(container, fieldName);
347
+ return {
348
+ message: this.getOptionalString(message, "message"),
349
+ data: this.getOptionalString(message, "data"),
350
+ identity: this.getRequiredString(message, "identity"),
351
+ topic: this.getRequiredString(message, "topic"),
352
+ room: this.getRequiredString(message, "room"),
353
+ timestamp: this.getRequiredNumber(message, "timestamp")
354
+ };
355
+ }
356
+ parseParticipant(container, fieldName) {
357
+ const participant = this.getRequiredObject(container, fieldName);
358
+ return {
359
+ identity: this.getRequiredString(participant, "identity"),
360
+ name: this.getOptionalString(participant, "name"),
361
+ room: this.getRequiredString(participant, "room"),
362
+ timestamp: this.getRequiredNumber(participant, "timestamp")
363
+ };
364
+ }
365
+ parseTrack(container, fieldName) {
366
+ const track = this.getRequiredObject(container, fieldName);
367
+ const trackKind = this.getRequiredString(track, "track_kind");
368
+ if (trackKind !== "audio" && trackKind !== "video") {
369
+ throw new Error('Field "track_kind" must be either "audio" or "video"');
370
+ }
371
+ return {
372
+ identity: this.getRequiredString(track, "identity"),
373
+ name: this.getOptionalString(track, "name"),
374
+ track_kind: trackKind,
375
+ track_sid: this.getRequiredString(track, "track_sid"),
376
+ room: this.getRequiredString(track, "room"),
377
+ timestamp: this.getRequiredNumber(track, "timestamp")
378
+ };
379
+ }
380
+ getRequiredObject(container, fieldName) {
381
+ const value = container[fieldName];
382
+ if (!this.isJsonObject(value)) {
383
+ throw new Error(`Field "${fieldName}" must be an object`);
384
+ }
385
+ return value;
386
+ }
387
+ getRequiredString(container, fieldName) {
388
+ const value = container[fieldName];
389
+ if (typeof value !== "string") {
390
+ throw new Error(`Field "${fieldName}" must be a string`);
391
+ }
392
+ return value;
393
+ }
394
+ getOptionalString(container, fieldName) {
395
+ const value = container[fieldName];
396
+ if (typeof value === "undefined" || value === null) {
397
+ return;
398
+ }
399
+ if (typeof value !== "string") {
400
+ throw new Error(`Field "${fieldName}" must be a string when present`);
401
+ }
402
+ return value;
403
+ }
404
+ getRequiredBoolean(container, fieldName) {
405
+ const value = container[fieldName];
406
+ if (typeof value !== "boolean") {
407
+ throw new Error(`Field "${fieldName}" must be a boolean`);
408
+ }
409
+ return value;
410
+ }
411
+ getRequiredNumber(container, fieldName) {
412
+ const value = container[fieldName];
413
+ if (typeof value !== "number" || !Number.isFinite(value)) {
414
+ throw new Error(`Field "${fieldName}" must be a finite number`);
415
+ }
416
+ return value;
417
+ }
418
+ isJsonObject(value) {
419
+ return typeof value === "object" && value !== null && !Array.isArray(value);
420
+ }
421
+ logProtocolWarning(message, payload) {
422
+ console.warn(`${message}; payload=${this.safeStringify(payload)}`);
423
+ }
424
+ safeStringify(payload) {
425
+ try {
426
+ return JSON.stringify(payload);
427
+ } catch {
428
+ return String(payload);
429
+ }
430
+ }
431
+ describeError(error) {
432
+ return error instanceof Error ? error.message : String(error);
433
+ }
434
+ async invokeCallback(callback, payload, label) {
435
+ if (!callback) {
436
+ return;
437
+ }
438
+ try {
439
+ await callback(payload);
440
+ } catch (error) {
441
+ await this.reportHandlerError(`${label} callback`, error);
442
+ }
443
+ }
444
+ async reportHandlerError(label, error) {
445
+ const message = `${label} failed: ${this.describeError(error)}`;
446
+ if (!this.errorCallback) {
447
+ console.error(message);
448
+ return;
449
+ }
450
+ try {
451
+ await this.errorCallback({
452
+ type: "error",
453
+ message
454
+ });
455
+ } catch (callbackError) {
456
+ console.error(message);
457
+ console.error(`error callback failed: ${this.describeError(callbackError)}`);
277
458
  }
278
459
  }
279
460
  createWebSocket(url) {
@@ -281,7 +462,7 @@ class SaynaClient {
281
462
  return new WS(url);
282
463
  }
283
464
  const headers = { Authorization: `Bearer ${this.apiKey}` };
284
- if (!hasNativeWebSocket) {
465
+ if (isNode) {
285
466
  return new WS(url, undefined, { headers });
286
467
  }
287
468
  if (isBun) {
@@ -393,9 +574,15 @@ class SaynaClient {
393
574
  registerOnMessage(callback) {
394
575
  this.messageCallback = callback;
395
576
  }
577
+ registerOnParticipantConnected(callback) {
578
+ this.participantConnectedCallback = callback;
579
+ }
396
580
  registerOnParticipantDisconnected(callback) {
397
581
  this.participantDisconnectedCallback = callback;
398
582
  }
583
+ registerOnTrackSubscribed(callback) {
584
+ this.trackSubscribedCallback = callback;
585
+ }
399
586
  registerOnTtsPlaybackComplete(callback) {
400
587
  this.ttsPlaybackCompleteCallback = callback;
401
588
  }
@@ -848,4 +1035,4 @@ export {
848
1035
  SaynaClient
849
1036
  };
850
1037
 
851
- //# debugId=F5D7B3AC02E5755064756E2164756E21
1038
+ //# debugId=855B93EB4E2A921264756E2164756E21