@limrun/api 0.13.1 → 0.13.2

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.
@@ -4,6 +4,16 @@ import { exec } from 'node:child_process';
4
4
  import { startTcpTunnel } from './tunnel';
5
5
  import type { Tunnel } from './tunnel';
6
6
 
7
+ /**
8
+ * Connection state of the instance client
9
+ */
10
+ export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
11
+
12
+ /**
13
+ * Callback function for connection state changes
14
+ */
15
+ export type ConnectionStateCallback = (state: ConnectionState) => void;
16
+
7
17
  /**
8
18
  * A client for interacting with a Limbar instance
9
19
  */
@@ -29,6 +39,17 @@ export type InstanceClient = {
29
39
  * rejects with an Error on failure.
30
40
  */
31
41
  sendAsset: (url: string) => Promise<void>;
42
+
43
+ /**
44
+ * Get current connection state
45
+ */
46
+ getConnectionState: () => ConnectionState;
47
+
48
+ /**
49
+ * Register callback for connection state changes
50
+ * @returns A function to unregister the callback
51
+ */
52
+ onConnectionStateChange: (callback: ConnectionStateCallback) => () => void;
32
53
  };
33
54
 
34
55
  /**
@@ -62,6 +83,21 @@ export type InstanceClientOptions = {
62
83
  * @default 'info'
63
84
  */
64
85
  logLevel?: LogLevel;
86
+ /**
87
+ * Maximum number of reconnection attempts
88
+ * @default 6
89
+ */
90
+ maxReconnectAttempts?: number;
91
+ /**
92
+ * Initial reconnection delay in milliseconds
93
+ * @default 1000
94
+ */
95
+ reconnectDelay?: number;
96
+ /**
97
+ * Maximum reconnection delay in milliseconds
98
+ * @default 30000
99
+ */
100
+ maxReconnectDelay?: number;
65
101
  };
66
102
 
