@mentra/sdk 1.1.19

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.
Files changed (101) hide show
  1. package/README.md +102 -0
  2. package/dist/constants/index.d.ts +14 -0
  3. package/dist/constants/index.d.ts.map +1 -0
  4. package/dist/constants/index.js +16 -0
  5. package/dist/examples/rtmp-streaming-example.d.ts +2 -0
  6. package/dist/examples/rtmp-streaming-example.d.ts.map +1 -0
  7. package/dist/examples/rtmp-streaming-example.js +102 -0
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +33 -0
  11. package/dist/logging/logger.d.ts +3 -0
  12. package/dist/logging/logger.d.ts.map +1 -0
  13. package/dist/logging/logger.js +79 -0
  14. package/dist/tpa/index.d.ts +6 -0
  15. package/dist/tpa/index.d.ts.map +1 -0
  16. package/dist/tpa/index.js +24 -0
  17. package/dist/tpa/server/index.d.ts +193 -0
  18. package/dist/tpa/server/index.d.ts.map +1 -0
  19. package/dist/tpa/server/index.js +436 -0
  20. package/dist/tpa/session/api-client.d.ts +49 -0
  21. package/dist/tpa/session/api-client.d.ts.map +1 -0
  22. package/dist/tpa/session/api-client.js +101 -0
  23. package/dist/tpa/session/dashboard.d.ts +52 -0
  24. package/dist/tpa/session/dashboard.d.ts.map +1 -0
  25. package/dist/tpa/session/dashboard.js +149 -0
  26. package/dist/tpa/session/events.d.ts +178 -0
  27. package/dist/tpa/session/events.d.ts.map +1 -0
  28. package/dist/tpa/session/events.js +294 -0
  29. package/dist/tpa/session/index.d.ts +391 -0
  30. package/dist/tpa/session/index.d.ts.map +1 -0
  31. package/dist/tpa/session/index.js +1452 -0
  32. package/dist/tpa/session/layouts.d.ts +150 -0
  33. package/dist/tpa/session/layouts.d.ts.map +1 -0
  34. package/dist/tpa/session/layouts.js +282 -0
  35. package/dist/tpa/session/modules/streaming.d.ts +100 -0
  36. package/dist/tpa/session/modules/streaming.d.ts.map +1 -0
  37. package/dist/tpa/session/modules/streaming.js +270 -0
  38. package/dist/tpa/session/settings.d.ts +202 -0
  39. package/dist/tpa/session/settings.d.ts.map +1 -0
  40. package/dist/tpa/session/settings.js +361 -0
  41. package/dist/tpa/token/index.d.ts +7 -0
  42. package/dist/tpa/token/index.d.ts.map +1 -0
  43. package/dist/tpa/token/index.js +22 -0
  44. package/dist/tpa/token/utils.d.ts +69 -0
  45. package/dist/tpa/token/utils.d.ts.map +1 -0
  46. package/dist/tpa/token/utils.js +144 -0
  47. package/dist/tpa/webview/index.d.ts +47 -0
  48. package/dist/tpa/webview/index.d.ts.map +1 -0
  49. package/dist/tpa/webview/index.js +344 -0
  50. package/dist/types/dashboard/index.d.ts +128 -0
  51. package/dist/types/dashboard/index.d.ts.map +1 -0
  52. package/dist/types/dashboard/index.js +12 -0
  53. package/dist/types/enums.d.ts +57 -0
  54. package/dist/types/enums.d.ts.map +1 -0
  55. package/dist/types/enums.js +72 -0
  56. package/dist/types/index.d.ts +38 -0
  57. package/dist/types/index.d.ts.map +1 -0
  58. package/dist/types/index.js +87 -0
  59. package/dist/types/layouts.d.ts +51 -0
  60. package/dist/types/layouts.d.ts.map +1 -0
  61. package/dist/types/layouts.js +3 -0
  62. package/dist/types/message-types.d.ts +109 -0
  63. package/dist/types/message-types.d.ts.map +1 -0
  64. package/dist/types/message-types.js +189 -0
  65. package/dist/types/messages/base.d.ts +12 -0
  66. package/dist/types/messages/base.d.ts.map +1 -0
  67. package/dist/types/messages/base.js +3 -0
  68. package/dist/types/messages/cloud-to-glasses.d.ts +126 -0
  69. package/dist/types/messages/cloud-to-glasses.d.ts.map +1 -0
  70. package/dist/types/messages/cloud-to-glasses.js +60 -0
  71. package/dist/types/messages/cloud-to-tpa.d.ts +228 -0
  72. package/dist/types/messages/cloud-to-tpa.d.ts.map +1 -0
  73. package/dist/types/messages/cloud-to-tpa.js +61 -0
  74. package/dist/types/messages/glasses-to-cloud.d.ts +219 -0
  75. package/dist/types/messages/glasses-to-cloud.d.ts.map +1 -0
  76. package/dist/types/messages/glasses-to-cloud.js +88 -0
  77. package/dist/types/messages/tpa-to-cloud.d.ts +146 -0
  78. package/dist/types/messages/tpa-to-cloud.d.ts.map +1 -0
  79. package/dist/types/messages/tpa-to-cloud.js +67 -0
  80. package/dist/types/models.d.ts +165 -0
  81. package/dist/types/models.d.ts.map +1 -0
  82. package/dist/types/models.js +84 -0
  83. package/dist/types/rtmp-stream.d.ts +68 -0
  84. package/dist/types/rtmp-stream.d.ts.map +1 -0
  85. package/dist/types/rtmp-stream.js +3 -0
  86. package/dist/types/streams.d.ts +138 -0
  87. package/dist/types/streams.d.ts.map +1 -0
  88. package/dist/types/streams.js +251 -0
  89. package/dist/types/token.d.ts +41 -0
  90. package/dist/types/token.d.ts.map +1 -0
  91. package/dist/types/token.js +7 -0
  92. package/dist/types/user-session.d.ts +73 -0
  93. package/dist/types/user-session.d.ts.map +1 -0
  94. package/dist/types/user-session.js +17 -0
  95. package/dist/types/webhooks.d.ts +107 -0
  96. package/dist/types/webhooks.d.ts.map +1 -0
  97. package/dist/types/webhooks.js +55 -0
  98. package/dist/utils/resource-tracker.d.ts +94 -0
  99. package/dist/utils/resource-tracker.d.ts.map +1 -0
  100. package/dist/utils/resource-tracker.js +153 -0
  101. package/package.json +50 -0
