@limrun/api 0.16.1 → 0.17.0-rc.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +0 -17
  2. package/client.d.mts +1 -1
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +1 -1
  5. package/client.d.ts.map +1 -1
  6. package/index.d.mts +1 -0
  7. package/index.d.mts.map +1 -1
  8. package/index.d.ts +1 -0
  9. package/index.d.ts.map +1 -1
  10. package/index.js +2 -1
  11. package/index.js.map +1 -1
  12. package/index.mjs +1 -0
  13. package/index.mjs.map +1 -1
  14. package/instance-client.d.mts.map +1 -1
  15. package/instance-client.d.ts.map +1 -1
  16. package/instance-client.js +8 -24
  17. package/instance-client.js.map +1 -1
  18. package/instance-client.mjs +9 -25
  19. package/instance-client.mjs.map +1 -1
  20. package/internal/tslib.js +4 -0
  21. package/ios-client.d.mts +207 -0
  22. package/ios-client.d.mts.map +1 -0
  23. package/ios-client.d.ts +207 -0
  24. package/ios-client.d.ts.map +1 -0
  25. package/ios-client.js +521 -0
  26. package/ios-client.js.map +1 -0
  27. package/ios-client.mjs +515 -0
  28. package/ios-client.mjs.map +1 -0
  29. package/package.json +11 -1
  30. package/src/client.ts +1 -1
  31. package/src/index.ts +1 -0
  32. package/src/instance-client.ts +9 -30
  33. package/src/ios-client.ts +788 -0
  34. package/src/tunnel.ts +6 -48
  35. package/src/version.ts +1 -1
  36. package/tunnel.d.mts +0 -1
  37. package/tunnel.d.mts.map +1 -1
  38. package/tunnel.d.ts +0 -1
  39. package/tunnel.d.ts.map +1 -1
  40. package/tunnel.js +6 -44
  41. package/tunnel.js.map +1 -1
  42. package/tunnel.mjs +6 -42
  43. package/tunnel.mjs.map +1 -1
  44. package/version.d.mts +1 -1
  45. package/version.d.mts.map +1 -1
  46. package/version.d.ts +1 -1
  47. package/version.d.ts.map +1 -1
  48. package/version.js +1 -1
  49. package/version.js.map +1 -1
  50. package/version.mjs +1 -1
  51. package/version.mjs.map +1 -1
