@aether-stack-dev/client-sdk 1.0.0 → 1.1.1
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/LICENSE +21 -0
- package/README.md +105 -220
- package/dist/AStackCSRClient.d.ts +26 -9
- package/dist/AnalyticsCollector.d.ts +1 -2
- package/dist/BillingMonitor.d.ts +5 -4
- package/dist/ConnectionStateManager.d.ts +4 -3
- package/dist/{WebRTCManager.d.ts → MediaManager.d.ts} +1 -6
- package/dist/PerformanceMonitor.d.ts +2 -3
- package/dist/SecurityLogger.d.ts +1 -5
- package/dist/SupabaseSignalingClient.d.ts +0 -1
- package/dist/UsageTracker.d.ts +0 -1
- package/dist/__tests__/setup.d.ts +12 -2
- package/dist/audio/AudioPlayer.d.ts +1 -1
- package/dist/audio/index.d.ts +0 -1
- package/dist/avatar/TalkingHeadAvatar.d.ts +0 -1
- package/dist/avatar/VRMAvatar.d.ts +0 -1
- package/dist/avatar/constants.d.ts +0 -1
- package/dist/avatar/index.d.ts +0 -1
- package/dist/core.d.ts +2 -7
- package/dist/index.d.ts +3 -15
- package/dist/index.esm.js +283 -1868
- package/dist/index.js +283 -1871
- package/dist/react/index.d.ts +0 -3
- package/dist/react/useAStackCSR.d.ts +0 -1
- package/dist/react.esm.js +263 -2088
- package/dist/react.js +261 -2087
- package/dist/types.d.ts +20 -19
- package/package.json +10 -10
- package/dist/AStackCSRClient.d.ts.map +0 -1
- package/dist/AStackClient.d.ts +0 -90
- package/dist/AStackClient.d.ts.map +0 -1
- package/dist/AStackEventSetup.d.ts +0 -35
- package/dist/AStackEventSetup.d.ts.map +0 -1
- package/dist/AStackMediaController.d.ts +0 -34
- package/dist/AStackMediaController.d.ts.map +0 -1
- package/dist/AnalyticsCollector.d.ts.map +0 -1
- package/dist/BillingMonitor.d.ts.map +0 -1
- package/dist/ConnectionStateManager.d.ts.map +0 -1
- package/dist/PerformanceMonitor.d.ts.map +0 -1
- package/dist/SecurityLogger.d.ts.map +0 -1
- package/dist/SessionManager.d.ts.map +0 -1
- package/dist/SupabaseSignalingClient.d.ts.map +0 -1
- package/dist/UsageTracker.d.ts.map +0 -1
- package/dist/WebRTCManager.d.ts.map +0 -1
- package/dist/__tests__/setup.d.ts.map +0 -1
- package/dist/audio/AudioPlayer.d.ts.map +0 -1
- package/dist/audio/index.d.ts.map +0 -1
- package/dist/avatar/TalkingHeadAvatar.d.ts.map +0 -1
- package/dist/avatar/VRMAvatar.d.ts.map +0 -1
- package/dist/avatar/constants.d.ts.map +0 -1
- package/dist/avatar/index.d.ts.map +0 -1
- package/dist/core.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.esm.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/react/index.d.ts.map +0 -1
- package/dist/react/useAStack.d.ts +0 -34
- package/dist/react/useAStack.d.ts.map +0 -1
- package/dist/react/useAStackCSR.d.ts.map +0 -1
- package/dist/react.esm.js.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
package/dist/index.esm.js
CHANGED
|
@@ -1,1814 +1,4 @@
|
|
|
1
1
|
import { EventEmitter } from 'eventemitter3';
|
|
2
|
-
import { createClient } from '@supabase/supabase-js';
|
|
3
|
-
|
|
4
|
-
class WebRTCManager extends EventEmitter {
|
|
5
|
-
constructor(config) {
|
|
6
|
-
super();
|
|
7
|
-
this.localStream = null;
|
|
8
|
-
this.config = config;
|
|
9
|
-
}
|
|
10
|
-
async initialize() {
|
|
11
|
-
this.emit('initialized');
|
|
12
|
-
}
|
|
13
|
-
async getStats() {
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
async getUserMedia() {
|
|
17
|
-
const constraints = {
|
|
18
|
-
audio: this.config.enableAudio !== false ? {
|
|
19
|
-
echoCancellation: this.config.audio?.echoCancellation ?? true,
|
|
20
|
-
noiseSuppression: this.config.audio?.noiseSuppression ?? true,
|
|
21
|
-
autoGainControl: this.config.audio?.autoGainControl ?? true
|
|
22
|
-
} : false
|
|
23
|
-
};
|
|
24
|
-
return navigator.mediaDevices.getUserMedia(constraints);
|
|
25
|
-
}
|
|
26
|
-
async addLocalStream(stream) {
|
|
27
|
-
this.localStream = stream;
|
|
28
|
-
this.emit('localStreamAdded', stream);
|
|
29
|
-
}
|
|
30
|
-
removeLocalStream(streamId) {
|
|
31
|
-
if (this.localStream) {
|
|
32
|
-
this.localStream.getTracks().forEach(track => {
|
|
33
|
-
if (track.id === streamId)
|
|
34
|
-
track.stop();
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
getLocalStreams() {
|
|
39
|
-
if (!this.localStream)
|
|
40
|
-
return [];
|
|
41
|
-
return this.localStream.getTracks().map(track => ({
|
|
42
|
-
id: track.id,
|
|
43
|
-
kind: track.kind,
|
|
44
|
-
active: track.enabled,
|
|
45
|
-
track
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
|
-
getRemoteStreams() {
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
async getMediaDevices() {
|
|
52
|
-
return navigator.mediaDevices.enumerateDevices();
|
|
53
|
-
}
|
|
54
|
-
async switchAudioInput(deviceId) {
|
|
55
|
-
if (this.localStream) {
|
|
56
|
-
this.localStream.getAudioTracks().forEach(track => track.stop());
|
|
57
|
-
}
|
|
58
|
-
const stream = await navigator.mediaDevices.getUserMedia({
|
|
59
|
-
audio: { deviceId: { exact: deviceId } }
|
|
60
|
-
});
|
|
61
|
-
this.localStream = stream;
|
|
62
|
-
this.emit('localStreamAdded', stream);
|
|
63
|
-
}
|
|
64
|
-
muteAudio() {
|
|
65
|
-
if (this.localStream) {
|
|
66
|
-
this.localStream.getAudioTracks().forEach(track => { track.enabled = false; });
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
unmuteAudio() {
|
|
70
|
-
if (this.localStream) {
|
|
71
|
-
this.localStream.getAudioTracks().forEach(track => { track.enabled = true; });
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
muteVideo() {
|
|
75
|
-
if (this.localStream) {
|
|
76
|
-
this.localStream.getVideoTracks().forEach(track => { track.enabled = false; });
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
unmuteVideo() {
|
|
80
|
-
if (this.localStream) {
|
|
81
|
-
this.localStream.getVideoTracks().forEach(track => { track.enabled = true; });
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
async createOffer() {
|
|
85
|
-
console.warn('WebRTC is deprecated. Use WebSocket-based AStackCSRClient instead.');
|
|
86
|
-
return { type: 'offer', sdp: '' };
|
|
87
|
-
}
|
|
88
|
-
async createAnswer(remoteSdp) {
|
|
89
|
-
console.warn('WebRTC is deprecated. Use WebSocket-based AStackCSRClient instead.');
|
|
90
|
-
return { type: 'answer', sdp: '' };
|
|
91
|
-
}
|
|
92
|
-
async handleAnswer(answer) {
|
|
93
|
-
console.warn('WebRTC is deprecated. Use WebSocket-based AStackCSRClient instead.');
|
|
94
|
-
}
|
|
95
|
-
async addIceCandidate(candidate) {
|
|
96
|
-
console.warn('WebRTC is deprecated. Use WebSocket-based AStackCSRClient instead.');
|
|
97
|
-
}
|
|
98
|
-
async close() {
|
|
99
|
-
if (this.localStream) {
|
|
100
|
-
this.localStream.getTracks().forEach(track => track.stop());
|
|
101
|
-
this.localStream = null;
|
|
102
|
-
}
|
|
103
|
-
this.emit('closed');
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
class AStackError extends Error {
|
|
108
|
-
constructor(message, code) {
|
|
109
|
-
super(message);
|
|
110
|
-
this.name = 'AStackError';
|
|
111
|
-
this.code = code;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
const ErrorCodes = {
|
|
115
|
-
INVALID_API_KEY: 'INVALID_API_KEY',
|
|
116
|
-
INVALID_TOKEN: 'INVALID_TOKEN',
|
|
117
|
-
AUTHENTICATION_FAILED: 'AUTHENTICATION_FAILED',
|
|
118
|
-
SESSION_EXPIRED: 'SESSION_EXPIRED',
|
|
119
|
-
SESSION_CREATION_FAILED: 'SESSION_CREATION_FAILED',
|
|
120
|
-
CONNECTION_LOST: 'CONNECTION_LOST',
|
|
121
|
-
SIGNALING_ERROR: 'SIGNALING_ERROR',
|
|
122
|
-
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
123
|
-
MEDIA_ACCESS_DENIED: 'MEDIA_ACCESS_DENIED',
|
|
124
|
-
AUDIO_ERROR: 'AUDIO_ERROR',
|
|
125
|
-
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
|
126
|
-
INSUFFICIENT_CREDITS: 'INSUFFICIENT_CREDITS',
|
|
127
|
-
BILLING_ERROR: 'BILLING_ERROR'
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
class SupabaseSignalingClient extends EventEmitter {
|
|
131
|
-
constructor(config) {
|
|
132
|
-
super();
|
|
133
|
-
this.channel = null;
|
|
134
|
-
this.sessionId = null;
|
|
135
|
-
this.channelName = null;
|
|
136
|
-
this.wsToken = null;
|
|
137
|
-
this.channelId = null;
|
|
138
|
-
this.connected = false;
|
|
139
|
-
this.reconnectAttempts = 0;
|
|
140
|
-
this.heartbeatInterval = null;
|
|
141
|
-
this.config = config;
|
|
142
|
-
this.maxReconnectAttempts = config.maxRetries || 3;
|
|
143
|
-
this.reconnectDelay = config.reconnectDelay || 1000;
|
|
144
|
-
this.supabase = createClient(config.supabaseUrl || process.env.NEXT_PUBLIC_SUPABASE_URL || '', config.supabaseAnonKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '', {
|
|
145
|
-
realtime: {
|
|
146
|
-
params: {
|
|
147
|
-
eventsPerSecond: 10
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
async connect(sessionId, channelName, wsToken) {
|
|
153
|
-
this.sessionId = sessionId;
|
|
154
|
-
this.channelName = channelName;
|
|
155
|
-
this.wsToken = wsToken;
|
|
156
|
-
return new Promise(async (resolve, reject) => {
|
|
157
|
-
try {
|
|
158
|
-
const { data: channelData, error } = await this.supabase
|
|
159
|
-
.from('signaling_channels')
|
|
160
|
-
.select('id')
|
|
161
|
-
.eq('channel_name', channelName)
|
|
162
|
-
.eq('ws_token', wsToken)
|
|
163
|
-
.single();
|
|
164
|
-
if (error || !channelData) {
|
|
165
|
-
throw new AStackError('Invalid channel credentials', ErrorCodes.SIGNALING_ERROR);
|
|
166
|
-
}
|
|
167
|
-
this.channelId = channelData.id;
|
|
168
|
-
this.channel = this.supabase.channel(channelName);
|
|
169
|
-
this.channel
|
|
170
|
-
.on('postgres_changes', {
|
|
171
|
-
event: 'INSERT',
|
|
172
|
-
schema: 'public',
|
|
173
|
-
table: 'signaling_messages',
|
|
174
|
-
filter: `channel_id=eq.${this.channelId}`
|
|
175
|
-
}, (payload) => {
|
|
176
|
-
this.handleSignalingMessage(payload.new);
|
|
177
|
-
})
|
|
178
|
-
.on('presence', { event: 'sync' }, () => {
|
|
179
|
-
const state = this.channel?.presenceState();
|
|
180
|
-
this.handlePresenceSync(state);
|
|
181
|
-
})
|
|
182
|
-
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
|
|
183
|
-
this.emit('userJoined', { key, presences: newPresences });
|
|
184
|
-
})
|
|
185
|
-
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
|
|
186
|
-
this.emit('userLeft', { key, presences: leftPresences });
|
|
187
|
-
})
|
|
188
|
-
.subscribe(async (status) => {
|
|
189
|
-
if (status === 'SUBSCRIBED') {
|
|
190
|
-
this.connected = true;
|
|
191
|
-
this.reconnectAttempts = 0;
|
|
192
|
-
await this.updateChannelStatus(true);
|
|
193
|
-
await this.channel.track({
|
|
194
|
-
user_type: 'client',
|
|
195
|
-
user_id: sessionId,
|
|
196
|
-
status: 'online',
|
|
197
|
-
last_seen: new Date().toISOString(),
|
|
198
|
-
metadata: {
|
|
199
|
-
userAgent: navigator.userAgent,
|
|
200
|
-
capabilities: {
|
|
201
|
-
audio: this.config.enableAudio !== false,
|
|
202
|
-
video: this.config.enableVideo === true,
|
|
203
|
-
text: this.config.enableText !== false
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
this.startHeartbeat();
|
|
208
|
-
this.emit('sessionJoined', { sessionId, channelName });
|
|
209
|
-
resolve();
|
|
210
|
-
}
|
|
211
|
-
else if (status === 'CHANNEL_ERROR') {
|
|
212
|
-
reject(new AStackError('Failed to subscribe to signaling channel', ErrorCodes.SIGNALING_ERROR));
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
catch (error) {
|
|
217
|
-
reject(new AStackError(`Failed to connect to signaling: ${error}`, ErrorCodes.SIGNALING_ERROR));
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
async handleSignalingMessage(message) {
|
|
222
|
-
if (message.sender_type === 'client') {
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
switch (message.message_type) {
|
|
226
|
-
case 'audio':
|
|
227
|
-
this.emit('audioResponse', message.payload);
|
|
228
|
-
break;
|
|
229
|
-
case 'text':
|
|
230
|
-
this.emit('textResponse', message.payload);
|
|
231
|
-
break;
|
|
232
|
-
case 'control':
|
|
233
|
-
this.emit('controlMessage', message.payload);
|
|
234
|
-
break;
|
|
235
|
-
case 'ready':
|
|
236
|
-
this.emit('workerReady', message.payload);
|
|
237
|
-
break;
|
|
238
|
-
case 'error':
|
|
239
|
-
this.emit('error', new AStackError(message.payload.message || 'Worker error', ErrorCodes.SIGNALING_ERROR));
|
|
240
|
-
break;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
handlePresenceSync(state) {
|
|
244
|
-
if (!state)
|
|
245
|
-
return;
|
|
246
|
-
const presences = Object.values(state);
|
|
247
|
-
const workerPresent = presences.some((presence) => presence.user_type === 'worker' && presence.status === 'online');
|
|
248
|
-
if (workerPresent) {
|
|
249
|
-
this.emit('workerConnected');
|
|
250
|
-
}
|
|
251
|
-
else {
|
|
252
|
-
this.emit('workerDisconnected');
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
async updateChannelStatus(connected) {
|
|
256
|
-
if (!this.channelId)
|
|
257
|
-
return;
|
|
258
|
-
try {
|
|
259
|
-
await this.supabase
|
|
260
|
-
.from('signaling_channels')
|
|
261
|
-
.update({ client_connected: connected })
|
|
262
|
-
.eq('id', this.channelId);
|
|
263
|
-
}
|
|
264
|
-
catch (error) { }
|
|
265
|
-
}
|
|
266
|
-
async sendText(text) {
|
|
267
|
-
await this.sendSignalingMessage('text', { text });
|
|
268
|
-
}
|
|
269
|
-
async sendAudio(audioData, metadata) {
|
|
270
|
-
await this.sendSignalingMessage('audio', { audio: audioData, ...metadata });
|
|
271
|
-
}
|
|
272
|
-
async sendControl(action, data) {
|
|
273
|
-
await this.sendSignalingMessage('control', { action, ...data });
|
|
274
|
-
}
|
|
275
|
-
async sendSignalingMessage(messageType, payload) {
|
|
276
|
-
if (!this.channelId || !this.connected) {
|
|
277
|
-
throw new AStackError('Not connected to signaling channel', ErrorCodes.SIGNALING_ERROR);
|
|
278
|
-
}
|
|
279
|
-
try {
|
|
280
|
-
const { error } = await this.supabase
|
|
281
|
-
.from('signaling_messages')
|
|
282
|
-
.insert({
|
|
283
|
-
channel_id: this.channelId,
|
|
284
|
-
sender_type: 'client',
|
|
285
|
-
message_type: messageType,
|
|
286
|
-
payload
|
|
287
|
-
});
|
|
288
|
-
if (error) {
|
|
289
|
-
throw new AStackError(`Failed to send ${messageType}: ${error.message}`, ErrorCodes.SIGNALING_ERROR);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
catch (error) {
|
|
293
|
-
throw error;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
sendTextMessage(text) {
|
|
297
|
-
this.sendText(text).catch(() => { });
|
|
298
|
-
}
|
|
299
|
-
sendAudioData(audioBlob) {
|
|
300
|
-
const reader = new FileReader();
|
|
301
|
-
reader.onload = () => {
|
|
302
|
-
const base64 = reader.result.split(',')[1];
|
|
303
|
-
this.sendAudio(base64).catch(() => { });
|
|
304
|
-
};
|
|
305
|
-
reader.readAsDataURL(audioBlob);
|
|
306
|
-
}
|
|
307
|
-
startHeartbeat() {
|
|
308
|
-
if (this.heartbeatInterval) {
|
|
309
|
-
clearInterval(this.heartbeatInterval);
|
|
310
|
-
}
|
|
311
|
-
const interval = this.config.heartbeatInterval || 30000;
|
|
312
|
-
this.heartbeatInterval = setInterval(async () => {
|
|
313
|
-
if (this.channel && this.connected) {
|
|
314
|
-
try {
|
|
315
|
-
await this.channel.track({
|
|
316
|
-
user_type: 'client',
|
|
317
|
-
user_id: this.sessionId,
|
|
318
|
-
status: 'online',
|
|
319
|
-
last_seen: new Date().toISOString()
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
catch (error) { }
|
|
323
|
-
}
|
|
324
|
-
}, interval);
|
|
325
|
-
}
|
|
326
|
-
stopHeartbeat() {
|
|
327
|
-
if (this.heartbeatInterval) {
|
|
328
|
-
clearInterval(this.heartbeatInterval);
|
|
329
|
-
this.heartbeatInterval = null;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
async attemptReconnect() {
|
|
333
|
-
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
334
|
-
this.emit('error', new AStackError('Maximum reconnection attempts reached', ErrorCodes.CONNECTION_LOST));
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
this.reconnectAttempts++;
|
|
338
|
-
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
339
|
-
this.emit('reconnecting', this.reconnectAttempts);
|
|
340
|
-
setTimeout(async () => {
|
|
341
|
-
if (!this.connected && this.sessionId && this.channelName && this.wsToken) {
|
|
342
|
-
try {
|
|
343
|
-
await this.connect(this.sessionId, this.channelName, this.wsToken);
|
|
344
|
-
this.emit('reconnected');
|
|
345
|
-
}
|
|
346
|
-
catch (error) {
|
|
347
|
-
this.attemptReconnect();
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}, delay);
|
|
351
|
-
}
|
|
352
|
-
async leaveSession() {
|
|
353
|
-
if (this.channel) {
|
|
354
|
-
try {
|
|
355
|
-
await this.channel.untrack();
|
|
356
|
-
await this.updateChannelStatus(false);
|
|
357
|
-
}
|
|
358
|
-
catch (error) { }
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
isConnected() {
|
|
362
|
-
return this.connected;
|
|
363
|
-
}
|
|
364
|
-
getSessionId() {
|
|
365
|
-
return this.sessionId;
|
|
366
|
-
}
|
|
367
|
-
async disconnect() {
|
|
368
|
-
this.connected = false;
|
|
369
|
-
this.stopHeartbeat();
|
|
370
|
-
if (this.channel) {
|
|
371
|
-
await this.leaveSession();
|
|
372
|
-
await this.channel.unsubscribe();
|
|
373
|
-
this.channel = null;
|
|
374
|
-
}
|
|
375
|
-
this.removeAllListeners();
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
class SessionManager {
|
|
380
|
-
constructor(apiEndpoint, sessionExpirationWarning = 1, onSessionExpiring) {
|
|
381
|
-
this.sessionCredentials = null;
|
|
382
|
-
this.workerInfo = null;
|
|
383
|
-
this.expirationTimer = null;
|
|
384
|
-
this.apiEndpoint = apiEndpoint;
|
|
385
|
-
this.sessionExpirationWarning = sessionExpirationWarning;
|
|
386
|
-
this.onSessionExpiring = onSessionExpiring;
|
|
387
|
-
}
|
|
388
|
-
async createSession(request) {
|
|
389
|
-
try {
|
|
390
|
-
const response = await fetch(`${this.apiEndpoint}/functions/v1/session`, {
|
|
391
|
-
method: 'POST',
|
|
392
|
-
headers: {
|
|
393
|
-
'Content-Type': 'application/json',
|
|
394
|
-
'Authorization': `Bearer ${request.apiKey}`
|
|
395
|
-
},
|
|
396
|
-
body: JSON.stringify({
|
|
397
|
-
organizationId: request.organizationId,
|
|
398
|
-
endUserId: request.endUserId,
|
|
399
|
-
metadata: request.metadata,
|
|
400
|
-
workerPreferences: request.workerPreferences
|
|
401
|
-
})
|
|
402
|
-
});
|
|
403
|
-
if (!response.ok) {
|
|
404
|
-
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
405
|
-
if (response.status === 401) {
|
|
406
|
-
throw new AStackError('Invalid API key', ErrorCodes.INVALID_API_KEY);
|
|
407
|
-
}
|
|
408
|
-
else if (response.status === 429) {
|
|
409
|
-
throw new AStackError('Rate limit exceeded', ErrorCodes.RATE_LIMIT_EXCEEDED);
|
|
410
|
-
}
|
|
411
|
-
else if (response.status === 402) {
|
|
412
|
-
throw new AStackError('Insufficient credits', ErrorCodes.INSUFFICIENT_CREDITS);
|
|
413
|
-
}
|
|
414
|
-
throw new AStackError(errorData.error || 'Session creation failed', ErrorCodes.SESSION_CREATION_FAILED);
|
|
415
|
-
}
|
|
416
|
-
const data = await response.json();
|
|
417
|
-
const expiresAt = new Date(Date.now() + (data.expiresIn * 1000));
|
|
418
|
-
this.sessionCredentials = {
|
|
419
|
-
sessionId: data.sessionId,
|
|
420
|
-
wsToken: data.wsToken,
|
|
421
|
-
channelName: data.channelName,
|
|
422
|
-
workerUrl: data.workerUrl,
|
|
423
|
-
expiresAt
|
|
424
|
-
};
|
|
425
|
-
if (data.workerInfo) {
|
|
426
|
-
this.workerInfo = data.workerInfo;
|
|
427
|
-
}
|
|
428
|
-
this.scheduleExpirationWarning();
|
|
429
|
-
return this.sessionCredentials;
|
|
430
|
-
}
|
|
431
|
-
catch (error) {
|
|
432
|
-
if (error instanceof AStackError) {
|
|
433
|
-
throw error;
|
|
434
|
-
}
|
|
435
|
-
throw new AStackError(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`, ErrorCodes.SESSION_CREATION_FAILED);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
async renewSession(apiKey) {
|
|
439
|
-
if (!this.sessionCredentials) {
|
|
440
|
-
throw new AStackError('No active session to renew', ErrorCodes.SESSION_EXPIRED);
|
|
441
|
-
}
|
|
442
|
-
const request = {
|
|
443
|
-
apiKey,
|
|
444
|
-
organizationId: this.sessionCredentials.sessionId.split('-')[0],
|
|
445
|
-
endUserId: undefined,
|
|
446
|
-
metadata: { previousSessionId: this.sessionCredentials.sessionId }
|
|
447
|
-
};
|
|
448
|
-
return this.createSession(request);
|
|
449
|
-
}
|
|
450
|
-
scheduleExpirationWarning() {
|
|
451
|
-
if (this.expirationTimer) {
|
|
452
|
-
clearTimeout(this.expirationTimer);
|
|
453
|
-
}
|
|
454
|
-
if (!this.sessionCredentials || !this.onSessionExpiring) {
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
const now = Date.now();
|
|
458
|
-
const expirationTime = this.sessionCredentials.expiresAt.getTime();
|
|
459
|
-
const warningTime = expirationTime - (this.sessionExpirationWarning * 60 * 1000);
|
|
460
|
-
if (warningTime > now) {
|
|
461
|
-
this.expirationTimer = setTimeout(() => {
|
|
462
|
-
if (this.onSessionExpiring && this.sessionCredentials) {
|
|
463
|
-
const minutesRemaining = Math.floor((this.sessionCredentials.expiresAt.getTime() - Date.now()) / 60000);
|
|
464
|
-
this.onSessionExpiring(minutesRemaining);
|
|
465
|
-
}
|
|
466
|
-
}, warningTime - now);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
getCredentials() {
|
|
470
|
-
return this.sessionCredentials;
|
|
471
|
-
}
|
|
472
|
-
isSessionValid() {
|
|
473
|
-
if (!this.sessionCredentials) {
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
return this.sessionCredentials.expiresAt.getTime() > Date.now();
|
|
477
|
-
}
|
|
478
|
-
getTimeUntilExpiration() {
|
|
479
|
-
if (!this.sessionCredentials) {
|
|
480
|
-
return 0;
|
|
481
|
-
}
|
|
482
|
-
const remaining = this.sessionCredentials.expiresAt.getTime() - Date.now();
|
|
483
|
-
return Math.max(0, remaining);
|
|
484
|
-
}
|
|
485
|
-
clearSession() {
|
|
486
|
-
this.sessionCredentials = null;
|
|
487
|
-
this.workerInfo = null;
|
|
488
|
-
if (this.expirationTimer) {
|
|
489
|
-
clearTimeout(this.expirationTimer);
|
|
490
|
-
this.expirationTimer = null;
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
getWorkerInfo() {
|
|
494
|
-
return this.workerInfo;
|
|
495
|
-
}
|
|
496
|
-
destroy() {
|
|
497
|
-
this.clearSession();
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
class BillingMonitor {
|
|
502
|
-
constructor(apiEndpoint, apiKey) {
|
|
503
|
-
this.apiEndpoint = apiEndpoint;
|
|
504
|
-
this.apiKey = apiKey;
|
|
505
|
-
this.updateCallbacks = [];
|
|
506
|
-
this.alertCallbacks = [];
|
|
507
|
-
this.warningThresholds = {
|
|
508
|
-
creditBalance: 10, // Warn when credits drop below 10
|
|
509
|
-
usagePercentage: 80 // Warn when usage reaches 80% of rate limit
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
async fetchBillingInfo() {
|
|
513
|
-
try {
|
|
514
|
-
const response = await fetch(`${this.apiEndpoint}/billing/info`, {
|
|
515
|
-
headers: {
|
|
516
|
-
'Authorization': `Bearer ${this.apiKey}`,
|
|
517
|
-
'Content-Type': 'application/json'
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
if (!response.ok) {
|
|
521
|
-
throw new Error(`Failed to fetch billing info: ${response.statusText}`);
|
|
522
|
-
}
|
|
523
|
-
const info = await response.json();
|
|
524
|
-
this.updateBillingInfo(info);
|
|
525
|
-
return info;
|
|
526
|
-
}
|
|
527
|
-
catch (error) {
|
|
528
|
-
throw error;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
updateBillingInfo(info) {
|
|
532
|
-
const previousInfo = this.billingInfo;
|
|
533
|
-
this.billingInfo = info;
|
|
534
|
-
this.updateCallbacks.forEach(callback => callback(info));
|
|
535
|
-
this.checkForAlerts(info, previousInfo);
|
|
536
|
-
}
|
|
537
|
-
checkForAlerts(current, previous) {
|
|
538
|
-
if (current.creditBalance < this.warningThresholds.creditBalance) {
|
|
539
|
-
this.triggerAlert({
|
|
540
|
-
type: 'low_balance',
|
|
541
|
-
details: {
|
|
542
|
-
currentBalance: current.creditBalance,
|
|
543
|
-
threshold: this.warningThresholds.creditBalance
|
|
544
|
-
}
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
const usagePercentage = (current.currentUsage / current.rateLimit) * 100;
|
|
548
|
-
if (usagePercentage >= this.warningThresholds.usagePercentage) {
|
|
549
|
-
this.triggerAlert({
|
|
550
|
-
type: 'limit_exceeded',
|
|
551
|
-
details: {
|
|
552
|
-
currentUsage: current.currentUsage,
|
|
553
|
-
rateLimit: current.rateLimit,
|
|
554
|
-
percentage: usagePercentage
|
|
555
|
-
}
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
if (previous && previous.creditBalance - current.creditBalance > 100) {
|
|
559
|
-
this.triggerAlert({
|
|
560
|
-
type: 'low_balance',
|
|
561
|
-
details: {
|
|
562
|
-
previousBalance: previous.creditBalance,
|
|
563
|
-
currentBalance: current.creditBalance,
|
|
564
|
-
spent: previous.creditBalance - current.creditBalance
|
|
565
|
-
}
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
triggerAlert(alert) {
|
|
570
|
-
this.alertCallbacks.forEach(callback => callback(alert));
|
|
571
|
-
}
|
|
572
|
-
startMonitoring(intervalMs = 60000) {
|
|
573
|
-
this.stopMonitoring();
|
|
574
|
-
this.fetchBillingInfo().catch(() => { });
|
|
575
|
-
this.checkInterval = setInterval(() => {
|
|
576
|
-
this.fetchBillingInfo().catch(() => { });
|
|
577
|
-
}, intervalMs);
|
|
578
|
-
}
|
|
579
|
-
stopMonitoring() {
|
|
580
|
-
if (this.checkInterval) {
|
|
581
|
-
clearInterval(this.checkInterval);
|
|
582
|
-
this.checkInterval = undefined;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
onUpdate(callback) {
|
|
586
|
-
this.updateCallbacks.push(callback);
|
|
587
|
-
}
|
|
588
|
-
onAlert(callback) {
|
|
589
|
-
this.alertCallbacks.push(callback);
|
|
590
|
-
}
|
|
591
|
-
removeUpdateCallback(callback) {
|
|
592
|
-
this.updateCallbacks = this.updateCallbacks.filter(cb => cb !== callback);
|
|
593
|
-
}
|
|
594
|
-
removeAlertCallback(callback) {
|
|
595
|
-
this.alertCallbacks = this.alertCallbacks.filter(cb => cb !== callback);
|
|
596
|
-
}
|
|
597
|
-
getCurrentBillingInfo() {
|
|
598
|
-
return this.billingInfo;
|
|
599
|
-
}
|
|
600
|
-
setWarningThresholds(thresholds) {
|
|
601
|
-
this.warningThresholds = { ...this.warningThresholds, ...thresholds };
|
|
602
|
-
}
|
|
603
|
-
async checkRateLimit() {
|
|
604
|
-
const info = await this.fetchBillingInfo();
|
|
605
|
-
return info.currentUsage < info.rateLimit;
|
|
606
|
-
}
|
|
607
|
-
destroy() {
|
|
608
|
-
this.stopMonitoring();
|
|
609
|
-
this.updateCallbacks = [];
|
|
610
|
-
this.alertCallbacks = [];
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
class SecurityLogger {
|
|
615
|
-
constructor(config) {
|
|
616
|
-
this.eventQueue = [];
|
|
617
|
-
this.config = {
|
|
618
|
-
enableLocalLogging: true,
|
|
619
|
-
batchSize: 10,
|
|
620
|
-
flushInterval: 5000,
|
|
621
|
-
sessionId: '',
|
|
622
|
-
organizationId: '',
|
|
623
|
-
...config
|
|
624
|
-
};
|
|
625
|
-
if (this.config.flushInterval > 0) {
|
|
626
|
-
this.startAutoFlush();
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
setSessionId(sessionId) {
|
|
630
|
-
this.config.sessionId = sessionId;
|
|
631
|
-
}
|
|
632
|
-
logEvent(eventType, severity, details) {
|
|
633
|
-
const event = {
|
|
634
|
-
eventType,
|
|
635
|
-
severity,
|
|
636
|
-
details: {
|
|
637
|
-
...details,
|
|
638
|
-
sessionId: this.config.sessionId,
|
|
639
|
-
organizationId: this.config.organizationId,
|
|
640
|
-
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
|
641
|
-
timestamp: new Date().toISOString()
|
|
642
|
-
},
|
|
643
|
-
timestamp: new Date()
|
|
644
|
-
};
|
|
645
|
-
if (this.config.enableLocalLogging) {
|
|
646
|
-
this.logToConsole(event);
|
|
647
|
-
}
|
|
648
|
-
this.eventQueue.push(event);
|
|
649
|
-
if (this.eventQueue.length >= this.config.batchSize) {
|
|
650
|
-
this.flush().catch(() => { });
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
logAuthFailure(reason, details) {
|
|
654
|
-
this.logEvent('auth_failure', 'warning', {
|
|
655
|
-
reason,
|
|
656
|
-
...details
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
logRateLimit(limit, current, endpoint) {
|
|
660
|
-
this.logEvent('rate_limit', 'warning', {
|
|
661
|
-
limit,
|
|
662
|
-
current,
|
|
663
|
-
endpoint,
|
|
664
|
-
exceeded: current >= limit
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
logInvalidRequest(reason, request) {
|
|
668
|
-
this.logEvent('invalid_request', 'warning', {
|
|
669
|
-
reason,
|
|
670
|
-
request: this.sanitizeRequest(request)
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
logSessionHijackAttempt(details) {
|
|
674
|
-
this.logEvent('session_hijack', 'critical', details);
|
|
675
|
-
}
|
|
676
|
-
sanitizeRequest(request) {
|
|
677
|
-
if (!request)
|
|
678
|
-
return undefined;
|
|
679
|
-
const sanitized = { ...request };
|
|
680
|
-
const sensitiveFields = ['password', 'token', 'apiKey', 'secret', 'credential'];
|
|
681
|
-
for (const field of sensitiveFields) {
|
|
682
|
-
if (sanitized[field]) {
|
|
683
|
-
sanitized[field] = '[REDACTED]';
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
return sanitized;
|
|
687
|
-
}
|
|
688
|
-
logToConsole(event) {
|
|
689
|
-
}
|
|
690
|
-
getConsoleLogLevel(severity) {
|
|
691
|
-
switch (severity) {
|
|
692
|
-
case 'info':
|
|
693
|
-
return 'log';
|
|
694
|
-
case 'warning':
|
|
695
|
-
return 'warn';
|
|
696
|
-
case 'error':
|
|
697
|
-
case 'critical':
|
|
698
|
-
return 'error';
|
|
699
|
-
default:
|
|
700
|
-
return 'log';
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
async flush() {
|
|
704
|
-
if (this.eventQueue.length === 0)
|
|
705
|
-
return;
|
|
706
|
-
const events = [...this.eventQueue];
|
|
707
|
-
this.eventQueue = [];
|
|
708
|
-
try {
|
|
709
|
-
const response = await fetch(`${this.config.apiEndpoint}/security/events`, {
|
|
710
|
-
method: 'POST',
|
|
711
|
-
headers: {
|
|
712
|
-
'Authorization': `Bearer ${this.config.apiKey}`,
|
|
713
|
-
'Content-Type': 'application/json'
|
|
714
|
-
},
|
|
715
|
-
body: JSON.stringify({ events })
|
|
716
|
-
});
|
|
717
|
-
if (!response.ok) {
|
|
718
|
-
this.eventQueue.unshift(...events);
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
catch (error) {
|
|
722
|
-
this.eventQueue.unshift(...events);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
startAutoFlush() {
|
|
726
|
-
this.flushInterval = setInterval(() => {
|
|
727
|
-
this.flush().catch(() => { });
|
|
728
|
-
}, this.config.flushInterval);
|
|
729
|
-
}
|
|
730
|
-
stopAutoFlush() {
|
|
731
|
-
if (this.flushInterval) {
|
|
732
|
-
clearInterval(this.flushInterval);
|
|
733
|
-
this.flushInterval = undefined;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
async destroy() {
|
|
737
|
-
this.stopAutoFlush();
|
|
738
|
-
await this.flush();
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
class ConnectionStateManager extends EventEmitter {
|
|
743
|
-
constructor() {
|
|
744
|
-
super();
|
|
745
|
-
this.stateHistory = [];
|
|
746
|
-
this.maxHistorySize = 100;
|
|
747
|
-
this.state = {
|
|
748
|
-
signaling: 'disconnected',
|
|
749
|
-
websocket: 'new',
|
|
750
|
-
overall: 'disconnected',
|
|
751
|
-
lastActivity: new Date(),
|
|
752
|
-
reconnectAttempts: 0,
|
|
753
|
-
errors: []
|
|
754
|
-
};
|
|
755
|
-
this.quality = {
|
|
756
|
-
qualityScore: 1.0
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
updateSignalingState(state) {
|
|
760
|
-
const previousState = this.state.signaling;
|
|
761
|
-
this.state.signaling = state;
|
|
762
|
-
this.state.lastActivity = new Date();
|
|
763
|
-
if (state === 'connected') {
|
|
764
|
-
this.state.reconnectAttempts = 0;
|
|
765
|
-
}
|
|
766
|
-
else if (state === 'reconnecting') {
|
|
767
|
-
this.state.reconnectAttempts++;
|
|
768
|
-
}
|
|
769
|
-
this.updateOverallState();
|
|
770
|
-
this.addToHistory();
|
|
771
|
-
if (previousState !== state) {
|
|
772
|
-
this.emit('signalingStateChange', state, previousState);
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
updateWebSocketState(state) {
|
|
776
|
-
const previousState = this.state.websocket;
|
|
777
|
-
this.state.websocket = state;
|
|
778
|
-
this.state.lastActivity = new Date();
|
|
779
|
-
this.updateOverallState();
|
|
780
|
-
this.addToHistory();
|
|
781
|
-
if (previousState !== state) {
|
|
782
|
-
this.emit('websocketStateChange', state, previousState);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
updateWebRTCState(state) {
|
|
786
|
-
this.updateWebSocketState(state);
|
|
787
|
-
}
|
|
788
|
-
updateICEState(state) {
|
|
789
|
-
// No-op for backwards compatibility
|
|
790
|
-
}
|
|
791
|
-
updateOverallState() {
|
|
792
|
-
const previousOverall = this.state.overall;
|
|
793
|
-
if (this.state.signaling === 'disconnected' || this.state.websocket === 'failed' || this.state.websocket === 'closed') {
|
|
794
|
-
this.state.overall = 'disconnected';
|
|
795
|
-
}
|
|
796
|
-
else if (this.state.signaling === 'connecting' || this.state.websocket === 'connecting') {
|
|
797
|
-
this.state.overall = 'connecting';
|
|
798
|
-
}
|
|
799
|
-
else if (this.state.signaling === 'connected' && this.state.websocket === 'connected') {
|
|
800
|
-
this.state.overall = 'connected';
|
|
801
|
-
}
|
|
802
|
-
else if (this.state.signaling === 'reconnecting' || this.state.websocket === 'disconnected') {
|
|
803
|
-
this.state.overall = 'degraded';
|
|
804
|
-
}
|
|
805
|
-
else if (this.state.errors.length > 0) {
|
|
806
|
-
this.state.overall = 'error';
|
|
807
|
-
}
|
|
808
|
-
else {
|
|
809
|
-
this.state.overall = 'connecting';
|
|
810
|
-
}
|
|
811
|
-
if (previousOverall !== this.state.overall) {
|
|
812
|
-
this.emit('overallStateChange', this.state.overall, previousOverall);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
addError(error) {
|
|
816
|
-
this.state.errors.push(error);
|
|
817
|
-
if (this.state.errors.length > 10) {
|
|
818
|
-
this.state.errors = this.state.errors.slice(-10);
|
|
819
|
-
}
|
|
820
|
-
this.updateOverallState();
|
|
821
|
-
this.emit('error', error);
|
|
822
|
-
}
|
|
823
|
-
clearErrors() {
|
|
824
|
-
this.state.errors = [];
|
|
825
|
-
this.updateOverallState();
|
|
826
|
-
}
|
|
827
|
-
updateConnectionQuality(stats) {
|
|
828
|
-
const previousScore = this.quality.qualityScore;
|
|
829
|
-
if (stats.latency !== undefined)
|
|
830
|
-
this.quality.latency = stats.latency;
|
|
831
|
-
if (stats.jitter !== undefined)
|
|
832
|
-
this.quality.jitter = stats.jitter;
|
|
833
|
-
if (stats.packetLoss !== undefined)
|
|
834
|
-
this.quality.packetLoss = stats.packetLoss;
|
|
835
|
-
if (stats.bandwidth !== undefined)
|
|
836
|
-
this.quality.bandwidth = stats.bandwidth;
|
|
837
|
-
this.quality.qualityScore = this.calculateQualityScore();
|
|
838
|
-
if (Math.abs(previousScore - this.quality.qualityScore) > 0.1) {
|
|
839
|
-
this.emit('qualityChange', this.quality);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
calculateQualityScore() {
|
|
843
|
-
let score = 1.0;
|
|
844
|
-
if (this.quality.latency !== undefined) {
|
|
845
|
-
if (this.quality.latency < 150) ;
|
|
846
|
-
else if (this.quality.latency < 300) {
|
|
847
|
-
score -= 0.1;
|
|
848
|
-
}
|
|
849
|
-
else if (this.quality.latency < 500) {
|
|
850
|
-
score -= 0.3;
|
|
851
|
-
}
|
|
852
|
-
else {
|
|
853
|
-
score -= 0.5;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
if (this.quality.packetLoss !== undefined) {
|
|
857
|
-
if (this.quality.packetLoss < 1) ;
|
|
858
|
-
else if (this.quality.packetLoss < 3) {
|
|
859
|
-
score -= 0.2;
|
|
860
|
-
}
|
|
861
|
-
else if (this.quality.packetLoss < 5) {
|
|
862
|
-
score -= 0.4;
|
|
863
|
-
}
|
|
864
|
-
else {
|
|
865
|
-
score -= 0.6;
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
if (this.quality.jitter !== undefined) {
|
|
869
|
-
if (this.quality.jitter < 30) ;
|
|
870
|
-
else if (this.quality.jitter < 50) {
|
|
871
|
-
score -= 0.1;
|
|
872
|
-
}
|
|
873
|
-
else if (this.quality.jitter < 100) {
|
|
874
|
-
score -= 0.2;
|
|
875
|
-
}
|
|
876
|
-
else {
|
|
877
|
-
score -= 0.3;
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
return Math.max(0, Math.min(1, score));
|
|
881
|
-
}
|
|
882
|
-
startHealthCheck(intervalMs = 5000) {
|
|
883
|
-
this.stopHealthCheck();
|
|
884
|
-
this.healthCheckInterval = setInterval(() => {
|
|
885
|
-
const timeSinceLastActivity = Date.now() - this.state.lastActivity.getTime();
|
|
886
|
-
if (timeSinceLastActivity > 30000 && this.state.overall === 'connected') {
|
|
887
|
-
this.emit('healthCheckFailed', timeSinceLastActivity);
|
|
888
|
-
}
|
|
889
|
-
}, intervalMs);
|
|
890
|
-
}
|
|
891
|
-
stopHealthCheck() {
|
|
892
|
-
if (this.healthCheckInterval) {
|
|
893
|
-
clearInterval(this.healthCheckInterval);
|
|
894
|
-
this.healthCheckInterval = undefined;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
addToHistory() {
|
|
898
|
-
this.stateHistory.push({
|
|
899
|
-
state: { ...this.state },
|
|
900
|
-
timestamp: new Date()
|
|
901
|
-
});
|
|
902
|
-
if (this.stateHistory.length > this.maxHistorySize) {
|
|
903
|
-
this.stateHistory = this.stateHistory.slice(-this.maxHistorySize);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
getState() {
|
|
907
|
-
return { ...this.state };
|
|
908
|
-
}
|
|
909
|
-
getQuality() {
|
|
910
|
-
return { ...this.quality };
|
|
911
|
-
}
|
|
912
|
-
getStateHistory() {
|
|
913
|
-
return [...this.stateHistory];
|
|
914
|
-
}
|
|
915
|
-
isConnected() {
|
|
916
|
-
return this.state.overall === 'connected';
|
|
917
|
-
}
|
|
918
|
-
isHealthy() {
|
|
919
|
-
return this.state.overall === 'connected' &&
|
|
920
|
-
this.quality.qualityScore > 0.5 &&
|
|
921
|
-
this.state.errors.length === 0;
|
|
922
|
-
}
|
|
923
|
-
getReconnectAttempts() {
|
|
924
|
-
return this.state.reconnectAttempts;
|
|
925
|
-
}
|
|
926
|
-
reset() {
|
|
927
|
-
this.state = {
|
|
928
|
-
signaling: 'disconnected',
|
|
929
|
-
websocket: 'new',
|
|
930
|
-
overall: 'disconnected',
|
|
931
|
-
lastActivity: new Date(),
|
|
932
|
-
reconnectAttempts: 0,
|
|
933
|
-
errors: []
|
|
934
|
-
};
|
|
935
|
-
this.quality = {
|
|
936
|
-
qualityScore: 1.0
|
|
937
|
-
};
|
|
938
|
-
this.stateHistory = [];
|
|
939
|
-
this.emit('reset');
|
|
940
|
-
}
|
|
941
|
-
destroy() {
|
|
942
|
-
this.stopHealthCheck();
|
|
943
|
-
this.removeAllListeners();
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
class PerformanceMonitor extends EventEmitter {
|
|
948
|
-
constructor(config) {
|
|
949
|
-
super();
|
|
950
|
-
this.benchmarks = [];
|
|
951
|
-
this.isCollecting = false;
|
|
952
|
-
this.config = {
|
|
953
|
-
collectionInterval: 5000,
|
|
954
|
-
enableAutoReport: true,
|
|
955
|
-
reportingInterval: 30000,
|
|
956
|
-
sessionId: '',
|
|
957
|
-
...config
|
|
958
|
-
};
|
|
959
|
-
this.startTime = Date.now();
|
|
960
|
-
this.metrics = this.initializeMetrics();
|
|
961
|
-
}
|
|
962
|
-
initializeMetrics() {
|
|
963
|
-
return {
|
|
964
|
-
latency: {
|
|
965
|
-
signaling: { avg: 0, min: Infinity, max: 0, samples: [] },
|
|
966
|
-
websocket: { avg: 0, min: Infinity, max: 0, samples: [] },
|
|
967
|
-
audio: { avg: 0, min: Infinity, max: 0, samples: [] }
|
|
968
|
-
},
|
|
969
|
-
throughput: {
|
|
970
|
-
audio: { inbound: 0, outbound: 0 },
|
|
971
|
-
data: { inbound: 0, outbound: 0 }
|
|
972
|
-
},
|
|
973
|
-
quality: {
|
|
974
|
-
audioQuality: 1.0,
|
|
975
|
-
connectionQuality: 1.0
|
|
976
|
-
},
|
|
977
|
-
resources: {
|
|
978
|
-
cpuUsage: 0,
|
|
979
|
-
memoryUsage: 0,
|
|
980
|
-
bandwidth: { upload: 0, download: 0 }
|
|
981
|
-
}
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
startCollection(sessionId) {
|
|
985
|
-
if (this.isCollecting)
|
|
986
|
-
return;
|
|
987
|
-
this.isCollecting = true;
|
|
988
|
-
if (sessionId) {
|
|
989
|
-
this.config.sessionId = sessionId;
|
|
990
|
-
}
|
|
991
|
-
this.collectionTimer = setInterval(() => {
|
|
992
|
-
this.collectMetrics();
|
|
993
|
-
}, this.config.collectionInterval);
|
|
994
|
-
if (this.config.enableAutoReport) {
|
|
995
|
-
this.reportingTimer = setInterval(() => {
|
|
996
|
-
this.reportMetrics();
|
|
997
|
-
}, this.config.reportingInterval);
|
|
998
|
-
}
|
|
999
|
-
this.emit('collectionStarted');
|
|
1000
|
-
}
|
|
1001
|
-
stopCollection() {
|
|
1002
|
-
if (!this.isCollecting)
|
|
1003
|
-
return;
|
|
1004
|
-
this.isCollecting = false;
|
|
1005
|
-
if (this.collectionTimer) {
|
|
1006
|
-
clearInterval(this.collectionTimer);
|
|
1007
|
-
this.collectionTimer = undefined;
|
|
1008
|
-
}
|
|
1009
|
-
if (this.reportingTimer) {
|
|
1010
|
-
clearInterval(this.reportingTimer);
|
|
1011
|
-
this.reportingTimer = undefined;
|
|
1012
|
-
}
|
|
1013
|
-
if (this.config.enableAutoReport) {
|
|
1014
|
-
this.reportMetrics();
|
|
1015
|
-
}
|
|
1016
|
-
this.emit('collectionStopped');
|
|
1017
|
-
}
|
|
1018
|
-
recordLatency(type, latency) {
|
|
1019
|
-
const key = type === 'webrtc' ? 'websocket' : type === 'video' ? 'audio' : type;
|
|
1020
|
-
const metric = this.metrics.latency[key];
|
|
1021
|
-
if (!metric)
|
|
1022
|
-
return;
|
|
1023
|
-
metric.samples.push(latency);
|
|
1024
|
-
if (metric.samples.length > 100) {
|
|
1025
|
-
metric.samples.shift();
|
|
1026
|
-
}
|
|
1027
|
-
metric.min = Math.min(metric.min, latency);
|
|
1028
|
-
metric.max = Math.max(metric.max, latency);
|
|
1029
|
-
metric.avg = metric.samples.reduce((a, b) => a + b, 0) / metric.samples.length;
|
|
1030
|
-
}
|
|
1031
|
-
updateThroughput(type, direction, bytesPerSecond) {
|
|
1032
|
-
const key = type === 'video' ? 'audio' : type;
|
|
1033
|
-
if (this.metrics.throughput[key]) {
|
|
1034
|
-
this.metrics.throughput[key][direction] = bytesPerSecond;
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
updateQuality(quality) {
|
|
1038
|
-
this.metrics.quality = { ...this.metrics.quality, ...quality };
|
|
1039
|
-
}
|
|
1040
|
-
updateResources(resources) {
|
|
1041
|
-
this.metrics.resources = { ...this.metrics.resources, ...resources };
|
|
1042
|
-
}
|
|
1043
|
-
recordBenchmark(benchmark) {
|
|
1044
|
-
const fullBenchmark = {
|
|
1045
|
-
...benchmark,
|
|
1046
|
-
timestamp: new Date()
|
|
1047
|
-
};
|
|
1048
|
-
this.benchmarks.push(fullBenchmark);
|
|
1049
|
-
if (this.benchmarks.length > 50) {
|
|
1050
|
-
this.benchmarks.shift();
|
|
1051
|
-
}
|
|
1052
|
-
this.emit('benchmarkRecorded', fullBenchmark);
|
|
1053
|
-
}
|
|
1054
|
-
getMetrics() {
|
|
1055
|
-
return JSON.parse(JSON.stringify(this.metrics));
|
|
1056
|
-
}
|
|
1057
|
-
getBenchmarks() {
|
|
1058
|
-
return [...this.benchmarks];
|
|
1059
|
-
}
|
|
1060
|
-
getLatencyStats(type) {
|
|
1061
|
-
if (type) {
|
|
1062
|
-
const key = type === 'webrtc' ? 'websocket' : type === 'video' ? 'audio' : type;
|
|
1063
|
-
return { ...this.metrics.latency[key] };
|
|
1064
|
-
}
|
|
1065
|
-
return JSON.parse(JSON.stringify(this.metrics.latency));
|
|
1066
|
-
}
|
|
1067
|
-
async reportMetrics() {
|
|
1068
|
-
if (!this.config.sessionId)
|
|
1069
|
-
return;
|
|
1070
|
-
try {
|
|
1071
|
-
const report = {
|
|
1072
|
-
sessionId: this.config.sessionId,
|
|
1073
|
-
timestamp: new Date(),
|
|
1074
|
-
duration: Date.now() - this.startTime,
|
|
1075
|
-
metrics: this.getMetrics(),
|
|
1076
|
-
benchmarks: this.getBenchmarks()
|
|
1077
|
-
};
|
|
1078
|
-
const response = await fetch(`${this.config.apiEndpoint}/performance/report`, {
|
|
1079
|
-
method: 'POST',
|
|
1080
|
-
headers: {
|
|
1081
|
-
'Content-Type': 'application/json'
|
|
1082
|
-
},
|
|
1083
|
-
body: JSON.stringify(report)
|
|
1084
|
-
});
|
|
1085
|
-
if (!response.ok) {
|
|
1086
|
-
throw new Error(`Failed to report metrics: ${response.statusText}`);
|
|
1087
|
-
}
|
|
1088
|
-
this.emit('metricsReported', report);
|
|
1089
|
-
}
|
|
1090
|
-
catch (error) {
|
|
1091
|
-
this.emit('error', error);
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
collectMetrics() {
|
|
1095
|
-
this.emit('collectMetrics', {
|
|
1096
|
-
timestamp: Date.now(),
|
|
1097
|
-
metrics: this.metrics
|
|
1098
|
-
});
|
|
1099
|
-
}
|
|
1100
|
-
destroy() {
|
|
1101
|
-
this.stopCollection();
|
|
1102
|
-
this.removeAllListeners();
|
|
1103
|
-
this.benchmarks = [];
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
class AnalyticsCollector extends EventEmitter {
|
|
1108
|
-
constructor(config) {
|
|
1109
|
-
super();
|
|
1110
|
-
this.events = [];
|
|
1111
|
-
this.sessionAnalytics = null;
|
|
1112
|
-
this.isCollecting = false;
|
|
1113
|
-
this.config = {
|
|
1114
|
-
enableAutoFlush: true,
|
|
1115
|
-
flushInterval: 60000, // 1 minute
|
|
1116
|
-
batchSize: 100,
|
|
1117
|
-
sessionId: '',
|
|
1118
|
-
userId: '',
|
|
1119
|
-
organizationId: '',
|
|
1120
|
-
...config
|
|
1121
|
-
};
|
|
1122
|
-
}
|
|
1123
|
-
startSession(sessionId) {
|
|
1124
|
-
if (this.isCollecting) {
|
|
1125
|
-
this.endSession();
|
|
1126
|
-
}
|
|
1127
|
-
this.isCollecting = true;
|
|
1128
|
-
this.config.sessionId = sessionId;
|
|
1129
|
-
this.sessionAnalytics = {
|
|
1130
|
-
sessionId,
|
|
1131
|
-
startTime: new Date(),
|
|
1132
|
-
duration: 0,
|
|
1133
|
-
eventsCount: 0,
|
|
1134
|
-
errorCount: 0,
|
|
1135
|
-
mediaTypes: [],
|
|
1136
|
-
connectionQuality: 1.0,
|
|
1137
|
-
dataTransferred: {
|
|
1138
|
-
audio: { sent: 0, received: 0 },
|
|
1139
|
-
video: { sent: 0, received: 0 },
|
|
1140
|
-
text: { sent: 0, received: 0 }
|
|
1141
|
-
}
|
|
1142
|
-
};
|
|
1143
|
-
if (this.config.enableAutoFlush) {
|
|
1144
|
-
this.flushTimer = setInterval(() => {
|
|
1145
|
-
this.flush();
|
|
1146
|
-
}, this.config.flushInterval);
|
|
1147
|
-
}
|
|
1148
|
-
this.trackEvent('session_started', {});
|
|
1149
|
-
}
|
|
1150
|
-
endSession() {
|
|
1151
|
-
if (!this.isCollecting || !this.sessionAnalytics)
|
|
1152
|
-
return;
|
|
1153
|
-
this.isCollecting = false;
|
|
1154
|
-
this.sessionAnalytics.endTime = new Date();
|
|
1155
|
-
this.sessionAnalytics.duration =
|
|
1156
|
-
(this.sessionAnalytics.endTime.getTime() - this.sessionAnalytics.startTime.getTime()) / 1000;
|
|
1157
|
-
this.trackEvent('session_ended', {
|
|
1158
|
-
duration: this.sessionAnalytics.duration
|
|
1159
|
-
});
|
|
1160
|
-
this.flush();
|
|
1161
|
-
if (this.flushTimer) {
|
|
1162
|
-
clearInterval(this.flushTimer);
|
|
1163
|
-
this.flushTimer = undefined;
|
|
1164
|
-
}
|
|
1165
|
-
this.sendSessionAnalytics();
|
|
1166
|
-
}
|
|
1167
|
-
trackEvent(eventType, metadata = {}) {
|
|
1168
|
-
const event = {
|
|
1169
|
-
eventType,
|
|
1170
|
-
timestamp: new Date(),
|
|
1171
|
-
sessionId: this.config.sessionId,
|
|
1172
|
-
userId: this.config.userId,
|
|
1173
|
-
organizationId: this.config.organizationId,
|
|
1174
|
-
metadata
|
|
1175
|
-
};
|
|
1176
|
-
this.events.push(event);
|
|
1177
|
-
if (this.sessionAnalytics) {
|
|
1178
|
-
this.sessionAnalytics.eventsCount++;
|
|
1179
|
-
if (eventType.includes('error') || metadata.error) {
|
|
1180
|
-
this.sessionAnalytics.errorCount++;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
if (this.events.length >= this.config.batchSize) {
|
|
1184
|
-
this.flush();
|
|
1185
|
-
}
|
|
1186
|
-
this.emit('eventTracked', event);
|
|
1187
|
-
}
|
|
1188
|
-
updateMediaTypes(mediaTypes) {
|
|
1189
|
-
if (this.sessionAnalytics) {
|
|
1190
|
-
this.sessionAnalytics.mediaTypes = [...new Set(mediaTypes)];
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
updateConnectionQuality(quality) {
|
|
1194
|
-
if (this.sessionAnalytics) {
|
|
1195
|
-
const weight = 0.1;
|
|
1196
|
-
this.sessionAnalytics.connectionQuality =
|
|
1197
|
-
this.sessionAnalytics.connectionQuality * (1 - weight) + quality * weight;
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
updateDataTransferred(type, direction, bytes) {
|
|
1201
|
-
if (this.sessionAnalytics) {
|
|
1202
|
-
this.sessionAnalytics.dataTransferred[type][direction] += bytes;
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
getSessionAnalytics() {
|
|
1206
|
-
if (!this.sessionAnalytics)
|
|
1207
|
-
return null;
|
|
1208
|
-
return {
|
|
1209
|
-
...this.sessionAnalytics,
|
|
1210
|
-
duration: this.isCollecting
|
|
1211
|
-
? (Date.now() - this.sessionAnalytics.startTime.getTime()) / 1000
|
|
1212
|
-
: this.sessionAnalytics.duration
|
|
1213
|
-
};
|
|
1214
|
-
}
|
|
1215
|
-
getEvents() {
|
|
1216
|
-
return [...this.events];
|
|
1217
|
-
}
|
|
1218
|
-
async flush() {
|
|
1219
|
-
if (this.events.length === 0)
|
|
1220
|
-
return;
|
|
1221
|
-
const eventsToSend = [...this.events];
|
|
1222
|
-
this.events = [];
|
|
1223
|
-
try {
|
|
1224
|
-
const response = await fetch(`${this.config.apiEndpoint}/analytics/events`, {
|
|
1225
|
-
method: 'POST',
|
|
1226
|
-
headers: {
|
|
1227
|
-
'Content-Type': 'application/json',
|
|
1228
|
-
'Authorization': `Bearer ${this.config.apiKey}`
|
|
1229
|
-
},
|
|
1230
|
-
body: JSON.stringify({
|
|
1231
|
-
events: eventsToSend
|
|
1232
|
-
})
|
|
1233
|
-
});
|
|
1234
|
-
if (!response.ok) {
|
|
1235
|
-
throw new Error(`Failed to send analytics: ${response.statusText}`);
|
|
1236
|
-
}
|
|
1237
|
-
this.emit('eventsFlushed', eventsToSend.length);
|
|
1238
|
-
}
|
|
1239
|
-
catch (error) {
|
|
1240
|
-
this.events.unshift(...eventsToSend);
|
|
1241
|
-
this.emit('error', error);
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
async sendSessionAnalytics() {
|
|
1245
|
-
if (!this.sessionAnalytics)
|
|
1246
|
-
return;
|
|
1247
|
-
try {
|
|
1248
|
-
const response = await fetch(`${this.config.apiEndpoint}/analytics/sessions`, {
|
|
1249
|
-
method: 'POST',
|
|
1250
|
-
headers: {
|
|
1251
|
-
'Content-Type': 'application/json',
|
|
1252
|
-
'Authorization': `Bearer ${this.config.apiKey}`
|
|
1253
|
-
},
|
|
1254
|
-
body: JSON.stringify(this.sessionAnalytics)
|
|
1255
|
-
});
|
|
1256
|
-
if (!response.ok) {
|
|
1257
|
-
throw new Error(`Failed to send session analytics: ${response.statusText}`);
|
|
1258
|
-
}
|
|
1259
|
-
this.emit('sessionAnalyticsSent', this.sessionAnalytics);
|
|
1260
|
-
}
|
|
1261
|
-
catch (error) {
|
|
1262
|
-
this.emit('error', error);
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
destroy() {
|
|
1266
|
-
if (this.isCollecting) {
|
|
1267
|
-
this.endSession();
|
|
1268
|
-
}
|
|
1269
|
-
if (this.flushTimer) {
|
|
1270
|
-
clearInterval(this.flushTimer);
|
|
1271
|
-
this.flushTimer = undefined;
|
|
1272
|
-
}
|
|
1273
|
-
this.removeAllListeners();
|
|
1274
|
-
this.events = [];
|
|
1275
|
-
this.sessionAnalytics = null;
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
class UsageTracker {
|
|
1280
|
-
constructor(sessionId) {
|
|
1281
|
-
this.inputTokens = 0;
|
|
1282
|
-
this.outputTokens = 0;
|
|
1283
|
-
this.errorCount = 0;
|
|
1284
|
-
this.qualityScores = [];
|
|
1285
|
-
this.sessionId = sessionId;
|
|
1286
|
-
this.startTime = new Date();
|
|
1287
|
-
}
|
|
1288
|
-
start() {
|
|
1289
|
-
this.startTime = new Date();
|
|
1290
|
-
}
|
|
1291
|
-
stop() {
|
|
1292
|
-
this.endTime = new Date();
|
|
1293
|
-
}
|
|
1294
|
-
trackInputTokens(tokens) {
|
|
1295
|
-
this.inputTokens += tokens;
|
|
1296
|
-
}
|
|
1297
|
-
trackOutputTokens(tokens) {
|
|
1298
|
-
this.outputTokens += tokens;
|
|
1299
|
-
}
|
|
1300
|
-
trackError() {
|
|
1301
|
-
this.errorCount++;
|
|
1302
|
-
}
|
|
1303
|
-
trackQualityScore(score) {
|
|
1304
|
-
if (score >= 0 && score <= 1) {
|
|
1305
|
-
this.qualityScores.push(score);
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
getDuration() {
|
|
1309
|
-
const end = this.endTime || new Date();
|
|
1310
|
-
return Math.floor((end.getTime() - this.startTime.getTime()) / 1000);
|
|
1311
|
-
}
|
|
1312
|
-
getAverageQualityScore() {
|
|
1313
|
-
if (this.qualityScores.length === 0)
|
|
1314
|
-
return undefined;
|
|
1315
|
-
const sum = this.qualityScores.reduce((acc, score) => acc + score, 0);
|
|
1316
|
-
return sum / this.qualityScores.length;
|
|
1317
|
-
}
|
|
1318
|
-
getMetrics() {
|
|
1319
|
-
return {
|
|
1320
|
-
sessionId: this.sessionId,
|
|
1321
|
-
duration: this.getDuration(),
|
|
1322
|
-
inputTokens: this.inputTokens > 0 ? this.inputTokens : undefined,
|
|
1323
|
-
outputTokens: this.outputTokens > 0 ? this.outputTokens : undefined,
|
|
1324
|
-
errorCount: this.errorCount,
|
|
1325
|
-
qualityScore: this.getAverageQualityScore()
|
|
1326
|
-
};
|
|
1327
|
-
}
|
|
1328
|
-
reset() {
|
|
1329
|
-
this.startTime = new Date();
|
|
1330
|
-
this.endTime = undefined;
|
|
1331
|
-
this.inputTokens = 0;
|
|
1332
|
-
this.outputTokens = 0;
|
|
1333
|
-
this.errorCount = 0;
|
|
1334
|
-
this.qualityScores = [];
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
/**
|
|
1339
|
-
* @deprecated This module is deprecated. Use AStackCSRClient for WebSocket-based communication.
|
|
1340
|
-
* This file is kept for backwards compatibility only.
|
|
1341
|
-
*/
|
|
1342
|
-
/**
|
|
1343
|
-
* @deprecated Use AStackCSRClient instead. This function is kept for backwards compatibility.
|
|
1344
|
-
*/
|
|
1345
|
-
function setupEventHandlers(emitter, deps) {
|
|
1346
|
-
console.warn('setupEventHandlers is deprecated. Use AStackCSRClient for WebSocket-based communication.');
|
|
1347
|
-
const { signaling, connectionStateManager, securityLogger } = deps;
|
|
1348
|
-
signaling.on('sessionJoined', () => {
|
|
1349
|
-
const sessionId = deps.getSessionId();
|
|
1350
|
-
const usageTracker = new UsageTracker(sessionId);
|
|
1351
|
-
usageTracker.start();
|
|
1352
|
-
deps.setUsageTracker(usageTracker);
|
|
1353
|
-
securityLogger.setSessionId(sessionId);
|
|
1354
|
-
connectionStateManager.updateSignalingState('connected');
|
|
1355
|
-
connectionStateManager.startHealthCheck();
|
|
1356
|
-
emitter.emit('sessionReady', sessionId);
|
|
1357
|
-
});
|
|
1358
|
-
signaling.on('aiResponse', (data) => {
|
|
1359
|
-
const response = {
|
|
1360
|
-
type: 'text',
|
|
1361
|
-
content: data.text || data.message,
|
|
1362
|
-
timestamp: data.timestamp || Date.now(),
|
|
1363
|
-
messageId: data.messageId
|
|
1364
|
-
};
|
|
1365
|
-
emitter.emit('messageReceived', response);
|
|
1366
|
-
});
|
|
1367
|
-
signaling.on('audioResponse', (data) => {
|
|
1368
|
-
if (data.audio) {
|
|
1369
|
-
try {
|
|
1370
|
-
const binaryString = atob(data.audio.split(',')[1]);
|
|
1371
|
-
const bytes = new Uint8Array(binaryString.length);
|
|
1372
|
-
for (let i = 0; i < binaryString.length; i++) {
|
|
1373
|
-
bytes[i] = binaryString.charCodeAt(i);
|
|
1374
|
-
}
|
|
1375
|
-
const audioBlob = new Blob([bytes], { type: 'audio/wav' });
|
|
1376
|
-
const response = {
|
|
1377
|
-
type: 'audio',
|
|
1378
|
-
content: audioBlob,
|
|
1379
|
-
timestamp: data.timestamp || Date.now(),
|
|
1380
|
-
messageId: data.messageId
|
|
1381
|
-
};
|
|
1382
|
-
emitter.emit('messageReceived', response);
|
|
1383
|
-
}
|
|
1384
|
-
catch (error) { }
|
|
1385
|
-
}
|
|
1386
|
-
});
|
|
1387
|
-
signaling.on('textResponse', (data) => {
|
|
1388
|
-
emitter.emit('text-response', data);
|
|
1389
|
-
});
|
|
1390
|
-
signaling.on('disconnected', () => {
|
|
1391
|
-
deps.setStatus({ status: 'disconnected' });
|
|
1392
|
-
connectionStateManager.updateSignalingState('disconnected');
|
|
1393
|
-
emitter.emit('disconnected');
|
|
1394
|
-
});
|
|
1395
|
-
signaling.on('reconnecting', (attempt) => {
|
|
1396
|
-
connectionStateManager.updateSignalingState('reconnecting');
|
|
1397
|
-
emitter.emit('reconnecting', attempt);
|
|
1398
|
-
});
|
|
1399
|
-
signaling.on('error', (error) => {
|
|
1400
|
-
const usageTracker = deps.getUsageTracker();
|
|
1401
|
-
if (usageTracker) {
|
|
1402
|
-
usageTracker.trackError();
|
|
1403
|
-
}
|
|
1404
|
-
connectionStateManager.addError(error);
|
|
1405
|
-
if (error.code === ErrorCodes.AUTHENTICATION_FAILED || error.code === ErrorCodes.INVALID_API_KEY) {
|
|
1406
|
-
securityLogger.logAuthFailure(error.message, { code: error.code });
|
|
1407
|
-
}
|
|
1408
|
-
else if (error.code === ErrorCodes.RATE_LIMIT_EXCEEDED) {
|
|
1409
|
-
securityLogger.logRateLimit(0, 0, 'signaling');
|
|
1410
|
-
}
|
|
1411
|
-
emitter.emit('error', error);
|
|
1412
|
-
});
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
class AStackMediaController {
|
|
1416
|
-
constructor(webrtc, signaling, analyticsCollector, config, emitter) {
|
|
1417
|
-
this.audioRecorder = null;
|
|
1418
|
-
this.isRecording = false;
|
|
1419
|
-
this.webrtc = webrtc;
|
|
1420
|
-
this.signaling = signaling;
|
|
1421
|
-
this.analyticsCollector = analyticsCollector;
|
|
1422
|
-
this.config = config;
|
|
1423
|
-
this.emitter = emitter;
|
|
1424
|
-
}
|
|
1425
|
-
async startVideo() {
|
|
1426
|
-
if (!this.config.enableVideo) {
|
|
1427
|
-
throw new AStackError('Video is disabled in configuration', ErrorCodes.VIDEO_ERROR);
|
|
1428
|
-
}
|
|
1429
|
-
try {
|
|
1430
|
-
const stream = await this.webrtc.getUserMedia();
|
|
1431
|
-
this.webrtc.addLocalStream(stream);
|
|
1432
|
-
this.emitter.emit('videoStarted');
|
|
1433
|
-
this.analyticsCollector.trackEvent('video_started');
|
|
1434
|
-
this.analyticsCollector.updateMediaTypes(['video']);
|
|
1435
|
-
return stream;
|
|
1436
|
-
}
|
|
1437
|
-
catch (error) {
|
|
1438
|
-
throw new AStackError(`Failed to start video: ${error}`, ErrorCodes.VIDEO_ERROR);
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
async stopVideo(localVideoRef) {
|
|
1442
|
-
const localStreams = this.webrtc.getLocalStreams();
|
|
1443
|
-
localStreams.forEach(stream => {
|
|
1444
|
-
if (stream.kind === 'video') {
|
|
1445
|
-
this.webrtc.removeLocalStream(stream.id);
|
|
1446
|
-
}
|
|
1447
|
-
});
|
|
1448
|
-
if (localVideoRef) {
|
|
1449
|
-
localVideoRef.srcObject = null;
|
|
1450
|
-
}
|
|
1451
|
-
this.emitter.emit('videoStopped');
|
|
1452
|
-
}
|
|
1453
|
-
async startAudio() {
|
|
1454
|
-
if (this.config.enableAudio === false) {
|
|
1455
|
-
throw new AStackError('Audio is disabled in configuration', ErrorCodes.AUDIO_ERROR);
|
|
1456
|
-
}
|
|
1457
|
-
try {
|
|
1458
|
-
const stream = await this.webrtc.getUserMedia();
|
|
1459
|
-
this.webrtc.addLocalStream(stream);
|
|
1460
|
-
this.emitter.emit('audioStarted');
|
|
1461
|
-
this.analyticsCollector.trackEvent('audio_started');
|
|
1462
|
-
this.analyticsCollector.updateMediaTypes(['audio']);
|
|
1463
|
-
return stream;
|
|
1464
|
-
}
|
|
1465
|
-
catch (error) {
|
|
1466
|
-
throw new AStackError(`Failed to start audio: ${error}`, ErrorCodes.AUDIO_ERROR);
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
async stopAudio() {
|
|
1470
|
-
const localStreams = this.webrtc.getLocalStreams();
|
|
1471
|
-
localStreams.forEach(stream => {
|
|
1472
|
-
if (stream.kind === 'audio') {
|
|
1473
|
-
this.webrtc.removeLocalStream(stream.id);
|
|
1474
|
-
}
|
|
1475
|
-
});
|
|
1476
|
-
if (this.isRecording) {
|
|
1477
|
-
this.stopRecording();
|
|
1478
|
-
}
|
|
1479
|
-
this.emitter.emit('audioStopped');
|
|
1480
|
-
}
|
|
1481
|
-
async sendText(message) {
|
|
1482
|
-
if (!this.signaling.isConnected()) {
|
|
1483
|
-
throw new AStackError('Not connected to AStack', ErrorCodes.CONNECTION_LOST);
|
|
1484
|
-
}
|
|
1485
|
-
try {
|
|
1486
|
-
this.signaling.sendTextMessage(message);
|
|
1487
|
-
this.emitter.emit('messageSent', message);
|
|
1488
|
-
}
|
|
1489
|
-
catch (error) {
|
|
1490
|
-
throw new AStackError(`Failed to send text message: ${error}`, ErrorCodes.SIGNALING_ERROR);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
async sendAudio(audioBlob) {
|
|
1494
|
-
if (!this.signaling.isConnected()) {
|
|
1495
|
-
throw new AStackError('Not connected to AStack', ErrorCodes.CONNECTION_LOST);
|
|
1496
|
-
}
|
|
1497
|
-
try {
|
|
1498
|
-
this.signaling.sendAudioData(audioBlob);
|
|
1499
|
-
this.emitter.emit('messageSent', audioBlob);
|
|
1500
|
-
}
|
|
1501
|
-
catch (error) {
|
|
1502
|
-
throw new AStackError(`Failed to send audio: ${error}`, ErrorCodes.AUDIO_ERROR);
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
startRecording() {
|
|
1506
|
-
const localStreams = this.webrtc.getLocalStreams();
|
|
1507
|
-
const audioStream = localStreams.find(s => s.kind === 'audio');
|
|
1508
|
-
if (!audioStream?.track) {
|
|
1509
|
-
throw new AStackError('No audio stream available for recording', ErrorCodes.AUDIO_ERROR);
|
|
1510
|
-
}
|
|
1511
|
-
const stream = new MediaStream([audioStream.track]);
|
|
1512
|
-
this.audioRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
|
1513
|
-
const audioChunks = [];
|
|
1514
|
-
this.audioRecorder.ondataavailable = (event) => {
|
|
1515
|
-
if (event.data.size > 0) {
|
|
1516
|
-
audioChunks.push(event.data);
|
|
1517
|
-
}
|
|
1518
|
-
};
|
|
1519
|
-
this.audioRecorder.onstop = () => {
|
|
1520
|
-
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
|
1521
|
-
this.sendAudio(audioBlob);
|
|
1522
|
-
};
|
|
1523
|
-
this.audioRecorder.start();
|
|
1524
|
-
this.isRecording = true;
|
|
1525
|
-
}
|
|
1526
|
-
stopRecording() {
|
|
1527
|
-
if (this.audioRecorder && this.isRecording) {
|
|
1528
|
-
this.audioRecorder.stop();
|
|
1529
|
-
this.isRecording = false;
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
getIsRecording() {
|
|
1533
|
-
return this.isRecording;
|
|
1534
|
-
}
|
|
1535
|
-
async getDevices() {
|
|
1536
|
-
return this.webrtc.getMediaDevices();
|
|
1537
|
-
}
|
|
1538
|
-
async switchCamera(deviceId) {
|
|
1539
|
-
throw new AStackError('Camera switching not yet implemented', ErrorCodes.VIDEO_ERROR);
|
|
1540
|
-
}
|
|
1541
|
-
async switchMicrophone(deviceId) {
|
|
1542
|
-
return this.webrtc.switchAudioInput(deviceId);
|
|
1543
|
-
}
|
|
1544
|
-
muteAudio() {
|
|
1545
|
-
this.webrtc.muteAudio();
|
|
1546
|
-
}
|
|
1547
|
-
unmuteAudio() {
|
|
1548
|
-
this.webrtc.unmuteAudio();
|
|
1549
|
-
}
|
|
1550
|
-
pauseVideo() {
|
|
1551
|
-
this.webrtc.muteVideo();
|
|
1552
|
-
}
|
|
1553
|
-
resumeVideo() {
|
|
1554
|
-
this.webrtc.unmuteVideo();
|
|
1555
|
-
}
|
|
1556
|
-
getLocalStreams() {
|
|
1557
|
-
return this.webrtc.getLocalStreams();
|
|
1558
|
-
}
|
|
1559
|
-
getRemoteStreams() {
|
|
1560
|
-
return this.webrtc.getRemoteStreams();
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
/**
|
|
1565
|
-
* @deprecated AStackClient is deprecated. Use AStackCSRClient for WebSocket-based communication.
|
|
1566
|
-
* This class is kept for backwards compatibility only.
|
|
1567
|
-
*
|
|
1568
|
-
* Example:
|
|
1569
|
-
* ```typescript
|
|
1570
|
-
* import { AStackCSRClient } from '@astack/client-sdk';
|
|
1571
|
-
* const client = new AStackCSRClient({ apiEndpoint: 'https://api.astack.com' });
|
|
1572
|
-
* ```
|
|
1573
|
-
*/
|
|
1574
|
-
class AStackClient extends EventEmitter {
|
|
1575
|
-
constructor(config) {
|
|
1576
|
-
super();
|
|
1577
|
-
this.usageTracker = null;
|
|
1578
|
-
this.sessionId = null;
|
|
1579
|
-
this.localVideoRef = null;
|
|
1580
|
-
this.remoteVideoRef = null;
|
|
1581
|
-
if (!config.apiKey) {
|
|
1582
|
-
throw new AStackError('API key is required', ErrorCodes.INVALID_API_KEY);
|
|
1583
|
-
}
|
|
1584
|
-
if (!config.supabaseUrl || !config.supabaseAnonKey) {
|
|
1585
|
-
throw new AStackError('Supabase URL and Anon Key are required', ErrorCodes.AUTHENTICATION_FAILED);
|
|
1586
|
-
}
|
|
1587
|
-
this.config = {
|
|
1588
|
-
enableVideo: false, enableAudio: true, enableText: true, autoReconnect: true,
|
|
1589
|
-
maxRetries: 3, reconnectDelay: 1000, heartbeatInterval: 30000, connectionTimeout: 10000,
|
|
1590
|
-
sessionExpirationWarning: 1, apiEndpoint: `${config.supabaseUrl}/functions/v1`, ...config
|
|
1591
|
-
};
|
|
1592
|
-
this.webrtc = new WebRTCManager(this.config);
|
|
1593
|
-
this.signaling = new SupabaseSignalingClient(this.config);
|
|
1594
|
-
this.sessionManager = new SessionManager(this.config.apiEndpoint, this.config.sessionExpirationWarning, (minutesRemaining) => this.emit('sessionExpiring', minutesRemaining));
|
|
1595
|
-
this.billingMonitor = new BillingMonitor(this.config.apiEndpoint, this.config.apiKey);
|
|
1596
|
-
this.billingMonitor.onAlert((alert) => this.emit('billingAlert', alert));
|
|
1597
|
-
this.securityLogger = new SecurityLogger({
|
|
1598
|
-
apiEndpoint: this.config.apiEndpoint, apiKey: this.config.apiKey,
|
|
1599
|
-
organizationId: this.config.organizationId, enableLocalLogging: true
|
|
1600
|
-
});
|
|
1601
|
-
this.connectionStateManager = new ConnectionStateManager();
|
|
1602
|
-
this.connectionStateManager.on('qualityChange', (quality) => {
|
|
1603
|
-
this.analyticsCollector.updateConnectionQuality(quality.qualityScore);
|
|
1604
|
-
});
|
|
1605
|
-
this.performanceMonitor = new PerformanceMonitor({
|
|
1606
|
-
apiEndpoint: this.config.apiEndpoint,
|
|
1607
|
-
enableAutoReport: this.config.enablePerformanceReporting ?? true,
|
|
1608
|
-
collectionInterval: this.config.performanceCollectionInterval ?? 5000,
|
|
1609
|
-
reportingInterval: this.config.performanceReportingInterval ?? 30000
|
|
1610
|
-
});
|
|
1611
|
-
this.analyticsCollector = new AnalyticsCollector({
|
|
1612
|
-
apiEndpoint: this.config.apiEndpoint, apiKey: this.config.apiKey,
|
|
1613
|
-
organizationId: this.config.organizationId, userId: this.config.endUserId,
|
|
1614
|
-
enableAutoFlush: this.config.enableAnalytics ?? true
|
|
1615
|
-
});
|
|
1616
|
-
this.mediaController = new AStackMediaController(this.webrtc, this.signaling, this.analyticsCollector, this.config, this);
|
|
1617
|
-
this.status = { id: '', status: 'pending', connectionState: 'disconnected', iceConnectionState: 'new' };
|
|
1618
|
-
this.initEventHandlers();
|
|
1619
|
-
}
|
|
1620
|
-
initEventHandlers() {
|
|
1621
|
-
setupEventHandlers(this, {
|
|
1622
|
-
webrtc: this.webrtc, signaling: this.signaling,
|
|
1623
|
-
connectionStateManager: this.connectionStateManager, analyticsCollector: this.analyticsCollector,
|
|
1624
|
-
securityLogger: this.securityLogger, performanceMonitor: this.performanceMonitor,
|
|
1625
|
-
getUsageTracker: () => this.usageTracker, setUsageTracker: (t) => { this.usageTracker = t; },
|
|
1626
|
-
getSessionId: () => this.sessionId, getStatus: () => this.status,
|
|
1627
|
-
setStatus: (s) => { this.status = { ...this.status, ...s }; },
|
|
1628
|
-
localVideoRef: this.localVideoRef, remoteVideoRef: this.remoteVideoRef,
|
|
1629
|
-
startWebRTCStatsCollection: () => this.startWebRTCStatsCollection(),
|
|
1630
|
-
stopWebRTCStatsCollection: () => this.stopWebRTCStatsCollection()
|
|
1631
|
-
});
|
|
1632
|
-
}
|
|
1633
|
-
startWebRTCStatsCollection() {
|
|
1634
|
-
this.stopWebRTCStatsCollection();
|
|
1635
|
-
this.statsCollectionTimer = setInterval(async () => {
|
|
1636
|
-
try {
|
|
1637
|
-
const stats = await this.webrtc.getStats();
|
|
1638
|
-
if (stats) {
|
|
1639
|
-
const processed = this.processWebRTCStats(stats);
|
|
1640
|
-
if (processed.audioLatency !== undefined)
|
|
1641
|
-
this.performanceMonitor.recordLatency('audio', processed.audioLatency);
|
|
1642
|
-
if (processed.videoLatency !== undefined)
|
|
1643
|
-
this.performanceMonitor.recordLatency('video', processed.videoLatency);
|
|
1644
|
-
if (processed.throughput) {
|
|
1645
|
-
this.performanceMonitor.updateThroughput('audio', 'inbound', processed.throughput.audio.inbound);
|
|
1646
|
-
this.performanceMonitor.updateThroughput('audio', 'outbound', processed.throughput.audio.outbound);
|
|
1647
|
-
this.performanceMonitor.updateThroughput('video', 'inbound', processed.throughput.video.inbound);
|
|
1648
|
-
this.performanceMonitor.updateThroughput('video', 'outbound', processed.throughput.video.outbound);
|
|
1649
|
-
}
|
|
1650
|
-
if (processed.quality)
|
|
1651
|
-
this.performanceMonitor.updateQuality(processed.quality);
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
catch (error) { }
|
|
1655
|
-
}, 5000);
|
|
1656
|
-
}
|
|
1657
|
-
stopWebRTCStatsCollection() {
|
|
1658
|
-
if (this.statsCollectionTimer) {
|
|
1659
|
-
clearInterval(this.statsCollectionTimer);
|
|
1660
|
-
this.statsCollectionTimer = undefined;
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
processWebRTCStats(stats) {
|
|
1664
|
-
return { throughput: { audio: { inbound: 0, outbound: 0 }, video: { inbound: 0, outbound: 0 } }, quality: {} };
|
|
1665
|
-
}
|
|
1666
|
-
async connect() {
|
|
1667
|
-
try {
|
|
1668
|
-
this.connectionStateManager.updateSignalingState('connecting');
|
|
1669
|
-
const sessionRequest = {
|
|
1670
|
-
apiKey: this.config.apiKey, organizationId: this.config.organizationId,
|
|
1671
|
-
endUserId: this.config.endUserId,
|
|
1672
|
-
metadata: { enableVideo: this.config.enableVideo, enableAudio: this.config.enableAudio },
|
|
1673
|
-
workerPreferences: this.config.workerPreferences
|
|
1674
|
-
};
|
|
1675
|
-
const credentials = await this.sessionManager.createSession(sessionRequest);
|
|
1676
|
-
this.sessionId = credentials.sessionId;
|
|
1677
|
-
this.status = {
|
|
1678
|
-
id: this.sessionId, status: 'connecting', connectionState: 'disconnected',
|
|
1679
|
-
iceConnectionState: 'new', startTime: new Date()
|
|
1680
|
-
};
|
|
1681
|
-
this.billingMonitor.startMonitoring();
|
|
1682
|
-
this.performanceMonitor.startCollection(this.sessionId);
|
|
1683
|
-
this.analyticsCollector.startSession(this.sessionId);
|
|
1684
|
-
await this.webrtc.initialize();
|
|
1685
|
-
await this.signaling.connect(credentials.sessionId, credentials.channelName, credentials.wsToken);
|
|
1686
|
-
}
|
|
1687
|
-
catch (error) {
|
|
1688
|
-
this.status.status = 'error';
|
|
1689
|
-
this.status.lastError = error;
|
|
1690
|
-
if (error instanceof AStackError) {
|
|
1691
|
-
if (error.code === ErrorCodes.INSUFFICIENT_CREDITS) {
|
|
1692
|
-
this.emit('billingAlert', { type: 'limit_exceeded', details: { error: error.message } });
|
|
1693
|
-
}
|
|
1694
|
-
else if (error.code === ErrorCodes.RATE_LIMIT_EXCEEDED) {
|
|
1695
|
-
this.emit('rateLimitWarning', { limit: 0, current: 0, resetIn: 60 });
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
|
-
throw error;
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
async startVideo() { return this.mediaController.startVideo(); }
|
|
1702
|
-
async stopVideo() { return this.mediaController.stopVideo(this.localVideoRef); }
|
|
1703
|
-
async startAudio() { return this.mediaController.startAudio(); }
|
|
1704
|
-
async stopAudio() { return this.mediaController.stopAudio(); }
|
|
1705
|
-
async sendText(message) { return this.mediaController.sendText(message); }
|
|
1706
|
-
async sendAudio(audioBlob) { return this.mediaController.sendAudio(audioBlob); }
|
|
1707
|
-
startRecording() { this.mediaController.startRecording(); }
|
|
1708
|
-
stopRecording() { this.mediaController.stopRecording(); }
|
|
1709
|
-
async getDevices() { return this.mediaController.getDevices(); }
|
|
1710
|
-
async switchCamera(deviceId) { return this.mediaController.switchCamera(deviceId); }
|
|
1711
|
-
async switchMicrophone(deviceId) { return this.mediaController.switchMicrophone(deviceId); }
|
|
1712
|
-
muteAudio() { this.mediaController.muteAudio(); }
|
|
1713
|
-
unmuteAudio() { this.mediaController.unmuteAudio(); }
|
|
1714
|
-
pauseVideo() { this.mediaController.pauseVideo(); }
|
|
1715
|
-
resumeVideo() { this.mediaController.resumeVideo(); }
|
|
1716
|
-
getSessionStatus() { return { ...this.status }; }
|
|
1717
|
-
getLocalStreams() { return this.mediaController.getLocalStreams(); }
|
|
1718
|
-
getRemoteStreams() { return this.mediaController.getRemoteStreams(); }
|
|
1719
|
-
attachVideo(localVideo, remoteVideo) {
|
|
1720
|
-
this.localVideoRef = localVideo;
|
|
1721
|
-
this.remoteVideoRef = remoteVideo;
|
|
1722
|
-
}
|
|
1723
|
-
addEventListener(event, listener) {
|
|
1724
|
-
return super.on(event, listener);
|
|
1725
|
-
}
|
|
1726
|
-
removeEventListener(event, listener) {
|
|
1727
|
-
return super.off(event, listener);
|
|
1728
|
-
}
|
|
1729
|
-
async disconnect() {
|
|
1730
|
-
this.status.status = 'disconnected';
|
|
1731
|
-
if (this.mediaController.getIsRecording())
|
|
1732
|
-
this.mediaController.stopRecording();
|
|
1733
|
-
if (this.usageTracker) {
|
|
1734
|
-
this.usageTracker.stop();
|
|
1735
|
-
this.usageTracker.getMetrics();
|
|
1736
|
-
}
|
|
1737
|
-
this.billingMonitor.stopMonitoring();
|
|
1738
|
-
this.performanceMonitor.stopCollection();
|
|
1739
|
-
this.analyticsCollector.endSession();
|
|
1740
|
-
this.connectionStateManager.stopHealthCheck();
|
|
1741
|
-
this.connectionStateManager.updateSignalingState('disconnected');
|
|
1742
|
-
await this.webrtc.close();
|
|
1743
|
-
await this.signaling.disconnect();
|
|
1744
|
-
this.sessionManager.clearSession();
|
|
1745
|
-
if (this.localVideoRef)
|
|
1746
|
-
this.localVideoRef.srcObject = null;
|
|
1747
|
-
if (this.remoteVideoRef)
|
|
1748
|
-
this.remoteVideoRef.srcObject = null;
|
|
1749
|
-
this.emit('sessionEnded', this.sessionId);
|
|
1750
|
-
this.sessionId = null;
|
|
1751
|
-
this.usageTracker = null;
|
|
1752
|
-
}
|
|
1753
|
-
async renewSession() {
|
|
1754
|
-
if (!this.sessionManager.isSessionValid()) {
|
|
1755
|
-
throw new AStackError('No active session to renew', ErrorCodes.SESSION_EXPIRED);
|
|
1756
|
-
}
|
|
1757
|
-
try {
|
|
1758
|
-
const credentials = await this.sessionManager.renewSession(this.config.apiKey);
|
|
1759
|
-
await this.signaling.disconnect();
|
|
1760
|
-
await this.signaling.connect(credentials.sessionId, credentials.channelName, credentials.wsToken);
|
|
1761
|
-
this.sessionId = credentials.sessionId;
|
|
1762
|
-
this.emit('sessionReady', this.sessionId);
|
|
1763
|
-
}
|
|
1764
|
-
catch (error) {
|
|
1765
|
-
this.emit('error', error);
|
|
1766
|
-
throw error;
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
getSessionTimeRemaining() { return this.sessionManager.getTimeUntilExpiration(); }
|
|
1770
|
-
isSessionValid() { return this.sessionManager.isSessionValid(); }
|
|
1771
|
-
getUsageMetrics() { return this.usageTracker?.getMetrics() || null; }
|
|
1772
|
-
trackQualityScore(score) { this.usageTracker?.trackQualityScore(score); }
|
|
1773
|
-
async getBillingInfo() { return this.billingMonitor.fetchBillingInfo(); }
|
|
1774
|
-
getCurrentBillingInfo() { return this.billingMonitor.getCurrentBillingInfo(); }
|
|
1775
|
-
setBillingWarningThresholds(thresholds) {
|
|
1776
|
-
this.billingMonitor.setWarningThresholds(thresholds);
|
|
1777
|
-
}
|
|
1778
|
-
getConnectionState() { return this.connectionStateManager.getState(); }
|
|
1779
|
-
getConnectionQuality() { return this.connectionStateManager.getQuality(); }
|
|
1780
|
-
isHealthy() { return this.connectionStateManager.isHealthy(); }
|
|
1781
|
-
getConnectionHistory() {
|
|
1782
|
-
return this.connectionStateManager.getStateHistory();
|
|
1783
|
-
}
|
|
1784
|
-
getPerformanceMetrics() { return this.performanceMonitor.getMetrics(); }
|
|
1785
|
-
getPerformanceBenchmarks() { return this.performanceMonitor.getBenchmarks(); }
|
|
1786
|
-
recordPerformanceBenchmark(benchmark) {
|
|
1787
|
-
this.performanceMonitor.recordBenchmark(benchmark);
|
|
1788
|
-
}
|
|
1789
|
-
recordLatency(type, latency) {
|
|
1790
|
-
this.performanceMonitor.recordLatency(type, latency);
|
|
1791
|
-
}
|
|
1792
|
-
async reportPerformanceMetrics() { await this.performanceMonitor.reportMetrics(); }
|
|
1793
|
-
setWorkerPreferences(preferences) { this.config.workerPreferences = preferences; }
|
|
1794
|
-
getWorkerPreferences() { return this.config.workerPreferences; }
|
|
1795
|
-
getAssignedWorkerInfo() { return this.sessionManager.getWorkerInfo() || undefined; }
|
|
1796
|
-
trackEvent(eventType, metadata) {
|
|
1797
|
-
this.analyticsCollector.trackEvent(eventType, metadata);
|
|
1798
|
-
}
|
|
1799
|
-
getSessionAnalytics() { return this.analyticsCollector.getSessionAnalytics(); }
|
|
1800
|
-
async flushAnalytics() { await this.analyticsCollector.flush(); }
|
|
1801
|
-
async destroy() {
|
|
1802
|
-
await this.disconnect();
|
|
1803
|
-
this.sessionManager.destroy();
|
|
1804
|
-
this.billingMonitor.destroy();
|
|
1805
|
-
this.connectionStateManager.destroy();
|
|
1806
|
-
this.performanceMonitor.destroy();
|
|
1807
|
-
this.analyticsCollector.destroy();
|
|
1808
|
-
await this.securityLogger.destroy();
|
|
1809
|
-
this.removeAllListeners();
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1812
2
|
|
|
1813
3
|
const ARKIT_BLENDSHAPES = [
|
|
1814
4
|
'jawOpen', 'jawForward', 'jawLeft', 'jawRight',
|
|
@@ -1829,12 +19,17 @@ const ARKIT_BLENDSHAPES = [
|
|
|
1829
19
|
];
|
|
1830
20
|
const BLENDSHAPE_COUNT = 52;
|
|
1831
21
|
|
|
22
|
+
const IDX_JAW_OPEN = ARKIT_BLENDSHAPES.indexOf('jawOpen');
|
|
23
|
+
const IDX_MOUTH_FUNNEL = ARKIT_BLENDSHAPES.indexOf('mouthFunnel');
|
|
24
|
+
const IDX_MOUTH_LOWER_DOWN_LEFT = ARKIT_BLENDSHAPES.indexOf('mouthLowerDownLeft');
|
|
25
|
+
const IDX_MOUTH_LOWER_DOWN_RIGHT = ARKIT_BLENDSHAPES.indexOf('mouthLowerDownRight');
|
|
1832
26
|
class AudioPlayer extends EventEmitter {
|
|
1833
27
|
constructor(sampleRate = 24000) {
|
|
1834
28
|
super();
|
|
1835
29
|
this.audioContext = null;
|
|
1836
30
|
this.audioQueue = [];
|
|
1837
31
|
this.isPlaying = false;
|
|
32
|
+
this.animationFrameId = null;
|
|
1838
33
|
this.sampleRate = sampleRate;
|
|
1839
34
|
}
|
|
1840
35
|
async ensureAudioContext() {
|
|
@@ -1847,16 +42,18 @@ class AudioPlayer extends EventEmitter {
|
|
|
1847
42
|
return this.audioContext;
|
|
1848
43
|
}
|
|
1849
44
|
enqueue(chunk) {
|
|
1850
|
-
console.log('[AudioPlayer] Enqueue called, queue length before:', this.audioQueue.length, 'isPlaying:', this.isPlaying);
|
|
1851
45
|
this.audioQueue.push(chunk);
|
|
1852
46
|
if (!this.isPlaying) {
|
|
1853
|
-
console.log('[AudioPlayer] Not playing, starting playNext');
|
|
1854
47
|
this.playNext();
|
|
1855
48
|
}
|
|
1856
49
|
}
|
|
1857
50
|
clearQueue() {
|
|
1858
51
|
this.audioQueue = [];
|
|
1859
52
|
this.isPlaying = false;
|
|
53
|
+
if (this.animationFrameId !== null) {
|
|
54
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
55
|
+
this.animationFrameId = null;
|
|
56
|
+
}
|
|
1860
57
|
this.emit('blendshapeUpdate', new Array(BLENDSHAPE_COUNT).fill(0));
|
|
1861
58
|
}
|
|
1862
59
|
async playNext() {
|
|
@@ -1883,33 +80,28 @@ class AudioPlayer extends EventEmitter {
|
|
|
1883
80
|
const jawOpen = Math.min(amplitude * 3, 1);
|
|
1884
81
|
const mouthOpen = Math.min(amplitude * 2.5, 0.8);
|
|
1885
82
|
const frameData = new Array(BLENDSHAPE_COUNT).fill(0);
|
|
1886
|
-
frameData[
|
|
1887
|
-
frameData[
|
|
1888
|
-
frameData[
|
|
1889
|
-
frameData[
|
|
83
|
+
frameData[IDX_JAW_OPEN] = jawOpen;
|
|
84
|
+
frameData[IDX_MOUTH_FUNNEL] = mouthOpen;
|
|
85
|
+
frameData[IDX_MOUTH_LOWER_DOWN_LEFT] = mouthOpen * 0.3;
|
|
86
|
+
frameData[IDX_MOUTH_LOWER_DOWN_RIGHT] = mouthOpen * 0.3;
|
|
1890
87
|
blendshapes.push(frameData);
|
|
1891
88
|
}
|
|
1892
89
|
return blendshapes;
|
|
1893
90
|
}
|
|
1894
91
|
async playChunk(chunk) {
|
|
1895
92
|
try {
|
|
1896
|
-
console.log('[AudioPlayer] playChunk called, audio byteLength:', chunk.audio.byteLength);
|
|
1897
93
|
const ctx = await this.ensureAudioContext();
|
|
1898
|
-
console.log('[AudioPlayer] AudioContext state:', ctx.state, 'sampleRate:', ctx.sampleRate);
|
|
1899
94
|
const int16Array = new Int16Array(chunk.audio);
|
|
1900
95
|
const floatArray = new Float32Array(int16Array.length);
|
|
1901
96
|
for (let i = 0; i < int16Array.length; i++) {
|
|
1902
97
|
floatArray[i] = int16Array[i] / 32768.0;
|
|
1903
98
|
}
|
|
1904
|
-
console.log('[AudioPlayer] Converted to float, samples:', floatArray.length);
|
|
1905
99
|
const audioBuffer = ctx.createBuffer(1, floatArray.length, this.sampleRate);
|
|
1906
100
|
audioBuffer.getChannelData(0).set(floatArray);
|
|
1907
101
|
const source = ctx.createBufferSource();
|
|
1908
102
|
source.buffer = audioBuffer;
|
|
1909
103
|
source.connect(ctx.destination);
|
|
1910
|
-
console.log('[AudioPlayer] Audio buffer duration:', audioBuffer.duration, 'seconds');
|
|
1911
104
|
const hasBlendshapes = chunk.blendshapes && chunk.blendshapes.length > 0;
|
|
1912
|
-
console.log('[AudioPlayer] Has blendshapes:', hasBlendshapes, 'generating amplitude fallback:', !hasBlendshapes);
|
|
1913
105
|
const blendshapes = hasBlendshapes
|
|
1914
106
|
? chunk.blendshapes
|
|
1915
107
|
: this.generateAmplitudeBlendshapes(floatArray);
|
|
@@ -1919,8 +111,13 @@ class AudioPlayer extends EventEmitter {
|
|
|
1919
111
|
this.isPlaying = true;
|
|
1920
112
|
this.emit('playbackStarted');
|
|
1921
113
|
const animate = () => {
|
|
114
|
+
if (!this.isPlaying) {
|
|
115
|
+
this.animationFrameId = null;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
1922
118
|
const elapsed = performance.now() - startTime;
|
|
1923
119
|
if (elapsed >= duration || frameIndex >= blendshapes.length) {
|
|
120
|
+
this.animationFrameId = null;
|
|
1924
121
|
this.emit('blendshapeUpdate', new Array(BLENDSHAPE_COUNT).fill(0));
|
|
1925
122
|
this.emit('playbackEnded');
|
|
1926
123
|
this.playNext();
|
|
@@ -1935,11 +132,10 @@ class AudioPlayer extends EventEmitter {
|
|
|
1935
132
|
this.emit('blendshapeUpdate', frame);
|
|
1936
133
|
}
|
|
1937
134
|
}
|
|
1938
|
-
requestAnimationFrame(animate);
|
|
135
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
1939
136
|
};
|
|
1940
|
-
console.log('[AudioPlayer] Starting audio playback');
|
|
1941
137
|
source.start();
|
|
1942
|
-
requestAnimationFrame(animate);
|
|
138
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
1943
139
|
}
|
|
1944
140
|
catch (err) {
|
|
1945
141
|
console.error('[AudioPlayer] Error in playChunk:', err);
|
|
@@ -1970,15 +166,24 @@ class AStackCSRClient extends EventEmitter {
|
|
|
1970
166
|
this.imageCaptureInterval = null;
|
|
1971
167
|
this.callStatus = 'idle';
|
|
1972
168
|
this.currentBlendshapes = new Array(BLENDSHAPE_COUNT).fill(0);
|
|
169
|
+
this.reconnectAttempts = 0;
|
|
170
|
+
this.reconnectTimer = null;
|
|
171
|
+
this.intentionalClose = false;
|
|
172
|
+
this.hasConnected = false;
|
|
173
|
+
this.clientId = null;
|
|
174
|
+
this.pendingAuth = null;
|
|
1973
175
|
this.config = {
|
|
1974
176
|
workerUrl: config.workerUrl,
|
|
1975
177
|
sessionToken: config.sessionToken || '',
|
|
1976
|
-
sessionId: config.sessionId || '',
|
|
1977
178
|
sampleRate: config.sampleRate || 24000,
|
|
1978
|
-
providers: config.providers || { asr: 'self', llm: 'self', tts: 'self' },
|
|
1979
179
|
fps: config.fps || 30,
|
|
1980
180
|
enableImageCapture: config.enableImageCapture ?? true,
|
|
1981
|
-
imageCaptureInterval: config.imageCaptureInterval || 5000
|
|
181
|
+
imageCaptureInterval: config.imageCaptureInterval || 5000,
|
|
182
|
+
autoReconnect: config.autoReconnect ?? true,
|
|
183
|
+
maxRetries: config.maxRetries ?? 5,
|
|
184
|
+
reconnectDelay: config.reconnectDelay ?? 1000,
|
|
185
|
+
audioProcessorUrl: config.audioProcessorUrl || '/audio-processor.js',
|
|
186
|
+
debug: config.debug ?? false,
|
|
1982
187
|
};
|
|
1983
188
|
this.audioPlayer = new AudioPlayer(this.config.sampleRate);
|
|
1984
189
|
this.setupAudioPlayerEvents();
|
|
@@ -1999,32 +204,93 @@ class AStackCSRClient extends EventEmitter {
|
|
|
1999
204
|
});
|
|
2000
205
|
}
|
|
2001
206
|
async connect() {
|
|
207
|
+
this.intentionalClose = false;
|
|
208
|
+
return this.connectInternal(false);
|
|
209
|
+
}
|
|
210
|
+
connectInternal(isReconnect) {
|
|
2002
211
|
return new Promise((resolve, reject) => {
|
|
2003
212
|
const wsUrl = this.config.workerUrl.replace(/^http/, 'ws');
|
|
2004
213
|
const url = new URL(wsUrl);
|
|
2005
|
-
if (
|
|
2006
|
-
|
|
2007
|
-
}
|
|
2008
|
-
if (this.config.sessionId) {
|
|
2009
|
-
url.searchParams.set('sessionId', this.config.sessionId);
|
|
214
|
+
if (url.protocol === 'ws:') {
|
|
215
|
+
console.warn('[CSR] Connecting over unencrypted ws:// — use wss:// in production');
|
|
2010
216
|
}
|
|
217
|
+
const timeout = setTimeout(() => {
|
|
218
|
+
if (this.ws) {
|
|
219
|
+
this.ws.close();
|
|
220
|
+
this.ws = null;
|
|
221
|
+
}
|
|
222
|
+
const error = new Error('WebSocket connection timeout');
|
|
223
|
+
this.emit('error', error);
|
|
224
|
+
if (!isReconnect)
|
|
225
|
+
reject(error);
|
|
226
|
+
}, 30000);
|
|
2011
227
|
this.ws = new WebSocket(url.toString());
|
|
2012
228
|
this.ws.onopen = () => {
|
|
2013
|
-
this.
|
|
2014
|
-
|
|
229
|
+
this.pendingAuth = {
|
|
230
|
+
resolve: () => {
|
|
231
|
+
clearTimeout(timeout);
|
|
232
|
+
this.reconnectAttempts = 0;
|
|
233
|
+
this.hasConnected = true;
|
|
234
|
+
if (isReconnect) {
|
|
235
|
+
if (this.callStatus === 'active' || this.callStatus === 'starting') {
|
|
236
|
+
this.stopCall();
|
|
237
|
+
this.emit('callStopped');
|
|
238
|
+
}
|
|
239
|
+
this.emit('reconnected');
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
this.emit('connected');
|
|
243
|
+
}
|
|
244
|
+
resolve();
|
|
245
|
+
},
|
|
246
|
+
reject: (err) => {
|
|
247
|
+
clearTimeout(timeout);
|
|
248
|
+
this.pendingAuth = null;
|
|
249
|
+
if (this.ws) {
|
|
250
|
+
this.ws.close();
|
|
251
|
+
this.ws = null;
|
|
252
|
+
}
|
|
253
|
+
this.emit('error', err);
|
|
254
|
+
if (!isReconnect)
|
|
255
|
+
reject(err);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
this.sendAuthentication();
|
|
2015
259
|
};
|
|
2016
260
|
this.ws.onclose = () => {
|
|
2017
|
-
|
|
2018
|
-
this.
|
|
261
|
+
clearTimeout(timeout);
|
|
262
|
+
this.ws = null;
|
|
263
|
+
if (this.pendingAuth) {
|
|
264
|
+
this.pendingAuth.reject(new Error('WebSocket closed before authentication completed'));
|
|
265
|
+
this.pendingAuth = null;
|
|
266
|
+
}
|
|
267
|
+
if (this.intentionalClose) {
|
|
268
|
+
this.stopCall();
|
|
269
|
+
this.emit('disconnected');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (this.hasConnected && this.config.autoReconnect) {
|
|
273
|
+
this.attemptReconnect();
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
this.stopCall();
|
|
277
|
+
this.emit('disconnected');
|
|
278
|
+
}
|
|
2019
279
|
};
|
|
2020
|
-
this.ws.onerror = (
|
|
280
|
+
this.ws.onerror = (_event) => {
|
|
281
|
+
clearTimeout(timeout);
|
|
2021
282
|
const error = new Error('WebSocket connection error');
|
|
2022
283
|
this.emit('error', error);
|
|
2023
|
-
|
|
284
|
+
if (!isReconnect)
|
|
285
|
+
reject(error);
|
|
2024
286
|
};
|
|
2025
287
|
this.ws.onmessage = (event) => {
|
|
2026
288
|
try {
|
|
2027
289
|
const data = JSON.parse(event.data);
|
|
290
|
+
if (!data || typeof data !== 'object' || typeof data.type !== 'string') {
|
|
291
|
+
console.error('[CSR] Invalid message: missing type');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
2028
294
|
this.handleMessage(data);
|
|
2029
295
|
}
|
|
2030
296
|
catch (err) {
|
|
@@ -2033,43 +299,123 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2033
299
|
};
|
|
2034
300
|
});
|
|
2035
301
|
}
|
|
302
|
+
sendAuthentication() {
|
|
303
|
+
if (!this.config.sessionToken) {
|
|
304
|
+
if (this.pendingAuth) {
|
|
305
|
+
this.pendingAuth.reject(new Error('Missing sessionToken in config'));
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (this.ws) {
|
|
310
|
+
this.ws.send(JSON.stringify({
|
|
311
|
+
type: 'authenticate',
|
|
312
|
+
sessionToken: this.config.sessionToken
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
attemptReconnect() {
|
|
317
|
+
if (this.reconnectAttempts >= this.config.maxRetries) {
|
|
318
|
+
this.stopCall();
|
|
319
|
+
this.emit('error', new Error('Maximum reconnection attempts reached'));
|
|
320
|
+
this.emit('disconnected');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this.reconnectAttempts++;
|
|
324
|
+
const delay = this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
325
|
+
this.emit('reconnecting', this.reconnectAttempts);
|
|
326
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
327
|
+
this.reconnectTimer = null;
|
|
328
|
+
try {
|
|
329
|
+
await this.connectInternal(true);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
this.attemptReconnect();
|
|
333
|
+
}
|
|
334
|
+
}, delay);
|
|
335
|
+
}
|
|
2036
336
|
handleMessage(data) {
|
|
2037
|
-
|
|
337
|
+
if (this.config.debug)
|
|
338
|
+
console.log('[CSR] Received message:', data.type);
|
|
2038
339
|
switch (data.type) {
|
|
2039
|
-
case '
|
|
340
|
+
case 'connected':
|
|
341
|
+
if (typeof data.clientId === 'string')
|
|
342
|
+
this.clientId = data.clientId;
|
|
343
|
+
break;
|
|
344
|
+
case 'authenticated':
|
|
345
|
+
if (this.pendingAuth) {
|
|
346
|
+
this.pendingAuth.resolve();
|
|
347
|
+
this.pendingAuth = null;
|
|
348
|
+
}
|
|
349
|
+
break;
|
|
350
|
+
case 'auth_error': {
|
|
351
|
+
const msg = typeof data.message === 'string' ? data.message : 'Authentication failed';
|
|
352
|
+
if (this.pendingAuth) {
|
|
353
|
+
this.pendingAuth.reject(new Error(msg));
|
|
354
|
+
this.pendingAuth = null;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
this.emit('error', new Error(msg));
|
|
358
|
+
}
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case 'call_started':
|
|
2040
362
|
this.callStatus = 'active';
|
|
2041
363
|
this.emit('callStarted');
|
|
2042
364
|
break;
|
|
2043
|
-
case '
|
|
365
|
+
case 'call_stopped':
|
|
2044
366
|
this.callStatus = 'idle';
|
|
2045
367
|
this.emit('callStopped');
|
|
2046
368
|
break;
|
|
2047
|
-
case '
|
|
369
|
+
case 'call_error':
|
|
2048
370
|
this.callStatus = 'error';
|
|
2049
|
-
this.emit('error', new Error(data.message));
|
|
371
|
+
this.emit('error', new Error(typeof data.message === 'string' ? data.message : 'Unknown error'));
|
|
372
|
+
break;
|
|
373
|
+
case 'call_interim':
|
|
374
|
+
if (typeof data.text !== 'string')
|
|
375
|
+
return;
|
|
376
|
+
this.emit('interim', data.text);
|
|
2050
377
|
break;
|
|
2051
|
-
case '
|
|
378
|
+
case 'call_transcript':
|
|
379
|
+
if (typeof data.text !== 'string')
|
|
380
|
+
return;
|
|
2052
381
|
this.emit('transcript', data.text);
|
|
2053
382
|
break;
|
|
2054
|
-
case '
|
|
383
|
+
case 'call_response':
|
|
384
|
+
if (typeof data.text !== 'string')
|
|
385
|
+
return;
|
|
2055
386
|
this.emit('response', data.text);
|
|
2056
387
|
break;
|
|
388
|
+
case 'call_response_complete':
|
|
389
|
+
this.emit('responseComplete');
|
|
390
|
+
break;
|
|
391
|
+
case 'call_speech_started':
|
|
392
|
+
this.emit('speechStarted');
|
|
393
|
+
break;
|
|
394
|
+
case 'call_utterance_end':
|
|
395
|
+
this.emit('utteranceEnd');
|
|
396
|
+
break;
|
|
397
|
+
case 'call_asr_error':
|
|
398
|
+
this.emit('asrError', typeof data.message === 'string' ? data.message : 'Unknown ASR error');
|
|
399
|
+
break;
|
|
2057
400
|
case 'call_chunk':
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
if (data.audio) {
|
|
401
|
+
if (this.config.debug)
|
|
402
|
+
console.log('[CSR] Audio chunk received, has audio:', !!data.audio, 'has blendshapes:', !!data.blendshapes);
|
|
403
|
+
if (typeof data.audio === 'string') {
|
|
2061
404
|
try {
|
|
2062
405
|
const binaryString = atob(data.audio);
|
|
2063
406
|
const bytes = new Uint8Array(binaryString.length);
|
|
2064
407
|
for (let i = 0; i < binaryString.length; i++) {
|
|
2065
408
|
bytes[i] = binaryString.charCodeAt(i);
|
|
2066
409
|
}
|
|
2067
|
-
|
|
410
|
+
if (this.config.debug)
|
|
411
|
+
console.log('[CSR] Decoded audio bytes:', bytes.length);
|
|
412
|
+
const blendshapes = Array.isArray(data.blendshapes) ? data.blendshapes : undefined;
|
|
2068
413
|
const chunk = {
|
|
2069
414
|
audio: bytes.buffer,
|
|
2070
|
-
blendshapes
|
|
415
|
+
blendshapes
|
|
2071
416
|
};
|
|
2072
|
-
|
|
417
|
+
if (this.config.debug)
|
|
418
|
+
console.log('[CSR] Enqueueing audio chunk');
|
|
2073
419
|
this.audioPlayer.enqueue(chunk);
|
|
2074
420
|
}
|
|
2075
421
|
catch (err) {
|
|
@@ -2080,15 +426,15 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2080
426
|
console.warn('[CSR] Audio chunk missing audio data');
|
|
2081
427
|
}
|
|
2082
428
|
break;
|
|
2083
|
-
case '
|
|
429
|
+
case 'model_status':
|
|
2084
430
|
this.emit('modelStatus', {
|
|
2085
|
-
model_loaded: data.model_loaded,
|
|
2086
|
-
blendshape_count: data.blendshape_count
|
|
431
|
+
model_loaded: typeof data.model_loaded === 'boolean' ? data.model_loaded : undefined,
|
|
432
|
+
blendshape_count: typeof data.blendshape_count === 'number' ? data.blendshape_count : undefined
|
|
2087
433
|
});
|
|
2088
434
|
break;
|
|
2089
435
|
}
|
|
2090
436
|
}
|
|
2091
|
-
async startCall() {
|
|
437
|
+
async startCall(options) {
|
|
2092
438
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
2093
439
|
throw new Error('WebSocket not connected');
|
|
2094
440
|
}
|
|
@@ -2099,7 +445,7 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2099
445
|
this.audioPlayer.clearQueue();
|
|
2100
446
|
try {
|
|
2101
447
|
this.audioContext = new AudioContext({ sampleRate: 16000 });
|
|
2102
|
-
await this.audioContext.audioWorklet.addModule(
|
|
448
|
+
await this.audioContext.audioWorklet.addModule(this.config.audioProcessorUrl);
|
|
2103
449
|
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
2104
450
|
audio: {
|
|
2105
451
|
sampleRate: 16000,
|
|
@@ -2127,17 +473,22 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2127
473
|
}
|
|
2128
474
|
}
|
|
2129
475
|
this.ws.send(JSON.stringify({
|
|
2130
|
-
type: '
|
|
2131
|
-
|
|
2132
|
-
|
|
476
|
+
type: 'call_start',
|
|
477
|
+
fps: this.config.fps,
|
|
478
|
+
...options
|
|
2133
479
|
}));
|
|
2134
480
|
const source = this.audioContext.createMediaStreamSource(this.mediaStream);
|
|
2135
481
|
this.audioProcessor = new AudioWorkletNode(this.audioContext, 'audio-processor');
|
|
2136
482
|
this.audioProcessor.port.onmessage = (event) => {
|
|
2137
483
|
if (event.data.type === 'audio_data' && this.ws?.readyState === WebSocket.OPEN) {
|
|
2138
|
-
const
|
|
484
|
+
const bytes = new Uint8Array(event.data.audioBuffer);
|
|
485
|
+
let binary = '';
|
|
486
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
487
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
|
|
488
|
+
}
|
|
489
|
+
const base64String = btoa(binary);
|
|
2139
490
|
this.ws.send(JSON.stringify({
|
|
2140
|
-
type: '
|
|
491
|
+
type: 'call_audio',
|
|
2141
492
|
audio: base64String
|
|
2142
493
|
}));
|
|
2143
494
|
}
|
|
@@ -2147,6 +498,22 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2147
498
|
}
|
|
2148
499
|
catch (err) {
|
|
2149
500
|
this.callStatus = 'error';
|
|
501
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
502
|
+
this.ws.send(JSON.stringify({ type: 'call_stop' }));
|
|
503
|
+
}
|
|
504
|
+
if (this.mediaStream) {
|
|
505
|
+
this.mediaStream.getTracks().forEach(track => track.stop());
|
|
506
|
+
this.mediaStream = null;
|
|
507
|
+
}
|
|
508
|
+
if (this.audioContext) {
|
|
509
|
+
this.audioContext.close();
|
|
510
|
+
this.audioContext = null;
|
|
511
|
+
}
|
|
512
|
+
if (this.videoRef) {
|
|
513
|
+
this.videoRef.srcObject = null;
|
|
514
|
+
this.videoRef = null;
|
|
515
|
+
}
|
|
516
|
+
this.imageCaptureCanvas = null;
|
|
2150
517
|
throw err;
|
|
2151
518
|
}
|
|
2152
519
|
}
|
|
@@ -2174,7 +541,7 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2174
541
|
const imageDataUrl = this.imageCaptureCanvas.toDataURL('image/jpeg', 0.7);
|
|
2175
542
|
const base64Image = imageDataUrl.split(',')[1];
|
|
2176
543
|
this.ws.send(JSON.stringify({
|
|
2177
|
-
type: '
|
|
544
|
+
type: 'call_image',
|
|
2178
545
|
image: base64Image,
|
|
2179
546
|
timestamp: Date.now()
|
|
2180
547
|
}));
|
|
@@ -2185,7 +552,7 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2185
552
|
this.imageCaptureInterval = null;
|
|
2186
553
|
}
|
|
2187
554
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
2188
|
-
this.ws.send(JSON.stringify({ type: '
|
|
555
|
+
this.ws.send(JSON.stringify({ type: 'call_stop' }));
|
|
2189
556
|
}
|
|
2190
557
|
if (this.videoRef) {
|
|
2191
558
|
this.videoRef.srcObject = null;
|
|
@@ -2214,17 +581,39 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2214
581
|
throw new Error('WebSocket not connected');
|
|
2215
582
|
}
|
|
2216
583
|
this.ws.send(JSON.stringify({
|
|
2217
|
-
type: '
|
|
584
|
+
type: 'call_text_input',
|
|
2218
585
|
text: message
|
|
2219
586
|
}));
|
|
2220
587
|
}
|
|
2221
|
-
disconnect() {
|
|
588
|
+
async disconnect() {
|
|
589
|
+
this.intentionalClose = true;
|
|
590
|
+
if (this.reconnectTimer) {
|
|
591
|
+
clearTimeout(this.reconnectTimer);
|
|
592
|
+
this.reconnectTimer = null;
|
|
593
|
+
}
|
|
594
|
+
this.reconnectAttempts = 0;
|
|
2222
595
|
this.stopCall();
|
|
2223
596
|
if (this.ws) {
|
|
597
|
+
await this.waitForFlush(this.ws);
|
|
2224
598
|
this.ws.close();
|
|
2225
599
|
this.ws = null;
|
|
2226
600
|
}
|
|
2227
601
|
}
|
|
602
|
+
waitForFlush(ws, timeoutMs = 100) {
|
|
603
|
+
if (ws.bufferedAmount === 0)
|
|
604
|
+
return Promise.resolve();
|
|
605
|
+
return new Promise((resolve) => {
|
|
606
|
+
const start = Date.now();
|
|
607
|
+
const check = () => {
|
|
608
|
+
if (ws.bufferedAmount === 0 || Date.now() - start >= timeoutMs) {
|
|
609
|
+
resolve();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
setTimeout(check, 10);
|
|
613
|
+
};
|
|
614
|
+
check();
|
|
615
|
+
});
|
|
616
|
+
}
|
|
2228
617
|
getCallStatus() {
|
|
2229
618
|
return this.callStatus;
|
|
2230
619
|
}
|
|
@@ -2234,12 +623,38 @@ class AStackCSRClient extends EventEmitter {
|
|
|
2234
623
|
isConnected() {
|
|
2235
624
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
2236
625
|
}
|
|
626
|
+
getReconnectAttempts() {
|
|
627
|
+
return this.reconnectAttempts;
|
|
628
|
+
}
|
|
2237
629
|
async destroy() {
|
|
2238
|
-
this.disconnect();
|
|
630
|
+
await this.disconnect();
|
|
2239
631
|
await this.audioPlayer.destroy();
|
|
2240
632
|
this.removeAllListeners();
|
|
2241
633
|
}
|
|
2242
634
|
}
|
|
2243
635
|
|
|
2244
|
-
|
|
2245
|
-
|
|
636
|
+
class AStackError extends Error {
|
|
637
|
+
constructor(message, code) {
|
|
638
|
+
super(message);
|
|
639
|
+
this.name = 'AStackError';
|
|
640
|
+
this.code = code;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const ErrorCodes = {
|
|
644
|
+
INVALID_TOKEN: 'INVALID_TOKEN',
|
|
645
|
+
AUTHENTICATION_FAILED: 'AUTHENTICATION_FAILED',
|
|
646
|
+
SESSION_EXPIRED: 'SESSION_EXPIRED',
|
|
647
|
+
SESSION_CREATION_FAILED: 'SESSION_CREATION_FAILED',
|
|
648
|
+
CONNECTION_LOST: 'CONNECTION_LOST',
|
|
649
|
+
SIGNALING_ERROR: 'SIGNALING_ERROR',
|
|
650
|
+
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
651
|
+
MEDIA_ACCESS_DENIED: 'MEDIA_ACCESS_DENIED',
|
|
652
|
+
AUDIO_ERROR: 'AUDIO_ERROR',
|
|
653
|
+
VIDEO_ERROR: 'VIDEO_ERROR',
|
|
654
|
+
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
|
655
|
+
INSUFFICIENT_CREDITS: 'INSUFFICIENT_CREDITS',
|
|
656
|
+
BILLING_ERROR: 'BILLING_ERROR',
|
|
657
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR'
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
export { ARKIT_BLENDSHAPES, AStackCSRClient, AStackError, AudioPlayer, BLENDSHAPE_COUNT, ErrorCodes, AStackCSRClient as default };
|