@limrun/api 0.13.1 → 0.14.0
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/CHANGELOG.md +12 -0
- package/README.md +2 -2
- package/client.d.mts +2 -2
- package/client.d.mts.map +1 -1
- package/client.d.ts +2 -2
- package/client.d.ts.map +1 -1
- package/client.js +2 -2
- package/client.js.map +1 -1
- package/client.mjs +2 -2
- package/client.mjs.map +1 -1
- package/index.d.mts +1 -1
- package/index.d.mts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +3 -3
- package/index.js.map +1 -1
- package/index.mjs +1 -1
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +32 -0
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +32 -0
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +193 -87
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +193 -87
- package/instance-client.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +3 -3
- package/src/index.ts +1 -1
- package/src/instance-client.ts +269 -101
- package/src/tunnel.ts +262 -43
- package/src/version.ts +1 -1
- package/tunnel.d.mts +50 -7
- package/tunnel.d.mts.map +1 -1
- package/tunnel.d.ts +50 -7
- package/tunnel.d.ts.map +1 -1
- package/tunnel.js +195 -44
- package/tunnel.js.map +1 -1
- package/tunnel.mjs +195 -44
- package/tunnel.mjs.map +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
package/src/instance-client.ts
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
245
|
+
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
246
|
+
logger.error(`Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`);
|
|
247
|
+
updateConnectionState('disconnected');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
166
250
|
|
|
167
|
-
|
|
168
|
-
const request = screenshotRequests.get(screenshotMessage.id);
|
|
251
|
+
const currentDelay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
|
169
252
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
);
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
253
|
+
reconnectAttempts++;
|
|
254
|
+
logger.debug(`Scheduling reconnection attempt ${reconnectAttempts} in ${currentDelay}ms...`);
|
|
255
|
+
updateConnectionState('reconnecting');
|
|
176
256
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
189
|
-
|
|
263
|
+
const setupWebSocket = (): void => {
|
|
264
|
+
cleanup();
|
|
265
|
+
updateConnectionState('connecting');
|
|
190
266
|
|
|
191
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
logger.debug('
|
|
215
|
-
request.
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
229
|
-
logger.warn(`Received unexpected message type: ${message.type}`);
|
|
230
|
-
break;
|
|
231
|
-
}
|
|
232
|
-
});
|
|
350
|
+
});
|
|
233
351
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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(
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
screenshot,
|
|
339
|
-
disconnect,
|
|
340
|
-
startAdbTunnel,
|
|
341
|
-
sendAsset,
|
|
342
|
-
});
|
|
343
|
-
});
|
|
509
|
+
|
|
510
|
+
// Start the initial connection
|
|
511
|
+
setupWebSocket();
|
|
344
512
|
});
|
|
345
513
|
}
|