diff --git a/README.md b/README.md index 04ffe2fd3..eb26d408b 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,17 @@ vapi.on('error', (e) => { ## Recording -When video recording is enabled on the call's artifact plan, Vapi starts a recording automatically when the call begins. When the server provides a meeting token for the call (`call.transport.callToken`), the SDK joins with it and Daily auto-starts the cloud recording, which is more reliable on weak networks. Otherwise the SDK starts the recording from the client after joining. Recording start is asynchronous: success is signaled by the `recording-started` event and failure by the `recording-error` event. +When video recording is enabled on the call's artifact plan, Vapi starts the recording automatically when the user joins the call. + +By default, the recording is started by the client. To have Vapi start the recording from the server, pass `videoRecordingStartMode: 'server'`. This may be more reliable on weak connections. + +```javascript +vapi.start(assistantId, undefined, undefined, undefined, undefined, { + videoRecordingStartMode: 'server', +}); +``` + +Recording start is asynchronous: success is signaled by the `recording-started` event and failure by the `recording-error` event. A failed recording start is not retried automatically. Use `startRecording()` and `stopRecording()` to control the recording yourself. For example, to retry after a failure: diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index 00e6fb09d..3cfbab35f 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -1,4 +1,5 @@ import Vapi from "../vapi"; // Adjust the import path based on your project structure +import { client } from "../client"; const mockGetUserMedia = jest.fn(); beforeEach(() => { @@ -50,13 +51,21 @@ jest.mock("../client", () => { // registered event handlers are kept so tests can fire Daily's events. function createMockDailyCall(updateInputSettings: jest.Mock) { const handlers: Record void> = {}; + const onceHandlers: Record void> = {}; return { handlers, + onceHandlers, + callClientId: "client_test", updateInputSettings, on: jest.fn((event: string, handler: (event: any) => void) => { handlers[event] = handler; }), + once: jest.fn((event: string, handler: (event: any) => void) => { + onceHandlers[event] = handler; + }), join: jest.fn().mockResolvedValue(undefined), + startRecording: jest.fn(), + stopRecording: jest.fn(), destroy: jest.fn().mockResolvedValue(undefined), iframe: jest.fn().mockReturnValue(null), setLocalAudio: jest.fn(), @@ -567,3 +576,377 @@ describe("Vapi local audio level observer", () => { expect(mockDailyCall.startLocalAudioLevelObserver).toHaveBeenCalledWith(100); }); }); + +// The SDK never starts a recording itself: a token (or Vapi's servers) does, +// and a missing token or a failed server-side start becomes recording-error. +describe("Vapi video recording start", () => { + const createWebCall = client.call.callControllerCreateWebCall as jest.Mock; + const baseWebCall = { + id: "call_test", + webCallUrl: "https://example.daily.co/test", + }; + const noTokenErrorMsg = + "Vapi returned no recording token, so the recording was not started. Call startRecording() to try starting it from the client."; + + // Restores the module-level default, which mockReset drops, so a queued + // mockResolvedValueOnce can't leak into another test. + function createWebCallReset() { + createWebCall.mockReset(); + createWebCall.mockResolvedValue({ data: baseWebCall }); + } + + function webCallResolveOnce(transport?: Record) { + createWebCall.mockResolvedValueOnce({ + data: { + ...baseWebCall, + artifactPlan: { videoRecordingEnabled: true }, + ...(transport ? { transport } : {}), + }, + }); + } + + // Queued after the SDK's own zero-delay timer, so it runs after it. + async function flushTimers() { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + // Records the events a recording test asserts on, in emission order. + function eventsRecord(vapi: Vapi) { + const order: string[] = []; + const recordingErrors: any[] = []; + const messages: any[] = []; + const setupProgress: any[] = []; + vapi.on("call-start-success", () => order.push("call-start-success")); + vapi.on("call-start-failed", () => order.push("call-start-failed")); + vapi.on("recording-error", (event) => { + order.push("recording-error"); + recordingErrors.push(event); + }); + vapi.on("message", (message) => messages.push(message)); + vapi.on("call-start-progress", (event) => { + if (event.stage === "video-recording-setup") setupProgress.push(event); + }); + return { order, recordingErrors, messages, setupProgress }; + } + + beforeEach(() => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + createWebCallReset(); + }); + + afterEach(() => { + mockDailyCall = null; + createWebCallReset(); + }); + + it("start() passes videoRecordingStartMode: 'server' to the API", async () => { + const vapi = new Vapi("dummy_token"); + + const call = await vapi.start("dummy_assistant_id", undefined, undefined, undefined, undefined, { + videoRecordingStartMode: "server", + }); + + expect(call).not.toBeNull(); + expect(createWebCall.mock.calls[0][0]).toMatchObject({ + videoRecordingStartMode: "server", + }); + }); + + // Servers that predate the field reject any value of it, so an unset option + // must never reach the wire. + it("start() without the option leaves videoRecordingStartMode out of the serialized body", async () => { + const vapi = new Vapi("dummy_token"); + + await vapi.start("dummy_assistant_id"); + + expect(JSON.stringify(createWebCall.mock.calls[0][0])).not.toContain( + "videoRecordingStartMode" + ); + }); + + it("joins with a client-mode token and starts nothing itself", async () => { + webCallResolveOnce({ callToken: "tok", videoRecordingStartMode: "client" }); + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + const call = await vapi.start("dummy_assistant_id"); + await flushTimers(); + + expect(call).not.toBeNull(); + expect(events.order).not.toContain("call-start-failed"); + expect(mockDailyCall.join).toHaveBeenCalledWith( + expect.objectContaining({ token: "tok" }) + ); + expect(mockDailyCall.startRecording).not.toHaveBeenCalled(); + expect(events.setupProgress).toEqual([ + expect.objectContaining({ + status: "completed", + metadata: { action: "auto-started-via-meeting-token" }, + }), + ]); + expect(events.recordingErrors).toEqual([]); + }); + + it("labels a server-mode token as started by the server", async () => { + webCallResolveOnce({ callToken: "tok", videoRecordingStartMode: "server" }); + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + const call = await vapi.start("dummy_assistant_id", undefined, undefined, undefined, undefined, { + videoRecordingStartMode: "server", + }); + await flushTimers(); + + expect(call).not.toBeNull(); + expect(events.order).not.toContain("call-start-failed"); + expect(mockDailyCall.startRecording).not.toHaveBeenCalled(); + expect(events.setupProgress).toEqual([ + expect.objectContaining({ + status: "completed", + metadata: { action: "started-by-server" }, + }), + ]); + expect(events.recordingErrors).toEqual([]); + }); + + it("start() with no token starts nothing and reports recording-error after the call started", async () => { + webCallResolveOnce(); + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + const call = await vapi.start("dummy_assistant_id"); + + expect(call).not.toBeNull(); + // Not yet: it is reported in a later task. + expect(events.recordingErrors).toEqual([]); + const lateListener = jest.fn(); + vapi.on("recording-error", lateListener); + + await flushTimers(); + + expect(mockDailyCall.startRecording).not.toHaveBeenCalled(); + expect(events.setupProgress).toEqual([ + expect.objectContaining({ + status: "failed", + metadata: { action: "no-meeting-token" }, + }), + ]); + expect(lateListener).toHaveBeenCalledTimes(1); + expect(lateListener).toHaveBeenCalledWith({ + action: "recording-error", + callClientId: "client_test", + errorMsg: noTokenErrorMsg, + }); + expect(events.order).toEqual(["call-start-success", "recording-error"]); + }); + + it("reconnect() with no token starts nothing and reports recording-error after the call started", async () => { + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + await vapi.reconnect({ + ...baseWebCall, + artifactPlan: { videoRecordingEnabled: true }, + }); + + expect(events.recordingErrors).toEqual([]); + const lateListener = jest.fn(); + vapi.on("recording-error", lateListener); + + await flushTimers(); + + expect(mockDailyCall.startRecording).not.toHaveBeenCalled(); + expect(events.setupProgress).toEqual([ + expect.objectContaining({ + status: "failed", + metadata: { action: "no-meeting-token" }, + }), + ]); + expect(lateListener).toHaveBeenCalledTimes(1); + expect(lateListener).toHaveBeenCalledWith({ + action: "recording-error", + callClientId: "client_test", + errorMsg: noTokenErrorMsg, + }); + expect(events.order).toEqual(["call-start-success", "recording-error"]); + }); + + it("reconnect() labels a server-mode token as started by the server", async () => { + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + await vapi.reconnect({ + ...baseWebCall, + artifactPlan: { videoRecordingEnabled: true }, + transport: { callToken: "tok", videoRecordingStartMode: "server" }, + }); + await flushTimers(); + + expect(events.order).not.toContain("call-start-failed"); + expect(mockDailyCall.join).toHaveBeenCalledWith( + expect.objectContaining({ token: "tok" }) + ); + expect(mockDailyCall.startRecording).not.toHaveBeenCalled(); + expect(events.setupProgress).toEqual([ + expect.objectContaining({ + status: "completed", + metadata: { action: "started-by-server" }, + }), + ]); + expect(events.recordingErrors).toEqual([]); + }); + + it("does not report the missing token for a call stopped before the report ran", async () => { + webCallResolveOnce(); + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + const call = await vapi.start("dummy_assistant_id"); + await vapi.stop(); + await flushTimers(); + + expect(call).not.toBeNull(); + expect(events.recordingErrors).toEqual([]); + }); + + it("keeps a throwing recording-error listener from affecting the call", async () => { + webCallResolveOnce(); + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + vapi.on("recording-error", () => { + throw new Error("listener failed"); + }); + + try { + const call = await vapi.start("dummy_assistant_id"); + await flushTimers(); + + expect(call).not.toBeNull(); + expect(events.order).not.toContain("call-start-failed"); + expect(consoleError).toHaveBeenCalledWith( + "[vapi] recording-error event listener threw", + expect.any(Error) + ); + } finally { + consoleError.mockRestore(); + } + }); + + it("reports nothing and starts nothing when video recording is off", async () => { + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + const call = await vapi.start("dummy_assistant_id"); + await flushTimers(); + + expect(call).not.toBeNull(); + expect(events.order).not.toContain("call-start-failed"); + expect(mockDailyCall.startRecording).not.toHaveBeenCalled(); + expect(events.setupProgress).toEqual([ + expect.objectContaining({ + status: "completed", + metadata: { action: "skipped-not-enabled" }, + }), + ]); + expect(events.recordingErrors).toEqual([]); + }); + + it("re-emits a video-recording.start-failed app message as recording-error, not message", async () => { + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + await vapi.start("dummy_assistant_id"); + + mockDailyCall.handlers["app-message"]({ + data: '{"type":"video-recording.start-failed","outcome":"failed","error":"boom"}', + }); + + expect(events.recordingErrors).toEqual([ + { + action: "recording-error", + callClientId: "client_test", + errorMsg: "Vapi couldn't start the recording (failed): boom", + }, + ]); + expect(events.messages).toEqual([]); + }); + + // Vapi starts a server-mode recording when it sees the user join, so the + // failure can arrive before join() resolves. Like Daily, the mock delivers + // it only to a listener that is already registered. + function startFailedDeliverDuringJoin() { + mockDailyCall.join.mockImplementationOnce(async () => { + mockDailyCall.handlers["app-message"]?.({ + data: '{"type":"video-recording.start-failed","outcome":"not-hosting","error":"boom"}', + }); + }); + } + + const startFailedDuringJoinError = { + action: "recording-error", + callClientId: "client_test", + errorMsg: "Vapi couldn't start the recording (not-hosting): boom", + }; + + it("start() reports a start-failed message that arrives during join", async () => { + webCallResolveOnce({ callToken: "tok", videoRecordingStartMode: "server" }); + startFailedDeliverDuringJoin(); + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + const call = await vapi.start("dummy_assistant_id", undefined, undefined, undefined, undefined, { + videoRecordingStartMode: "server", + }); + + expect(call).not.toBeNull(); + expect(events.recordingErrors).toEqual([startFailedDuringJoinError]); + expect(events.order).toEqual(["recording-error", "call-start-success"]); + expect(events.messages).toEqual([]); + }); + + it("reconnect() reports a start-failed message that arrives during join", async () => { + startFailedDeliverDuringJoin(); + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + + await vapi.reconnect({ + ...baseWebCall, + artifactPlan: { videoRecordingEnabled: true }, + transport: { callToken: "tok", videoRecordingStartMode: "server" }, + }); + + expect(events.recordingErrors).toEqual([startFailedDuringJoinError]); + expect(events.order).toEqual(["recording-error", "call-start-success"]); + expect(events.messages).toEqual([]); + }); + + it("falls back to 'unknown' for a start-failed message without outcome or error", async () => { + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + await vapi.start("dummy_assistant_id"); + + mockDailyCall.handlers["app-message"]({ + data: '{"type":"video-recording.start-failed"}', + }); + + expect(events.recordingErrors.map((event) => event.errorMsg)).toEqual([ + "Vapi couldn't start the recording (unknown): unknown", + ]); + expect(events.messages).toEqual([]); + }); + + it("still emits other app messages as message", async () => { + const vapi = new Vapi("dummy_token"); + const events = eventsRecord(vapi); + await vapi.start("dummy_assistant_id"); + + mockDailyCall.handlers["app-message"]({ + data: '{"type":"status-update","status":"in-progress"}', + }); + + expect(events.messages).toEqual([ + { type: "status-update", status: "in-progress" }, + ]); + expect(events.recordingErrors).toEqual([]); + }); +}); diff --git a/example/src/App.tsx b/example/src/App.tsx index eaa2a51b6..fcd16eba2 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -31,6 +31,8 @@ function App() { const [networkTestResults, setNetworkTestResults] = useState(null); const [simulateFailure, setSimulateFailure] = useState('none'); const [videoRecordingEnabled, setVideoRecordingEnabled] = useState(false); + // 'default' omits videoRecordingStartMode so the server picks the mode. + const [videoRecordingStartMode, setVideoRecordingStartMode] = useState<'default' | 'client' | 'server'>('default'); const [localVideoTrack, setLocalVideoTrack] = useState(null); const localVideoRef = useRef(null); @@ -122,7 +124,19 @@ function App() { console.log('📊 Call Start Progress:', event); const statusEmoji = event.status === 'completed' ? '✅' : event.status === 'failed' ? '❌' : '⏳'; const duration = event.duration ? ` (${event.duration}ms)` : ''; - addMessage('system', `${statusEmoji} ${event.stage}: ${event.status}${duration}`); + const action = event.metadata?.action ? ` [${event.metadata.action}]` : ''; + addMessage('system', `${statusEmoji} ${event.stage}: ${event.status}${duration}${action}`); + }); + + // Recording events + vapi.on('recording-started', (event) => { + console.log('⏺️ Recording Started:', event); + addMessage('system', '⏺️ Recording started'); + }); + + vapi.on('recording-error', (event) => { + console.error('⏺️ Recording Error:', event); + addMessage('system', `⏺️ Recording error: ${event.errorMsg}`); }); vapi.on('call-start-success', (event) => { @@ -232,7 +246,8 @@ function App() { // Max call duration (in seconds) - 10 minutes maxDurationSeconds: 600 }, undefined, undefined, undefined, undefined, { - roomDeleteOnUserLeaveEnabled: false + roomDeleteOnUserLeaveEnabled: false, + videoRecordingStartMode: videoRecordingStartMode === 'default' ? undefined : videoRecordingStartMode }); // Store webCall in localStorage if it was created successfully @@ -527,9 +542,23 @@ function App() { /> 🎥 Video Recording Enabled +

- When enabled, your camera turns on and the call is recorded to video. Applies to the next - call you start. + When enabled, your camera turns on and the call is recorded to video. The start mode sets + who starts the recording: the client when it joins, or Vapi from its servers. Applies to + the next call you start.

diff --git a/vapi.ts b/vapi.ts index 37f7961a0..2ff3eaaab 100644 --- a/vapi.ts +++ b/vapi.ts @@ -23,6 +23,7 @@ import { CreateSquadDTO, CreateAssistantDTO, AssistantOverrides, + CreateWebCallDTO, CreateWorkflowDTO, WorkflowOverrides, } from './api'; @@ -205,6 +206,21 @@ type VapiEventListeners = { 'recording-upload-completed': (event: DailyEventObjectNoPayload) => void; }; +type VideoRecordingStartMode = NonNullable< + CreateWebCallDTO['videoRecordingStartMode'] +>; + +// The app message Vapi sends when it couldn't start a server-mode recording. +const VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE = 'video-recording.start-failed'; + +type VideoRecordingStartFailedMessage = { + type: typeof VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE; + /** `not-hosting` when Daily saw nobody in the room; `failed` for any other failure. */ + outcome: 'not-hosting' | 'failed'; + /** A short description of the failure. */ + error: string; +}; + type StartCallOptions = { /** Acquire the microphone before creating a backend call. Defaults to true. */ microphonePreflightEnabled?: boolean; @@ -217,6 +233,25 @@ type StartCallOptions = { * @example true */ roomDeleteOnUserLeaveEnabled?: boolean; + /** + * This determines who starts the video recording. Only applies when video + * recording is enabled (`artifactPlan.videoRecordingEnabled`). + * + * - `client`: the web client starts the recording when it joins. + * - `server`: Vapi starts the recording once the user has joined, so a weak + * client connection can't stop it from starting. If Vapi can't set up the + * server-side start, or the start itself fails, the SDK emits + * `recording-error`. + * + * In `server` mode the SDK never calls `startRecording()` itself. If the + * server-side start couldn't be set up, calling it yourself may still start + * the recording. + * + * Requires a Vapi API that supports this field; older servers reject the + * request. The server defaults to `client` when it is not set. + * @example "server" + */ + videoRecordingStartMode?: VideoRecordingStartMode; } type WebCall = { @@ -244,12 +279,22 @@ type WebCall = { assistant?: { voice?: { provider?: string } }; /** * The Vapi WebCall transport. When video recording is enabled, the server - * sets `callToken` to a Daily meeting token that auto-starts the cloud - * recording on join. + * sets `callToken` to the Daily meeting token to join with. In `client` mode + * the token auto-starts the cloud recording on join; in `server` mode it + * starts nothing and Vapi starts the recording. If there is no token, the + * SDK emits `recording-error` and starts nothing, though `startRecording()` + * may still start the recording from the client. + * + * `videoRecordingStartMode` is `server` when Vapi starts the recording, and + * is absent when video recording is off or the server-side start couldn't + * be set up. * * call.transport */ - transport?: { callToken?: string }; + transport?: { + callToken?: string; + videoRecordingStartMode?: VideoRecordingStartMode; + }; } async function startAudioPlayer( @@ -554,18 +599,23 @@ export default class Vapi extends VapiEventEmitter { const webCallStartTime = Date.now(); + // An unset option stays undefined, so JSON.stringify drops the key and + // older servers never see it. + const createWebCallBody: CreateWebCallDTO = { + assistant: typeof assistant === 'string' ? undefined : assistant, + assistantId: typeof assistant === 'string' ? assistant : undefined, + assistantOverrides, + squad: typeof squad === 'string' ? undefined : squad, + squadId: typeof squad === 'string' ? squad : undefined, + workflow: typeof workflow === 'string' ? undefined : workflow, + workflowId: typeof workflow === 'string' ? workflow : undefined, + workflowOverrides, + roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled, + videoRecordingStartMode: options?.videoRecordingStartMode, + }; + const webCall = ( - await client.call.callControllerCreateWebCall({ - assistant: typeof assistant === 'string' ? undefined : assistant, - assistantId: typeof assistant === 'string' ? assistant : undefined, - assistantOverrides, - squad: typeof squad === 'string' ? undefined : squad, - squadId: typeof squad === 'string' ? squad : undefined, - workflow: typeof workflow === 'string' ? undefined : workflow, - workflowId: typeof workflow === 'string' ? workflow : undefined, - workflowOverrides, - roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled, - }) + await client.call.callControllerCreateWebCall(createWebCallBody) ).data; const webCallDuration = Date.now() - webCallStartTime; @@ -585,12 +635,16 @@ export default class Vapi extends VapiEventEmitter { webCall?.artifactPlan?.videoRecordingEnabled ?? false; // When video recording is enabled, the server sends a Daily meeting - // token that auto-starts the cloud recording on join — more reliable - // than calling startRecording() from a weak client network. Older - // servers don't send one; then the client starts the recording itself. - const callToken = ( - webCall?.transport as { callToken?: string } | undefined - )?.callToken; + // token. In client mode it auto-starts the cloud recording on join. In + // server mode it starts nothing: Vapi starts the recording from its + // servers once the user has joined, which a weak client network can't + // stop. Without a token the SDK emits recording-error and starts + // nothing. + const transport = webCall?.transport as WebCall['transport']; + const callToken = transport?.callToken; + const isRecordingStartedByServer = + transport?.videoRecordingStartMode === 'server'; + const recordingTokenMissing = isVideoRecordingEnabled && !callToken; // Stage 2: Create Daily call object this.emit('call-start-progress', { @@ -727,6 +781,11 @@ export default class Vapi extends VapiEventEmitter { this.detachAudioPlayer(e.participant.session_id); }); + // Registered before join(), as in reconnect(): Vapi starts a server-mode + // recording when it sees the user join, so its start-failed message can + // arrive while join() is still in flight. + this.call.on('app-message', (e) => this.onAppMessage(e)); + // Registered before join(): a token-auto-started recording can begin // while join() is still in flight, so a listener added after join // could miss the event. @@ -827,52 +886,28 @@ export default class Vapi extends VapiEventEmitter { // Stage 5: Video recording setup (if enabled) if (isVideoRecordingEnabled && callToken) { - // The meeting token auto-started the recording at join, so there is - // nothing to request from the client. + // The meeting token auto-started the recording at join, or Vapi + // starts it from its servers, so there is nothing to request from the + // client. this.emit('call-start-progress', { stage: 'video-recording-setup', status: 'completed', timestamp: new Date().toISOString(), - metadata: { action: 'auto-started-via-meeting-token' } + metadata: { + action: isRecordingStartedByServer + ? 'started-by-server' + : 'auto-started-via-meeting-token' + } }); } else if (isVideoRecordingEnabled) { + // No token: nothing starts the recording. The recording-error is + // reported once start() has finished. this.emit('call-start-progress', { stage: 'video-recording-setup', - status: 'started', - timestamp: new Date().toISOString() + status: 'failed', + timestamp: new Date().toISOString(), + metadata: { action: 'no-meeting-token' } }); - - const recordingStartTime = Date.now(); - - try { - recordingRequestedTime = new Date().getTime(); - this.startRecording(); - - const recordingSetupDuration = Date.now() - recordingStartTime; - this.emit('call-start-progress', { - stage: 'video-recording-setup', - status: 'completed', - duration: recordingSetupDuration, - timestamp: new Date().toISOString() - }); - } catch (error) { - const recordingSetupDuration = Date.now() - recordingStartTime; - const serializedError = serializeError(error); - this.emit('call-start-progress', { - stage: 'video-recording-setup', - status: 'failed', - duration: recordingSetupDuration, - timestamp: new Date().toISOString(), - metadata: { error: serializedError.message } - }); - this.emit('error', { - type: 'video-recording-setup-error', - stage: 'video-recording-setup', - error: serializedError, - timestamp: new Date().toISOString() - }); - // Don't throw here, video recording is optional - } } else { this.emit('call-start-progress', { stage: 'video-recording-setup', @@ -927,8 +962,6 @@ export default class Vapi extends VapiEventEmitter { if (e) this.handleLocalAudioLevel(e); }); - this.call.on('app-message', (e) => this.onAppMessage(e)); - this.call.on('nonfatal-error', (e) => { // https://docs.daily.co/reference/daily-js/events/meeting-events#type-audio-processor-error if (e?.type === 'local-audio-level-observer-error') { @@ -1018,6 +1051,10 @@ export default class Vapi extends VapiEventEmitter { timestamp: new Date().toISOString() }); + if (recordingTokenMissing) { + this.recordingTokenMissingReportSchedule(); + } + return webCall; } catch (e) { const totalDuration = Date.now() - startTime; @@ -1069,6 +1106,12 @@ export default class Vapi extends VapiEventEmitter { } else { try { const parsedMessage = JSON.parse(e.data); + // Not a Vapi client message: re-emitted as recording-error, and kept + // out of 'message' because its consumers switch on `type`. + if (parsedMessage?.type === VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE) { + this.videoRecordingStartFailedReport(parsedMessage); + return; + } this.emit('message', parsedMessage); if (parsedMessage && 'type' in parsedMessage && 'status' in parsedMessage && parsedMessage.type === 'status-update' && parsedMessage.status === 'ended') { this.hasEmittedCallEndedStatus = true; @@ -1267,6 +1310,41 @@ export default class Vapi extends VapiEventEmitter { } } + /** + * Reported in a later task, like Daily's own recording-error: start() or + * reconnect() has resolved and the caller's await continuation has run, so a + * listener registered right after it still hears this, and one that calls + * stop() can't interrupt call setup. Skipped if that call was stopped or + * replaced. + */ + private recordingTokenMissingReportSchedule() { + const call = this.call; + if (!call) return; + setTimeout(() => { + if (!this.started || this.call !== call) return; + this.emitToConsumer('recording-error', { + action: 'recording-error', + callClientId: call.callClientId, + errorMsg: + 'Vapi returned no recording token, so the recording was not started. Call startRecording() to try starting it from the client.', + }); + }, 0); + } + + /** The fields are read defensively: the app message is untrusted JSON. */ + private videoRecordingStartFailedReport( + message: Partial, + ) { + const outcome = + typeof message.outcome === 'string' ? message.outcome : 'unknown'; + const error = typeof message.error === 'string' ? message.error : 'unknown'; + this.emitToConsumer('recording-error', { + action: 'recording-error', + callClientId: this.call?.callClientId ?? '', + errorMsg: `Vapi couldn't start the recording (${outcome}): ${error}`, + }); + } + /** Drops the reference before removing, so nothing hands out a detached element. */ private detachAudioPlayer(participantId: string) { if (this.audioPlayer?.dataset.participantId === participantId) { @@ -1535,10 +1613,15 @@ export default class Vapi extends VapiEventEmitter { const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false; - // Same auto-start token as in start(). Rejoining with it while the + // Same token as in start(). In client mode, rejoining with it while the // recording is still running is safe; if the recording stopped when the - // user left, the token starts a new one. + // user left, the token starts a new one. In server mode it starts + // nothing. Without a token the SDK emits recording-error and starts + // nothing. const callToken = webCall?.transport?.callToken; + const isRecordingStartedByServer = + webCall?.transport?.videoRecordingStartMode === 'server'; + const recordingTokenMissing = isVideoRecordingEnabled && !callToken; // Stage 1: Create Daily call object this.emit('call-start-progress', { @@ -1767,46 +1850,28 @@ export default class Vapi extends VapiEventEmitter { // Stage 4: Video recording setup (if enabled) if (isVideoRecordingEnabled && callToken) { - // The meeting token auto-started the recording at join, so there is - // nothing to request from the client. + // The meeting token auto-started the recording at join, or Vapi + // starts it from its servers, so there is nothing to request from the + // client. this.emit('call-start-progress', { stage: 'video-recording-setup', status: 'completed', timestamp: new Date().toISOString(), - metadata: { action: 'auto-started-via-meeting-token' } + metadata: { + action: isRecordingStartedByServer + ? 'started-by-server' + : 'auto-started-via-meeting-token' + } }); } else if (isVideoRecordingEnabled) { + // No token: nothing starts the recording. The recording-error is + // reported once reconnect() has finished. this.emit('call-start-progress', { stage: 'video-recording-setup', - status: 'started', - timestamp: new Date().toISOString() + status: 'failed', + timestamp: new Date().toISOString(), + metadata: { action: 'no-meeting-token' } }); - - const recordingStartTime = Date.now(); - - try { - recordingRequestedTime = new Date().getTime(); - this.startRecording(); - - const recordingSetupDuration = Date.now() - recordingStartTime; - this.emit('call-start-progress', { - stage: 'video-recording-setup', - status: 'completed', - duration: recordingSetupDuration, - timestamp: new Date().toISOString() - }); - } catch (error) { - const recordingSetupDuration = Date.now() - recordingStartTime; - const serializedError = serializeError(error); - this.emit('call-start-progress', { - stage: 'video-recording-setup', - status: 'failed', - duration: recordingSetupDuration, - timestamp: new Date().toISOString(), - metadata: { error: serializedError.message } - }); - // Don't throw here, video recording is optional - } } else { this.emit('call-start-progress', { stage: 'video-recording-setup', @@ -1909,6 +1974,10 @@ export default class Vapi extends VapiEventEmitter { this.emit('call-start'); + if (recordingTokenMissing) { + this.recordingTokenMissingReportSchedule(); + } + } catch (e) { const totalDuration = Date.now() - startTime; const serializedError = serializeError(e);