@limrun/api 0.5.2 → 0.7.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +27 -103
  2. package/client.d.mts +7 -6
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +7 -6
  5. package/client.d.ts.map +1 -1
  6. package/client.js +14 -8
  7. package/client.js.map +1 -1
  8. package/client.mjs +13 -7
  9. package/client.mjs.map +1 -1
  10. package/index.d.mts +0 -1
  11. package/index.d.ts +0 -1
  12. package/index.js +0 -2
  13. package/index.js.map +1 -1
  14. package/index.mjs +0 -1
  15. package/package.json +1 -4
  16. package/resources/android-instances-helpers.d.mts +4 -28
  17. package/resources/android-instances-helpers.d.mts.map +1 -1
  18. package/resources/android-instances-helpers.d.ts +4 -28
  19. package/resources/android-instances-helpers.d.ts.map +1 -1
  20. package/resources/android-instances-helpers.js +18 -124
  21. package/resources/android-instances-helpers.js.map +1 -1
  22. package/resources/android-instances-helpers.mjs +16 -120
  23. package/resources/android-instances-helpers.mjs.map +1 -1
  24. package/resources/android-instances.d.mts +4 -7
  25. package/resources/android-instances.d.mts.map +1 -1
  26. package/resources/android-instances.d.ts +4 -7
  27. package/resources/android-instances.d.ts.map +1 -1
  28. package/resources/android-instances.js.map +1 -1
  29. package/resources/android-instances.mjs.map +1 -1
  30. package/resources/index.d.mts +2 -1
  31. package/resources/index.d.mts.map +1 -1
  32. package/resources/index.d.ts +2 -1
  33. package/resources/index.d.ts.map +1 -1
  34. package/resources/index.js +2 -2
  35. package/resources/index.js.map +1 -1
  36. package/resources/index.mjs +1 -1
  37. package/resources/index.mjs.map +1 -1
  38. package/resources/instance-client.d.mts +57 -0
  39. package/resources/instance-client.d.mts.map +1 -0
  40. package/resources/instance-client.d.ts +57 -0
  41. package/resources/instance-client.d.ts.map +1 -0
  42. package/resources/instance-client.js +221 -0
  43. package/resources/instance-client.js.map +1 -0
  44. package/resources/instance-client.mjs +218 -0
  45. package/resources/instance-client.mjs.map +1 -0
  46. package/resources/tunnel.d.mts +25 -0
  47. package/resources/tunnel.d.mts.map +1 -0
  48. package/resources/tunnel.d.ts +25 -0
  49. package/resources/tunnel.d.ts.map +1 -0
  50. package/resources/tunnel.js +102 -0
  51. package/resources/tunnel.js.map +1 -0
  52. package/resources/tunnel.mjs +98 -0
  53. package/resources/tunnel.mjs.map +1 -0
  54. package/src/client.ts +19 -15
  55. package/src/index.ts +0 -2
  56. package/src/resources/android-instances-helpers.ts +25 -157
  57. package/src/resources/android-instances.ts +3 -8
  58. package/src/resources/index.ts +2 -2
  59. package/src/resources/instance-client.ts +350 -0
  60. package/src/resources/tunnel.ts +126 -0
  61. package/src/version.ts +1 -1
  62. package/version.d.mts +1 -1
  63. package/version.d.ts +1 -1
  64. package/version.js +1 -1
  65. package/version.mjs +1 -1
