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