Skip to content

Commit 9a9aebf

Browse files
authored
Merge pull request #29 from VapiAI/steven-diaz/vap-9999-add-cookie-option-for-voice-reconnect
feat: add cookie option for voice reconnect
2 parents 8b626c7 + 374881c commit 9a9aebf

File tree

9 files changed

+231
-69
lines changed

9 files changed

+231
-69
lines changed

README.md

Lines changed: 52 additions & 38 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
},
7575
"devDependencies": {
7676
"@playwright/test": "^1.53.1",
77+
"@types/js-cookie": "^3.0.6",
7778
"@types/node": "^24.0.6",
7879
"@types/react": "^18.2.66",
7980
"@types/react-dom": "^18.2.22",
@@ -102,6 +103,7 @@
102103
"dependencies": {
103104
"@microsoft/fetch-event-source": "^2.0.1",
104105
"@phosphor-icons/react": "^2.1.10",
106+
"js-cookie": "^3.0.5",
105107
"react-colorful": "^5.6.1",
106108
"react-markdown": "^10.1.0"
107109
}

src/components/VapiWidget.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
6262
voiceShowTranscript,
6363
showTranscript = false, // deprecated
6464
voiceAutoReconnect = false,
65+
voiceReconnectStorage = 'session',
6566
reconnectStorageKey = 'vapi_widget_web_call',
6667
// Consent configuration
6768
consentRequired,
@@ -150,6 +151,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
150151
apiUrl,
151152
firstChatMessage: effectiveChatFirstMessage,
152153
voiceAutoReconnect,
154+
voiceReconnectStorage,
153155
reconnectStorageKey,
154156
onCallStart: effectiveOnVoiceStart,
155157
onCallEnd: effectiveOnVoiceEnd,

src/components/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export interface VapiWidgetProps {
4949
// Voice Configuration
5050
voiceShowTranscript?: boolean;
5151
voiceAutoReconnect?: boolean;
52+
voiceReconnectStorage?: 'session' | 'cookies';
5253
reconnectStorageKey?: string;
5354

5455
// Consent Configuration

src/hooks/useVapiCall.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect, useRef, useCallback } from 'react';
22
import Vapi from '@vapi-ai/web';
33
import * as vapiCallStorage from '../utils/vapiCallStorage';
4+
import type { StorageType } from '../utils/vapiCallStorage';
45

56
export interface VapiCallState {
67
isCallActive: boolean;
@@ -25,6 +26,7 @@ export interface UseVapiCallOptions {
2526
apiUrl?: string;
2627
enabled?: boolean;
2728
voiceAutoReconnect?: boolean;
29+
voiceReconnectStorage?: StorageType;
2830
reconnectStorageKey?: string;
2931
onCallStart?: () => void;
3032
onCallEnd?: () => void;
@@ -43,6 +45,7 @@ export const useVapiCall = ({
4345
apiUrl,
4446
enabled = true,
4547
voiceAutoReconnect = false,
48+
voiceReconnectStorage = 'session',
4649
reconnectStorageKey = 'vapi_widget_web_call',
4750
onCallStart,
4851
onCallEnd,
@@ -98,7 +101,10 @@ export const useVapiCall = ({
98101
setIsSpeaking(false);
99102
setIsMuted(false);
100103
// Clear stored call data on successful call end
101-
vapiCallStorage.clearStoredCall(reconnectStorageKey);
104+
vapiCallStorage.clearStoredCall(
105+
reconnectStorageKey,
106+
voiceReconnectStorage
107+
);
102108
callbacksRef.current.onCallEnd?.();
103109
};
104110

@@ -153,7 +159,7 @@ export const useVapiCall = ({
153159
vapi.removeListener('message', handleMessage);
154160
vapi.removeListener('error', handleError);
155161
};
156-
}, [vapi, reconnectStorageKey]);
162+
}, [vapi, reconnectStorageKey, voiceReconnectStorage]);
157163

158164
useEffect(() => {
159165
return () => {
@@ -194,14 +200,26 @@ export const useVapiCall = ({
194200

195201
// Store call data for reconnection if call was successful and auto-reconnect is enabled
196202
if (call && voiceAutoReconnect) {
197-
vapiCallStorage.storeCallData(reconnectStorageKey, call, callOptions);
203+
vapiCallStorage.storeCallData(
204+
reconnectStorageKey,
205+
call,
206+
callOptions,
207+
voiceReconnectStorage
208+
);
198209
}
199210
} catch (error) {
200211
console.error('Error starting call:', error);
201212
setConnectionStatus('disconnected');
202213
callbacksRef.current.onError?.(error as Error);
203214
}
204-
}, [vapi, callOptions, enabled, voiceAutoReconnect, reconnectStorageKey]);
215+
}, [
216+
vapi,
217+
callOptions,
218+
enabled,
219+
voiceAutoReconnect,
220+
voiceReconnectStorage,
221+
reconnectStorageKey,
222+
]);
205223

206224
const endCall = useCallback(
207225
async ({ force = false }: { force?: boolean } = {}) => {
@@ -250,7 +268,10 @@ export const useVapiCall = ({
250268
return;
251269
}
252270

253-
const storedData = vapiCallStorage.getStoredCallData(reconnectStorageKey);
271+
const storedData = vapiCallStorage.getStoredCallData(
272+
reconnectStorageKey,
273+
voiceReconnectStorage
274+
);
254275

255276
if (!storedData) {
256277
console.warn('No stored call data found for reconnection');
@@ -264,7 +285,10 @@ export const useVapiCall = ({
264285
console.warn(
265286
'CallOptions have changed since last call, clearing stored data and skipping reconnection'
266287
);
267-
vapiCallStorage.clearStoredCall(reconnectStorageKey);
288+
vapiCallStorage.clearStoredCall(
289+
reconnectStorageKey,
290+
voiceReconnectStorage
291+
);
268292
return;
269293
}
270294

@@ -281,14 +305,17 @@ export const useVapiCall = ({
281305
} catch (error) {
282306
setConnectionStatus('disconnected');
283307
console.error('Reconnection failed:', error);
284-
vapiCallStorage.clearStoredCall(reconnectStorageKey);
308+
vapiCallStorage.clearStoredCall(
309+
reconnectStorageKey,
310+
voiceReconnectStorage
311+
);
285312
callbacksRef.current.onError?.(error as Error);
286313
}
287-
}, [vapi, enabled, reconnectStorageKey, callOptions]);
314+
}, [vapi, enabled, reconnectStorageKey, voiceReconnectStorage, callOptions]);
288315

289316
const clearStoredCall = useCallback(() => {
290-
vapiCallStorage.clearStoredCall(reconnectStorageKey);
291-
}, [reconnectStorageKey]);
317+
vapiCallStorage.clearStoredCall(reconnectStorageKey, voiceReconnectStorage);
318+
}, [reconnectStorageKey, voiceReconnectStorage]);
292319

293320
useEffect(() => {
294321
if (!vapi || !enabled || !voiceAutoReconnect) {

src/hooks/useVapiWidget.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface UseVapiWidgetOptions {
1616
apiUrl?: string;
1717
firstChatMessage?: string;
1818
voiceAutoReconnect?: boolean;
19+
voiceReconnectStorage?: 'session' | 'cookies';
1920
reconnectStorageKey?: string;
2021
onCallStart?: () => void;
2122
onCallEnd?: () => void;
@@ -32,6 +33,7 @@ export const useVapiWidget = ({
3233
apiUrl,
3334
firstChatMessage,
3435
voiceAutoReconnect = false,
36+
voiceReconnectStorage = 'session',
3537
reconnectStorageKey,
3638
onCallStart,
3739
onCallEnd,
@@ -68,6 +70,7 @@ export const useVapiWidget = ({
6870
apiUrl,
6971
enabled: voiceEnabled,
7072
voiceAutoReconnect,
73+
voiceReconnectStorage,
7174
reconnectStorageKey,
7275
onCallStart: () => {
7376
// In hybrid mode, clear all conversations when starting voice

src/utils/vapiCallStorage.ts

Lines changed: 115 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Cookies from 'js-cookie';
2+
13
export interface StoredCallData {
24
webCallUrl: string;
35
id?: string;
@@ -11,50 +13,142 @@ export interface StoredCallData {
1113
};
1214
callOptions?: any;
1315
timestamp: number;
16+
tabId?: string;
17+
}
18+
19+
export type StorageType = 'session' | 'cookies';
20+
21+
// Generate or retrieve tab-specific ID (stored in sessionStorage)
22+
function getTabId(): string {
23+
const TAB_ID_KEY = '_vapi_tab_id';
24+
let tabId = sessionStorage.getItem(TAB_ID_KEY);
25+
26+
if (!tabId) {
27+
tabId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
28+
sessionStorage.setItem(TAB_ID_KEY, tabId);
29+
}
30+
31+
return tabId;
32+
}
33+
34+
// Get root domain for cookie (e.g., ".domain.com")
35+
function getRootDomain(): string {
36+
const hostname = window.location.hostname;
37+
38+
// Handle localhost/IP
39+
if (
40+
hostname === 'localhost' ||
41+
hostname === '127.0.0.1' ||
42+
/^\d+\.\d+\.\d+\.\d+$/.test(hostname)
43+
) {
44+
return hostname;
45+
}
46+
47+
const parts = hostname.split('.');
48+
49+
// Already root domain
50+
if (parts.length <= 2) {
51+
return hostname;
52+
}
53+
54+
// Return root domain with leading dot (e.g., ".domain.com")
55+
return '.' + parts.slice(-2).join('.');
1456
}
1557

1658
export const storeCallData = (
1759
reconnectStorageKey: string,
1860
call: any,
19-
callOptions?: any
61+
callOptions?: any,
62+
storageType: StorageType = 'session'
2063
) => {
2164
const webCallUrl =
2265
(call as any).webCallUrl || (call.transport as any)?.callUrl;
2366

24-
if (webCallUrl) {
25-
const webCallToStore = {
26-
webCallUrl,
27-
id: call.id,
28-
artifactPlan: call.artifactPlan,
29-
assistant: call.assistant,
30-
callOptions,
31-
timestamp: Date.now(),
32-
};
33-
sessionStorage.setItem(reconnectStorageKey, JSON.stringify(webCallToStore));
34-
console.log('Stored call data for reconnection:', webCallToStore);
35-
} else {
67+
if (!webCallUrl) {
3668
console.warn(
3769
'No webCallUrl found in call object, cannot store for reconnection'
3870
);
71+
return;
72+
}
73+
74+
const webCallToStore: StoredCallData = {
75+
webCallUrl,
76+
id: call.id,
77+
artifactPlan: call.artifactPlan,
78+
assistant: call.assistant,
79+
callOptions,
80+
timestamp: Date.now(),
81+
};
82+
83+
if (storageType === 'session') {
84+
sessionStorage.setItem(reconnectStorageKey, JSON.stringify(webCallToStore));
85+
} else if (storageType === 'cookies') {
86+
const tabId = getTabId();
87+
const webCallToStoreWithTab = { ...webCallToStore, tabId };
88+
89+
try {
90+
const rootDomain = getRootDomain();
91+
92+
Cookies.set(reconnectStorageKey, JSON.stringify(webCallToStoreWithTab), {
93+
domain: rootDomain,
94+
path: '/',
95+
secure: true,
96+
sameSite: 'lax',
97+
expires: 1 / 24, // 1 hour (expires takes days, so 1/24 = 1 hour)
98+
});
99+
} catch (error) {
100+
console.error('Failed to store call data in cookie:', error);
101+
}
39102
}
40103
};
41104

42105
export const getStoredCallData = (
43-
reconnectStorageKey: string
106+
reconnectStorageKey: string,
107+
storageType: StorageType = 'session'
44108
): StoredCallData | null => {
45109
try {
46-
const stored = sessionStorage.getItem(reconnectStorageKey);
47-
if (!stored) return null;
110+
if (storageType === 'session') {
111+
const sessionData = sessionStorage.getItem(reconnectStorageKey);
112+
if (!sessionData) return null;
48113

49-
return JSON.parse(stored);
50-
} catch {
51-
sessionStorage.removeItem(reconnectStorageKey);
114+
return JSON.parse(sessionData);
115+
} else if (storageType === 'cookies') {
116+
const currentTabId = getTabId();
117+
const cookieValue = Cookies.get(reconnectStorageKey);
118+
119+
if (!cookieValue) return null;
120+
121+
const data = JSON.parse(cookieValue);
122+
123+
// Verify tab ID matches (prevents multi-tab reconnection)
124+
if (data.tabId !== currentTabId) {
125+
console.warn('Tab ID mismatch - ignoring call data from different tab');
126+
return null;
127+
}
128+
129+
return data;
130+
}
131+
132+
return null;
133+
} catch (error) {
134+
console.error('Error reading stored call data:', error);
52135
return null;
53136
}
54137
};
55138

56-
export const clearStoredCall = (reconnectStorageKey: string) => {
57-
sessionStorage.removeItem(reconnectStorageKey);
139+
export const clearStoredCall = (
140+
reconnectStorageKey: string,
141+
storageType: StorageType = 'session'
142+
) => {
143+
if (storageType === 'session') {
144+
sessionStorage.removeItem(reconnectStorageKey);
145+
} else if (storageType === 'cookies') {
146+
const rootDomain = getRootDomain();
147+
Cookies.remove(reconnectStorageKey, {
148+
domain: rootDomain,
149+
path: '/',
150+
});
151+
}
58152
};
59153

60154
export const areCallOptionsEqual = (options1: any, options2: any): boolean => {

0 commit comments

Comments
 (0)