Skip to content

Commit 9b8f2e8

Browse files
Vapi Taskerclaude
andcommitted
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 <noreply@anthropic.com>
1 parent bdafc5e commit 9b8f2e8

File tree

2 files changed

+349
-6
lines changed

2 files changed

+349
-6
lines changed

__tests__/serialization.test.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/**
2+
* Tests for serialization utilities to prevent DataCloneError
3+
*
4+
* The structured clone algorithm used by postMessage cannot handle:
5+
* - Functions
6+
* - DOM nodes
7+
* - MediaStreamTrack objects
8+
* - Symbols
9+
* - WeakMap/WeakSet
10+
* - Error objects (partially)
11+
*
12+
* These tests verify that our sanitization utilities properly handle these cases.
13+
*/
14+
15+
import { sanitizeForPostMessage, describeMediaSource } from '../vapi';
16+
17+
describe('sanitizeForPostMessage', () => {
18+
it('should pass through primitive values unchanged', () => {
19+
expect(sanitizeForPostMessage('hello')).toBe('hello');
20+
expect(sanitizeForPostMessage(123)).toBe(123);
21+
expect(sanitizeForPostMessage(true)).toBe(true);
22+
expect(sanitizeForPostMessage(false)).toBe(false);
23+
expect(sanitizeForPostMessage(null)).toBe(null);
24+
expect(sanitizeForPostMessage(undefined)).toBe(undefined);
25+
});
26+
27+
it('should pass through simple objects unchanged', () => {
28+
const obj = { a: 1, b: 'test', c: true };
29+
const result = sanitizeForPostMessage(obj);
30+
expect(result).toEqual(obj);
31+
});
32+
33+
it('should pass through arrays unchanged', () => {
34+
const arr = [1, 2, 'test', true];
35+
const result = sanitizeForPostMessage(arr);
36+
expect(result).toEqual(arr);
37+
});
38+
39+
it('should convert functions to descriptive strings', () => {
40+
const fn = function testFunc() { return 42; };
41+
const result = sanitizeForPostMessage(fn);
42+
expect(result).toBe('[Function: testFunc]');
43+
});
44+
45+
it('should convert arrow functions to descriptive strings', () => {
46+
const fn = () => 42;
47+
const result = sanitizeForPostMessage(fn);
48+
expect(typeof result).toBe('string');
49+
expect(result).toContain('[Function');
50+
});
51+
52+
it('should convert anonymous functions to descriptive strings', () => {
53+
// Note: Modern JS engines infer function names from variable assignments
54+
// so `const fn = function() {}` results in a function named 'fn'
55+
// To get a truly anonymous function, we need to pass it directly
56+
const result = sanitizeForPostMessage(function() { return 42; });
57+
expect(result).toBe('[Function: anonymous]');
58+
});
59+
60+
it('should sanitize nested objects with functions', () => {
61+
const obj = {
62+
name: 'test',
63+
callback: () => {},
64+
nested: {
65+
fn: function handler() {}
66+
}
67+
};
68+
const result = sanitizeForPostMessage(obj);
69+
expect(result).toEqual({
70+
name: 'test',
71+
callback: '[Function: callback]', // Arrow functions in object properties get inferred names
72+
nested: {
73+
fn: '[Function: handler]'
74+
}
75+
});
76+
});
77+
78+
it('should sanitize arrays containing functions', () => {
79+
const arr = [1, () => {}, 'test'];
80+
const result = sanitizeForPostMessage(arr) as unknown[];
81+
expect(Array.isArray(result)).toBe(true);
82+
expect(result[0]).toBe(1);
83+
expect(typeof result[1]).toBe('string');
84+
expect(result[1]).toContain('[Function');
85+
expect(result[2]).toBe('test');
86+
});
87+
88+
it('should convert Symbol values to strings', () => {
89+
const sym = Symbol('test');
90+
const result = sanitizeForPostMessage(sym);
91+
expect(result).toBe('Symbol(test)');
92+
});
93+
94+
it('should handle objects with Symbol values', () => {
95+
const obj = {
96+
name: 'test',
97+
sym: Symbol('mySymbol')
98+
};
99+
const result = sanitizeForPostMessage(obj);
100+
expect(result).toEqual({
101+
name: 'test',
102+
sym: 'Symbol(mySymbol)'
103+
});
104+
});
105+
106+
it('should handle Date objects', () => {
107+
const date = new Date('2024-01-15T12:00:00Z');
108+
const result = sanitizeForPostMessage(date);
109+
// Dates should be converted to ISO strings for safe serialization
110+
expect(result).toBe(date.toISOString());
111+
});
112+
113+
it('should handle objects with circular references by returning a placeholder', () => {
114+
const obj: any = { name: 'test' };
115+
obj.self = obj;
116+
// This should not throw and should handle the circular reference
117+
const result = sanitizeForPostMessage(obj) as Record<string, unknown>;
118+
expect(result).toBeDefined();
119+
expect(result.name).toBe('test');
120+
expect(result.self).toBe('[Circular Reference]');
121+
});
122+
123+
it('should handle deeply nested structures', () => {
124+
const obj = {
125+
level1: {
126+
level2: {
127+
level3: {
128+
value: 'deep',
129+
fn: () => {}
130+
}
131+
}
132+
}
133+
};
134+
const result = sanitizeForPostMessage(obj) as any;
135+
expect(result.level1.level2.level3.value).toBe('deep');
136+
expect(result.level1.level2.level3.fn).toContain('[Function');
137+
});
138+
});
139+
140+
describe('describeMediaSource', () => {
141+
it('should return boolean values as-is', () => {
142+
expect(describeMediaSource(true)).toBe(true);
143+
expect(describeMediaSource(false)).toBe(false);
144+
});
145+
146+
it('should return string device IDs as-is', () => {
147+
expect(describeMediaSource('device-123')).toBe('device-123');
148+
});
149+
150+
it('should describe MediaStreamTrack objects', () => {
151+
// Create a mock MediaStreamTrack
152+
const mockTrack = {
153+
kind: 'audio',
154+
id: 'track-abc123',
155+
label: 'Built-in Microphone',
156+
};
157+
158+
const result = describeMediaSource(mockTrack as unknown as MediaStreamTrack);
159+
expect(result).toBe('[MediaStreamTrack: audio, id=track-abc123]');
160+
});
161+
162+
it('should handle MediaStreamTrack without label', () => {
163+
const mockTrack = {
164+
kind: 'video',
165+
id: 'track-xyz789',
166+
};
167+
168+
const result = describeMediaSource(mockTrack as unknown as MediaStreamTrack);
169+
expect(result).toBe('[MediaStreamTrack: video, id=track-xyz789]');
170+
});
171+
172+
it('should handle null and undefined', () => {
173+
expect(describeMediaSource(null as any)).toBe(null);
174+
expect(describeMediaSource(undefined as any)).toBe(undefined);
175+
});
176+
});
177+
178+
describe('DataCloneError prevention in call-start-progress events', () => {
179+
it('should produce serializable metadata when audioSource is a MediaStreamTrack', () => {
180+
// Simulate what happens when a MediaStreamTrack is passed as audioSource
181+
const mockTrack = {
182+
kind: 'audio',
183+
id: 'track-123',
184+
readyState: 'live',
185+
enabled: true,
186+
};
187+
188+
const metadata = {
189+
audioSource: describeMediaSource(mockTrack as unknown as MediaStreamTrack),
190+
videoSource: describeMediaSource(true),
191+
isVideoRecordingEnabled: false,
192+
isVideoEnabled: false,
193+
};
194+
195+
// Verify it can be JSON serialized (which postMessage also requires)
196+
expect(() => JSON.stringify(metadata)).not.toThrow();
197+
198+
// Verify the values are correct
199+
expect(metadata.audioSource).toBe('[MediaStreamTrack: audio, id=track-123]');
200+
expect(metadata.videoSource).toBe(true);
201+
});
202+
203+
it('should handle typical call-start-progress event metadata', () => {
204+
const mockAudioTrack = {
205+
kind: 'audio',
206+
id: 'audio-track-456',
207+
readyState: 'live',
208+
enabled: true,
209+
};
210+
211+
const mockVideoTrack = {
212+
kind: 'video',
213+
id: 'video-track-789',
214+
readyState: 'live',
215+
enabled: true,
216+
};
217+
218+
const progressEvent = {
219+
stage: 'daily-call-object-creation',
220+
status: 'started',
221+
timestamp: new Date().toISOString(),
222+
metadata: {
223+
audioSource: describeMediaSource(mockAudioTrack as unknown as MediaStreamTrack),
224+
videoSource: describeMediaSource(mockVideoTrack as unknown as MediaStreamTrack),
225+
isVideoRecordingEnabled: true,
226+
isVideoEnabled: false,
227+
}
228+
};
229+
230+
// Verify it can be serialized
231+
expect(() => JSON.stringify(progressEvent)).not.toThrow();
232+
233+
// Verify structure
234+
expect(progressEvent.metadata.audioSource).toBe('[MediaStreamTrack: audio, id=audio-track-456]');
235+
expect(progressEvent.metadata.videoSource).toBe('[MediaStreamTrack: video, id=video-track-789]');
236+
});
237+
});