67
103
  type ScreenshotRequest = {
@@ -111,7 +147,15 @@ type ServerMessage =
111
147
  export async function createInstanceClient(options: InstanceClientOptions): Promise<InstanceClient> {
112
148
  const serverAddress = `${options.endpointUrl}?token=${options.token}`;
113
149
  const logLevel = options.logLevel ?? 'info';
150
+ const maxReconnectAttempts = options.maxReconnectAttempts ?? 6;
151
+ const reconnectDelay = options.reconnectDelay ?? 1000;
152
+ const maxReconnectDelay = options.maxReconnectDelay ?? 30000;
153
+
114
154
  let ws: WebSocket | undefined = undefined;
155
+ let connectionState: ConnectionState = 'connecting';
156
+ let reconnectAttempts = 0;
157
+ let reconnectTimeout: NodeJS.Timeout | undefined;
158
+ let intentionalDisconnect = false;
115
159
 
116
160
  const screenshotRequests: Map<
117
161
  string,
@@ -129,6 +173,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
129
173
  }
130
174
  > = new Map();
131
175
 
176
+ const stateChangeCallbacks: Set<ConnectionStateCallback> = new Set();
177
+
132
178
  // Logger functions
133
179
  const logger = {
134
180
  debug: (...args: any[]) => {
@@ -145,104 +191,213 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
145
191
  },
146
192
  };
147
193
 
194
+ const updateConnectionState = (newState: ConnectionState): void => {
195
+ if (connectionState !== newState) {
196
+ connectionState = newState;
197
+ logger.debug(`Connection state changed to: ${newState}`);
198
+ stateChangeCallbacks.forEach((callback) => {
199
+ try {
200
+ callback(newState);
201
+ } catch (err) {
202
+ logger.error('Error in connection state callback:', err);
203
+ }
204
+ });
205
+ }
206
+ };
207
+
208
+ const failPendingRequests = (reason: string): void => {
209
+ screenshotRequests.forEach((request) => request.rejecter(new Error(reason)));
210
+ screenshotRequests.clear();
211
+ assetRequests.forEach((request) => request.rejecter(new Error(reason)));
212
+ assetRequests.clear();
213
+ };
214
+
215
+ const cleanup = (): void => {
216
+ if (reconnectTimeout) {
217
+ clearTimeout(reconnectTimeout);
218
+ reconnectTimeout = undefined;
219
+ }
220
+ if (pingInterval) {
221
+ clearInterval(pingInterval);
222
+ pingInterval = undefined;
223
+ }
224
+ if (ws) {
225
+ ws.removeAllListeners();
226
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
227
+ ws.close();
228
+ }
229
+ ws = undefined;
230
+ }
231
+ };
232
+
233
+ let pingInterval: NodeJS.Timeout | undefined;
234
+
148
235
  return new Promise<InstanceClient>((resolveConnection, rejectConnection) => {
149
- logger.debug(`Attempting to connect to WebSocket server at ${serverAddress}...`);
150
- ws = new WebSocket(serverAddress);
151
- ws.on('message', (data: Data) => {
152
- let message: ServerMessage;
153
- try {
154
- message = JSON.parse(data.toString());
155
- } catch (e) {
156
- logger.error({ data, error: e }, 'Failed to parse JSON message');
236
+ let hasResolved = false;
237
+
238
+ // Reconnection logic with exponential backoff
239
+ const scheduleReconnect = (): void => {
240
+ if (intentionalDisconnect) {
241
+ logger.debug('Skipping reconnection (intentional disconnect)');
157
242
  return;
158
243
  }
159
244
 
160
- switch (message.type) {
161
- case 'screenshot': {
162
- if (!('dataUri' in message) || typeof message.dataUri !== 'string' || !('id' in message)) {
163
- logger.warn('Received invalid screenshot message:', message);
164
- break;
165
- }
245
+ if (reconnectAttempts >= maxReconnectAttempts) {
246
+ logger.error(`Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`);
247
+ updateConnectionState('disconnected');
248
+ return;
249
+ }
166
250
 
167
- const screenshotMessage = message as ScreenshotResponse;
168
- const request = screenshotRequests.get(screenshotMessage.id);
251
+ const currentDelay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
169
252
 
170
- if (!request) {
171
- logger.warn(
172
- `Received screenshot data for unknown or already handled session: ${screenshotMessage.id}`,
173
- );
174
- break;
175
- }
253
+ reconnectAttempts++;
254
+ logger.debug(`Scheduling reconnection attempt ${reconnectAttempts} in ${currentDelay}ms...`);
255
+ updateConnectionState('reconnecting');
176
256
 
177
- logger.debug(`Received screenshot data URI for session ${screenshotMessage.id}.`);
178
- request.resolver({ dataUri: screenshotMessage.dataUri });
179
- screenshotRequests.delete(screenshotMessage.id);
180
- break;
181
- }
182
- case 'screenshotError': {
183
- if (!('message' in message) || !('id' in message)) {
184
- logger.warn('Received invalid screenshot error message:', message);
185
- break;
186
- }
257
+ reconnectTimeout = setTimeout(() => {
258
+ logger.debug(`Attempting to reconnect (attempt ${reconnectAttempts})...`);
259
+ setupWebSocket();
260
+ }, currentDelay);
261
+ };
187
262
 
188
- const errorMessage = message as ScreenshotErrorResponse;
189
- const request = screenshotRequests.get(errorMessage.id);
263
+ const setupWebSocket = (): void => {
264
+ cleanup();
265
+ updateConnectionState('connecting');
190
266
 
191
- if (!request) {
192
- logger.warn(
193
- `Received screenshot error for unknown or already handled session: ${errorMessage.id}`,
194
- );
195
- break;
196
- }
267
+ ws = new WebSocket(serverAddress);
197
268
 
198
- logger.error(
199
- `Server reported an error capturing screenshot for session ${errorMessage.id}:`,
200
- errorMessage.message,
201
- );
202
- request.rejecter(new Error(errorMessage.message));
203
- screenshotRequests.delete(errorMessage.id);
204
- break;
269
+ ws.on('message', (data: Data) => {
270
+ let message: ServerMessage;
271
+ try {
272
+ message = JSON.parse(data.toString());
273
+ } catch (e) {
274
+ logger.error({ data, error: e }, 'Failed to parse JSON message');
275
+ return;
205
276
  }
206
- case 'assetResult': {
207
- logger.debug('Received assetResult:', message);
208
- const request = assetRequests.get(message.url as string);
209
- if (!request) {
210
- logger.warn(`Received assetResult for unknown or already handled url: ${message.url}`);
277
+
278
+ switch (message.type) {
279
+ case 'screenshot': {
280
+ if (!('dataUri' in message) || typeof message.dataUri !== 'string' || !('id' in message)) {
281
+ logger.warn('Received invalid screenshot message:', message);
282
+ break;
283
+ }
284
+
285
+ const screenshotMessage = message as ScreenshotResponse;
286
+ const request = screenshotRequests.get(screenshotMessage.id);
287
+
288
+ if (!request) {
289
+ logger.warn(
290
+ `Received screenshot data for unknown or already handled session: ${screenshotMessage.id}`,
291
+ );
292
+ break;
293
+ }
294
+
295
+ logger.debug(`Received screenshot data URI for session ${screenshotMessage.id}.`);
296
+ request.resolver({ dataUri: screenshotMessage.dataUri });
297
+ screenshotRequests.delete(screenshotMessage.id);
298
+ break;
299
+ }
300
+ case 'screenshotError': {
301
+ if (!('message' in message) || !('id' in message)) {
302
+ logger.warn('Received invalid screenshot error message:', message);
303
+ break;
304
+ }
305
+
306
+ const errorMessage = message as ScreenshotErrorResponse;
307
+ const request = screenshotRequests.get(errorMessage.id);
308
+
309
+ if (!request) {
310
+ logger.warn(
311
+ `Received screenshot error for unknown or already handled session: ${errorMessage.id}`,
312
+ );
313
+ break;
314
+ }
315
+
316
+ logger.error(
317
+ `Server reported an error capturing screenshot for session ${errorMessage.id}:`,
318
+ errorMessage.message,
319
+ );
320
+ request.rejecter(new Error(errorMessage.message));
321
+ screenshotRequests.delete(errorMessage.id);
211
322
  break;
212
323
  }
213
- if (message.result === 'success') {
214
- logger.debug('Asset result is success');
215
- request.resolver();
324
+ case 'assetResult': {
325
+ logger.debug('Received assetResult:', message);
326
+ const request = assetRequests.get(message.url as string);
327
+ if (!request) {
328
+ logger.warn(`Received assetResult for unknown or already handled url: ${message.url}`);
329
+ break;
330
+ }
331
+ if (message.result === 'success') {
332
+ logger.debug('Asset result is success');
333
+ request.resolver();
334
+ assetRequests.delete(message.url as string);
335
+ break;
336
+ }
337
+ const errorMessage =
338
+ typeof message.message === 'string' && message.message ?
339
+ message.message
340
+ : `Asset processing failed: ${JSON.stringify(message)}`;
341
+ logger.debug('Asset result is failure', errorMessage);
342
+ request.rejecter(new Error(errorMessage));
216
343
  assetRequests.delete(message.url as string);
217
344
  break;
218
345
  }
219
- const errorMessage =
220
- typeof message.message === 'string' && message.message ?
221
- message.message
222
- : `Asset processing failed: ${JSON.stringify(message)}`;
223
- logger.debug('Asset result is failure', errorMessage);
224
- request.rejecter(new Error(errorMessage));
225
- assetRequests.delete(message.url as string);
226
- break;
346
+ default:
347
+ logger.warn(`Received unexpected message type: ${message.type}`);
348
+ break;
227
349
  }
228
- default:
229
- logger.warn(`Received unexpected message type: ${message.type}`);
230
- break;
231
- }
232
- });
350
+ });
233
351
 
234
- ws.on('error', (err: Error) => {
235
- logger.error('WebSocket error:', err.message);
236
- if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
237
- rejectConnection(err);
238
- }
239
- screenshotRequests.forEach((request) => request.rejecter(err));
240
- });
352
+ ws.on('error', (err: Error) => {
353
+ logger.error('WebSocket error:', err.message);
354
+ if (!hasResolved && (ws?.readyState === WebSocket.CONNECTING || ws?.readyState === WebSocket.OPEN)) {
355
+ rejectConnection(err);
356
+ }
357
+ });
358
+
359
+ ws.on('close', () => {
360
+ if (pingInterval) {
361
+ clearInterval(pingInterval);
362
+ pingInterval = undefined;
363
+ }
364
+
365
+ const shouldReconnect = !intentionalDisconnect && connectionState !== 'disconnected';
366
+ updateConnectionState('disconnected');
367
+
368
+ logger.debug('Disconnected from server.');
241
369
 
242
- ws.on('close', () => {
243
- logger.debug('Disconnected from server.');
244
- screenshotRequests.forEach((request) => request.rejecter('Disconnected from server'));
245
- });
370
+ failPendingRequests('Connection closed');
371
+
372
+ if (shouldReconnect) {
373
+ scheduleReconnect();
374
+ }
375
+ });
376
+
377
+ ws.on('open', () => {
378
+ logger.debug(`Connected to ${serverAddress}`);
379
+ reconnectAttempts = 0;
380
+ updateConnectionState('connected');
381
+
382
+ pingInterval = setInterval(() => {
383
+ if (ws && ws.readyState === WebSocket.OPEN) {
384
+ (ws as any).ping();
385
+ }
386
+ }, 30_000);
387
+
388
+ if (!hasResolved) {
389
+ hasResolved = true;
390
+ resolveConnection({
391
+ screenshot,
392
+ disconnect,
393
+ startAdbTunnel,
394
+ sendAsset,
395
+ getConnectionState,
396
+ onConnectionStateChange,
397
+ });
398
+ }
399
+ });
400
+ };
246
401
 
247
402
  const screenshot = async (): Promise<ScreenshotData> => {
248
403
  if (!ws || ws.readyState !== WebSocket.OPEN) {
@@ -287,11 +442,22 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
287
442
  };
288
443
 
289
444
  const disconnect = (): void => {
290
- if (ws) {
291
- logger.debug('Closing WebSocket connection.');
292
- ws.close();
293
- }
294
- screenshotRequests.forEach((request) => request.rejecter('Websocket connection closed'));
445
+ intentionalDisconnect = true;
446
+ cleanup();
447
+ updateConnectionState('disconnected');
448
+ failPendingRequests('Intentional disconnect');
449
+ logger.debug('Intentionally disconnected from WebSocket.');
450
+ };
451
+
452
+ const getConnectionState = (): ConnectionState => {
453
+ return connectionState;
454
+ };
455
+
456
+ const onConnectionStateChange = (callback: ConnectionStateCallback): (() => void) => {
457
+ stateChangeCallbacks.add(callback);
458
+ return () => {
459
+ stateChangeCallbacks.delete(callback);
460
+ };
295
461
  };
296
462
 
297
463
  /**
@@ -299,20 +465,28 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
299
465
  * client to it.
300
466
  */
301
467
  const startAdbTunnel = async (): Promise<Tunnel> => {
302
- const { address, close } = await startTcpTunnel(options.adbUrl, options.token, '127.0.0.1', 0);
468
+ const tunnel = await startTcpTunnel(options.adbUrl, options.token, '127.0.0.1', 0, {
469
+ maxReconnectAttempts,
470
+ reconnectDelay,
471
+ maxReconnectDelay,
472
+ logLevel,
473
+ });
303
474
  try {
304
475
  await new Promise<void>((resolve, reject) => {
305
- exec(`${options.adbPath ?? 'adb'} connect ${address.address}:${address.port}`, (err) => {
306
- if (err) return reject(err);
307
- resolve();
308
- });
476
+ exec(
477
+ `${options.adbPath ?? 'adb'} connect ${tunnel.address.address}:${tunnel.address.port}`,
478
+ (err) => {
479
+ if (err) return reject(err);
480
+ resolve();
481
+ },
482
+ );
309
483
  });
310
- logger.debug(`ADB connected on ${address.address}`);
484
+ logger.debug(`ADB connected on ${tunnel.address.address}`);
311
485
  } catch (err) {
312
- close();
486
+ tunnel.close();
313
487
  throw err;
314
488
  }
315
- return { address, close };
489
+ return tunnel;
316
490
  };
317
491
 
318
492
  const sendAsset = async (url: string): Promise<void> => {
@@ -332,14 +506,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
332
506
  assetRequests.set(url, { resolver: resolve, rejecter: reject });
333
507
  });
334
508
  };
335
- ws.on('open', () => {
336
- logger.debug(`Connected to ${serverAddress}`);
337
- resolveConnection({
338
- screenshot,
339
- disconnect,
340
- startAdbTunnel,
341
- sendAsset,
342
- });
343
- });
509
+
510
+ // Start the initial connection
511
+ setupWebSocket();
344
512
  });
345
513
  }