@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/README.md CHANGED
@@ -240,14 +240,24 @@ Registers a callback for error messages.
240
240
 
241
241
  Registers a callback for participant messages.
242
242
 
243
+ ### `client.registerOnParticipantConnected(callback)`
244
+
245
+ Registers a callback for participant connection events.
246
+
243
247
  ### `client.registerOnParticipantDisconnected(callback)`
244
248
 
245
249
  Registers a callback for participant disconnection events.
246
250
 
251
+ ### `client.registerOnTrackSubscribed(callback)`
252
+
253
+ Registers a callback for track subscription events.
254
+
247
255
  ### `client.registerOnTtsPlaybackComplete(callback)`
248
256
 
249
257
  Registers a callback for TTS playback completion events.
250
258
 
259
+ Unknown or malformed websocket control messages are logged and ignored so protocol drift does not break the client receive loop. Actual server `error` messages still reach `client.registerOnError(callback)`.
260
+
251
261
  ### `await client.speak(text, flush?, allowInterruption?)`
252
262
 
253
263
  Sends text to be synthesized as speech.
package/dist/index.cjs CHANGED
@@ -98,9 +98,11 @@ class SaynaServerError extends SaynaError {
98
98
 
99
99
  // src/sayna-client.ts
100
100
  var isBun = typeof process !== "undefined" && typeof process.versions?.bun === "string";
101
- var hasNativeWebSocket = typeof globalThis.WebSocket !== "undefined";
101
+ var isNode = typeof process !== "undefined" && typeof process.versions?.node === "string" && !isBun;
102
102
  var WS;
103
- if (hasNativeWebSocket) {
103
+ if (isNode) {
104
+ WS = require("ws");
105
+ } else if (typeof globalThis.WebSocket !== "undefined") {
104
106
  WS = globalThis.WebSocket;
105
107
  } else {
106
108
  WS = require("ws");
@@ -126,7 +128,9 @@ class SaynaClient {
126
128
  ttsCallback;
127
129
  errorCallback;
128
130
  messageCallback;
131
+ participantConnectedCallback;
129
132
  participantDisconnectedCallback;
133
+ trackSubscribedCallback;
130
134
  ttsPlaybackCompleteCallback;
131
135
  sipTransferErrorCallback;
132
136
  readyPromiseResolve;
@@ -155,7 +159,7 @@ class SaynaClient {
155
159
  if (this.isConnected) {
156
160
  return;
157
161
  }
158
- const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + (this.url.endsWith("/") ? "ws" : "/ws");
162
+ const wsUrl = this.getWebSocketUrl();
159
163
  return new Promise((resolve, reject) => {
160
164
  this.readyPromiseResolve = resolve;
161
165
  this.readyPromiseReject = reject;
@@ -197,7 +201,7 @@ class SaynaClient {
197
201
  const wasReady = this.isReady;
198
202
  this.cleanup();
199
203
  if (!wasReady && this.readyPromiseReject) {
200
- const reason = event.reason.length > 0 ? event.reason : "none";
204
+ const reason = event.reason && event.reason.length > 0 ? event.reason : "none";
201
205
  this.readyPromiseReject(new SaynaConnectionError(`WebSocket closed before ready (code: ${event.code}, reason: ${reason})`));
202
206
  }
203
207
  };
@@ -210,27 +214,30 @@ class SaynaClient {
210
214
  try {
211
215
  if (event.data instanceof Blob || event.data instanceof ArrayBuffer) {
212
216
  const buffer = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
213
- if (this.ttsCallback) {
214
- await this.ttsCallback(buffer);
215
- }
217
+ await this.invokeCallback(this.ttsCallback, buffer, "TTS audio");
216
218
  } else {
217
219
  if (typeof event.data !== "string") {
218
- throw new Error("Expected string data for JSON messages");
220
+ this.logProtocolWarning("Ignoring websocket message with unsupported non-binary payload type", event.data);
221
+ return;
222
+ }
223
+ let data;
224
+ try {
225
+ data = JSON.parse(event.data);
226
+ } catch (error) {
227
+ this.logProtocolWarning(`Ignoring invalid websocket JSON: ${this.describeError(error)}`, event.data);
228
+ return;
219
229
  }
220
- const data = JSON.parse(event.data);
221
230
  await this.handleJsonMessage(data);
222
231
  }
223
232
  } catch (error) {
224
- if (this.errorCallback) {
225
- const errorMessage = error instanceof Error ? error.message : String(error);
226
- await this.errorCallback({
227
- type: "error",
228
- message: `Failed to process message: ${errorMessage}`
229
- });
230
- }
233
+ await this.reportHandlerError("WebSocket message", error);
231
234
  }
232
235
  }
233
- async handleJsonMessage(data) {
236
+ async handleJsonMessage(raw) {
237
+ const data = this.parseIncomingMessage(raw);
238
+ if (!data) {
239
+ return;
240
+ }
234
241
  const messageType = data.type;
235
242
  try {
236
243
  switch (messageType) {
@@ -248,72 +255,246 @@ class SaynaClient {
248
255
  break;
249
256
  }
250
257
  case "stt_result": {
251
- const sttResult = data;
252
- if (this.sttCallback) {
253
- await this.sttCallback(sttResult);
254
- }
258
+ await this.invokeCallback(this.sttCallback, data, "STT result");
255
259
  break;
256
260
  }
257
261
  case "error": {
258
- const errorMsg = data;
259
- if (this.errorCallback) {
260
- await this.errorCallback(errorMsg);
261
- }
262
+ await this.invokeCallback(this.errorCallback, data, "error");
262
263
  if (this.readyPromiseReject && !this.isReady) {
263
- this.readyPromiseReject(new SaynaServerError(errorMsg.message));
264
+ this.readyPromiseReject(new SaynaServerError(data.message));
264
265
  }
265
266
  break;
266
267
  }
267
268
  case "sip_transfer_error": {
268
- const sipTransferError = data;
269
269
  if (this.sipTransferErrorCallback) {
270
- await this.sipTransferErrorCallback(sipTransferError);
270
+ await this.invokeCallback(this.sipTransferErrorCallback, data, "SIP transfer error");
271
271
  } else if (this.errorCallback) {
272
272
  await this.errorCallback({
273
273
  type: "error",
274
- message: sipTransferError.message
274
+ message: data.message
275
275
  });
276
276
  }
277
277
  break;
278
278
  }
279
279
  case "message": {
280
- const messageData = data;
281
- if (this.messageCallback) {
282
- await this.messageCallback(messageData.message);
283
- }
280
+ await this.invokeCallback(this.messageCallback, data.message, "message");
281
+ break;
282
+ }
283
+ case "participant_connected": {
284
+ await this.invokeCallback(this.participantConnectedCallback, data.participant, "participant connected");
284
285
  break;
285
286
  }
286
287
  case "participant_disconnected": {
287
- const participantMsg = data;
288
- if (this.participantDisconnectedCallback) {
289
- await this.participantDisconnectedCallback(participantMsg.participant);
290
- }
288
+ await this.invokeCallback(this.participantDisconnectedCallback, data.participant, "participant disconnected");
291
289
  break;
292
290
  }
293
- case "tts_playback_complete": {
294
- const ttsPlaybackCompleteMsg = data;
295
- if (this.ttsPlaybackCompleteCallback) {
296
- await this.ttsPlaybackCompleteCallback(ttsPlaybackCompleteMsg.timestamp);
297
- }
291
+ case "track_subscribed": {
292
+ await this.invokeCallback(this.trackSubscribedCallback, data.track, "track subscribed");
298
293
  break;
299
294
  }
300
- default: {
301
- const unknownMessage = data;
302
- const errorMessage = `Unknown message type received: ${unknownMessage.type}`;
303
- if (this.errorCallback) {
304
- await this.errorCallback({ type: "error", message: errorMessage });
305
- } else {
306
- console.warn(errorMessage);
307
- }
295
+ case "tts_playback_complete": {
296
+ await this.invokeCallback(this.ttsPlaybackCompleteCallback, data.timestamp, "TTS playback complete");
297
+ break;
308
298
  }
309
299
  }
310
300
  } catch (error) {
311
- if (this.errorCallback) {
312
- await this.errorCallback({
313
- type: "error",
314
- message: `Handler error: ${error instanceof Error ? error.message : String(error)}`
315
- });
301
+ await this.reportHandlerError(`"${messageType}"`, error);
302
+ }
303
+ }
304
+ getWebSocketUrl() {
305
+ const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
306
+ const parsedUrl = new URL(wsUrl);
307
+ if (!parsedUrl.pathname.endsWith("/ws")) {
308
+ parsedUrl.pathname = parsedUrl.pathname.endsWith("/") ? `${parsedUrl.pathname}ws` : `${parsedUrl.pathname}/ws`;
309
+ }
310
+ return parsedUrl.toString();
311
+ }
312
+ parseIncomingMessage(raw) {
313
+ if (!this.isJsonObject(raw)) {
314
+ this.logProtocolWarning("Ignoring websocket payload because it is not a JSON object", raw);
315
+ return;
316
+ }
317
+ const messageType = raw.type;
318
+ if (typeof messageType !== "string") {
319
+ this.logProtocolWarning('Ignoring websocket payload without a string "type" field', raw);
320
+ return;
321
+ }
322
+ try {
323
+ switch (messageType) {
324
+ case "ready":
325
+ return {
326
+ type: "ready",
327
+ stream_id: this.getOptionalString(raw, "stream_id"),
328
+ livekit_room_name: this.getOptionalString(raw, "livekit_room_name"),
329
+ livekit_url: this.getOptionalString(raw, "livekit_url"),
330
+ sayna_participant_identity: this.getOptionalString(raw, "sayna_participant_identity"),
331
+ sayna_participant_name: this.getOptionalString(raw, "sayna_participant_name")
332
+ };
333
+ case "stt_result":
334
+ return {
335
+ type: "stt_result",
336
+ transcript: this.getRequiredString(raw, "transcript"),
337
+ is_final: this.getRequiredBoolean(raw, "is_final"),
338
+ is_speech_final: this.getRequiredBoolean(raw, "is_speech_final"),
339
+ confidence: this.getRequiredNumber(raw, "confidence")
340
+ };
341
+ case "error":
342
+ return {
343
+ type: "error",
344
+ message: this.getRequiredString(raw, "message")
345
+ };
346
+ case "sip_transfer_error":
347
+ return {
348
+ type: "sip_transfer_error",
349
+ message: this.getRequiredString(raw, "message")
350
+ };
351
+ case "message":
352
+ return {
353
+ type: "message",
354
+ message: this.parseSaynaMessage(raw, "message")
355
+ };
356
+ case "participant_connected":
357
+ return {
358
+ type: "participant_connected",
359
+ participant: this.parseParticipant(raw, "participant")
360
+ };
361
+ case "participant_disconnected":
362
+ return {
363
+ type: "participant_disconnected",
364
+ participant: this.parseParticipant(raw, "participant")
365
+ };
366
+ case "track_subscribed":
367
+ return {
368
+ type: "track_subscribed",
369
+ track: this.parseTrack(raw, "track")
370
+ };
371
+ case "tts_playback_complete":
372
+ return {
373
+ type: "tts_playback_complete",
374
+ timestamp: this.getRequiredNumber(raw, "timestamp")
375
+ };
376
+ default:
377
+ this.logProtocolWarning(`Ignoring unknown websocket message type "${messageType}"`, raw);
378
+ return;
316
379
  }
380
+ } catch (error) {
381
+ this.logProtocolWarning(`Ignoring malformed websocket message "${messageType}": ${this.describeError(error)}`, raw);
382
+ return;
383
+ }
384
+ }
385
+ parseSaynaMessage(container, fieldName) {
386
+ const message = this.getRequiredObject(container, fieldName);
387
+ return {
388
+ message: this.getOptionalString(message, "message"),
389
+ data: this.getOptionalString(message, "data"),
390
+ identity: this.getRequiredString(message, "identity"),
391
+ topic: this.getRequiredString(message, "topic"),
392
+ room: this.getRequiredString(message, "room"),
393
+ timestamp: this.getRequiredNumber(message, "timestamp")
394
+ };
395
+ }
396
+ parseParticipant(container, fieldName) {
397
+ const participant = this.getRequiredObject(container, fieldName);
398
+ return {
399
+ identity: this.getRequiredString(participant, "identity"),
400
+ name: this.getOptionalString(participant, "name"),
401
+ room: this.getRequiredString(participant, "room"),
402
+ timestamp: this.getRequiredNumber(participant, "timestamp")
403
+ };
404
+ }
405
+ parseTrack(container, fieldName) {
406
+ const track = this.getRequiredObject(container, fieldName);
407
+ const trackKind = this.getRequiredString(track, "track_kind");
408
+ if (trackKind !== "audio" && trackKind !== "video") {
409
+ throw new Error('Field "track_kind" must be either "audio" or "video"');
410
+ }
411
+ return {
412
+ identity: this.getRequiredString(track, "identity"),
413
+ name: this.getOptionalString(track, "name"),
414
+ track_kind: trackKind,
415
+ track_sid: this.getRequiredString(track, "track_sid"),
416
+ room: this.getRequiredString(track, "room"),
417
+ timestamp: this.getRequiredNumber(track, "timestamp")
418
+ };
419
+ }
420
+ getRequiredObject(container, fieldName) {
421
+ const value = container[fieldName];
422
+ if (!this.isJsonObject(value)) {
423
+ throw new Error(`Field "${fieldName}" must be an object`);
424
+ }
425
+ return value;
426
+ }
427
+ getRequiredString(container, fieldName) {
428
+ const value = container[fieldName];
429
+ if (typeof value !== "string") {
430
+ throw new Error(`Field "${fieldName}" must be a string`);
431
+ }
432
+ return value;
433
+ }
434
+ getOptionalString(container, fieldName) {
435
+ const value = container[fieldName];
436
+ if (typeof value === "undefined" || value === null) {
437
+ return;
438
+ }
439
+ if (typeof value !== "string") {
440
+ throw new Error(`Field "${fieldName}" must be a string when present`);
441
+ }
442
+ return value;
443
+ }
444
+ getRequiredBoolean(container, fieldName) {
445
+ const value = container[fieldName];
446
+ if (typeof value !== "boolean") {
447
+ throw new Error(`Field "${fieldName}" must be a boolean`);
448
+ }
449
+ return value;
450
+ }
451
+ getRequiredNumber(container, fieldName) {
452
+ const value = container[fieldName];
453
+ if (typeof value !== "number" || !Number.isFinite(value)) {
454
+ throw new Error(`Field "${fieldName}" must be a finite number`);
455
+ }
456
+ return value;
457
+ }
458
+ isJsonObject(value) {
459
+ return typeof value === "object" && value !== null && !Array.isArray(value);
460
+ }
461
+ logProtocolWarning(message, payload) {
462
+ console.warn(`${message}; payload=${this.safeStringify(payload)}`);
463
+ }
464
+ safeStringify(payload) {
465
+ try {
466
+ return JSON.stringify(payload);
467
+ } catch {
468
+ return String(payload);
469
+ }
470
+ }
471
+ describeError(error) {
472
+ return error instanceof Error ? error.message : String(error);
473
+ }
474
+ async invokeCallback(callback, payload, label) {
475
+ if (!callback) {
476
+ return;
477
+ }
478
+ try {
479
+ await callback(payload);
480
+ } catch (error) {
481
+ await this.reportHandlerError(`${label} callback`, error);
482
+ }
483
+ }
484
+ async reportHandlerError(label, error) {
485
+ const message = `${label} failed: ${this.describeError(error)}`;
486
+ if (!this.errorCallback) {
487
+ console.error(message);
488
+ return;
489
+ }
490
+ try {
491
+ await this.errorCallback({
492
+ type: "error",
493
+ message
494
+ });
495
+ } catch (callbackError) {
496
+ console.error(message);
497
+ console.error(`error callback failed: ${this.describeError(callbackError)}`);
317
498
  }
318
499
  }
319
500
  createWebSocket(url) {
@@ -321,7 +502,7 @@ class SaynaClient {
321
502
  return new WS(url);
322
503
  }
323
504
  const headers = { Authorization: `Bearer ${this.apiKey}` };
324
- if (!hasNativeWebSocket) {
505
+ if (isNode) {
325
506
  return new WS(url, undefined, { headers });
326
507
  }
327
508
  if (isBun) {
@@ -433,9 +614,15 @@ class SaynaClient {
433
614
  registerOnMessage(callback) {
434
615
  this.messageCallback = callback;
435
616
  }
617
+ registerOnParticipantConnected(callback) {
618
+ this.participantConnectedCallback = callback;
619
+ }
436
620
  registerOnParticipantDisconnected(callback) {
437
621
  this.participantDisconnectedCallback = callback;
438
622
  }
623
+ registerOnTrackSubscribed(callback) {
624
+ this.trackSubscribedCallback = callback;
625
+ }
439
626
  registerOnTtsPlaybackComplete(callback) {
440
627
  this.ttsPlaybackCompleteCallback = callback;
441
628
  }
@@ -877,4 +1064,4 @@ async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAud
877
1064
  return client;
878
1065
  }
879
1066
 
880
- //# debugId=53D09ADB3CD3B13764756E2164756E21
1067
+ //# debugId=5E4C5FDAA012925764756E2164756E21