@@ -0,0 +1,1452 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TpaSession = void 0;
7
+ /**
8
+ * 🎯 TPA Session Module
9
+ *
10
+ * Manages an active Third Party App session with AugmentOS Cloud.
11
+ * Handles real-time communication, event subscriptions, and display management.
12
+ */
13
+ const ws_1 = require("ws");
14
+ const events_1 = require("./events");
15
+ const layouts_1 = require("./layouts");
16
+ const settings_1 = require("./settings");
17
+ const streaming_1 = require("./modules/streaming");
18
+ const resource_tracker_1 = require("../../utils/resource-tracker");
19
+ const types_1 = require("../../types");
20
+ const axios_1 = __importDefault(require("axios"));
21
+ const events_2 = __importDefault(require("events"));
22
+ // Import the cloud-to-tpa specific type guards
23
+ const cloud_to_tpa_1 = require("../../types/messages/cloud-to-tpa");
24
+ // List of event types that should never be subscribed to as streams
25
+ const TPA_TO_TPA_EVENT_TYPES = [
26
+ 'tpa_message_received',
27
+ 'tpa_user_joined',
28
+ 'tpa_user_left',
29
+ 'tpa_room_updated',
30
+ 'tpa_direct_message_response'
31
+ ];
32
+ /**
33
+ * 🚀 TPA Session Implementation
34
+ *
35
+ * Manages a live connection between your TPA and AugmentOS Cloud.
36
+ * Provides interfaces for:
37
+ * - 🎮 Event handling (transcription, head position, etc.)
38
+ * - 📱 Display management in AR view
39
+ * - 🔌 Connection lifecycle
40
+ * - 🔄 Automatic reconnection
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const session = new TpaSession({
45
+ * packageName: 'org.example.myapp',
46
+ * apiKey: 'your_api_key'
47
+ * });
48
+ *
49
+ * // Handle events
50
+ * session.onTranscription((data) => {
51
+ * session.layouts.showTextWall(data.text);
52
+ * });
53
+ *
54
+ * // Connect to cloud
55
+ * await session.connect('session_123');
56
+ * ```
57
+ */
58
+ class TpaSession {
59
+ constructor(config) {
60
+ this.config = config;
61
+ /** WebSocket connection to AugmentOS Cloud */
62
+ this.ws = null;
63
+ /** Current session identifier */
64
+ this.sessionId = null;
65
+ /** Number of reconnection attempts made */
66
+ this.reconnectAttempts = 0;
67
+ /** Active event subscriptions */
68
+ this.subscriptions = new Set();
69
+ /** Resource tracker for automatic cleanup */
70
+ this.resources = new resource_tracker_1.ResourceTracker();
71
+ /** Internal settings storage - use public settings API instead */
72
+ this.settingsData = [];
73
+ /** TPA configuration loaded from tpa_config.json */
74
+ this.tpaConfig = null;
75
+ /** Whether to update subscriptions when settings change */
76
+ this.shouldUpdateSubscriptionsOnSettingsChange = false;
77
+ /** Settings that should trigger subscription updates when changed */
78
+ this.subscriptionUpdateTriggers = [];
79
+ /** Pending photo requests waiting for responses */
80
+ this.pendingPhotoRequests = new Map();
81
+ /** Pending user discovery requests waiting for responses */
82
+ this.pendingUserDiscoveryRequests = new Map();
83
+ /** Pending direct message requests waiting for responses */
84
+ this.pendingDirectMessages = new Map();
85
+ /** Dedicated emitter for TPA-to-TPA events */
86
+ this.tpaEvents = new events_2.default();
87
+ // Set defaults and merge with provided config
88
+ this.config = {
89
+ augmentOSWebsocketUrl: `ws://localhost:8002/tpa-ws`, // Use localhost as default
90
+ autoReconnect: true, // Enable auto-reconnection by default for better reliability
91
+ maxReconnectAttempts: 3, // Default to 3 reconnection attempts for better resilience
92
+ reconnectDelay: 1000, // Start with 1 second delay (uses exponential backoff)
93
+ ...config
94
+ };
95
+ this.tpaServer = this.config.tpaServer;
96
+ this.logger = this.tpaServer.logger.child({ userId: this.config.userId, service: 'tpa-session' });
97
+ this.userId = this.config.userId;
98
+ // Make sure the URL is correctly formatted to prevent double protocol issues
99
+ if (this.config.augmentOSWebsocketUrl) {
100
+ try {
101
+ const url = new URL(this.config.augmentOSWebsocketUrl);
102
+ if (!['ws:', 'wss:'].includes(url.protocol)) {
103
+ // Fix URLs with incorrect protocol (e.g., 'ws://http://host')
104
+ const fixedUrl = this.config.augmentOSWebsocketUrl.replace(/^ws:\/\/http:\/\//, 'ws://');
105
+ this.config.augmentOSWebsocketUrl = fixedUrl;
106
+ this.logger.warn(`⚠️ [${this.config.packageName}] Fixed malformed WebSocket URL: ${fixedUrl}`);
107
+ }
108
+ }
109
+ catch (error) {
110
+ this.logger.error(error, `⚠️ [${this.config.packageName}] Invalid WebSocket URL format: ${this.config.augmentOSWebsocketUrl}`);
111
+ }
112
+ }
113
+ // Log initialization
114
+ this.logger.debug(`🚀 [${this.config.packageName}] TPA Session initialized`);
115
+ this.logger.debug(`🚀 [${this.config.packageName}] WebSocket URL: ${this.config.augmentOSWebsocketUrl}`);
116
+ // Validate URL format - give early warning for obvious issues
117
+ // Check URL format but handle undefined case
118
+ if (this.config.augmentOSWebsocketUrl) {
119
+ try {
120
+ const url = new URL(this.config.augmentOSWebsocketUrl);
121
+ if (!['ws:', 'wss:'].includes(url.protocol)) {
122
+ this.logger.error({ config: this.config }, `⚠️ [${this.config.packageName}] Invalid WebSocket URL protocol: ${url.protocol}. Should be ws: or wss:`);
123
+ }
124
+ }
125
+ catch (error) {
126
+ this.logger.error(error, `⚠️ [${this.config.packageName}] Invalid WebSocket URL format: ${this.config.augmentOSWebsocketUrl}`);
127
+ }
128
+ }
129
+ this.events = new events_1.EventManager(this.subscribe.bind(this), this.unsubscribe.bind(this));
130
+ this.layouts = new layouts_1.LayoutManager(config.packageName, this.send.bind(this));
131
+ // Initialize settings manager with all necessary parameters, including subscribeFn for AugmentOS settings
132
+ this.settings = new settings_1.SettingsManager(this.settingsData, this.config.packageName, this.config.augmentOSWebsocketUrl, this.sessionId ?? undefined, async (streams) => {
133
+ this.logger.debug(`[TpaSession] subscribeFn called for streams:`, streams);
134
+ streams.forEach((stream) => {
135
+ if (!this.subscriptions.has(stream)) {
136
+ this.subscriptions.add(stream);
137
+ this.logger.debug(`[TpaSession] Auto-subscribed to stream '${stream}' for AugmentOS setting.`);
138
+ }
139
+ else {
140
+ this.logger.debug(`[TpaSession] Already subscribed to stream '${stream}'.`);
141
+ }
142
+ });
143
+ this.logger.debug(`[TpaSession] Current subscriptions after subscribeFn:`, Array.from(this.subscriptions));
144
+ if (this.ws?.readyState === 1) {
145
+ this.updateSubscriptions();
146
+ this.logger.debug(`[TpaSession] Sent updated subscriptions to cloud after auto-subscribing to AugmentOS setting.`);
147
+ }
148
+ else {
149
+ this.logger.debug(`[TpaSession] WebSocket not open, will send subscriptions when connected.`);
150
+ }
151
+ });
152
+ // Initialize dashboard API with this session instance
153
+ // Import DashboardManager dynamically to avoid circular dependency
154
+ const { DashboardManager } = require('./dashboard');
155
+ this.dashboard = new DashboardManager(this, this.send.bind(this));
156
+ // Initialize streaming module with session reference
157
+ this.streaming = new streaming_1.StreamingModule(this.config.packageName, this.sessionId || 'unknown-session-id', this.send.bind(this), this // Pass session reference
158
+ );
159
+ }
160
+ /**
161
+ * Get the current session ID
162
+ * @returns The current session ID or 'unknown-session-id' if not connected
163
+ */
164
+ getSessionId() {
165
+ return this.sessionId || 'unknown-session-id';
166
+ }
167
+ /**
168
+ * Get the package name for this TPA
169
+ * @returns The package name
170
+ */
171
+ getPackageName() {
172
+ return this.config.packageName;
173
+ }
174
+ // =====================================
175
+ // 🎮 Direct Event Handling Interface
176
+ // =====================================
177
+ /**
178
+ * 🎤 Listen for speech transcription events
179
+ * @param handler - Function to handle transcription data
180
+ * @returns Cleanup function to remove the handler
181
+ */
182
+ onTranscription(handler) {
183
+ return this.events.onTranscription(handler);
184
+ }
185
+ /**
186
+ * 🌐 Listen for speech transcription events in a specific language
187
+ * @param language - Language code (e.g., "en-US")
188
+ * @param handler - Function to handle transcription data
189
+ * @returns Cleanup function to remove the handler
190
+ * @throws Error if language code is invalid
191
+ */
192
+ onTranscriptionForLanguage(language, handler) {
193
+ return this.events.onTranscriptionForLanguage(language, handler);
194
+ }
195
+ /**
196
+ * 🌐 Listen for speech translation events for a specific language pair
197
+ * @param sourceLanguage - Source language code (e.g., "es-ES")
198
+ * @param targetLanguage - Target language code (e.g., "en-US")
199
+ * @param handler - Function to handle translation data
200
+ * @returns Cleanup function to remove the handler
201
+ * @throws Error if language codes are invalid
202
+ */
203
+ onTranslationForLanguage(sourceLanguage, targetLanguage, handler) {
204
+ return this.events.ontranslationForLanguage(sourceLanguage, targetLanguage, handler);
205
+ }
206
+ /**
207
+ * 👤 Listen for head position changes
208
+ * @param handler - Function to handle head position updates
209
+ * @returns Cleanup function to remove the handler
210
+ */
211
+ onHeadPosition(handler) {
212
+ return this.events.onHeadPosition(handler);
213
+ }
214
+ /**
215
+ * 🔘 Listen for hardware button press events
216
+ * @param handler - Function to handle button events
217
+ * @returns Cleanup function to remove the handler
218
+ */
219
+ onButtonPress(handler) {
220
+ return this.events.onButtonPress(handler);
221
+ }
222
+ /**
223
+ * 📱 Listen for phone notification events
224
+ * @param handler - Function to handle notifications
225
+ * @returns Cleanup function to remove the handler
226
+ */
227
+ onPhoneNotifications(handler) {
228
+ return this.events.onPhoneNotifications(handler);
229
+ }
230
+ /**
231
+ * 📡 Listen for VPS coordinates updates
232
+ * @param handler - Function to handle VPS coordinates
233
+ * @returns Cleanup function to remove the handler
234
+ */
235
+ onVpsCoordinates(handler) {
236
+ this.subscribe(types_1.StreamType.VPS_COORDINATES);
237
+ return this.events.onVpsCoordinates(handler);
238
+ }
239
+ /**
240
+ * 📸 Listen for photo responses
241
+ * @param handler - Function to handle photo response data
242
+ * @returns Cleanup function to remove the handler
243
+ */
244
+ onPhotoTaken(handler) {
245
+ this.subscribe(types_1.StreamType.PHOTO_TAKEN);
246
+ return this.events.onPhotoTaken(handler);
247
+ }
248
+ // =====================================
249
+ // 📡 Pub/Sub Interface
250
+ // =====================================
251
+ /**
252
+ * 📬 Subscribe to a specific event stream
253
+ * @param type - Type of event to subscribe to
254
+ */
255
+ subscribe(type) {
256
+ if (TPA_TO_TPA_EVENT_TYPES.includes(type)) {
257
+ this.logger.warn(`[TpaSession] Attempted to subscribe to TPA-to-TPA event type '${type}', which is not a valid stream. Use the event handler (e.g., onTpaMessage) instead.`);
258
+ return;
259
+ }
260
+ this.subscriptions.add(type);
261
+ if (this.ws?.readyState === 1) {
262
+ this.updateSubscriptions();
263
+ }
264
+ }
265
+ /**
266
+ * 📭 Unsubscribe from a specific event stream
267
+ * @param type - Type of event to unsubscribe from
268
+ */
269
+ unsubscribe(type) {
270
+ if (TPA_TO_TPA_EVENT_TYPES.includes(type)) {
271
+ this.logger.warn(`[TpaSession] Attempted to unsubscribe from TPA-to-TPA event type '${type}', which is not a valid stream.`);
272
+ return;
273
+ }
274
+ this.subscriptions.delete(type);
275
+ if (this.ws?.readyState === 1) {
276
+ this.updateSubscriptions();
277
+ }
278
+ }
279
+ /**
280
+ * 🎯 Generic event listener (pub/sub style)
281
+ * @param event - Event name to listen for
282
+ * @param handler - Event handler function
283
+ */
284
+ on(event, handler) {
285
+ return this.events.on(event, handler);
286
+ }
287
+ // =====================================
288
+ // 🔌 Connection Management
289
+ // =====================================
290
+ /**
291
+ * 🚀 Connect to AugmentOS Cloud
292
+ * @param sessionId - Unique session identifier
293
+ * @returns Promise that resolves when connected
294
+ */
295
+ async connect(sessionId) {
296
+ this.sessionId = sessionId;
297
+ // Configure settings API client with the WebSocket URL and session ID
298
+ // This allows settings to be fetched from the correct server
299
+ this.settings.configureApiClient(this.config.packageName, this.config.augmentOSWebsocketUrl || '', sessionId);
300
+ // Update the sessionId in the streaming module
301
+ if (this.streaming) {
302
+ Object.defineProperty(this.streaming, 'sessionId', { value: sessionId });
303
+ }
304
+ return new Promise((resolve, reject) => {
305
+ try {
306
+ // Clear previous resources if reconnecting
307
+ if (this.ws) {
308
+ // Don't call full dispose() as that would clear subscriptions
309
+ if (this.ws.readyState !== 3) { // 3 = CLOSED
310
+ this.ws.close();
311
+ }
312
+ this.ws = null;
313
+ }
314
+ // Validate WebSocket URL before attempting connection
315
+ if (!this.config.augmentOSWebsocketUrl) {
316
+ this.logger.error('WebSocket URL is missing or undefined');
317
+ reject(new Error('WebSocket URL is required'));
318
+ return;
319
+ }
320
+ // Add debug logging for connection attempts
321
+ this.logger.info(`🔌🔌🔌 [${this.config.packageName}] Attempting to connect to: ${this.config.augmentOSWebsocketUrl} for session ${this.sessionId}`);
322
+ // Create connection with error handling
323
+ this.ws = new ws_1.WebSocket(this.config.augmentOSWebsocketUrl);
324
+ // Track WebSocket for automatic cleanup
325
+ this.resources.track(() => {
326
+ if (this.ws && this.ws.readyState !== 3) { // 3 = CLOSED
327
+ this.ws.close();
328
+ }
329
+ });
330
+ this.ws.on('open', () => {
331
+ try {
332
+ this.sendConnectionInit();
333
+ }
334
+ catch (error) {
335
+ this.logger.error(error, 'Error during connection initialization');
336
+ const errorMessage = error instanceof Error ? error.message : String(error);
337
+ this.events.emit('error', new Error(`Connection initialization failed: ${errorMessage}`));
338
+ reject(error);
339
+ }
340
+ });
341
+ // Message handler with comprehensive error recovery
342
+ const messageHandler = async (data, isBinary) => {
343
+ try {
344
+ // Handle binary messages (typically audio data)
345
+ if (isBinary && Buffer.isBuffer(data)) {
346
+ try {
347
+ // Validate buffer before processing
348
+ if (data.length === 0) {
349
+ this.events.emit('error', new Error('Received empty binary data'));
350
+ return;
351
+ }
352
+ // Convert Node.js Buffer to ArrayBuffer safely
353
+ const arrayBuf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
354
+ // Create AUDIO_CHUNK event message with validation
355
+ const audioChunk = {
356
+ type: types_1.StreamType.AUDIO_CHUNK,
357
+ arrayBuffer: arrayBuf,
358
+ timestamp: new Date() // Ensure timestamp is present
359
+ };
360
+ this.handleMessage(audioChunk);
361
+ return;
362
+ }
363
+ catch (error) {
364
+ this.logger.error(error, 'Error processing binary message:');
365
+ const errorMessage = error instanceof Error ? error.message : String(error);
366
+ this.events.emit('error', new Error(`Failed to process binary message: ${errorMessage}`));
367
+ return;
368
+ }
369
+ }
370
+ // Handle ArrayBuffer data type directly
371
+ if (data instanceof ArrayBuffer) {
372
+ return;
373
+ }
374
+ // Handle JSON messages with validation
375
+ try {
376
+ // Convert string data to JSON safely
377
+ let jsonData;
378
+ if (typeof data === 'string') {
379
+ jsonData = data;
380
+ }
381
+ else if (Buffer.isBuffer(data)) {
382
+ jsonData = data.toString('utf8');
383
+ }
384
+ else {
385
+ throw new Error('Unknown message format');
386
+ }
387
+ // Validate JSON before parsing
388
+ if (!jsonData || jsonData.trim() === '') {
389
+ this.events.emit('error', new Error('Received empty JSON message'));
390
+ return;
391
+ }
392
+ // Parse JSON with error handling
393
+ const message = JSON.parse(jsonData);
394
+ // Basic schema validation
395
+ if (!message || typeof message !== 'object' || !('type' in message)) {
396
+ this.events.emit('error', new Error('Malformed message: missing type property'));
397
+ return;
398
+ }
399
+ // Process the validated message
400
+ this.handleMessage(message);
401
+ }
402
+ catch (error) {
403
+ this.logger.error(error, 'JSON parsing error');
404
+ const errorMessage = error instanceof Error ? error.message : String(error);
405
+ this.events.emit('error', new Error(`Failed to parse JSON message: ${errorMessage}`));
406
+ }
407
+ }
408
+ catch (error) {
409
+ // Final catch - should never reach here if individual handlers work correctly
410
+ this.logger.error({ error }, 'Unhandled message processing error');
411
+ const errorMessage = error instanceof Error ? error.message : String(error);
412
+ this.events.emit('error', new Error(`Unhandled message error: ${errorMessage}`));
413
+ }
414
+ };
415
+ this.ws.on('message', messageHandler);
416
+ // Track event handler removal for automatic cleanup
417
+ this.resources.track(() => {
418
+ if (this.ws) {
419
+ this.ws.off('message', messageHandler);
420
+ }
421
+ });
422
+ // Connection closure handler
423
+ const closeHandler = (code, reason) => {
424
+ const reasonStr = reason ? `: ${reason}` : '';
425
+ const closeInfo = `Connection closed (code: ${code})${reasonStr}`;
426
+ // Emit the disconnected event with structured data for better handling
427
+ this.events.emit('disconnected', {
428
+ message: closeInfo,
429
+ code: code,
430
+ reason: reason || '',
431
+ wasClean: code === 1000 || code === 1001,
432
+ });
433
+ // Only attempt reconnection for abnormal closures
434
+ // https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
435
+ // 1000 (Normal Closure) and 1001 (Going Away) are normal
436
+ // 1002-1015 are abnormal, and reason "App stopped" means intentional closure
437
+ // 1008 usually when the userSession no longer exists on server. i.e user disconnected from cloud.
438
+ const isNormalClosure = (code === 1000 || code === 1001 || code === 1008);
439
+ const isManualStop = reason && reason.includes('App stopped');
440
+ // Log closure details for diagnostics
441
+ this.logger.debug(`🔌 [${this.config.packageName}] WebSocket closed with code ${code}${reasonStr}`);
442
+ this.logger.debug(`🔌 [${this.config.packageName}] isNormalClosure: ${isNormalClosure}, isManualStop: ${isManualStop}`);
443
+ if (!isNormalClosure && !isManualStop) {
444
+ this.logger.warn(`🔌 [${this.config.packageName}] Abnormal closure detected, attempting reconnection`);
445
+ this.handleReconnection();
446
+ }
447
+ else {
448
+ this.logger.debug(`🔌 [${this.config.packageName}] Normal closure detected, not attempting reconnection`);
449
+ }
450
+ };
451
+ this.ws.on('close', closeHandler);
452
+ // Track event handler removal
453
+ this.resources.track(() => {
454
+ if (this.ws) {
455
+ this.ws.off('close', closeHandler);
456
+ }
457
+ });
458
+ // Connection error handler
459
+ const errorHandler = (error) => {
460
+ this.logger.error(error, 'WebSocket error');
461
+ this.events.emit('error', error);
462
+ };
463
+ // Enhanced error handler with detailed logging
464
+ this.ws.on('error', (error) => {
465
+ this.logger.error(error, `⛔️⛔️⛔️ [${this.config.packageName}] WebSocket connection error: ${error.message}`);
466
+ // Try to provide more context
467
+ const errMsg = error.message || '';
468
+ if (errMsg.includes('ECONNREFUSED')) {
469
+ this.logger.error(`⛔️⛔️⛔️ [${this.config.packageName}] Connection refused - Check if the server is running at the specified URL`);
470
+ }
471
+ else if (errMsg.includes('ETIMEDOUT')) {
472
+ this.logger.error(`⛔️⛔️⛔️ [${this.config.packageName}] Connection timed out - Check network connectivity and firewall rules`);
473
+ }
474
+ errorHandler(error);
475
+ });
476
+ // Track event handler removal
477
+ this.resources.track(() => {
478
+ if (this.ws) {
479
+ this.ws.off('error', errorHandler);
480
+ }
481
+ });
482
+ // Set up connection success handler
483
+ const connectedCleanup = this.events.onConnected(() => resolve());
484
+ // Track event handler removal
485
+ this.resources.track(connectedCleanup);
486
+ // Connection timeout with configurable duration
487
+ const timeoutMs = 5000; // 5 seconds default
488
+ const connectionTimeout = this.resources.setTimeout(() => {
489
+ // Use tracked timeout that will be auto-cleared
490
+ this.logger.error({
491
+ config: this.config,
492
+ sessionId: this.sessionId,
493
+ timeoutMs
494
+ }, `⏱️⏱️⏱️ [${this.config.packageName}] Connection timeout after ${timeoutMs}ms`);
495
+ this.events.emit('error', new Error(`Connection timeout after ${timeoutMs}ms`));
496
+ reject(new Error('Connection timeout'));
497
+ }, timeoutMs);
498
+ // Clear timeout on successful connection
499
+ const timeoutCleanup = this.events.onConnected(() => {
500
+ clearTimeout(connectionTimeout);
501
+ resolve();
502
+ });
503
+ // Track event handler removal
504
+ this.resources.track(timeoutCleanup);
505
+ }
506
+ catch (error) {
507
+ this.logger.error(error, 'Connection setup error');
508
+ const errorMessage = error instanceof Error ? error.message : String(error);
509
+ reject(new Error(`Failed to setup connection: ${errorMessage}`));
510
+ }
511
+ });
512
+ }
513
+ /**
514
+ * 👋 Disconnect from AugmentOS Cloud
515
+ */
516
+ disconnect() {
517
+ // Use the resource tracker to clean up everything
518
+ this.resources.dispose();
519
+ // Clean up additional resources not handled by the tracker
520
+ this.ws = null;
521
+ this.sessionId = null;
522
+ this.subscriptions.clear();
523
+ this.reconnectAttempts = 0;
524
+ }
525
+ /**
526
+ * 📸 Request a photo from the connected glasses
527
+ * @param options - Optional configuration for the photo request
528
+ * @returns Promise that resolves with the URL to the captured photo
529
+ */
530
+ requestPhoto(options) {
531
+ return new Promise((resolve, reject) => {
532
+ try {
533
+ // Generate unique request ID
534
+ const requestId = `photo_req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
535
+ // Store promise resolvers for when we get the response
536
+ this.pendingPhotoRequests.set(requestId, { resolve, reject });
537
+ // Create photo request message
538
+ const message = {
539
+ type: types_1.TpaToCloudMessageType.PHOTO_REQUEST,
540
+ packageName: this.config.packageName,
541
+ sessionId: this.sessionId,
542
+ timestamp: new Date(),
543
+ saveToGallery: options?.saveToGallery || false
544
+ };
545
+ // Send request to cloud
546
+ this.send(message);
547
+ // Set timeout to avoid hanging promises
548
+ const timeoutMs = 30000; // 30 seconds
549
+ this.resources.setTimeout(() => {
550
+ if (this.pendingPhotoRequests.has(requestId)) {
551
+ this.pendingPhotoRequests.get(requestId).reject(new Error('Photo request timed out'));
552
+ this.pendingPhotoRequests.delete(requestId);
553
+ }
554
+ }, timeoutMs);
555
+ }
556
+ catch (error) {
557
+ const errorMessage = error instanceof Error ? error.message : String(error);
558
+ reject(new Error(`Failed to request photo: ${errorMessage}`));
559
+ }
560
+ });
561
+ }
562
+ /**
563
+ * 🛠️ Get all current user settings
564
+ * @returns A copy of the current settings array
565
+ * @deprecated Use session.settings.getAll() instead
566
+ */
567
+ getSettings() {
568
+ return this.settings.getAll();
569
+ }
570
+ /**
571
+ * 🔍 Get a specific setting value by key
572
+ * @param key The setting key to look for
573
+ * @returns The setting's value, or undefined if not found
574
+ * @deprecated Use session.settings.get(key) instead
575
+ */
576
+ getSetting(key) {
577
+ return this.settings.get(key);
578
+ }
579
+ /**
580
+ * ⚙️ Configure settings-based subscription updates
581
+ * This allows TPAs to automatically update their subscriptions when certain settings change
582
+ * @param options Configuration options for settings-based subscriptions
583
+ */
584
+ setSubscriptionSettings(options) {
585
+ this.shouldUpdateSubscriptionsOnSettingsChange = true;
586
+ this.subscriptionUpdateTriggers = options.updateOnChange;
587
+ this.subscriptionSettingsHandler = options.handler;
588
+ // If we already have settings, update subscriptions immediately
589
+ if (this.settingsData.length > 0) {
590
+ this.updateSubscriptionsFromSettings();
591
+ }
592
+ }
593
+ /**
594
+ * 🔄 Update subscriptions based on current settings
595
+ * Called automatically when relevant settings change
596
+ */
597
+ updateSubscriptionsFromSettings() {
598
+ if (!this.subscriptionSettingsHandler)
599
+ return;
600
+ try {
601
+ // Get new subscriptions from handler
602
+ const newSubscriptions = this.subscriptionSettingsHandler(this.settingsData);
603
+ // Update all subscriptions at once
604
+ this.subscriptions.clear();
605
+ newSubscriptions.forEach(subscription => {
606
+ this.subscriptions.add(subscription);
607
+ });
608
+ // Send subscription update to cloud if connected
609
+ if (this.ws && this.ws.readyState === 1) {
610
+ this.updateSubscriptions();
611
+ }
612
+ }
613
+ catch (error) {
614
+ this.logger.error(error, 'Error updating subscriptions from settings');
615
+ const errorMessage = error instanceof Error ? error.message : String(error);
616
+ this.events.emit('error', new Error(`Failed to update subscriptions: ${errorMessage}`));
617
+ }
618
+ }
619
+ /**
620
+ * 🧪 For testing: Update settings locally
621
+ * In normal operation, settings come from the cloud
622
+ * @param newSettings The new settings to apply
623
+ */
624
+ updateSettingsForTesting(newSettings) {
625
+ this.settingsData = newSettings;
626
+ // Update the settings manager with the new settings
627
+ this.settings.updateSettings(newSettings);
628
+ // Emit update event for backwards compatibility
629
+ this.events.emit('settings_update', this.settingsData);
630
+ // Check if we should update subscriptions
631
+ if (this.shouldUpdateSubscriptionsOnSettingsChange) {
632
+ this.updateSubscriptionsFromSettings();
633
+ }
634
+ }
635
+ /**
636
+ * 📝 Load configuration from a JSON file
637
+ * @param jsonData JSON string containing TPA configuration
638
+ * @returns The loaded configuration
639
+ * @throws Error if the configuration is invalid
640
+ */
641
+ loadConfigFromJson(jsonData) {
642
+ try {
643
+ const parsedConfig = JSON.parse(jsonData);
644
+ if ((0, types_1.validateTpaConfig)(parsedConfig)) {
645
+ this.tpaConfig = parsedConfig;
646
+ return parsedConfig;
647
+ }
648
+ else {
649
+ throw new Error('Invalid TPA configuration format');
650
+ }
651
+ }
652
+ catch (error) {
653
+ const errorMessage = error instanceof Error ? error.message : String(error);
654
+ throw new Error(`Failed to load TPA configuration: ${errorMessage}`);
655
+ }
656
+ }
657
+ /**
658
+ * 📋 Get the loaded TPA configuration
659
+ * @returns The current TPA configuration or null if not loaded
660
+ */
661
+ getConfig() {
662
+ return this.tpaConfig;
663
+ }
664
+ /**
665
+ * 🔌 Get the WebSocket server URL for this session
666
+ * @returns The WebSocket server URL used by this session
667
+ */
668
+ getServerUrl() {
669
+ return this.config.augmentOSWebsocketUrl;
670
+ }
671
+ getHttpsServerUrl() {
672
+ if (!this.config.augmentOSWebsocketUrl) {
673
+ return undefined;
674
+ }
675
+ return TpaSession.convertToHttps(this.config.augmentOSWebsocketUrl);
676
+ }
677
+ static convertToHttps(rawUrl) {
678
+ if (!rawUrl)
679
+ return '';
680
+ // Remove ws:// or wss://
681
+ let url = rawUrl.replace(/^wss?:\/\//, '');
682
+ // Remove trailing /tpa-ws
683
+ url = url.replace(/\/tpa-ws$/, '');
684
+ // Prepend https://
685
+ return `https://${url}`;
686
+ }
687
+ /**
688
+ * 🔍 Get default settings from the TPA configuration
689
+ * @returns Array of settings with default values
690
+ * @throws Error if configuration is not loaded
691
+ */
692
+ getDefaultSettings() {
693
+ if (!this.tpaConfig) {
694
+ throw new Error('TPA configuration not loaded. Call loadConfigFromJson first.');
695
+ }
696
+ return this.tpaConfig.settings
697
+ .filter((s) => s.type !== 'group')
698
+ .map((s) => ({
699
+ ...s,
700
+ value: s.defaultValue // Set value to defaultValue
701
+ }));
702
+ }
703
+ /**
704
+ * 🔍 Get setting schema from configuration
705
+ * @param key Setting key to look up
706
+ * @returns The setting schema or undefined if not found
707
+ */
708
+ getSettingSchema(key) {
709
+ if (!this.tpaConfig)
710
+ return undefined;
711
+ const setting = this.tpaConfig.settings.find((s) => s.type !== 'group' && 'key' in s && s.key === key);
712
+ return setting;
713
+ }
714
+ // =====================================
715
+ // 🔧 Private Methods
716
+ // =====================================
717
+ /**
718
+ * 📨 Handle incoming messages from cloud
719
+ */
720
+ handleMessage(message) {
721
+ try {
722
+ // Validate message before processing
723
+ if (!this.validateMessage(message)) {
724
+ this.events.emit('error', new Error('Invalid message format received'));
725
+ return;
726
+ }
727
+ // Handle binary data (audio or video)
728
+ if (message instanceof ArrayBuffer) {
729
+ this.handleBinaryMessage(message);
730
+ return;
731
+ }
732
+ // Using type guards to determine message type and safely handle each case
733
+ try {
734
+ if ((0, types_1.isTpaConnectionAck)(message)) {
735
+ // Get settings from connection acknowledgment
736
+ const receivedSettings = message.settings || [];
737
+ this.settingsData = receivedSettings;
738
+ // Store config if provided
739
+ if (message.config && (0, types_1.validateTpaConfig)(message.config)) {
740
+ this.tpaConfig = message.config;
741
+ }
742
+ // Use default settings from config if no settings were provided
743
+ if (receivedSettings.length === 0 && this.tpaConfig) {
744
+ try {
745
+ this.settingsData = this.getDefaultSettings();
746
+ }
747
+ catch (error) {
748
+ this.logger.warn('Failed to load default settings from config:', error);
749
+ }
750
+ }
751
+ // Update the settings manager with the new settings
752
+ this.settings.updateSettings(this.settingsData);
753
+ // Handle AugmentOS system settings if provided
754
+ this.logger.debug(`[TpaSession] CONNECTION_ACK augmentosSettings:`, message.augmentosSettings);
755
+ if (message.augmentosSettings) {
756
+ this.logger.info(`[TpaSession] Calling updateAugmentosSettings with:`, message.augmentosSettings);
757
+ this.settings.updateAugmentosSettings(message.augmentosSettings);
758
+ }
759
+ else {
760
+ this.logger.warn(`[TpaSession] CONNECTION_ACK message missing augmentosSettings field`);
761
+ }
762
+ // Emit connected event with settings
763
+ this.events.emit('connected', this.settingsData);
764
+ // Update subscriptions (normal flow)
765
+ this.updateSubscriptions();
766
+ // If settings-based subscriptions are enabled, update those too
767
+ if (this.shouldUpdateSubscriptionsOnSettingsChange && this.settingsData.length > 0) {
768
+ this.updateSubscriptionsFromSettings();
769
+ }
770
+ }
771
+ else if ((0, types_1.isTpaConnectionError)(message) || message.type === 'connection_error') {
772
+ // Handle both TPA-specific connection_error and standard connection_error
773
+ const errorMessage = message.message || 'Unknown connection error';
774
+ this.events.emit('error', new Error(errorMessage));
775
+ }
776
+ else if (message.type === types_1.StreamType.AUDIO_CHUNK) {
777
+ if (this.subscriptions.has(types_1.StreamType.AUDIO_CHUNK)) {
778
+ // Only process if we're subscribed to avoid unnecessary processing
779
+ this.events.emit(types_1.StreamType.AUDIO_CHUNK, message);
780
+ }
781
+ }
782
+ else if ((0, types_1.isDataStream)(message)) {
783
+ // Ensure streamType exists before emitting the event
784
+ let messageStreamType = message.streamType;
785
+ if (message.streamType === types_1.StreamType.TRANSCRIPTION) {
786
+ const transcriptionData = message.data;
787
+ if (transcriptionData.transcribeLanguage) {
788
+ messageStreamType = (0, types_1.createTranscriptionStream)(transcriptionData.transcribeLanguage);
789
+ }
790
+ }
791
+ else if (message.streamType === types_1.StreamType.TRANSLATION) {
792
+ const translationData = message.data;
793
+ if (translationData.transcribeLanguage && translationData.translateLanguage) {
794
+ messageStreamType = (0, types_1.createTranslationStream)(translationData.transcribeLanguage, translationData.translateLanguage);
795
+ }
796
+ }
797
+ if (messageStreamType && this.subscriptions.has(messageStreamType)) {
798
+ const sanitizedData = this.sanitizeEventData(messageStreamType, message.data);
799
+ this.events.emit(messageStreamType, sanitizedData);
800
+ }
801
+ }
802
+ else if ((0, cloud_to_tpa_1.isPhotoResponse)(message)) {
803
+ // Handle photo response by resolving the pending promise
804
+ if (this.pendingPhotoRequests.has(message.requestId)) {
805
+ const { resolve } = this.pendingPhotoRequests.get(message.requestId);
806
+ resolve(message.photoUrl);
807
+ this.pendingPhotoRequests.delete(message.requestId);
808
+ }
809
+ }
810
+ else if ((0, cloud_to_tpa_1.isRtmpStreamStatus)(message)) {
811
+ // Emit as a standard stream event if subscribed
812
+ if (this.subscriptions.has(types_1.StreamType.RTMP_STREAM_STATUS)) {
813
+ this.events.emit(types_1.StreamType.RTMP_STREAM_STATUS, message);
814
+ }
815
+ // Update streaming module's internal state
816
+ this.streaming.updateStreamState(message);
817
+ }
818
+ else if ((0, types_1.isSettingsUpdate)(message)) {
819
+ // Store previous settings to check for changes
820
+ const prevSettings = [...this.settingsData];
821
+ // Update internal settings storage
822
+ this.settingsData = message.settings || [];
823
+ // Update the settings manager with the new settings
824
+ const changes = this.settings.updateSettings(this.settingsData);
825
+ // Emit settings update event (for backwards compatibility)
826
+ this.events.emit('settings_update', this.settingsData);
827
+ // --- AugmentOS settings update logic ---
828
+ // If the message.settings looks like AugmentOS settings (object with known keys), update augmentosSettings
829
+ if (message.settings && typeof message.settings === 'object') {
830
+ this.settings.updateAugmentosSettings(message.settings);
831
+ }
832
+ // Check if we should update subscriptions
833
+ if (this.shouldUpdateSubscriptionsOnSettingsChange) {
834
+ // Check if any subscription trigger settings changed
835
+ const shouldUpdateSubs = this.subscriptionUpdateTriggers.some(key => {
836
+ return key in changes;
837
+ });
838
+ if (shouldUpdateSubs) {
839
+ this.updateSubscriptionsFromSettings();
840
+ }
841
+ }
842
+ }
843
+ else if ((0, types_1.isAppStopped)(message)) {
844
+ const reason = message.reason || 'unknown';
845
+ const displayReason = `App stopped: ${reason}`;
846
+ // Emit disconnected event with clean closure info to prevent reconnection attempts
847
+ this.events.emit('disconnected', {
848
+ message: displayReason,
849
+ code: 1000, // Normal closure code
850
+ reason: displayReason,
851
+ wasClean: true,
852
+ });
853
+ // Clear reconnection state
854
+ this.reconnectAttempts = 0;
855
+ }
856
+ // Handle dashboard mode changes
857
+ else if ((0, types_1.isDashboardModeChanged)(message)) {
858
+ try {
859
+ // Use proper type
860
+ const mode = message.mode || 'none';
861
+ // Update dashboard state in the API
862
+ if (this.dashboard && 'content' in this.dashboard) {
863
+ this.dashboard.content.setCurrentMode(mode);
864
+ }
865
+ }
866
+ catch (error) {
867
+ this.logger.error(error, 'Error handling dashboard mode change');
868
+ }
869
+ }
870
+ // Handle always-on dashboard state changes
871
+ else if ((0, types_1.isDashboardAlwaysOnChanged)(message)) {
872
+ try {
873
+ // Use proper type
874
+ const enabled = !!message.enabled;
875
+ // Update dashboard state in the API
876
+ if (this.dashboard && 'content' in this.dashboard) {
877
+ this.dashboard.content.setAlwaysOnEnabled(enabled);
878
+ }
879
+ }
880
+ catch (error) {
881
+ this.logger.error(error, 'Error handling dashboard always-on change');
882
+ }
883
+ }
884
+ // Handle custom messages
885
+ else if (message.type === types_1.CloudToTpaMessageType.CUSTOM_MESSAGE) {
886
+ this.events.emit('custom_message', message);
887
+ return;
888
+ }
889
+ // Handle TPA-to-TPA communication messages
890
+ else if (message.type === 'tpa_message_received') {
891
+ this.tpaEvents.emit('tpa_message_received', message);
892
+ }
893
+ else if (message.type === 'tpa_user_joined') {
894
+ this.tpaEvents.emit('tpa_user_joined', message);
895
+ }
896
+ else if (message.type === 'tpa_user_left') {
897
+ this.tpaEvents.emit('tpa_user_left', message);
898
+ }
899
+ else if (message.type === 'tpa_room_updated') {
900
+ this.tpaEvents.emit('tpa_room_updated', message);
901
+ }
902
+ else if (message.type === 'tpa_direct_message_response') {
903
+ const response = message;
904
+ if (response.messageId && this.pendingDirectMessages.has(response.messageId)) {
905
+ const { resolve } = this.pendingDirectMessages.get(response.messageId);
906
+ resolve(response.success);
907
+ this.pendingDirectMessages.delete(response.messageId);
908
+ }
909
+ }
910
+ else if (message.type === 'augmentos_settings_update') {
911
+ const augmentosMsg = message;
912
+ if (augmentosMsg.settings && typeof augmentosMsg.settings === 'object') {
913
+ this.settings.updateAugmentosSettings(augmentosMsg.settings);
914
+ }
915
+ }
916
+ // Handle 'connection_error' as a specific case if cloud sends this string literal
917
+ else if (message.type === 'connection_error') {
918
+ // Treat 'connection_error' (string literal) like TpaConnectionError
919
+ // This handles cases where the cloud might send the type as a direct string
920
+ // instead of the enum's 'tpa_connection_error' value.
921
+ const errorMessage = message.message || 'Unknown connection error (type: connection_error)';
922
+ this.logger.warn(`Received 'connection_error' type directly. Consider aligning cloud to send 'tpa_connection_error'. Message: ${errorMessage}`);
923
+ this.events.emit('error', new Error(errorMessage));
924
+ }
925
+ else if (message.type === 'permission_error') {
926
+ // Handle permission errors from cloud
927
+ this.logger.warn('Permission error received:', {
928
+ message: message.message,
929
+ details: message.details,
930
+ detailsCount: message.details?.length || 0,
931
+ rejectedStreams: message.details?.map(d => d.stream) || []
932
+ });
933
+ // Emit permission error event for application handling
934
+ this.events.emit('permission_error', {
935
+ message: message.message,
936
+ details: message.details,
937
+ timestamp: message.timestamp
938
+ });
939
+ // Optionally emit individual permission denied events for each stream
940
+ message.details?.forEach(detail => {
941
+ this.events.emit('permission_denied', {
942
+ stream: detail.stream,
943
+ requiredPermission: detail.requiredPermission,
944
+ message: detail.message
945
+ });
946
+ });
947
+ }
948
+ // Handle unrecognized message types gracefully
949
+ else {
950
+ console.log(`Unrecognized message type: ${message.type}. Full message details:`, {
951
+ messageType: message.type,
952
+ fullMessage: message,
953
+ messageKeys: Object.keys(message || {}),
954
+ messageStringified: JSON.stringify(message, null, 2)
955
+ });
956
+ // Log all message object details for debugging
957
+ this.logger.warn(`Unrecognized message type: ${message.type}. Full message details:`, {
958
+ messageType: message.type,
959
+ fullMessage: message,
960
+ messageKeys: Object.keys(message || {}),
961
+ messageStringified: JSON.stringify(message, null, 2)
962
+ });
963
+ this.events.emit('error', new Error(`Unrecognized message type: ${message.type}`));
964
+ }
965
+ }
966
+ catch (processingError) {
967
+ // Catch any errors during message processing to prevent TPA crashes
968
+ this.logger.error(processingError, 'Error processing message:');
969
+ const errorMessage = processingError instanceof Error ? processingError.message : String(processingError);
970
+ this.events.emit('error', new Error(`Error processing message: ${errorMessage}`));
971
+ }
972
+ }
973
+ catch (error) {
974
+ // Final safety net to ensure the TPA doesn't crash on any unexpected errors
975
+ this.logger.error(error, 'Unexpected error in message handler');
976
+ const errorMessage = error instanceof Error ? error.message : String(error);
977
+ this.events.emit('error', new Error(`Unexpected error in message handler: ${errorMessage}`));
978
+ }
979
+ }
980
+ /**
981
+ * 🧪 Validate incoming message structure
982
+ * @param message - Message to validate
983
+ * @returns boolean indicating if the message is valid
984
+ */
985
+ validateMessage(message) {
986
+ // Handle ArrayBuffer case separately
987
+ if (message instanceof ArrayBuffer) {
988
+ return true; // ArrayBuffers are always considered valid at this level
989
+ }
990
+ // Check if message is null or undefined
991
+ if (!message) {
992
+ return false;
993
+ }
994
+ // Check if message has a type property
995
+ if (!('type' in message)) {
996
+ return false;
997
+ }
998
+ // All other message types should be objects with a type property
999
+ return true;
1000
+ }
1001
+ /**
1002
+ * 📦 Handle binary message data (audio or video)
1003
+ * @param buffer - Binary data as ArrayBuffer
1004
+ */
1005
+ handleBinaryMessage(buffer) {
1006
+ try {
1007
+ // Safety check - only process if we're subscribed to avoid unnecessary work
1008
+ if (!this.subscriptions.has(types_1.StreamType.AUDIO_CHUNK)) {
1009
+ return;
1010
+ }
1011
+ // Validate buffer has content before processing
1012
+ if (!buffer || buffer.byteLength === 0) {
1013
+ this.events.emit('error', new Error('Received empty binary message'));
1014
+ return;
1015
+ }
1016
+ // Create a safety wrapped audio chunk with proper defaults
1017
+ const audioChunk = {
1018
+ type: types_1.StreamType.AUDIO_CHUNK,
1019
+ timestamp: new Date(),
1020
+ arrayBuffer: buffer,
1021
+ sampleRate: 16000 // Default sample rate
1022
+ };
1023
+ // Emit to subscribers
1024
+ this.events.emit(types_1.StreamType.AUDIO_CHUNK, audioChunk);
1025
+ }
1026
+ catch (error) {
1027
+ this.logger.error(error, 'Error processing binary message');
1028
+ const errorMessage = error instanceof Error ? error.message : String(error);
1029
+ this.events.emit('error', new Error(`Error processing binary message: ${errorMessage}`));
1030
+ }
1031
+ }
1032
+ /**
1033
+ * 🧹 Sanitize event data to prevent crashes from malformed data
1034
+ * @param streamType - The type of stream data
1035
+ * @param data - The potentially unsafe data to sanitize
1036
+ * @returns Sanitized data safe for processing
1037
+ */
1038
+ sanitizeEventData(streamType, data) {
1039
+ try {
1040
+ // If data is null or undefined, return an empty object to prevent crashes
1041
+ if (data === null || data === undefined) {
1042
+ return {};
1043
+ }
1044
+ // For specific stream types, perform targeted sanitization
1045
+ switch (streamType) {
1046
+ case types_1.StreamType.TRANSCRIPTION:
1047
+ // Ensure text field exists and is a string
1048
+ if (typeof data.text !== 'string') {
1049
+ return {
1050
+ text: '',
1051
+ isFinal: true,
1052
+ startTime: Date.now(),
1053
+ endTime: Date.now()
1054
+ };
1055
+ }
1056
+ break;
1057
+ case types_1.StreamType.HEAD_POSITION:
1058
+ // Ensure position data has required numeric fields
1059
+ // Handle HeadPosition - Note the property position instead of x,y,z
1060
+ const pos = data;
1061
+ if (typeof pos?.position !== 'string') {
1062
+ return { position: 'up', timestamp: new Date() };
1063
+ }
1064
+ break;
1065
+ case types_1.StreamType.BUTTON_PRESS:
1066
+ // Ensure button type is valid
1067
+ const btn = data;
1068
+ if (!btn.buttonId || !btn.pressType) {
1069
+ return { buttonId: 'unknown', pressType: 'short', timestamp: new Date() };
1070
+ }
1071
+ break;
1072
+ }
1073
+ return data;
1074
+ }
1075
+ catch (error) {
1076
+ this.logger.error(error, `Error sanitizing ${streamType} data`);
1077
+ // Return a safe empty object if something goes wrong
1078
+ return {};
1079
+ }
1080
+ }
1081
+ /**
1082
+ * 🔐 Send connection initialization message
1083
+ */
1084
+ sendConnectionInit() {
1085
+ const message = {
1086
+ type: types_1.TpaToCloudMessageType.CONNECTION_INIT,
1087
+ sessionId: this.sessionId,
1088
+ packageName: this.config.packageName,
1089
+ apiKey: this.config.apiKey,
1090
+ timestamp: new Date()
1091
+ };
1092
+ this.send(message);
1093
+ }
1094
+ /**
1095
+ * 📝 Update subscription list with cloud
1096
+ */
1097
+ updateSubscriptions() {
1098
+ this.logger.info(`[TpaSession] updateSubscriptions: sending subscriptions to cloud:`, Array.from(this.subscriptions));
1099
+ const message = {
1100
+ type: types_1.TpaToCloudMessageType.SUBSCRIPTION_UPDATE,
1101
+ packageName: this.config.packageName,
1102
+ subscriptions: Array.from(this.subscriptions),
1103
+ sessionId: this.sessionId,
1104
+ timestamp: new Date()
1105
+ };
1106
+ this.send(message);
1107
+ }
1108
+ /**
1109
+ * 🔄 Handle reconnection with exponential backoff
1110
+ */
1111
+ async handleReconnection() {
1112
+ // Check if reconnection is allowed
1113
+ if (!this.config.autoReconnect || !this.sessionId) {
1114
+ this.logger.debug(`🔄 Reconnection skipped: autoReconnect=${this.config.autoReconnect}, sessionId=${this.sessionId ? 'valid' : 'invalid'}`);
1115
+ return;
1116
+ }
1117
+ // Check if we've exceeded the maximum attempts
1118
+ const maxAttempts = this.config.maxReconnectAttempts || 3;
1119
+ if (this.reconnectAttempts >= maxAttempts) {
1120
+ this.logger.info(`🔄 Maximum reconnection attempts (${maxAttempts}) reached, giving up`);
1121
+ // Emit a permanent disconnection event to trigger onStop in the TPA server
1122
+ this.events.emit('disconnected', {
1123
+ message: `Connection permanently lost after ${maxAttempts} failed reconnection attempts`,
1124
+ code: 4000, // Custom code for max reconnection attempts exhausted
1125
+ reason: 'Maximum reconnection attempts exceeded',
1126
+ wasClean: false,
1127
+ permanent: true // Flag this as a permanent disconnection
1128
+ });
1129
+ return;
1130
+ }
1131
+ // Calculate delay with exponential backoff
1132
+ const baseDelay = this.config.reconnectDelay || 1000;
1133
+ const delay = baseDelay * Math.pow(2, this.reconnectAttempts);
1134
+ this.reconnectAttempts++;
1135
+ this.logger.debug(`🔄 [${this.config.packageName}] Reconnection attempt ${this.reconnectAttempts}/${maxAttempts} in ${delay}ms`);
1136
+ // Use the resource tracker for the timeout
1137
+ await new Promise(resolve => {
1138
+ this.resources.setTimeout(() => resolve(), delay);
1139
+ });
1140
+ try {
1141
+ this.logger.debug(`🔄 [${this.config.packageName}] Attempting to reconnect...`);
1142
+ await this.connect(this.sessionId);
1143
+ this.logger.debug(`✅ [${this.config.packageName}] Reconnection successful!`);
1144
+ this.reconnectAttempts = 0;
1145
+ }
1146
+ catch (error) {
1147
+ const errorMessage = error instanceof Error ? error.message : String(error);
1148
+ this.logger.error(error, `❌ [${this.config.packageName}] Reconnection failed for user ${this.userId}`);
1149
+ this.events.emit('error', new Error(`Reconnection failed: ${errorMessage}`));
1150
+ // Check if this was the last attempt
1151
+ if (this.reconnectAttempts >= maxAttempts) {
1152
+ this.logger.debug(`🔄 [${this.config.packageName}] Final reconnection attempt failed, emitting permanent disconnection`);
1153
+ // Emit permanent disconnection event after the last failed attempt
1154
+ this.events.emit('disconnected', {
1155
+ message: `Connection permanently lost after ${maxAttempts} failed reconnection attempts`,
1156
+ code: 4000, // Custom code for max reconnection attempts exhausted
1157
+ reason: 'Maximum reconnection attempts exceeded',
1158
+ wasClean: false,
1159
+ permanent: true // Flag this as a permanent disconnection
1160
+ });
1161
+ }
1162
+ }
1163
+ }
1164
+ /**
1165
+ * 📤 Send message to cloud with validation and error handling
1166
+ * @throws {Error} If WebSocket is not connected
1167
+ */
1168
+ send(message) {
1169
+ try {
1170
+ // Verify WebSocket connection is valid
1171
+ if (!this.ws) {
1172
+ throw new Error('WebSocket connection not established');
1173
+ }
1174
+ if (this.ws.readyState !== 1) {
1175
+ const stateMap = {
1176
+ 0: 'CONNECTING',
1177
+ 1: 'OPEN',
1178
+ 2: 'CLOSING',
1179
+ 3: 'CLOSED'
1180
+ };
1181
+ const stateName = stateMap[this.ws.readyState] || 'UNKNOWN';
1182
+ throw new Error(`WebSocket not connected (current state: ${stateName})`);
1183
+ }
1184
+ // Validate message before sending
1185
+ if (!message || typeof message !== 'object') {
1186
+ throw new Error('Invalid message: must be an object');
1187
+ }
1188
+ if (!('type' in message)) {
1189
+ throw new Error('Invalid message: missing "type" property');
1190
+ }
1191
+ // Ensure message format is consistent
1192
+ if (!('timestamp' in message) || !(message.timestamp instanceof Date)) {
1193
+ message.timestamp = new Date();
1194
+ }
1195
+ // Try to send with error handling
1196
+ try {
1197
+ const serializedMessage = JSON.stringify(message);
1198
+ this.ws.send(serializedMessage);
1199
+ }
1200
+ catch (sendError) {
1201
+ const errorMessage = sendError instanceof Error ? sendError.message : String(sendError);
1202
+ throw new Error(`Failed to send message: ${errorMessage}`);
1203
+ }
1204
+ }
1205
+ catch (error) {
1206
+ // Log the error and emit an event so TPA developers are aware
1207
+ this.logger.error(error, 'Message send error');
1208
+ // Ensure we always emit an Error object
1209
+ if (error instanceof Error) {
1210
+ this.events.emit('error', error);
1211
+ }
1212
+ else {
1213
+ this.events.emit('error', new Error(String(error)));
1214
+ }
1215
+ // Re-throw to maintain the original function behavior
1216
+ throw error;
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Fetch the onboarding instructions for this session from the backend.
1221
+ * @returns Promise resolving to the instructions string or null
1222
+ */
1223
+ async getInstructions() {
1224
+ try {
1225
+ const baseUrl = this.getServerUrl();
1226
+ const response = await axios_1.default.get(`${baseUrl}/api/instructions`, { params: { userId: this.userId } });
1227
+ return response.data.instructions || null;
1228
+ }
1229
+ catch (err) {
1230
+ this.logger.error('Error fetching instructions from backend:', err);
1231
+ return null;
1232
+ }
1233
+ }
1234
+ // =====================================
1235
+ // 👥 TPA-to-TPA Communication Interface
1236
+ // =====================================
1237
+ /**
1238
+ * 👥 Discover other users currently using the same TPA
1239
+ * @param includeProfiles - Whether to include user profile information
1240
+ * @returns Promise that resolves with list of active users
1241
+ */
1242
+ async discoverTpaUsers(domain, includeProfiles = false) {
1243
+ // Use the domain argument as the base URL if provided
1244
+ if (!domain) {
1245
+ throw new Error('Domain (API base URL) is required for user discovery');
1246
+ }
1247
+ const url = `${domain}/api/tpa-communication/discover-users`;
1248
+ // Use the user's core token for authentication
1249
+ const tpaApiKey = this.config.apiKey; // This may need to be updated if you store the core token elsewhere
1250
+ if (!tpaApiKey) {
1251
+ throw new Error('Core token (apiKey) is required for user discovery');
1252
+ }
1253
+ const body = {
1254
+ packageName: this.config.packageName,
1255
+ userId: this.userId,
1256
+ includeUserProfiles: includeProfiles
1257
+ };
1258
+ const response = await fetch(url, {
1259
+ method: 'POST',
1260
+ headers: {
1261
+ 'Authorization': `Bearer ${tpaApiKey}`,
1262
+ 'Content-Type': 'application/json'
1263
+ },
1264
+ body: JSON.stringify(body)
1265
+ });
1266
+ if (!response.ok) {
1267
+ const errorText = await response.text();
1268
+ throw new Error(`Failed to discover users: ${response.status} ${response.statusText} - ${errorText}`);
1269
+ }
1270
+ return await response.json();
1271
+ }
1272
+ /**
1273
+ * 🔍 Check if a specific user is currently active
1274
+ * @param userId - User ID to check for
1275
+ * @returns Promise that resolves with boolean indicating if user is active
1276
+ */
1277
+ async isUserActive(userId) {
1278
+ try {
1279
+ const userList = await this.discoverTpaUsers('', false);
1280
+ return userList.users.some((user) => user.userId === userId);
1281
+ }
1282
+ catch (error) {
1283
+ this.logger.error({ error, userId }, 'Error checking if user is active');
1284
+ return false;
1285
+ }
1286
+ }
1287
+ /**
1288
+ * 📊 Get user count for this TPA
1289
+ * @returns Promise that resolves with number of active users
1290
+ */
1291
+ async getUserCount(domain) {
1292
+ try {
1293
+ const userList = await this.discoverTpaUsers(domain, false);
1294
+ return userList.totalUsers;
1295
+ }
1296
+ catch (error) {
1297
+ this.logger.error(error, 'Error getting user count');
1298
+ return 0;
1299
+ }
1300
+ }
1301
+ /**
1302
+ * 📢 Send broadcast message to all users with same TPA active
1303
+ * @param payload - Message payload to send
1304
+ * @param roomId - Optional room ID for room-based messaging
1305
+ * @returns Promise that resolves when message is sent
1306
+ */
1307
+ async broadcastToTpaUsers(payload, roomId) {
1308
+ try {
1309
+ const messageId = this.generateMessageId();
1310
+ const message = {
1311
+ type: 'tpa_broadcast_message',
1312
+ packageName: this.config.packageName,
1313
+ sessionId: this.sessionId,
1314
+ payload,
1315
+ messageId,
1316
+ senderUserId: this.userId,
1317
+ timestamp: new Date()
1318
+ };
1319
+ this.send(message);
1320
+ }
1321
+ catch (error) {
1322
+ const errorMessage = error instanceof Error ? error.message : String(error);
1323
+ throw new Error(`Failed to broadcast message: ${errorMessage}`);
1324
+ }
1325
+ }
1326
+ /**
1327
+ * 📤 Send direct message to specific user
1328
+ * @param targetUserId - User ID to send message to
1329
+ * @param payload - Message payload to send
1330
+ * @returns Promise that resolves with success status
1331
+ */
1332
+ async sendDirectMessage(targetUserId, payload) {
1333
+ return new Promise((resolve, reject) => {
1334
+ try {
1335
+ const messageId = this.generateMessageId();
1336
+ // Store promise resolver
1337
+ this.pendingDirectMessages.set(messageId, { resolve, reject });
1338
+ const message = {
1339
+ type: 'tpa_direct_message',
1340
+ packageName: this.config.packageName,
1341
+ sessionId: this.sessionId,
1342
+ targetUserId,
1343
+ payload,
1344
+ messageId,
1345
+ senderUserId: this.userId,
1346
+ timestamp: new Date()
1347
+ };
1348
+ this.send(message);
1349
+ // Set timeout to avoid hanging promises
1350
+ const timeoutMs = 15000; // 15 seconds
1351
+ this.resources.setTimeout(() => {
1352
+ if (this.pendingDirectMessages.has(messageId)) {
1353
+ this.pendingDirectMessages.get(messageId).reject(new Error('Direct message timed out'));
1354
+ this.pendingDirectMessages.delete(messageId);
1355
+ }
1356
+ }, timeoutMs);
1357
+ }
1358
+ catch (error) {
1359
+ const errorMessage = error instanceof Error ? error.message : String(error);
1360
+ reject(new Error(`Failed to send direct message: ${errorMessage}`));
1361
+ }
1362
+ });
1363
+ }
1364
+ /**
1365
+ * 🏠 Join a communication room for group messaging
1366
+ * @param roomId - Room ID to join
1367
+ * @param roomConfig - Optional room configuration
1368
+ * @returns Promise that resolves when room is joined
1369
+ */
1370
+ async joinTpaRoom(roomId, roomConfig) {
1371
+ try {
1372
+ const message = {
1373
+ type: 'tpa_room_join',
1374
+ packageName: this.config.packageName,
1375
+ sessionId: this.sessionId,
1376
+ roomId,
1377
+ roomConfig,
1378
+ timestamp: new Date()
1379
+ };
1380
+ this.send(message);
1381
+ }
1382
+ catch (error) {
1383
+ const errorMessage = error instanceof Error ? error.message : String(error);
1384
+ throw new Error(`Failed to join room: ${errorMessage}`);
1385
+ }
1386
+ }
1387
+ /**
1388
+ * 🚪 Leave a communication room
1389
+ * @param roomId - Room ID to leave
1390
+ * @returns Promise that resolves when room is left
1391
+ */
1392
+ async leaveTpaRoom(roomId) {
1393
+ try {
1394
+ const message = {
1395
+ type: 'tpa_room_leave',
1396
+ packageName: this.config.packageName,
1397
+ sessionId: this.sessionId,
1398
+ roomId,
1399
+ timestamp: new Date()
1400
+ };
1401
+ this.send(message);
1402
+ }
1403
+ catch (error) {
1404
+ const errorMessage = error instanceof Error ? error.message : String(error);
1405
+ throw new Error(`Failed to leave room: ${errorMessage}`);
1406
+ }
1407
+ }
1408
+ /**
1409
+ * 📨 Listen for messages from other TPA users
1410
+ * @param handler - Function to handle incoming messages
1411
+ * @returns Cleanup function to remove the handler
1412
+ */
1413
+ onTpaMessage(handler) {
1414
+ this.tpaEvents.on('tpa_message_received', handler);
1415
+ return () => this.tpaEvents.off('tpa_message_received', handler);
1416
+ }
1417
+ /**
1418
+ * 👋 Listen for user join events
1419
+ * @param handler - Function to handle user join events
1420
+ * @returns Cleanup function to remove the handler
1421
+ */
1422
+ onTpaUserJoined(handler) {
1423
+ this.tpaEvents.on('tpa_user_joined', handler);
1424
+ return () => this.tpaEvents.off('tpa_user_joined', handler);
1425
+ }
1426
+ /**
1427
+ * 🚪 Listen for user leave events
1428
+ * @param handler - Function to handle user leave events
1429
+ * @returns Cleanup function to remove the handler
1430
+ */
1431
+ onTpaUserLeft(handler) {
1432
+ this.tpaEvents.on('tpa_user_left', handler);
1433
+ return () => this.tpaEvents.off('tpa_user_left', handler);
1434
+ }
1435
+ /**
1436
+ * 🏠 Listen for room update events
1437
+ * @param handler - Function to handle room updates
1438
+ * @returns Cleanup function to remove the handler
1439
+ */
1440
+ onTpaRoomUpdated(handler) {
1441
+ this.tpaEvents.on('tpa_room_updated', handler);
1442
+ return () => this.tpaEvents.off('tpa_room_updated', handler);
1443
+ }
1444
+ /**
1445
+ * 🔧 Generate unique message ID
1446
+ * @returns Unique message identifier
1447
+ */
1448
+ generateMessageId() {
1449
+ return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1450
+ }
1451
+ }
1452
+ exports.TpaSession = TpaSession;