@sayna-ai/node-sdk 0.0.19 → 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
@@ -128,7 +128,9 @@ class SaynaClient {
128
128
  ttsCallback;
129
129
  errorCallback;
130
130
  messageCallback;
131
+ participantConnectedCallback;
131
132
  participantDisconnectedCallback;
133
+ trackSubscribedCallback;
132
134
  ttsPlaybackCompleteCallback;
133
135
  sipTransferErrorCallback;
134
136
  readyPromiseResolve;
@@ -157,7 +159,7 @@ class SaynaClient {
157
159
  if (this.isConnected) {
158
160
  return;
159
161
  }
160
- const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + (this.url.endsWith("/") ? "ws" : "/ws");
162
+ const wsUrl = this.getWebSocketUrl();
161
163
  return new Promise((resolve, reject) => {
162
164
  this.readyPromiseResolve = resolve;
163
165
  this.readyPromiseReject = reject;
@@ -212,27 +214,30 @@ class SaynaClient {
212
214
  try {
213
215
  if (event.data instanceof Blob || event.data instanceof ArrayBuffer) {
214
216
  const buffer = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
215
- if (this.ttsCallback) {
216
- await this.ttsCallback(buffer);
217
- }
217
+ await this.invokeCallback(this.ttsCallback, buffer, "TTS audio");
218
218
  } else {
219
219
  if (typeof event.data !== "string") {
220
- 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;
221
229
  }
222
- const data = JSON.parse(event.data);
223
230
  await this.handleJsonMessage(data);
224
231
  }
225
232
  } catch (error) {
226
- if (this.errorCallback) {
227
- const errorMessage = error instanceof Error ? error.message : String(error);
228
- await this.errorCallback({
229
- type: "error",
230
- message: `Failed to process message: ${errorMessage}`
231
- });
232
- }
233
+ await this.reportHandlerError("WebSocket message", error);
233
234
  }
234
235
  }
235
- async handleJsonMessage(data) {
236
+ async handleJsonMessage(raw) {
237
+ const data = this.parseIncomingMessage(raw);
238
+ if (!data) {
239
+ return;
240
+ }
236
241
  const messageType = data.type;
237
242
  try {
238
243
  switch (messageType) {
@@ -250,72 +255,246 @@ class SaynaClient {
250
255
  break;
251
256
  }
252
257
  case "stt_result": {
253
- const sttResult = data;
254
- if (this.sttCallback) {
255
- await this.sttCallback(sttResult);
256
- }
258
+ await this.invokeCallback(this.sttCallback, data, "STT result");
257
259
  break;
258
260
  }
259
261
  case "error": {
260
- const errorMsg = data;
261
- if (this.errorCallback) {
262
- await this.errorCallback(errorMsg);
263
- }
262
+ await this.invokeCallback(this.errorCallback, data, "error");
264
263
  if (this.readyPromiseReject && !this.isReady) {
265
- this.readyPromiseReject(new SaynaServerError(errorMsg.message));
264
+ this.readyPromiseReject(new SaynaServerError(data.message));
266
265
  }
267
266
  break;
268
267
  }
269
268
  case "sip_transfer_error": {
270
- const sipTransferError = data;
271
269
  if (this.sipTransferErrorCallback) {
272
- await this.sipTransferErrorCallback(sipTransferError);
270
+ await this.invokeCallback(this.sipTransferErrorCallback, data, "SIP transfer error");
273
271
  } else if (this.errorCallback) {
274
272
  await this.errorCallback({
275
273
  type: "error",
276
- message: sipTransferError.message
274
+ message: data.message
277
275
  });
278
276
  }
279
277
  break;
280
278
  }
281
279
  case "message": {
282
- const messageData = data;
283
- if (this.messageCallback) {
284
- await this.messageCallback(messageData.message);
285
- }
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");
286
285
  break;
287
286
  }
288
287
  case "participant_disconnected": {
289
- const participantMsg = data;
290
- if (this.participantDisconnectedCallback) {
291
- await this.participantDisconnectedCallback(participantMsg.participant);
292
- }
288
+ await this.invokeCallback(this.participantDisconnectedCallback, data.participant, "participant disconnected");
293
289
  break;
294
290
  }
295
- case "tts_playback_complete": {
296
- const ttsPlaybackCompleteMsg = data;
297
- if (this.ttsPlaybackCompleteCallback) {
298
- await this.ttsPlaybackCompleteCallback(ttsPlaybackCompleteMsg.timestamp);
299
- }
291
+ case "track_subscribed": {
292
+ await this.invokeCallback(this.trackSubscribedCallback, data.track, "track subscribed");
300
293
  break;
301
294
  }
302
- default: {
303
- const unknownMessage = data;
304
- const errorMessage = `Unknown message type received: ${unknownMessage.type}`;
305
- if (this.errorCallback) {
306
- await this.errorCallback({ type: "error", message: errorMessage });
307
- } else {
308
- console.warn(errorMessage);
309
- }
295
+ case "tts_playback_complete": {
296
+ await this.invokeCallback(this.ttsPlaybackCompleteCallback, data.timestamp, "TTS playback complete");
297
+ break;
310
298
  }
311
299
  }
312
300
  } catch (error) {
313
- if (this.errorCallback) {
314
- await this.errorCallback({
315
- type: "error",
316
- message: `Handler error: ${error instanceof Error ? error.message : String(error)}`
317
- });
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;
318
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)}`);
319
498
  }
320
499
  }
321
500
  createWebSocket(url) {
@@ -435,9 +614,15 @@ class SaynaClient {
435
614
  registerOnMessage(callback) {
436
615
  this.messageCallback = callback;
437
616
  }
617
+ registerOnParticipantConnected(callback) {
618
+ this.participantConnectedCallback = callback;
619
+ }
438
620
  registerOnParticipantDisconnected(callback) {
439
621
  this.participantDisconnectedCallback = callback;
440
622
  }
623
+ registerOnTrackSubscribed(callback) {
624
+ this.trackSubscribedCallback = callback;
625
+ }
441
626
  registerOnTtsPlaybackComplete(callback) {
442
627
  this.ttsPlaybackCompleteCallback = callback;
443
628
  }
@@ -879,4 +1064,4 @@ async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAud
879
1064
  return client;
880
1065
  }
881
1066
 
882
- //# debugId=83EF29EA6526F17D64756E2164756E21
1067
+ //# debugId=5E4C5FDAA012925764756E2164756E21