@@ -0,0 +1,788 @@
1
+ import { WebSocket, Data } from 'ws';
2
+ import fs from 'fs';
3
+ import { EventEmitter } from 'events';
4
+
5
+ /**
6
+ * Connection state of the instance client
7
+ */
8
+ export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
9
+
10
+ /**
11
+ * Callback function for connection state changes
12
+ */
13
+ export type ConnectionStateCallback = (state: ConnectionState) => void;
14
+
15
+ /**
16
+ * Events emitted by a simctl execution
17
+ */
18
+ export interface SimctlExecutionEvents {
19
+ stdout: (data: Buffer) => void;
20
+ stderr: (data: Buffer) => void;
21
+ 'line-stdout': (line: string) => void;
22
+ 'line-stderr': (line: string) => void;
23
+ exit: (code: number) => void;
24
+ error: (error: Error) => void;
25
+ }
26
+
27
+ /**
28
+ * A client for interacting with a Limrun iOS instance
29
+ */
30
+ export type InstanceClient = {
31
+ /**
32
+ * Take a screenshot of the current screen
33
+ * @returns A promise that resolves to the screenshot data
34
+ */
35
+ screenshot: () => Promise<ScreenshotData>;
36
+
37
+ /**
38
+ * Disconnect from the Limrun instance
39
+ */
40
+ disconnect: () => void;
41
+
42
+ /**
43
+ * Get current connection state
44
+ */
45
+ getConnectionState: () => ConnectionState;
46
+
47
+ /**
48
+ * Register callback for connection state changes
49
+ * @returns A function to unregister the callback
50
+ */
51
+ onConnectionStateChange: (callback: ConnectionStateCallback) => () => void;
52
+
53
+ /**
54
+ * Run `simctl` command targeting the instance with given arguments.
55
+ * Returns an EventEmitter that streams stdout, stderr, and exit events.
56
+ *
57
+ * @param args Arguments to pass to simctl
58
+ * @returns A SimctlExecution handle for listening to command output
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const execution = client.simctl(['boot', 'device-id']);
63
+ *
64
+ * // Listen to raw data
65
+ * execution.on('stdout', (data) => {
66
+ * console.log('stdout:', data.toString());
67
+ * });
68
+ *
69
+ * // Or listen line-by-line
70
+ * execution.on('line-stdout', (line) => {
71
+ * console.log('Line:', line);
72
+ * });
73
+ *
74
+ * execution.on('line-stderr', (line) => {
75
+ * console.error('Error:', line);
76
+ * });
77
+ *
78
+ * execution.on('exit', (code) => {
79
+ * console.log('Process exited with code:', code);
80
+ * });
81
+ *
82
+ * // Or wait for completion
83
+ * const result = await execution.wait();
84
+ * console.log('Exit code:', result.code);
85
+ * console.log('Full stdout:', result.stdout.toString());
86
+ * ```
87
+ */
88
+ simctl: (args: string[]) => SimctlExecution;
89
+
90
+ /**
91
+ * Copy a file to the sandbox of the simulator. Returns the path of the file that can be used in simctl commands.
92
+ * @param name The name of the file in the sandbox of the simulator.
93
+ * @param path The path of the file to copy to the sandbox of the simulator.
94
+ * @returns A promise that resolves to the path of the file that can be used in simctl commands.
95
+ */
96
+ cp: (name: string, path: string) => Promise<string>;
97
+ };
98
+
99
+ /**
100
+ * Controls the verbosity of logging in the client
101
+ */
102
+ export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
103
+
104
+ /**
105
+ * Configuration options for creating an iOS client
106
+ */
107
+ export type InstanceClientOptions = {
108
+ /**
109
+ * The API URL for the instance.
110
+ */
111
+ apiUrl: string;
112
+ /**
113
+ * The token to use for authentication.
114
+ */
115
+ token: string;
116
+ /**
117
+ * Controls logging verbosity
118
+ * @default 'info'
119
+ */
120
+ logLevel?: LogLevel;
121
+ /**
122
+ * Maximum number of reconnection attempts
123
+ * @default 6
124
+ */
125
+ maxReconnectAttempts?: number;
126
+ /**
127
+ * Initial reconnection delay in milliseconds
128
+ * @default 1000
129
+ */
130
+ reconnectDelay?: number;
131
+ /**
132
+ * Maximum reconnection delay in milliseconds
133
+ * @default 30000
134
+ */
135
+ maxReconnectDelay?: number;
136
+ };
137
+
138
+ type ScreenshotRequest = {
139
+ type: 'screenshot';
140
+ id: string;
141
+ };
142
+
143
+ type ScreenshotResponse = {
144
+ type: 'screenshot';
145
+ dataUri: string;
146
+ id: string;
147
+ };
148
+
149
+ type ScreenshotData = {
150
+ dataUri: string;
151
+ };
152
+
153
+ type ScreenshotErrorResponse = {
154
+ type: 'screenshotError';
155
+ message: string;
156
+ id: string;
157
+ };
158
+
159
+ type SimctlRequest = {
160
+ type: 'simctl';
161
+ id: string;
162
+ args: string[];
163
+ };
164
+
165
+ type SimctlStreamResponse = {
166
+ type: 'simctlStream';
167
+ id: string;
168
+ stdout?: string; // base64 encoded
169
+ stderr?: string; // base64 encoded
170
+ exitCode?: number;
171
+ };
172
+
173
+ type SimctlErrorResponse = {
174
+ type: 'simctlError';
175
+ id: string;
176
+ message: string;
177
+ };
178
+
179
+ type ServerMessage =
180
+ | ScreenshotResponse
181
+ | ScreenshotErrorResponse
182
+ | SimctlStreamResponse
183
+ | SimctlErrorResponse
184
+ | { type: string; [key: string]: unknown };
185
+
186
+ /**
187
+ * Handle for a running simctl command execution.
188
+ *
189
+ * This class extends EventEmitter and provides streaming access to command output.
190
+ * Methods starting with underscore (_) are internal and should not be called directly.
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * const execution = client.simctl(['boot', 'device-id']);
195
+ *
196
+ * // Listen to raw output
197
+ * execution.on('stdout', (data) => console.log(data.toString()));
198
+ *
199
+ * // Or listen line-by-line (more convenient for most use cases)
200
+ * execution.on('line-stdout', (line) => console.log('Output:', line));
201
+ * execution.on('line-stderr', (line) => console.error('Error:', line));
202
+ *
203
+ * execution.on('exit', (code) => console.log('Exit code:', code));
204
+ * ```
205
+ */
206
+ export class SimctlExecution extends EventEmitter {
207
+ private stdoutChunks: Buffer[] = [];
208
+ private stderrChunks: Buffer[] = [];
209
+ private stdoutLineBuffer = '';
210
+ private stderrLineBuffer = '';
211
+ private exitCodeValue: number | null = null;
212
+ private completed = false;
213
+ private waitPromise: Promise<{ code: number; stdout: Buffer; stderr: Buffer }> | null = null;
214
+ private stopCallback: (() => void) | null = null;
215
+
216
+ public get isRunning(): boolean {
217
+ return !this.completed;
218
+ }
219
+
220
+ constructor(stopCallback: () => void) {
221
+ super();
222
+ this.stopCallback = stopCallback;
223
+ }
224
+
225
+ /**
226
+ * Register an event listener for stdout, stderr, line-stdout, line-stderr, exit, or error events.
227
+ * @param event The event name
228
+ * @param listener The callback function for this event
229
+ */
230
+ override on<E extends keyof SimctlExecutionEvents>(event: E, listener: SimctlExecutionEvents[E]): this {
231
+ return super.on(event, listener as any);
232
+ }
233
+
234
+ /**
235
+ * Register a one-time event listener that will be removed after firing once.
236
+ * @param event The event name
237
+ * @param listener The callback function for this event
238
+ */
239
+ override once<E extends keyof SimctlExecutionEvents>(event: E, listener: SimctlExecutionEvents[E]): this {
240
+ return super.once(event, listener as any);
241
+ }
242
+
243
+ /**
244
+ * Remove an event listener.
245
+ * @param event The event name
246
+ * @param listener The callback function to remove
247
+ */
248
+ override off<E extends keyof SimctlExecutionEvents>(event: E, listener: SimctlExecutionEvents[E]): this {
249
+ return super.off(event, listener as any);
250
+ }
251
+
252
+ /**
253
+ * Wait for the command to complete and get the full result.
254
+ * This accumulates all stdout/stderr chunks in memory.
255
+ * @returns Promise that resolves with exit code and complete output
256
+ */
257
+ wait(): Promise<{ code: number; stdout: Buffer; stderr: Buffer }> {
258
+ if (this.waitPromise) {
259
+ return this.waitPromise;
260
+ }
261
+
262
+ this.waitPromise = new Promise((resolve, reject) => {
263
+ if (this.completed) {
264
+ resolve({
265
+ code: this.exitCodeValue!,
266
+ stdout: Buffer.concat(this.stdoutChunks),
267
+ stderr: Buffer.concat(this.stderrChunks),
268
+ });
269
+ return;
270
+ }
271
+
272
+ this.once('exit', (code) => {
273
+ resolve({
274
+ code,
275
+ stdout: Buffer.concat(this.stdoutChunks),
276
+ stderr: Buffer.concat(this.stderrChunks),
277
+ });
278
+ });
279
+
280
+ this.once('error', (error) => {
281
+ reject(error);
282
+ });
283
+ });
284
+
285
+ return this.waitPromise;
286
+ }
287
+
288
+ /**
289
+ * Stop the running simctl command (if supported by server).
290
+ * This cleans up the execution tracking.
291
+ */
292
+ stop(): void {
293
+ if (this.stopCallback) {
294
+ this.stopCallback();
295
+ }
296
+ }
297
+
298
+ /** @internal - Handle stdout data from server */
299
+ _handleStdout(data: Buffer): void {
300
+ this.stdoutChunks.push(data);
301
+ this.emit('stdout', data);
302
+
303
+ // Process line-by-line
304
+ this.stdoutLineBuffer += data.toString('utf-8');
305
+ const lines = this.stdoutLineBuffer.split('\n');
306
+ // Keep the last incomplete line in the buffer
307
+ this.stdoutLineBuffer = lines.pop() || '';
308
+
309
+ // Emit complete lines
310
+ for (const line of lines) {
311
+ this.emit('line-stdout', line);
312
+ }
313
+ }
314
+
315
+ /** @internal - Handle stderr data from server */
316
+ _handleStderr(data: Buffer): void {
317
+ this.stderrChunks.push(data);
318
+ this.emit('stderr', data);
319
+
320
+ // Process line-by-line
321
+ this.stderrLineBuffer += data.toString('utf-8');
322
+ const lines = this.stderrLineBuffer.split('\n');
323
+ // Keep the last incomplete line in the buffer
324
+ this.stderrLineBuffer = lines.pop() || '';
325
+
326
+ // Emit complete lines
327
+ for (const line of lines) {
328
+ this.emit('line-stderr', line);
329
+ }
330
+ }
331
+
332
+ /** @internal - Handle exit code from server */
333
+ _handleExit(code: number): void {
334
+ // Emit any remaining partial lines before exit
335
+ if (this.stdoutLineBuffer) {
336
+ this.emit('line-stdout', this.stdoutLineBuffer);
337
+ this.stdoutLineBuffer = '';
338
+ }
339
+ if (this.stderrLineBuffer) {
340
+ this.emit('line-stderr', this.stderrLineBuffer);
341
+ this.stderrLineBuffer = '';
342
+ }
343
+
344
+ this.exitCodeValue = code;
345
+ this.completed = true;
346
+ this.emit('exit', code);
347
+ }
348
+
349
+ /** @internal - Handle errors from server or connection */
350
+ _handleError(error: Error): void {
351
+ this.completed = true;
352
+ this.emit('error', error);
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Creates a client for interacting with a Limrun iOS instance
358
+ * @param options Configuration options including webrtcUrl, token and log level
359
+ * @returns An InstanceClient for controlling the instance
360
+ */
361
+ export async function createInstanceClient(options: InstanceClientOptions): Promise<InstanceClient> {
362
+ const endpointWebSocketUrl = `${options.apiUrl}/signaling?token=${options.token}`;
363
+ const logLevel = options.logLevel ?? 'info';
364
+ const maxReconnectAttempts = options.maxReconnectAttempts ?? 6;
365
+ const reconnectDelay = options.reconnectDelay ?? 1000;
366
+ const maxReconnectDelay = options.maxReconnectDelay ?? 30000;
367
+
368
+ let ws: WebSocket | undefined = undefined;
369
+ let connectionState: ConnectionState = 'connecting';
370
+ let reconnectAttempts = 0;
371
+ let reconnectTimeout: NodeJS.Timeout | undefined;
372
+ let intentionalDisconnect = false;
373
+
374
+ const screenshotRequests: Map<
375
+ string,
376
+ {
377
+ resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => void;
378
+ rejecter: (reason?: any) => void;
379
+ }
380
+ > = new Map();
381
+
382
+ const simctlExecutions: Map<string, SimctlExecution> = new Map();
383
+
384
+ const stateChangeCallbacks: Set<ConnectionStateCallback> = new Set();
385
+
386
+ // Logger functions
387
+ const logger = {
388
+ debug: (...args: any[]) => {
389
+ if (logLevel === 'debug') console.log(...args);
390
+ },
391
+ info: (...args: any[]) => {
392
+ if (logLevel === 'info' || logLevel === 'debug') console.log(...args);
393
+ },
394
+ warn: (...args: any[]) => {
395
+ if (logLevel === 'warn' || logLevel === 'info' || logLevel === 'debug') console.warn(...args);
396
+ },
397
+ error: (...args: any[]) => {
398
+ if (logLevel !== 'none') console.error(...args);
399
+ },
400
+ };
401
+
402
+ const updateConnectionState = (newState: ConnectionState): void => {
403
+ if (connectionState !== newState) {
404
+ connectionState = newState;
405
+ logger.debug(`Connection state changed to: ${newState}`);
406
+ stateChangeCallbacks.forEach((callback) => {
407
+ try {
408
+ callback(newState);
409
+ } catch (err) {
410
+ logger.error('Error in connection state callback:', err);
411
+ }
412
+ });
413
+ }
414
+ };
415
+
416
+ const failPendingRequests = (reason: string): void => {
417
+ screenshotRequests.forEach((request) => request.rejecter(new Error(reason)));
418
+ screenshotRequests.clear();
419
+
420
+ simctlExecutions.forEach((execution) => execution._handleError(new Error(reason)));
421
+ simctlExecutions.clear();
422
+ };
423
+
424
+ const cleanup = (): void => {
425
+ if (reconnectTimeout) {
426
+ clearTimeout(reconnectTimeout);
427
+ reconnectTimeout = undefined;
428
+ }
429
+ if (pingInterval) {
430
+ clearInterval(pingInterval);
431
+ pingInterval = undefined;
432
+ }
433
+ if (ws) {
434
+ ws.removeAllListeners();
435
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
436
+ ws.close();
437
+ }
438
+ ws = undefined;
439
+ }
440
+ };
441
+
442
+ let pingInterval: NodeJS.Timeout | undefined;
443
+
444
+ return new Promise<InstanceClient>((resolveConnection, rejectConnection) => {
445
+ let hasResolved = false;
446
+
447
+ // Reconnection logic with exponential backoff
448
+ const scheduleReconnect = (): void => {
449
+ if (intentionalDisconnect) {
450
+ logger.debug('Skipping reconnection (intentional disconnect)');
451
+ return;
452
+ }
453
+
454
+ if (reconnectAttempts >= maxReconnectAttempts) {
455
+ logger.error(`Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`);
456
+ updateConnectionState('disconnected');
457
+ return;
458
+ }
459
+
460
+ const currentDelay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
461
+
462
+ reconnectAttempts++;
463
+ logger.debug(`Scheduling reconnection attempt ${reconnectAttempts} in ${currentDelay}ms...`);
464
+ updateConnectionState('reconnecting');
465
+
466
+ reconnectTimeout = setTimeout(() => {
467
+ logger.debug(`Attempting to reconnect (attempt ${reconnectAttempts})...`);
468
+ setupWebSocket();
469
+ }, currentDelay);
470
+ };
471
+
472
+ const setupWebSocket = (): void => {
473
+ cleanup();
474
+ updateConnectionState('connecting');
475
+
476
+ ws = new WebSocket(endpointWebSocketUrl);
477
+
478
+ ws.on('message', (data: Data) => {
479
+ let message: ServerMessage;
480
+ try {
481
+ message = JSON.parse(data.toString());
482
+ } catch (e) {
483
+ logger.error({ data, error: e }, 'Failed to parse JSON message');
484
+ return;
485
+ }
486
+
487
+ switch (message.type) {
488
+ case 'screenshot': {
489
+ if (!('dataUri' in message) || typeof message.dataUri !== 'string' || !('id' in message)) {
490
+ logger.warn('Received invalid screenshot message:', message);
491
+ break;
492
+ }
493
+
494
+ const screenshotMessage = message as ScreenshotResponse;
495
+ const request = screenshotRequests.get(screenshotMessage.id);
496
+
497
+ if (!request) {
498
+ logger.warn(
499
+ `Received screenshot data for unknown or already handled session: ${screenshotMessage.id}`,
500
+ );
501
+ break;
502
+ }
503
+
504
+ logger.debug(`Received screenshot data URI for session ${screenshotMessage.id}.`);
505
+ request.resolver({ dataUri: screenshotMessage.dataUri });
506
+ screenshotRequests.delete(screenshotMessage.id);
507
+ break;
508
+ }
509
+ case 'screenshotError': {
510
+ if (!('message' in message) || !('id' in message)) {
511
+ logger.warn('Received invalid screenshot error message:', message);
512
+ break;
513
+ }
514
+
515
+ const errorMessage = message as ScreenshotErrorResponse;
516
+ const request = screenshotRequests.get(errorMessage.id);
517
+
518
+ if (!request) {
519
+ logger.warn(
520
+ `Received screenshot error for unknown or already handled session: ${errorMessage.id}`,
521
+ );
522
+ break;
523
+ }
524
+
525
+ logger.error(
526
+ `Server reported an error capturing screenshot for session ${errorMessage.id}:`,
527
+ errorMessage.message,
528
+ );
529
+ request.rejecter(new Error(errorMessage.message));
530
+ screenshotRequests.delete(errorMessage.id);
531
+ break;
532
+ }
533
+ case 'simctlStream': {
534
+ if (!('id' in message)) {
535
+ logger.warn('Received invalid simctl stream message:', message);
536
+ break;
537
+ }
538
+
539
+ const streamMessage = message as SimctlStreamResponse;
540
+ const execution = simctlExecutions.get(streamMessage.id);
541
+
542
+ if (!execution) {
543
+ logger.warn(
544
+ `Received simctl stream for unknown or already completed execution: ${streamMessage.id}`,
545
+ );
546
+ break;
547
+ }
548
+
549
+ // Handle stdout if present
550
+ if (streamMessage.stdout) {
551
+ try {
552
+ const stdoutBuffer = Buffer.from(streamMessage.stdout, 'base64');
553
+ execution._handleStdout(stdoutBuffer);
554
+ } catch (err) {
555
+ logger.error('Failed to decode stdout data:', err);
556
+ }
557
+ }
558
+
559
+ // Handle stderr if present
560
+ if (streamMessage.stderr) {
561
+ try {
562
+ const stderrBuffer = Buffer.from(streamMessage.stderr, 'base64');
563
+ execution._handleStderr(stderrBuffer);
564
+ } catch (err) {
565
+ logger.error('Failed to decode stderr data:', err);
566
+ }
567
+ }
568
+
569
+ // Handle exit code if present (final message)
570
+ if (streamMessage.exitCode !== undefined) {
571
+ logger.debug(
572
+ `Simctl execution ${streamMessage.id} completed with exit code ${streamMessage.exitCode}`,
573
+ );
574
+ execution._handleExit(streamMessage.exitCode);
575
+ simctlExecutions.delete(streamMessage.id);
576
+ }
577
+ break;
578
+ }
579
+ case 'simctlError': {
580
+ if (!('message' in message) || !('id' in message)) {
581
+ logger.warn('Received invalid simctl error message:', message);
582
+ break;
583
+ }
584
+
585
+ const errorMessage = message as SimctlErrorResponse;
586
+ const execution = simctlExecutions.get(errorMessage.id);
587
+
588
+ if (!execution) {
589
+ logger.warn(
590
+ `Received simctl error for unknown or already handled execution: ${errorMessage.id}`,
591
+ );
592
+ break;
593
+ }
594
+
595
+ logger.error(
596
+ `Server reported an error for simctl execution ${errorMessage.id}:`,
597
+ errorMessage.message,
598
+ );
599
+ execution._handleError(new Error(errorMessage.message));
600
+ simctlExecutions.delete(errorMessage.id);
601
+ break;
602
+ }
603
+ default:
604
+ logger.warn('Received unexpected message type:', message);
605
+ break;
606
+ }
607
+ });
608
+
609
+ ws.on('error', (err: Error) => {
610
+ logger.error('WebSocket error:', err.message);
611
+ if (!hasResolved && (ws?.readyState === WebSocket.CONNECTING || ws?.readyState === WebSocket.OPEN)) {
612
+ rejectConnection(err);
613
+ }
614
+ });
615
+
616
+ ws.on('close', () => {
617
+ if (pingInterval) {
618
+ clearInterval(pingInterval);
619
+ pingInterval = undefined;
620
+ }
621
+
622
+ const shouldReconnect = !intentionalDisconnect && connectionState !== 'disconnected';
623
+ updateConnectionState('disconnected');
624
+
625
+ logger.debug('Disconnected from server.');
626
+
627
+ failPendingRequests('Connection closed');
628
+
629
+ if (shouldReconnect) {
630
+ scheduleReconnect();
631
+ }
632
+ });
633
+
634
+ ws.on('open', () => {
635
+ logger.debug(`Connected to ${endpointWebSocketUrl}`);
636
+ reconnectAttempts = 0;
637
+ updateConnectionState('connected');
638
+
639
+ pingInterval = setInterval(() => {
640
+ if (ws && ws.readyState === WebSocket.OPEN) {
641
+ (ws as any).ping();
642
+ }
643
+ }, 30_000);
644
+
645
+ if (!hasResolved) {
646
+ hasResolved = true;
647
+ resolveConnection({
648
+ screenshot,
649
+ disconnect,
650
+ getConnectionState,
651
+ onConnectionStateChange,
652
+ simctl,
653
+ cp,
654
+ });
655
+ }
656
+ });
657
+ };
658
+
659
+ const screenshot = async (): Promise<ScreenshotData> => {
660
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
661
+ return Promise.reject(new Error('WebSocket is not connected or connection is not open.'));
662
+ }
663
+
664
+ const id = 'ts-client-' + Date.now();
665
+ const screenshotRequest: ScreenshotRequest = {
666
+ type: 'screenshot',
667
+ id,
668
+ };
669
+
670
+ return new Promise<ScreenshotData>((resolve, reject) => {
671
+ logger.debug('Sending screenshot request:', screenshotRequest);
672
+ ws!.send(JSON.stringify(screenshotRequest), (err?: Error) => {
673
+ if (err) {
674
+ logger.error('Failed to send screenshot request:', err);
675
+ reject(err);
676
+ }
677
+ });
678
+
679
+ const timeout = setTimeout(() => {
680
+ if (screenshotRequests.has(id)) {
681
+ logger.error(`Screenshot request timed out for session ${id}`);
682
+ screenshotRequests.get(id)?.rejecter(new Error('Screenshot request timed out'));
683
+ screenshotRequests.delete(id);
684
+ }
685
+ }, 30000);
686
+ screenshotRequests.set(id, {
687
+ resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => {
688
+ clearTimeout(timeout);
689
+ resolve(value);
690
+ screenshotRequests.delete(id);
691
+ },
692
+ rejecter: (reason?: any) => {
693
+ clearTimeout(timeout);
694
+ reject(reason);
695
+ screenshotRequests.delete(id);
696
+ },
697
+ });
698
+ });
699
+ };
700
+
701
+ const simctl = (args: string[]): SimctlExecution => {
702
+ const id = 'ts-simctl-' + Date.now() + '-' + Math.random().toString(36).substring(7);
703
+
704
+ const cancelCallback = () => {
705
+ // Clean up execution tracking
706
+ simctlExecutions.delete(id);
707
+ logger.debug(`Simctl execution ${id} cancelled`);
708
+ };
709
+
710
+ const execution = new SimctlExecution(cancelCallback);
711
+ simctlExecutions.set(id, execution);
712
+
713
+ // Send request asynchronously
714
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
715
+ // Defer error to next tick to allow caller to attach listeners
716
+ process.nextTick(() => {
717
+ execution._handleError(new Error('WebSocket is not connected or connection is not open.'));
718
+ simctlExecutions.delete(id);
719
+ });
720
+ return execution;
721
+ }
722
+
723
+ const simctlRequest: SimctlRequest = {
724
+ type: 'simctl',
725
+ id,
726
+ args,
727
+ };
728
+
729
+ logger.debug('Sending simctl request:', simctlRequest);
730
+ ws.send(JSON.stringify(simctlRequest), (err?: Error) => {
731
+ if (err) {
732
+ logger.error('Failed to send simctl request:', err);
733
+ execution._handleError(err);
734
+ simctlExecutions.delete(id);
735
+ }
736
+ });
737
+
738
+ return execution;
739
+ };
740
+
741
+ const cp = async (name: string, filePath: string): Promise<string> => {
742
+ const fileStream = fs.createReadStream(filePath);
743
+ const uploadUrl = `${options.apiUrl}/files?name=${encodeURIComponent(name)}`;
744
+ try {
745
+ const response = await fetch(uploadUrl, {
746
+ method: 'PUT',
747
+ headers: {
748
+ 'Content-Type': 'application/octet-stream',
749
+ 'Content-Length': fs.statSync(filePath).size.toString(),
750
+ Authorization: `Bearer ${options.token}`,
751
+ },
752
+ body: fileStream,
753
+ duplex: 'half',
754
+ });
755
+ if (!response.ok) {
756
+ const errorBody = await response.text();
757
+ logger.debug(`Upload failed: ${response.status} ${errorBody}`);
758
+ throw new Error(`Upload failed: ${response.status} ${errorBody}`);
759
+ }
760
+ const result = (await response.json()) as { path: string };
761
+ return result.path;
762
+ } catch (err) {
763
+ logger.debug(`Failed to upload file ${filePath}:`, err);
764
+ throw err;
765
+ }
766
+ };
767
+
768
+ const disconnect = (): void => {
769
+ intentionalDisconnect = true;
770
+ cleanup();
771
+ updateConnectionState('disconnected');
772
+ failPendingRequests('Intentional disconnect');
773
+ logger.debug('Intentionally disconnected from WebSocket.');
774
+ };
775
+
776
+ const getConnectionState = (): ConnectionState => {
777
+ return connectionState;
778
+ };
779
+
780
+ const onConnectionStateChange = (callback: ConnectionStateCallback): (() => void) => {
781
+ stateChangeCallbacks.add(callback);
782
+ return () => {
783
+ stateChangeCallbacks.delete(callback);
784
+ };
785
+ };
786
+ setupWebSocket();
787
+ });
788
+ }