From 9b8f2e8cac53e3c4c174f57685a17b4d00330b2f Mon Sep 17 00:00:00 2001 From: Vapi Tasker Date: Wed, 4 Feb 2026 12:35:11 +0000 Subject: [PATCH] fix: prevent DataCloneError in postMessage by sanitizing MediaStreamTrack objects When users pass MediaStreamTrack objects as audioSource or videoSource, these were being included directly in call-start-progress event metadata. MediaStreamTrack objects cannot be cloned by the structured clone algorithm used by postMessage, causing DataCloneError during Daily call join. This fix: - Adds describeMediaSource() to convert MediaStreamTrack to serializable strings - Adds sanitizeForPostMessage() for general non-cloneable value handling - Updates call-start-progress events to use describeMediaSource() for metadata - Adds comprehensive tests for serialization utilities Fixes DEVREL-464 Co-Authored-By: Claude Opus 4.5 --- __tests__/serialization.test.ts | 237 ++++++++++++++++++++++++++++++++ vapi.ts | 118 +++++++++++++++- 2 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 __tests__/serialization.test.ts diff --git a/__tests__/serialization.test.ts b/__tests__/serialization.test.ts new file mode 100644 index 000000000..a847417a9 --- /dev/null +++ b/__tests__/serialization.test.ts @@ -0,0 +1,237 @@ +/** + * Tests for serialization utilities to prevent DataCloneError + * + * The structured clone algorithm used by postMessage cannot handle: + * - Functions + * - DOM nodes + * - MediaStreamTrack objects + * - Symbols + * - WeakMap/WeakSet + * - Error objects (partially) + * + * These tests verify that our sanitization utilities properly handle these cases. + */ + +import { sanitizeForPostMessage, describeMediaSource } from '../vapi'; + +describe('sanitizeForPostMessage', () => { + it('should pass through primitive values unchanged', () => { + expect(sanitizeForPostMessage('hello')).toBe('hello'); + expect(sanitizeForPostMessage(123)).toBe(123); + expect(sanitizeForPostMessage(true)).toBe(true); + expect(sanitizeForPostMessage(false)).toBe(false); + expect(sanitizeForPostMessage(null)).toBe(null); + expect(sanitizeForPostMessage(undefined)).toBe(undefined); + }); + + it('should pass through simple objects unchanged', () => { + const obj = { a: 1, b: 'test', c: true }; + const result = sanitizeForPostMessage(obj); + expect(result).toEqual(obj); + }); + + it('should pass through arrays unchanged', () => { + const arr = [1, 2, 'test', true]; + const result = sanitizeForPostMessage(arr); + expect(result).toEqual(arr); + }); + + it('should convert functions to descriptive strings', () => { + const fn = function testFunc() { return 42; }; + const result = sanitizeForPostMessage(fn); + expect(result).toBe('[Function: testFunc]'); + }); + + it('should convert arrow functions to descriptive strings', () => { + const fn = () => 42; + const result = sanitizeForPostMessage(fn); + expect(typeof result).toBe('string'); + expect(result).toContain('[Function'); + }); + + it('should convert anonymous functions to descriptive strings', () => { + // Note: Modern JS engines infer function names from variable assignments + // so `const fn = function() {}` results in a function named 'fn' + // To get a truly anonymous function, we need to pass it directly + const result = sanitizeForPostMessage(function() { return 42; }); + expect(result).toBe('[Function: anonymous]'); + }); + + it('should sanitize nested objects with functions', () => { + const obj = { + name: 'test', + callback: () => {}, + nested: { + fn: function handler() {} + } + }; + const result = sanitizeForPostMessage(obj); + expect(result).toEqual({ + name: 'test', + callback: '[Function: callback]', // Arrow functions in object properties get inferred names + nested: { + fn: '[Function: handler]' + } + }); + }); + + it('should sanitize arrays containing functions', () => { + const arr = [1, () => {}, 'test']; + const result = sanitizeForPostMessage(arr) as unknown[]; + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toBe(1); + expect(typeof result[1]).toBe('string'); + expect(result[1]).toContain('[Function'); + expect(result[2]).toBe('test'); + }); + + it('should convert Symbol values to strings', () => { + const sym = Symbol('test'); + const result = sanitizeForPostMessage(sym); + expect(result).toBe('Symbol(test)'); + }); + + it('should handle objects with Symbol values', () => { + const obj = { + name: 'test', + sym: Symbol('mySymbol') + }; + const result = sanitizeForPostMessage(obj); + expect(result).toEqual({ + name: 'test', + sym: 'Symbol(mySymbol)' + }); + }); + + it('should handle Date objects', () => { + const date = new Date('2024-01-15T12:00:00Z'); + const result = sanitizeForPostMessage(date); + // Dates should be converted to ISO strings for safe serialization + expect(result).toBe(date.toISOString()); + }); + + it('should handle objects with circular references by returning a placeholder', () => { + const obj: any = { name: 'test' }; + obj.self = obj; + // This should not throw and should handle the circular reference + const result = sanitizeForPostMessage(obj) as Record; + expect(result).toBeDefined(); + expect(result.name).toBe('test'); + expect(result.self).toBe('[Circular Reference]'); + }); + + it('should handle deeply nested structures', () => { + const obj = { + level1: { + level2: { + level3: { + value: 'deep', + fn: () => {} + } + } + } + }; + const result = sanitizeForPostMessage(obj) as any; + expect(result.level1.level2.level3.value).toBe('deep'); + expect(result.level1.level2.level3.fn).toContain('[Function'); + }); +}); + +describe('describeMediaSource', () => { + it('should return boolean values as-is', () => { + expect(describeMediaSource(true)).toBe(true); + expect(describeMediaSource(false)).toBe(false); + }); + + it('should return string device IDs as-is', () => { + expect(describeMediaSource('device-123')).toBe('device-123'); + }); + + it('should describe MediaStreamTrack objects', () => { + // Create a mock MediaStreamTrack + const mockTrack = { + kind: 'audio', + id: 'track-abc123', + label: 'Built-in Microphone', + }; + + const result = describeMediaSource(mockTrack as unknown as MediaStreamTrack); + expect(result).toBe('[MediaStreamTrack: audio, id=track-abc123]'); + }); + + it('should handle MediaStreamTrack without label', () => { + const mockTrack = { + kind: 'video', + id: 'track-xyz789', + }; + + const result = describeMediaSource(mockTrack as unknown as MediaStreamTrack); + expect(result).toBe('[MediaStreamTrack: video, id=track-xyz789]'); + }); + + it('should handle null and undefined', () => { + expect(describeMediaSource(null as any)).toBe(null); + expect(describeMediaSource(undefined as any)).toBe(undefined); + }); +}); + +describe('DataCloneError prevention in call-start-progress events', () => { + it('should produce serializable metadata when audioSource is a MediaStreamTrack', () => { + // Simulate what happens when a MediaStreamTrack is passed as audioSource + const mockTrack = { + kind: 'audio', + id: 'track-123', + readyState: 'live', + enabled: true, + }; + + const metadata = { + audioSource: describeMediaSource(mockTrack as unknown as MediaStreamTrack), + videoSource: describeMediaSource(true), + isVideoRecordingEnabled: false, + isVideoEnabled: false, + }; + + // Verify it can be JSON serialized (which postMessage also requires) + expect(() => JSON.stringify(metadata)).not.toThrow(); + + // Verify the values are correct + expect(metadata.audioSource).toBe('[MediaStreamTrack: audio, id=track-123]'); + expect(metadata.videoSource).toBe(true); + }); + + it('should handle typical call-start-progress event metadata', () => { + const mockAudioTrack = { + kind: 'audio', + id: 'audio-track-456', + readyState: 'live', + enabled: true, + }; + + const mockVideoTrack = { + kind: 'video', + id: 'video-track-789', + readyState: 'live', + enabled: true, + }; + + const progressEvent = { + stage: 'daily-call-object-creation', + status: 'started', + timestamp: new Date().toISOString(), + metadata: { + audioSource: describeMediaSource(mockAudioTrack as unknown as MediaStreamTrack), + videoSource: describeMediaSource(mockVideoTrack as unknown as MediaStreamTrack), + isVideoRecordingEnabled: true, + isVideoEnabled: false, + } + }; + + // Verify it can be serialized + expect(() => JSON.stringify(progressEvent)).not.toThrow(); + + // Verify structure + expect(progressEvent.metadata.audioSource).toBe('[MediaStreamTrack: audio, id=audio-track-456]'); + expect(progressEvent.metadata.videoSource).toBe('[MediaStreamTrack: video, id=video-track-789]'); + }); +}); diff --git a/vapi.ts b/vapi.ts index e659313ff..213415690 100644 --- a/vapi.ts +++ b/vapi.ts @@ -165,6 +165,112 @@ function serializeError(error: unknown): SerializedError { return { message: String(error) }; } +/** + * Describes a media source (audioSource/videoSource) in a serializable way. + * MediaStreamTrack objects cannot be cloned by the structured clone algorithm + * used by postMessage, so we convert them to descriptive strings. + * + * @param source - The media source which can be a boolean, string device ID, or MediaStreamTrack + * @returns A serializable representation of the source + */ +export function describeMediaSource( + source: string | boolean | MediaStreamTrack | null | undefined +): string | boolean | null | undefined { + if (source === null || source === undefined) { + return source; + } + + if (typeof source === 'boolean' || typeof source === 'string') { + return source; + } + + // It's a MediaStreamTrack - convert to a descriptive string + if (typeof source === 'object' && 'kind' in source && 'id' in source) { + return `[MediaStreamTrack: ${source.kind}, id=${source.id}]`; + } + + // Fallback for any other object type + return '[Unknown MediaSource]'; +} + +/** + * Sanitizes a value to ensure it can be safely passed through postMessage. + * The structured clone algorithm used by postMessage cannot handle: + * - Functions + * - DOM nodes + * - MediaStreamTrack objects + * - Symbols + * - WeakMap/WeakSet + * + * This function recursively processes objects and arrays, converting + * non-cloneable values to serializable representations. + * + * @param value - The value to sanitize + * @param seen - Set to track circular references (internal use) + * @returns A sanitized value safe for postMessage + */ +export function sanitizeForPostMessage(value: unknown, seen: WeakSet = new WeakSet()): unknown { + // Handle null and undefined + if (value === null || value === undefined) { + return value; + } + + // Handle primitives + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + // Handle symbols + if (typeof value === 'symbol') { + return value.toString(); + } + + // Handle functions + if (typeof value === 'function') { + const name = value.name || 'anonymous'; + return `[Function: ${name}]`; + } + + // Handle Date objects + if (value instanceof Date) { + return value.toISOString(); + } + + // Handle arrays + if (Array.isArray(value)) { + return value.map(item => sanitizeForPostMessage(item, seen)); + } + + // Handle objects + if (typeof value === 'object') { + // Check for circular references + if (seen.has(value)) { + return '[Circular Reference]'; + } + seen.add(value); + + // Check if it's a MediaStreamTrack-like object + if ('kind' in value && 'id' in value && ('readyState' in value || 'enabled' in value)) { + return describeMediaSource(value as MediaStreamTrack); + } + + // Handle Error objects + if (value instanceof Error) { + return serializeError(value); + } + + // Handle plain objects + const sanitized: Record = {}; + for (const key of Object.keys(value)) { + sanitized[key] = sanitizeForPostMessage((value as Record)[key], seen); + } + return sanitized; + } + + // Fallback: convert to string + return String(value); +} + type VapiEventListeners = { 'call-end': () => void; 'call-start': () => void; @@ -452,15 +558,15 @@ export default class Vapi extends VapiEventEmitter { status: 'started', timestamp: new Date().toISOString(), metadata: { - audioSource: this.dailyCallObject.audioSource ?? true, - videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled, + audioSource: describeMediaSource(this.dailyCallObject.audioSource ?? true), + videoSource: describeMediaSource(this.dailyCallObject.videoSource ?? isVideoRecordingEnabled), isVideoRecordingEnabled, isVideoEnabled } }); - + const dailyCallStartTime = Date.now(); - + try { this.call = DailyIframe.createCallObject({ audioSource: this.dailyCallObject.audioSource ?? true, @@ -1093,8 +1199,8 @@ export default class Vapi extends VapiEventEmitter { status: 'started', timestamp: new Date().toISOString(), metadata: { - audioSource: this.dailyCallObject.audioSource ?? true, - videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled, + audioSource: describeMediaSource(this.dailyCallObject.audioSource ?? true), + videoSource: describeMediaSource(this.dailyCallObject.videoSource ?? isVideoRecordingEnabled), isVideoRecordingEnabled, isVideoEnabled }