@vapi-ai/web 3.0.0 → 3.1.0

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/vapi.js CHANGED
@@ -58,6 +58,8 @@ function serializeError(error) {
58
58
  }
59
59
  return { message: String(error) };
60
60
  }
61
+ // The app message Vapi sends when it couldn't start a server-mode recording.
62
+ const VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE = 'video-recording.start-failed';
61
63
  async function startAudioPlayer(player, track) {
62
64
  player.muted = false;
63
65
  player.autoplay = true;
@@ -88,13 +90,13 @@ function destroyAudioPlayer(participantId) {
88
90
  const player = document.querySelector(`audio[data-participant-id="${participantId}"]`);
89
91
  player?.remove();
90
92
  }
91
- function subscribeToTracks(e, call, isVideoRecordingEnabled, isVideoEnabled) {
93
+ function subscribeToTracks(e, call, isVideoRecordingEnabled) {
92
94
  if (e.participant.local)
93
95
  return;
94
96
  call.updateParticipant(e.participant.session_id, {
95
97
  setSubscribedTracks: {
96
98
  audio: true,
97
- video: isVideoRecordingEnabled || isVideoEnabled,
99
+ video: isVideoRecordingEnabled,
98
100
  },
99
101
  });
100
102
  }
@@ -134,6 +136,39 @@ class Vapi extends VapiEventEmitter {
134
136
  speakingTimeout = null;
135
137
  dailyCallConfig = {};
136
138
  dailyCallObject = {};
139
+ microphoneTrack = null;
140
+ cancelMicrophonePreflight = null;
141
+ async microphoneAcquire() {
142
+ const source = this.dailyCallObject.audioSource;
143
+ if (source && typeof source === 'object') {
144
+ if (source.readyState === 'ended') {
145
+ throw new DOMException('The supplied microphone track has ended.', 'NotReadableError');
146
+ }
147
+ return source;
148
+ }
149
+ return new Promise((resolve, reject) => {
150
+ let cancelled = false;
151
+ this.cancelMicrophonePreflight = () => {
152
+ cancelled = true;
153
+ reject(new DOMException('Microphone preflight cancelled.', 'AbortError'));
154
+ };
155
+ navigator.mediaDevices.getUserMedia({
156
+ audio: typeof source === 'string' ? { deviceId: { exact: source } } : true,
157
+ }).then((stream) => {
158
+ const track = stream.getAudioTracks()[0];
159
+ if (cancelled || !track || track.readyState === 'ended') {
160
+ stream.getTracks().forEach((item) => item.stop());
161
+ reject(new DOMException('No live microphone track available.', 'NotFoundError'));
162
+ return;
163
+ }
164
+ stream.getTracks().filter((item) => item !== track).forEach((item) => item.stop());
165
+ this.microphoneTrack = track;
166
+ resolve(track);
167
+ }, reject);
168
+ }).finally(() => {
169
+ this.cancelMicrophonePreflight = null;
170
+ });
171
+ }
137
172
  hasEmittedCallEndedStatus = false;
138
173
  constructor(apiToken, apiBaseUrl, dailyCallConfig, dailyCallObject) {
139
174
  super();
@@ -143,6 +178,8 @@ class Vapi extends VapiEventEmitter {
143
178
  this.dailyCallObject = (0, daily_guards_1.createSafeDailyFactoryOptions)(dailyCallObject);
144
179
  }
145
180
  async cleanup() {
181
+ this.microphoneTrack?.stop();
182
+ this.microphoneTrack = null;
146
183
  this.started = false;
147
184
  this.hasEmittedCallEndedStatus = false;
148
185
  if (this.call) {
@@ -243,7 +280,16 @@ class Vapi extends VapiEventEmitter {
243
280
  }
244
281
  });
245
282
  this.started = true;
283
+ let failureStage = 'microphone-preflight';
246
284
  try {
285
+ if (this.call) {
286
+ await this.cleanup();
287
+ this.started = true;
288
+ }
289
+ const audioSource = options?.microphonePreflightEnabled === false
290
+ ? this.dailyCallObject.audioSource ?? true
291
+ : await this.microphoneAcquire();
292
+ failureStage = 'unknown';
247
293
  // Stage 1: Create web call
248
294
  this.emit('call-start-progress', {
249
295
  stage: 'web-call-creation',
@@ -251,7 +297,9 @@ class Vapi extends VapiEventEmitter {
251
297
  timestamp: new Date().toISOString()
252
298
  });
253
299
  const webCallStartTime = Date.now();
254
- const webCall = (await client_1.client.call.callControllerCreateWebCall({
300
+ // An unset option stays undefined, so JSON.stringify drops the key and
301
+ // older servers never see it.
302
+ const createWebCallBody = {
255
303
  assistant: typeof assistant === 'string' ? undefined : assistant,
256
304
  assistantId: typeof assistant === 'string' ? assistant : undefined,
257
305
  assistantOverrides,
@@ -261,7 +309,9 @@ class Vapi extends VapiEventEmitter {
261
309
  workflowId: typeof workflow === 'string' ? workflow : undefined,
262
310
  workflowOverrides,
263
311
  roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
264
- })).data;
312
+ videoRecordingStartMode: options?.videoRecordingStartMode,
313
+ };
314
+ const webCall = (await client_1.client.call.callControllerCreateWebCall(createWebCallBody)).data;
265
315
  const webCallDuration = Date.now() - webCallStartTime;
266
316
  this.emit('call-start-progress', {
267
317
  stage: 'web-call-creation',
@@ -274,22 +324,17 @@ class Vapi extends VapiEventEmitter {
274
324
  voiceProvider: webCall?.assistant?.voice?.provider || 'unknown'
275
325
  }
276
326
  });
277
- if (this.call) {
278
- this.emit('call-start-progress', {
279
- stage: 'daily-call-object-creation',
280
- status: 'started',
281
- timestamp: new Date().toISOString(),
282
- metadata: { action: 'cleanup-existing' }
283
- });
284
- await this.cleanup();
285
- }
286
327
  const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
287
- const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
288
328
  // When video recording is enabled, the server sends a Daily meeting
289
- // token that auto-starts the cloud recording on join — more reliable
290
- // than calling startRecording() from a weak client network. Older
291
- // servers don't send one; then the client starts the recording itself.
292
- const callToken = webCall?.transport?.callToken;
329
+ // token. In client mode it auto-starts the cloud recording on join. In
330
+ // server mode it starts nothing: Vapi starts the recording from its
331
+ // servers once the user has joined, which a weak client network can't
332
+ // stop. Without a token the SDK emits recording-error and starts
333
+ // nothing.
334
+ const transport = webCall?.transport;
335
+ const callToken = transport?.callToken;
336
+ const isRecordingStartedByServer = transport?.videoRecordingStartMode === 'server';
337
+ const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
293
338
  // Stage 2: Create Daily call object
294
339
  this.emit('call-start-progress', {
295
340
  stage: 'daily-call-object-creation',
@@ -299,13 +344,12 @@ class Vapi extends VapiEventEmitter {
299
344
  audioSource: this.dailyCallObject.audioSource ?? true,
300
345
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
301
346
  isVideoRecordingEnabled,
302
- isVideoEnabled
303
347
  }
304
348
  });
305
349
  const dailyCallStartTime = Date.now();
306
350
  try {
307
351
  this.call = daily_js_1.default.createCallObject({
308
- audioSource: this.dailyCallObject.audioSource ?? true,
352
+ audioSource,
309
353
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
310
354
  dailyConfig: this.dailyCallConfig,
311
355
  });
@@ -400,7 +444,7 @@ class Vapi extends VapiEventEmitter {
400
444
  this.call.on('participant-joined', (e) => {
401
445
  if (!e || !this.call)
402
446
  return;
403
- subscribeToTracks(e, this.call, isVideoRecordingEnabled, isVideoEnabled);
447
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled);
404
448
  });
405
449
  this.call.on('participant-updated', (e) => {
406
450
  if (!e) {
@@ -414,6 +458,10 @@ class Vapi extends VapiEventEmitter {
414
458
  }
415
459
  this.detachAudioPlayer(e.participant.session_id);
416
460
  });
461
+ // Registered before join(), as in reconnect(): Vapi starts a server-mode
462
+ // recording when it sees the user join, so its start-failed message can
463
+ // arrive while join() is still in flight.
464
+ this.call.on('app-message', (e) => this.onAppMessage(e));
417
465
  // Registered before join(): a token-auto-started recording can begin
418
466
  // while join() is still in flight, so a listener added after join
419
467
  // could miss the event.
@@ -508,51 +556,29 @@ class Vapi extends VapiEventEmitter {
508
556
  }
509
557
  // Stage 5: Video recording setup (if enabled)
510
558
  if (isVideoRecordingEnabled && callToken) {
511
- // The meeting token auto-started the recording at join, so there is
512
- // nothing to request from the client.
559
+ // The meeting token auto-started the recording at join, or Vapi
560
+ // starts it from its servers, so there is nothing to request from the
561
+ // client.
513
562
  this.emit('call-start-progress', {
514
563
  stage: 'video-recording-setup',
515
564
  status: 'completed',
516
565
  timestamp: new Date().toISOString(),
517
- metadata: { action: 'auto-started-via-meeting-token' }
566
+ metadata: {
567
+ action: isRecordingStartedByServer
568
+ ? 'started-by-server'
569
+ : 'auto-started-via-meeting-token'
570
+ }
518
571
  });
519
572
  }
520
573
  else if (isVideoRecordingEnabled) {
574
+ // No token: nothing starts the recording. The recording-error is
575
+ // reported once start() has finished.
521
576
  this.emit('call-start-progress', {
522
577
  stage: 'video-recording-setup',
523
- status: 'started',
524
- timestamp: new Date().toISOString()
578
+ status: 'failed',
579
+ timestamp: new Date().toISOString(),
580
+ metadata: { action: 'no-meeting-token' }
525
581
  });
526
- const recordingStartTime = Date.now();
527
- try {
528
- recordingRequestedTime = new Date().getTime();
529
- this.startRecording();
530
- const recordingSetupDuration = Date.now() - recordingStartTime;
531
- this.emit('call-start-progress', {
532
- stage: 'video-recording-setup',
533
- status: 'completed',
534
- duration: recordingSetupDuration,
535
- timestamp: new Date().toISOString()
536
- });
537
- }
538
- catch (error) {
539
- const recordingSetupDuration = Date.now() - recordingStartTime;
540
- const serializedError = serializeError(error);
541
- this.emit('call-start-progress', {
542
- stage: 'video-recording-setup',
543
- status: 'failed',
544
- duration: recordingSetupDuration,
545
- timestamp: new Date().toISOString(),
546
- metadata: { error: serializedError.message }
547
- });
548
- this.emit('error', {
549
- type: 'video-recording-setup-error',
550
- stage: 'video-recording-setup',
551
- error: serializedError,
552
- timestamp: new Date().toISOString()
553
- });
554
- // Don't throw here, video recording is optional
555
- }
556
582
  }
557
583
  else {
558
584
  this.emit('call-start-progress', {
@@ -605,7 +631,6 @@ class Vapi extends VapiEventEmitter {
605
631
  if (e)
606
632
  this.handleLocalAudioLevel(e);
607
633
  });
608
- this.call.on('app-message', (e) => this.onAppMessage(e));
609
634
  this.call.on('nonfatal-error', (e) => {
610
635
  // https://docs.daily.co/reference/daily-js/events/meeting-events#type-audio-processor-error
611
636
  if (e?.type === 'local-audio-level-observer-error') {
@@ -688,13 +713,17 @@ class Vapi extends VapiEventEmitter {
688
713
  callId: webCall?.id || 'unknown',
689
714
  timestamp: new Date().toISOString()
690
715
  });
716
+ if (recordingTokenMissing) {
717
+ this.recordingTokenMissingReportSchedule();
718
+ }
691
719
  return webCall;
692
720
  }
693
721
  catch (e) {
694
722
  const totalDuration = Date.now() - startTime;
695
723
  const serializedError = serializeError(e);
724
+ await this.cleanup();
696
725
  this.emit('call-start-failed', {
697
- stage: 'unknown',
726
+ stage: failureStage,
698
727
  totalDuration,
699
728
  error: serializedError.message,
700
729
  errorStack: serializedError.stack || 'No stack trace available',
@@ -708,8 +737,10 @@ class Vapi extends VapiEventEmitter {
708
737
  });
709
738
  // Also emit the generic error event for backward compatibility
710
739
  this.emit('error', {
711
- type: 'start-method-error',
712
- stage: 'unknown',
740
+ type: failureStage === 'microphone-preflight'
741
+ ? `microphone-preflight-${e && typeof e === 'object' && 'name' in e ? e.name : 'error'}`
742
+ : 'start-method-error',
743
+ stage: failureStage,
713
744
  error: serializedError,
714
745
  totalDuration,
715
746
  timestamp: new Date().toISOString(),
@@ -720,7 +751,6 @@ class Vapi extends VapiEventEmitter {
720
751
  isMobile: this.isMobileDevice()
721
752
  }
722
753
  });
723
- await this.cleanup();
724
754
  return null;
725
755
  }
726
756
  }
@@ -735,6 +765,12 @@ class Vapi extends VapiEventEmitter {
735
765
  else {
736
766
  try {
737
767
  const parsedMessage = JSON.parse(e.data);
768
+ // Not a Vapi client message: re-emitted as recording-error, and kept
769
+ // out of 'message' because its consumers switch on `type`.
770
+ if (parsedMessage?.type === VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE) {
771
+ this.videoRecordingStartFailedReport(parsedMessage);
772
+ return;
773
+ }
738
774
  this.emit('message', parsedMessage);
739
775
  if (parsedMessage && 'type' in parsedMessage && 'status' in parsedMessage && parsedMessage.type === 'status-update' && parsedMessage.status === 'ended') {
740
776
  this.hasEmittedCallEndedStatus = true;
@@ -778,6 +814,12 @@ class Vapi extends VapiEventEmitter {
778
814
  * If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
779
815
  */
780
816
  async stop() {
817
+ if (this.cancelMicrophonePreflight) {
818
+ this.cancelMicrophonePreflight();
819
+ return;
820
+ }
821
+ this.microphoneTrack?.stop();
822
+ this.microphoneTrack = null;
781
823
  this.started = false;
782
824
  if (this.call) {
783
825
  await this.call.destroy();
@@ -896,6 +938,37 @@ class Vapi extends VapiEventEmitter {
896
938
  console.error(`[vapi] ${event} event listener threw`, error);
897
939
  }
898
940
  }
941
+ /**
942
+ * Reported in a later task, like Daily's own recording-error: start() or
943
+ * reconnect() has resolved and the caller's await continuation has run, so a
944
+ * listener registered right after it still hears this, and one that calls
945
+ * stop() can't interrupt call setup. Skipped if that call was stopped or
946
+ * replaced.
947
+ */
948
+ recordingTokenMissingReportSchedule() {
949
+ const call = this.call;
950
+ if (!call)
951
+ return;
952
+ setTimeout(() => {
953
+ if (!this.started || this.call !== call)
954
+ return;
955
+ this.emitToConsumer('recording-error', {
956
+ action: 'recording-error',
957
+ callClientId: call.callClientId,
958
+ errorMsg: 'Vapi returned no recording token, so the recording was not started. Call startRecording() to try starting it from the client.',
959
+ });
960
+ }, 0);
961
+ }
962
+ /** The fields are read defensively: the app message is untrusted JSON. */
963
+ videoRecordingStartFailedReport(message) {
964
+ const outcome = typeof message.outcome === 'string' ? message.outcome : 'unknown';
965
+ const error = typeof message.error === 'string' ? message.error : 'unknown';
966
+ this.emitToConsumer('recording-error', {
967
+ action: 'recording-error',
968
+ callClientId: this.call?.callClientId ?? '',
969
+ errorMsg: `Vapi couldn't start the recording (${outcome}): ${error}`,
970
+ });
971
+ }
899
972
  /** Drops the reference before removing, so nothing hands out a detached element. */
900
973
  detachAudioPlayer(participantId) {
901
974
  if (this.audioPlayer?.dataset.participantId === participantId) {
@@ -1128,11 +1201,14 @@ class Vapi extends VapiEventEmitter {
1128
1201
  });
1129
1202
  }
1130
1203
  const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
1131
- const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
1132
- // Same auto-start token as in start(). Rejoining with it while the
1204
+ // Same token as in start(). In client mode, rejoining with it while the
1133
1205
  // recording is still running is safe; if the recording stopped when the
1134
- // user left, the token starts a new one.
1206
+ // user left, the token starts a new one. In server mode it starts
1207
+ // nothing. Without a token the SDK emits recording-error and starts
1208
+ // nothing.
1135
1209
  const callToken = webCall?.transport?.callToken;
1210
+ const isRecordingStartedByServer = webCall?.transport?.videoRecordingStartMode === 'server';
1211
+ const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
1136
1212
  // Stage 1: Create Daily call object
1137
1213
  this.emit('call-start-progress', {
1138
1214
  stage: 'daily-call-object-creation',
@@ -1142,7 +1218,6 @@ class Vapi extends VapiEventEmitter {
1142
1218
  audioSource: this.dailyCallObject.audioSource ?? true,
1143
1219
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
1144
1220
  isVideoRecordingEnabled,
1145
- isVideoEnabled
1146
1221
  }
1147
1222
  });
1148
1223
  const dailyCallStartTime = Date.now();
@@ -1224,7 +1299,7 @@ class Vapi extends VapiEventEmitter {
1224
1299
  this.call.on('participant-joined', (e) => {
1225
1300
  if (!e || !this.call)
1226
1301
  return;
1227
- subscribeToTracks(e, this.call, isVideoRecordingEnabled, isVideoEnabled);
1302
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled);
1228
1303
  });
1229
1304
  this.call.on('participant-updated', (e) => {
1230
1305
  if (!e) {
@@ -1341,45 +1416,29 @@ class Vapi extends VapiEventEmitter {
1341
1416
  });
1342
1417
  // Stage 4: Video recording setup (if enabled)
1343
1418
  if (isVideoRecordingEnabled && callToken) {
1344
- // The meeting token auto-started the recording at join, so there is
1345
- // nothing to request from the client.
1419
+ // The meeting token auto-started the recording at join, or Vapi
1420
+ // starts it from its servers, so there is nothing to request from the
1421
+ // client.
1346
1422
  this.emit('call-start-progress', {
1347
1423
  stage: 'video-recording-setup',
1348
1424
  status: 'completed',
1349
1425
  timestamp: new Date().toISOString(),
1350
- metadata: { action: 'auto-started-via-meeting-token' }
1426
+ metadata: {
1427
+ action: isRecordingStartedByServer
1428
+ ? 'started-by-server'
1429
+ : 'auto-started-via-meeting-token'
1430
+ }
1351
1431
  });
1352
1432
  }
1353
1433
  else if (isVideoRecordingEnabled) {
1434
+ // No token: nothing starts the recording. The recording-error is
1435
+ // reported once reconnect() has finished.
1354
1436
  this.emit('call-start-progress', {
1355
1437
  stage: 'video-recording-setup',
1356
- status: 'started',
1357
- timestamp: new Date().toISOString()
1438
+ status: 'failed',
1439
+ timestamp: new Date().toISOString(),
1440
+ metadata: { action: 'no-meeting-token' }
1358
1441
  });
1359
- const recordingStartTime = Date.now();
1360
- try {
1361
- recordingRequestedTime = new Date().getTime();
1362
- this.startRecording();
1363
- const recordingSetupDuration = Date.now() - recordingStartTime;
1364
- this.emit('call-start-progress', {
1365
- stage: 'video-recording-setup',
1366
- status: 'completed',
1367
- duration: recordingSetupDuration,
1368
- timestamp: new Date().toISOString()
1369
- });
1370
- }
1371
- catch (error) {
1372
- const recordingSetupDuration = Date.now() - recordingStartTime;
1373
- const serializedError = serializeError(error);
1374
- this.emit('call-start-progress', {
1375
- stage: 'video-recording-setup',
1376
- status: 'failed',
1377
- duration: recordingSetupDuration,
1378
- timestamp: new Date().toISOString(),
1379
- metadata: { error: serializedError.message }
1380
- });
1381
- // Don't throw here, video recording is optional
1382
- }
1383
1442
  }
1384
1443
  else {
1385
1444
  this.emit('call-start-progress', {
@@ -1473,6 +1532,9 @@ class Vapi extends VapiEventEmitter {
1473
1532
  timestamp: new Date().toISOString()
1474
1533
  });
1475
1534
  this.emit('call-start');
1535
+ if (recordingTokenMissing) {
1536
+ this.recordingTokenMissingReportSchedule();
1537
+ }
1476
1538
  }
1477
1539
  catch (e) {
1478
1540
  const totalDuration = Date.now() - startTime;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vapi-ai/web",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "",
5
5
  "main": "dist/vapi.js",
6
6
  "types": "dist/vapi.d.ts",
@@ -31,7 +31,7 @@
31
31
  "node": ">=22.14.0"
32
32
  },
33
33
  "dependencies": {
34
- "@daily-co/daily-js": "^0.93.0",
34
+ "@daily-co/daily-js": "^0.94.0",
35
35
  "events": "^3.3.0"
36
36
  },
37
37
  "devDependencies": {