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