@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/dist/index.js CHANGED
@@ -88,7 +88,9 @@ class SaynaClient {
88
88
  ttsCallback;
89
89
  errorCallback;
90
90
  messageCallback;
91
+ participantConnectedCallback;
91
92
  participantDisconnectedCallback;
93
+ trackSubscribedCallback;
92
94
  ttsPlaybackCompleteCallback;
93
95
  sipTransferErrorCallback;
94
96
  readyPromiseResolve;
@@ -117,7 +119,7 @@ class SaynaClient {
117
119
  if (this.isConnected) {
118
120
  return;
119
121
  }
120
- const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + (this.url.endsWith("/") ? "ws" : "/ws");
122
+ const wsUrl = this.getWebSocketUrl();
121
123
  return new Promise((resolve, reject) => {
122
124
  this.readyPromiseResolve = resolve;
123
125
  this.readyPromiseReject = reject;
@@ -172,27 +174,30 @@ class SaynaClient {
172
174
  try {
173
175
  if (event.data instanceof Blob || event.data instanceof ArrayBuffer) {
174
176
  const buffer = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
175
- if (this.ttsCallback) {
176
- await this.ttsCallback(buffer);
177
- }
177
+ await this.invokeCallback(this.ttsCallback, buffer, "TTS audio");
178
178
  } else {
179
179
  if (typeof event.data !== "string") {
180
- 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;
181
189
  }
182
- const data = JSON.parse(event.data);
183
190
  await this.handleJsonMessage(data);
184
191
  }
185
192
  } catch (error) {
186
- if (this.errorCallback) {
187
- const errorMessage = error instanceof Error ? error.message : String(error);
188
- await this.errorCallback({
189
- type: "error",
190
- message: `Failed to process message: ${errorMessage}`
191
- });
192
- }
193
+ await this.reportHandlerError("WebSocket message", error);
193
194
  }
194
195
  }
195
- async handleJsonMessage(data) {
196
+ async handleJsonMessage(raw) {
197
+ const data = this.parseIncomingMessage(raw);
198
+ if (!data) {
199
+ return;
200
+ }
196
201
  const messageType = data.type;
197
202
  try {
198
203
  switch (messageType) {
@@ -210,72 +215,246 @@ class SaynaClient {
210
215
  break;
211
216
  }
212
217
  case "stt_result": {
213
- const sttResult = data;
214
- if (this.sttCallback) {
215
- await this.sttCallback(sttResult);
216
- }
218
+ await this.invokeCallback(this.sttCallback, data, "STT result");
217
219
  break;
218
220
  }
219
221
  case "error": {
220
- const errorMsg = data;
221
- if (this.errorCallback) {
222
- await this.errorCallback(errorMsg);
223
- }
222
+ await this.invokeCallback(this.errorCallback, data, "error");
224
223
  if (this.readyPromiseReject && !this.isReady) {
225
- this.readyPromiseReject(new SaynaServerError(errorMsg.message));
224
+ this.readyPromiseReject(new SaynaServerError(data.message));
226
225
  }
227
226
  break;
228
227
  }
229
228
  case "sip_transfer_error": {
230
- const sipTransferError = data;
231
229
  if (this.sipTransferErrorCallback) {
232
- await this.sipTransferErrorCallback(sipTransferError);
230
+ await this.invokeCallback(this.sipTransferErrorCallback, data, "SIP transfer error");
233
231
  } else if (this.errorCallback) {
234
232
  await this.errorCallback({
235
233
  type: "error",
236
- message: sipTransferError.message
234
+ message: data.message
237
235
  });
238
236
  }
239
237
  break;
240
238
  }
241
239
  case "message": {
242
- const messageData = data;
243
- if (this.messageCallback) {
244
- await this.messageCallback(messageData.message);
245
- }
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");
246
245
  break;
247
246
  }
248
247
  case "participant_disconnected": {
249
- const participantMsg = data;
250
- if (this.participantDisconnectedCallback) {
251
- await this.participantDisconnectedCallback(participantMsg.participant);
252
- }
248
+ await this.invokeCallback(this.participantDisconnectedCallback, data.participant, "participant disconnected");
253
249
  break;
254
250
  }
255
- case "tts_playback_complete": {
256
- const ttsPlaybackCompleteMsg = data;
257
- if (this.ttsPlaybackCompleteCallback) {
258
- await this.ttsPlaybackCompleteCallback(ttsPlaybackCompleteMsg.timestamp);
259
- }
251
+ case "track_subscribed": {
252
+ await this.invokeCallback(this.trackSubscribedCallback, data.track, "track subscribed");
260
253
  break;
261
254
  }
262
- default: {
263
- const unknownMessage = data;
264
- const errorMessage = `Unknown message type received: ${unknownMessage.type}`;
265
- if (this.errorCallback) {
266
- await this.errorCallback({ type: "error", message: errorMessage });
267
- } else {
268
- console.warn(errorMessage);
269
- }
255
+ case "tts_playback_complete": {
256
+ await this.invokeCallback(this.ttsPlaybackCompleteCallback, data.timestamp, "TTS playback complete");
257
+ break;
270
258
  }
271
259
  }
272
260
  } catch (error) {
273
- if (this.errorCallback) {
274
- await this.errorCallback({
275
- type: "error",
276
- message: `Handler error: ${error instanceof Error ? error.message : String(error)}`
277
- });
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;
278
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)}`);
279
458
  }
280
459
  }
281
460
  createWebSocket(url) {
@@ -395,9 +574,15 @@ class SaynaClient {
395
574
  registerOnMessage(callback) {
396
575
  this.messageCallback = callback;
397
576
  }
577
+ registerOnParticipantConnected(callback) {
578
+ this.participantConnectedCallback = callback;
579
+ }
398
580
  registerOnParticipantDisconnected(callback) {
399
581
  this.participantDisconnectedCallback = callback;
400
582
  }
583
+ registerOnTrackSubscribed(callback) {
584
+ this.trackSubscribedCallback = callback;
585
+ }
401
586
  registerOnTtsPlaybackComplete(callback) {
402
587
  this.ttsPlaybackCompleteCallback = callback;
403
588
  }
@@ -850,4 +1035,4 @@ export {
850
1035
  SaynaClient
851
1036
  };
852
1037
 
853
- //# debugId=FD330BDBB490EFF164756E2164756E21
1038
+ //# debugId=855B93EB4E2A921264756E2164756E21