@@ -0,0 +1,350 @@
1
+ import { WebSocket, Data } from 'ws';
2
+ import { exec } from 'node:child_process';
3
+
4
+ import { startTcpTunnel } from './tunnel.js';
5
+ import type { Tunnel } from './tunnel.js';
6
+ export type { Tunnel } from './tunnel.js';
7
+ import { AndroidInstance } from './android-instances.js';
8
+
9
+ /**
10
+ * A client for interacting with a Limbar instance
11
+ */
12
+ export type InstanceClient = {
13
+ /**
14
+ * Take a screenshot of the current screen
15
+ * @returns A promise that resolves to the screenshot data
16
+ */
17
+ screenshot: () => Promise<ScreenshotData>;
18
+ /**
19
+ * Disconnect from the Limbar instance
20
+ */
21
+ disconnect: () => void;
22
+
23
+ /**
24
+ * Establish an ADB tunnel to the instance.
25
+ * Returns the local TCP port and a cleanup function.
26
+ */
27
+ startAdbTunnel: () => Promise<Tunnel>;
28
+ /**
29
+ * Send an asset URL to the instance. The instance will download the asset
30
+ * and process it (currently APK install is supported). Resolves on success,
31
+ * rejects with an Error on failure.
32
+ */
33
+ sendAsset: (url: string) => Promise<void>;
34
+ };
35
+
36
+ /**
37
+ * Controls the verbosity of logging in the client
38
+ */
39
+ export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
40
+
41
+ /**
42
+ * Configuration options for creating an Instance API client
43
+ */
44
+ export type InstanceClientOptions = {
45
+ /**
46
+ * Path to the ADB executable.
47
+ * @default 'adb'
48
+ */
49
+ adbPath?: string;
50
+ /**
51
+ * Controls logging verbosity
52
+ * @default 'info'
53
+ */
54
+ logLevel?: LogLevel;
55
+ };
56
+
57
+ type ScreenshotRequest = {
58
+ type: 'screenshot';
59
+ id: string;
60
+ };
61
+
62
+ type ScreenshotResponse = {
63
+ type: 'screenshot';
64
+ dataUri: string;
65
+ id: string;
66
+ };
67
+
68
+ type ScreenshotData = {
69
+ dataUri: string;
70
+ };
71
+
72
+ type ScreenshotErrorResponse = {
73
+ type: 'screenshotError';
74
+ message: string;
75
+ id: string;
76
+ };
77
+
78
+ type AssetRequest = {
79
+ type: 'asset';
80
+ url: string;
81
+ };
82
+
83
+ type AssetResultResponse = {
84
+ type: 'assetResult';
85
+ result: 'success' | 'failure' | string;
86
+ url: string;
87
+ message?: string;
88
+ };
89
+
90
+ type ServerMessage =
91
+ | ScreenshotResponse
92
+ | ScreenshotErrorResponse
93
+ | AssetResultResponse
94
+ | { type: string; [key: string]: unknown };
95
+
96
+ /**
97
+ * Creates a client for interacting with a Limbar instance
98
+ * @param options Configuration options including webrtcUrl, token and log level
99
+ * @returns An InstanceClient for controlling the instance
100
+ */
101
+ export async function createInstanceClient(
102
+ androidInstance: AndroidInstance,
103
+ options: InstanceClientOptions = {
104
+ adbPath: 'adb',
105
+ logLevel: 'info',
106
+ },
107
+ ): Promise<InstanceClient> {
108
+ const token = androidInstance.status.token;
109
+ const serverAddress = `${androidInstance.status.endpointWebSocketUrl}?token=${token}`;
110
+ const logLevel = options.logLevel ?? 'info';
111
+ let ws: WebSocket | undefined = undefined;
112
+
113
+ const screenshotRequests: Map<
114
+ string,
115
+ {
116
+ resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => void;
117
+ rejecter: (reason?: any) => void;
118
+ }
119
+ > = new Map();
120
+
121
+ const assetRequests: Map<
122
+ string,
123
+ {
124
+ resolver: (value: void | PromiseLike<void>) => void;
125
+ rejecter: (reason?: any) => void;
126
+ }
127
+ > = new Map();
128
+
129
+ // Logger functions
130
+ const logger = {
131
+ debug: (...args: any[]) => {
132
+ if (logLevel === 'debug') console.log(...args);
133
+ },
134
+ info: (...args: any[]) => {
135
+ if (logLevel === 'info' || logLevel === 'debug') console.log(...args);
136
+ },
137
+ warn: (...args: any[]) => {
138
+ if (logLevel === 'warn' || logLevel === 'info' || logLevel === 'debug') console.warn(...args);
139
+ },
140
+ error: (...args: any[]) => {
141
+ if (logLevel !== 'none') console.error(...args);
142
+ },
143
+ };
144
+
145
+ return new Promise<InstanceClient>((resolveConnection, rejectConnection) => {
146
+ logger.debug(`Attempting to connect to WebSocket server at ${serverAddress}...`);
147
+ ws = new WebSocket(serverAddress);
148
+ ws.on('message', (data: Data) => {
149
+ let message: ServerMessage;
150
+ try {
151
+ message = JSON.parse(data.toString());
152
+ } catch (e) {
153
+ logger.error({ data, error: e }, 'Failed to parse JSON message');
154
+ return;
155
+ }
156
+
157
+ switch (message.type) {
158
+ case 'screenshot': {
159
+ if (!('dataUri' in message) || typeof message.dataUri !== 'string' || !('id' in message)) {
160
+ logger.warn('Received invalid screenshot message:', message);
161
+ break;
162
+ }
163
+
164
+ const screenshotMessage = message as ScreenshotResponse;
165
+ const request = screenshotRequests.get(screenshotMessage.id);
166
+
167
+ if (!request) {
168
+ logger.warn(
169
+ `Received screenshot data for unknown or already handled session: ${screenshotMessage.id}`,
170
+ );
171
+ break;
172
+ }
173
+
174
+ logger.debug(`Received screenshot data URI for session ${screenshotMessage.id}.`);
175
+ request.resolver({ dataUri: screenshotMessage.dataUri });
176
+ screenshotRequests.delete(screenshotMessage.id);
177
+ break;
178
+ }
179
+ case 'screenshotError': {
180
+ if (!('message' in message) || !('id' in message)) {
181
+ logger.warn('Received invalid screenshot error message:', message);
182
+ break;
183
+ }
184
+
185
+ const errorMessage = message as ScreenshotErrorResponse;
186
+ const request = screenshotRequests.get(errorMessage.id);
187
+
188
+ if (!request) {
189
+ logger.warn(
190
+ `Received screenshot error for unknown or already handled session: ${errorMessage.id}`,
191
+ );
192
+ break;
193
+ }
194
+
195
+ logger.error(
196
+ `Server reported an error capturing screenshot for session ${errorMessage.id}:`,
197
+ errorMessage.message,
198
+ );
199
+ request.rejecter(new Error(errorMessage.message));
200
+ screenshotRequests.delete(errorMessage.id);
201
+ break;
202
+ }
203
+ case 'assetResult': {
204
+ logger.debug('Received assetResult:', message);
205
+ const request = assetRequests.get(message.url as string);
206
+ if (!request) {
207
+ logger.warn(`Received assetResult for unknown or already handled url: ${message.url}`);
208
+ break;
209
+ }
210
+ if (message.result === 'success') {
211
+ logger.debug('Asset result is success');
212
+ request.resolver();
213
+ assetRequests.delete(message.url as string);
214
+ break;
215
+ }
216
+ const errorMessage =
217
+ typeof message.message === 'string' && message.message ?
218
+ message.message
219
+ : `Asset processing failed: ${JSON.stringify(message)}`;
220
+ logger.debug('Asset result is failure', errorMessage);
221
+ request.rejecter(new Error(errorMessage));
222
+ assetRequests.delete(message.url as string);
223
+ break;
224
+ }
225
+ default:
226
+ logger.warn(`Received unexpected message type: ${message.type}`);
227
+ break;
228
+ }
229
+ });
230
+
231
+ ws.on('error', (err: Error) => {
232
+ logger.error('WebSocket error:', err.message);
233
+ if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
234
+ rejectConnection(err);
235
+ }
236
+ screenshotRequests.forEach((request) => request.rejecter(err));
237
+ });
238
+
239
+ ws.on('close', () => {
240
+ logger.debug('Disconnected from server.');
241
+ screenshotRequests.forEach((request) => request.rejecter('Disconnected from server'));
242
+ });
243
+
244
+ const screenshot = async (): Promise<ScreenshotData> => {
245
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
246
+ return Promise.reject(new Error('WebSocket is not connected or connection is not open.'));
247
+ }
248
+
249
+ const id = 'ts-client-' + Date.now();
250
+ const screenshotRequest: ScreenshotRequest = {
251
+ type: 'screenshot',
252
+ id,
253
+ };
254
+
255
+ return new Promise<ScreenshotData>((resolve, reject) => {
256
+ logger.debug('Sending screenshot request:', screenshotRequest);
257
+ ws!.send(JSON.stringify(screenshotRequest), (err?: Error) => {
258
+ if (err) {
259
+ logger.error('Failed to send screenshot request:', err);
260
+ reject(err);
261
+ }
262
+ });
263
+
264
+ const timeout = setTimeout(() => {
265
+ if (screenshotRequests.has(id)) {
266
+ logger.error(`Screenshot request timed out for session ${id}`);
267
+ screenshotRequests.get(id)?.rejecter(new Error('Screenshot request timed out'));
268
+ screenshotRequests.delete(id);
269
+ }
270
+ }, 30000);
271
+ screenshotRequests.set(id, {
272
+ resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => {
273
+ clearTimeout(timeout);
274
+ resolve(value);
275
+ screenshotRequests.delete(id);
276
+ },
277
+ rejecter: (reason?: any) => {
278
+ clearTimeout(timeout);
279
+ reject(reason);
280
+ screenshotRequests.delete(id);
281
+ },
282
+ });
283
+ });
284
+ };
285
+
286
+ const disconnect = (): void => {
287
+ if (ws) {
288
+ logger.debug('Closing WebSocket connection.');
289
+ ws.close();
290
+ }
291
+ screenshotRequests.forEach((request) => request.rejecter('Websocket connection closed'));
292
+ };
293
+
294
+ /**
295
+ * Opens a WebSocket TCP proxy for the ADB port and connects the local adb
296
+ * client to it.
297
+ */
298
+ const startAdbTunnel = async (): Promise<Tunnel> => {
299
+ if (!androidInstance.status.adbWebSocketUrl) {
300
+ return Promise.reject(new Error('ADB WebSocket URL is not set'));
301
+ }
302
+ const { address, close } = await startTcpTunnel(
303
+ androidInstance.status.adbWebSocketUrl,
304
+ token,
305
+ '127.0.0.1',
306
+ 0,
307
+ );
308
+ try {
309
+ await new Promise<void>((resolve, reject) => {
310
+ exec(`${options.adbPath ?? 'adb'} connect ${address.address}:${address.port}`, (err) => {
311
+ if (err) return reject(err);
312
+ resolve();
313
+ });
314
+ });
315
+ logger.debug(`ADB connected on ${address.address}`);
316
+ } catch (err) {
317
+ close();
318
+ throw err;
319
+ }
320
+ return { address, close };
321
+ };
322
+
323
+ const sendAsset = async (url: string): Promise<void> => {
324
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
325
+ return Promise.reject(new Error('WebSocket is not connected or connection is not open.'));
326
+ }
327
+ const assetRequest: AssetRequest = {
328
+ type: 'asset',
329
+ url,
330
+ };
331
+ ws.send(JSON.stringify(assetRequest), (err?: Error) => {
332
+ if (err) {
333
+ logger.error('Failed to send asset request:', err);
334
+ }
335
+ });
336
+ return new Promise<void>((resolve, reject) => {
337
+ assetRequests.set(url, { resolver: resolve, rejecter: reject });
338
+ });
339
+ };
340
+ ws.on('open', () => {
341
+ logger.debug(`Connected to ${serverAddress}`);
342
+ resolveConnection({
343
+ screenshot,
344
+ disconnect,
345
+ startAdbTunnel,
346
+ sendAsset,
347
+ });
348
+ });
349
+ });
350
+ }
@@ -0,0 +1,126 @@
1
+ import * as net from 'net';
2
+ import { WebSocket } from 'ws';
3
+
4
+ export interface Tunnel {
5
+ address: {
6
+ address: string;
7
+ port: number;
8
+ };
9
+ close: () => void;
10
+ }
11
+
12
+ /**
13
+ * Starts a one-shot TCP → WebSocket proxy.
14
+ *
15
+ * The function creates a local TCP server that listens on an ephemeral port on
16
+ * 127.0.0.1. As soon as the **first** TCP client connects the server stops
17
+ * accepting further connections and forwards all traffic between that client
18
+ * and `remoteURL` through an authenticated WebSocket. If you need to proxy
19
+ * more than one TCP connection, call `startTcpTunnel` again to create a new
20
+ * proxy instance.
21
+ *
22
+ * @param remoteURL Remote WebSocket endpoint (e.g. wss://example.com/instance)
23
+ * @param token Bearer token sent as `Authorization` header
24
+ * @param hostname Optional IP address to listen on. Default is 127.0.0.1
25
+ * @param port Optional port number to listen on. Default is to ask Node.js
26
+ * to find an available non-privileged port.
27
+ */
28
+ export async function startTcpTunnel(
29
+ remoteURL: string,
30
+ token: string,
31
+ hostname: string,
32
+ port: number,
33
+ ): Promise<Tunnel> {
34
+ return new Promise((resolve, reject) => {
35
+ const server = net.createServer();
36
+
37
+ let ws: WebSocket | undefined;
38
+ let pingInterval: NodeJS.Timeout | undefined;
39
+
40
+ // close helper
41
+ const close = () => {
42
+ if (pingInterval) {
43
+ clearInterval(pingInterval);
44
+ pingInterval = undefined;
45
+ }
46
+ if (ws && ws.readyState === WebSocket.OPEN) {
47
+ ws.close(1000, 'close');
48
+ }
49
+ if (server.listening) {
50
+ server.close();
51
+ }
52
+ };
53
+
54
+ // No AbortController support – proxy can be closed via the returned handle
55
+
56
+ // TCP server error
57
+ server.once('error', (err) => {
58
+ close();
59
+ reject(new Error(`TCP server error: ${err.message}`));
60
+ });
61
+
62
+ // Listening
63
+ server.once('listening', () => {
64
+ const address = server.address();
65
+ if (!address || typeof address === 'string') {
66
+ close();
67
+ return reject(new Error('Failed to obtain listening address'));
68
+ }
69
+ resolve({ address, close });
70
+ });
71
+
72
+ // On first TCP connection
73
+ server.on('connection', (tcpSocket) => {
74
+ // Single-connection proxy
75
+ server.close();
76
+
77
+ ws = new WebSocket(remoteURL, {
78
+ headers: { Authorization: `Bearer ${token}` },
79
+ perMessageDeflate: false,
80
+ });
81
+
82
+ // WebSocket error
83
+ ws.once('error', (err: any) => {
84
+ console.error('WebSocket error:', err);
85
+ tcpSocket.destroy();
86
+ close();
87
+ });
88
+
89
+ ws.once('open', () => {
90
+ const socket = ws as WebSocket; // non-undefined after open
91
+
92
+ pingInterval = setInterval(() => {
93
+ if (socket.readyState === WebSocket.OPEN) {
94
+ (socket as any).ping();
95
+ }
96
+ }, 30_000);
97
+
98
+ // TCP → WS
99
+ tcpSocket.on('data', (chunk) => {
100
+ if (socket.readyState === WebSocket.OPEN) {
101
+ socket.send(chunk);
102
+ }
103
+ });
104
+
105
+ // WS → TCP
106
+ socket.on('message', (data: any) => {
107
+ if (!tcpSocket.destroyed) {
108
+ tcpSocket.write(data as Buffer);
109
+ }
110
+ });
111
+ });
112
+
113
+ // Mutual close
114
+ tcpSocket.on('close', close);
115
+ tcpSocket.on('error', (err: any) => {
116
+ console.error('TCP socket error:', err);
117
+ close();
118
+ });
119
+
120
+ ws.on('close', () => tcpSocket.destroy());
121
+ });
122
+
123
+ // Start listening
124
+ server.listen(port, hostname);
125
+ });
126
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.5.2'; // x-release-please-version
1
+ export const VERSION = '0.7.0'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.5.2";
1
+ export declare const VERSION = "0.7.0";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.5.2";
1
+ export declare const VERSION = "0.7.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '0.5.2'; // x-release-please-version
4
+ exports.VERSION = '0.7.0'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.5.2'; // x-release-please-version
1
+ export const VERSION = '0.7.0'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map