vapi.ts

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,112 @@ function serializeError(error: unknown): SerializedError {
165165
return { message: String(error) };
166166
}
167167

168+
/**
169+
* Describes a media source (audioSource/videoSource) in a serializable way.
170+
* MediaStreamTrack objects cannot be cloned by the structured clone algorithm
171+
* used by postMessage, so we convert them to descriptive strings.
172+
*
173+
* @param source - The media source which can be a boolean, string device ID, or MediaStreamTrack
174+
* @returns A serializable representation of the source
175+
*/
176+
export function describeMediaSource(
177+
source: string | boolean | MediaStreamTrack | null | undefined
178+
): string | boolean | null | undefined {
179+
if (source === null || source === undefined) {
180+
return source;
181+
}
182+
183+
if (typeof source === 'boolean' || typeof source === 'string') {
184+
return source;
185+
}
186+
187+
// It's a MediaStreamTrack - convert to a descriptive string
188+
if (typeof source === 'object' && 'kind' in source && 'id' in source) {
189+
return `[MediaStreamTrack: ${source.kind}, id=${source.id}]`;
190+
}
191+
192+
// Fallback for any other object type
193+
return '[Unknown MediaSource]';
194+
}
195+
196+
/**
197+
* Sanitizes a value to ensure it can be safely passed through postMessage.
198+
* The structured clone algorithm used by postMessage cannot handle:
199+
* - Functions
200+
* - DOM nodes
201+
* - MediaStreamTrack objects
202+
* - Symbols
203+
* - WeakMap/WeakSet
204+
*
205+
* This function recursively processes objects and arrays, converting
206+
* non-cloneable values to serializable representations.
207+
*
208+
* @param value - The value to sanitize
209+
* @param seen - Set to track circular references (internal use)
210+
* @returns A sanitized value safe for postMessage
211+
*/
212+
export function sanitizeForPostMessage(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {
213+
// Handle null and undefined
214+
if (value === null || value === undefined) {
215+
return value;
216+
}
217+
218+
// Handle primitives
219+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
220+
return value;
221+
}
222+
223+
// Handle symbols
224+
if (typeof value === 'symbol') {
225+
return value.toString();
226+
}
227+
228+
// Handle functions
229+
if (typeof value === 'function') {
230+
const name = value.name || 'anonymous';
231+
return `[Function: ${name}]`;
232+
}
233+
234+
// Handle Date objects
235+
if (value instanceof Date) {
236+
return value.toISOString();
237+
}
238+
239+
// Handle arrays
240+
if (Array.isArray(value)) {
241+
return value.map(item => sanitizeForPostMessage(item, seen));
242+
}
243+
244+
// Handle objects
245+
if (typeof value === 'object') {
246+
// Check for circular references
247+
if (seen.has(value)) {
248+
return '[Circular Reference]';
249+
}
250+
seen.add(value);
251+
252+
// Check if it's a MediaStreamTrack-like object
253+
if ('kind' in value && 'id' in value && ('readyState' in value || 'enabled' in value)) {
254+
return describeMediaSource(value as MediaStreamTrack);
255+
}
256+
257+
// Handle Error objects
258+
if (value instanceof Error) {
259+
return serializeError(value);
260+
}
261+
262+
// Handle plain objects
263+
const sanitized: Record<string, unknown> = {};
264+
for (const key of Object.keys(value)) {
265+
sanitized[key] = sanitizeForPostMessage((value as Record<string, unknown>)[key], seen);
266+
}
267+
return sanitized;
268+
}
269+
270+
// Fallback: convert to string
271+
return String(value);
272+
}
273+
168274
type VapiEventListeners = {
169275
'call-end': () => void;
170276
'call-start': () => void;
@@ -452,15 +558,15 @@ export default class Vapi extends VapiEventEmitter {
452558
status: 'started',
453559
timestamp: new Date().toISOString(),
454560
metadata: {
455-
audioSource: this.dailyCallObject.audioSource ?? true,
456-
videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
561+
audioSource: describeMediaSource(this.dailyCallObject.audioSource ?? true),
562+
videoSource: describeMediaSource(this.dailyCallObject.videoSource ?? isVideoRecordingEnabled),
457563
isVideoRecordingEnabled,
458564
isVideoEnabled
459565
}
460566
});
461-
567+
462568
const dailyCallStartTime = Date.now();
463-
569+
464570
try {
465571
this.call = DailyIframe.createCallObject({
466572
audioSource: this.dailyCallObject.audioSource ?? true,
@@ -1093,8 +1199,8 @@ export default class Vapi extends VapiEventEmitter {
10931199
status: 'started',
10941200
timestamp: new Date().toISOString(),
10951201
metadata: {
1096-
audioSource: this.dailyCallObject.audioSource ?? true,
1097-
videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
1202+
audioSource: describeMediaSource(this.dailyCallObject.audioSource ?? true),
1203+
videoSource: describeMediaSource(this.dailyCallObject.videoSource ?? isVideoRecordingEnabled),
10981204
isVideoRecordingEnabled,
10991205
isVideoEnabled
11001206
}

0 commit comments

Comments
 (0)