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