@limrun/api 0.19.3 → 0.20.1

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 (68) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/client.d.mts +3 -2
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +3 -2
  5. package/client.d.ts.map +1 -1
  6. package/client.js +11 -3
  7. package/client.js.map +1 -1
  8. package/client.mjs +11 -3
  9. package/client.mjs.map +1 -1
  10. package/exec-client.d.mts +106 -0
  11. package/exec-client.d.mts.map +1 -0
  12. package/exec-client.d.ts +106 -0
  13. package/exec-client.d.ts.map +1 -0
  14. package/exec-client.js +274 -0
  15. package/exec-client.js.map +1 -0
  16. package/exec-client.mjs +268 -0
  17. package/exec-client.mjs.map +1 -0
  18. package/folder-sync.d.mts +16 -2
  19. package/folder-sync.d.mts.map +1 -1
  20. package/folder-sync.d.ts +16 -2
  21. package/folder-sync.d.ts.map +1 -1
  22. package/folder-sync.js +43 -14
  23. package/folder-sync.js.map +1 -1
  24. package/folder-sync.mjs +43 -13
  25. package/folder-sync.mjs.map +1 -1
  26. package/index.d.mts +2 -0
  27. package/index.d.mts.map +1 -1
  28. package/index.d.ts +2 -0
  29. package/index.d.ts.map +1 -1
  30. package/index.js +5 -1
  31. package/index.js.map +1 -1
  32. package/index.mjs +2 -0
  33. package/index.mjs.map +1 -1
  34. package/internal/parse.d.mts.map +1 -1
  35. package/internal/parse.d.ts.map +1 -1
  36. package/internal/parse.js +5 -0
  37. package/internal/parse.js.map +1 -1
  38. package/internal/parse.mjs +5 -0
  39. package/internal/parse.mjs.map +1 -1
  40. package/ios-client.d.mts +10 -3
  41. package/ios-client.d.mts.map +1 -1
  42. package/ios-client.d.ts +10 -3
  43. package/ios-client.d.ts.map +1 -1
  44. package/ios-client.js +19 -4
  45. package/ios-client.js.map +1 -1
  46. package/ios-client.mjs +18 -3
  47. package/ios-client.mjs.map +1 -1
  48. package/package.json +23 -1
  49. package/sandbox-client.d.mts +124 -0
  50. package/sandbox-client.d.mts.map +1 -0
  51. package/sandbox-client.d.ts +124 -0
  52. package/sandbox-client.d.ts.map +1 -0
  53. package/sandbox-client.js +149 -0
  54. package/sandbox-client.js.map +1 -0
  55. package/sandbox-client.mjs +146 -0
  56. package/sandbox-client.mjs.map +1 -0
  57. package/src/client.ts +17 -5
  58. package/src/exec-client.ts +327 -0
  59. package/src/folder-sync.ts +66 -18
  60. package/src/index.ts +16 -0
  61. package/src/internal/parse.ts +6 -0
  62. package/src/ios-client.ts +35 -5
  63. package/src/sandbox-client.ts +267 -0
  64. package/src/version.ts +1 -1
  65. package/version.d.mts +1 -1
  66. package/version.d.ts +1 -1
  67. package/version.js +1 -1
  68. package/version.mjs +1 -1
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Client for executing commands on limbuild server with streaming output.
3
+ *
4
+ * The interface is designed to be similar to Node.js's child_process.spawn()
5
+ * for familiarity and ease of extension.
6
+ */
7
+
8
+ import { createEventSource, type EventSourceClient, type EventSourceMessage } from 'eventsource-client';
9
+
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ export type ExecRequest = {
15
+ command: 'xcodebuild';
16
+ xcodebuild?: {
17
+ workspace?: string;
18
+ project?: string;
19
+ scheme?: string;
20
+ };
21
+ };
22
+
23
+ export type ExecOptions = {
24
+ apiUrl: string;
25
+ token: string;
26
+ log?: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
27
+ };
28
+
29
+ export type ExecResult = {
30
+ exitCode: number;
31
+ execId: string;
32
+ status: 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
33
+ };
34
+
35
+ type DataListener = (chunk: string) => void;
36
+ type CloseListener = () => void;
37
+ type ExitListener = (code: number) => void;
38
+
39
+ /**
40
+ * A Readable-like stream interface, similar to Node.js stream.Readable.
41
+ * Emits 'data' for each chunk and 'close' when the stream ends.
42
+ */
43
+ export class ReadableStream {
44
+ private dataListeners: DataListener[] = [];
45
+ private closeListeners: CloseListener[] = [];
46
+ private closed = false;
47
+
48
+ on(event: 'data', listener: DataListener): this;
49
+ on(event: 'close', listener: CloseListener): this;
50
+ on(event: 'data' | 'close', listener: DataListener | CloseListener): this {
51
+ if (event === 'data') {
52
+ this.dataListeners.push(listener as DataListener);
53
+ } else if (event === 'close') {
54
+ this.closeListeners.push(listener as CloseListener);
55
+ }
56
+ return this;
57
+ }
58
+
59
+ /** @internal */
60
+ emit(event: 'data', chunk: string): void;
61
+ emit(event: 'close'): void;
62
+ emit(event: 'data' | 'close', arg?: string): void {
63
+ if (event === 'data' && typeof arg === 'string') {
64
+ for (const l of this.dataListeners) l(arg);
65
+ } else if (event === 'close' && !this.closed) {
66
+ this.closed = true;
67
+ for (const l of this.closeListeners) l();
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A ChildProcess-like object similar to Node.js's ChildProcess.
74
+ *
75
+ * Implements PromiseLike so it can be awaited directly.
76
+ *
77
+ * @example
78
+ * // Stream-based (like Node.js spawn)
79
+ * const proc = exec({ command: 'xcodebuild' }, options);
80
+ * proc.stdout.on('data', (chunk) => process.stdout.write(chunk));
81
+ * proc.stderr.on('data', (chunk) => process.stderr.write(chunk));
82
+ * proc.on('exit', (code) => console.log(`Exited with code ${code}`));
83
+ *
84
+ * // Promise-based (can be awaited)
85
+ * const { exitCode, status } = await proc;
86
+ */
87
+ export class ExecChildProcess implements PromiseLike<ExecResult> {
88
+ /** Stdout stream - emits 'data' and 'close' events */
89
+ readonly stdout = new ReadableStream();
90
+
91
+ /** Stderr stream - emits 'data' and 'close' events */
92
+ readonly stderr = new ReadableStream();
93
+
94
+ /** The remote process/build identifier (similar to pid in Node.js) */
95
+ execId: string | undefined;
96
+
97
+ private readonly resultPromise: Promise<ExecResult>;
98
+ private readonly exitListeners: ExitListener[] = [];
99
+ private abortController = new AbortController();
100
+ private sseConnection: EventSourceClient | null = null;
101
+ private killed = false;
102
+ private readonly options: ExecOptions;
103
+ private readonly log: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
104
+
105
+ constructor(request: ExecRequest, options: ExecOptions) {
106
+ this.options = options;
107
+ this.log = options.log ?? (() => {});
108
+ this.resultPromise = this.run(request);
109
+ }
110
+
111
+ /** Implement PromiseLike so this object can be awaited */
112
+ then<TResult1 = ExecResult, TResult2 = never>(
113
+ onfulfilled?: ((value: ExecResult) => TResult1 | PromiseLike<TResult1>) | null,
114
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
115
+ ): Promise<TResult1 | TResult2> {
116
+ return this.resultPromise.then(onfulfilled, onrejected);
117
+ }
118
+
119
+ /** Catch errors */
120
+ catch<TResult = never>(
121
+ onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
122
+ ): Promise<ExecResult | TResult> {
123
+ return this.resultPromise.catch(onrejected);
124
+ }
125
+
126
+ /** Finally handler */
127
+ finally(onfinally?: (() => void) | null): Promise<ExecResult> {
128
+ return this.resultPromise.finally(onfinally);
129
+ }
130
+
131
+ /** Listen for process events */
132
+ on(event: 'exit', listener: ExitListener): this {
133
+ if (event === 'exit') {
134
+ this.exitListeners.push(listener);
135
+ }
136
+ return this;
137
+ }
138
+
139
+ /** Send a signal to terminate the process */
140
+ async kill(): Promise<void> {
141
+ this.killed = true;
142
+ this.abortController.abort();
143
+ if (this.sseConnection) {
144
+ this.sseConnection.close();
145
+ this.sseConnection = null;
146
+ }
147
+ if (!this.execId) {
148
+ this.log('warn', 'Failed to cancel build: execId is not set');
149
+ return;
150
+ }
151
+ try {
152
+ await fetch(`${this.options.apiUrl}/exec/${this.execId}/cancel`, {
153
+ method: 'POST',
154
+ headers: {
155
+ Authorization: `Bearer ${this.options.token}`,
156
+ },
157
+ });
158
+ this.log('info', 'Build cancelled');
159
+ } catch (err) {
160
+ this.log('warn', `Failed to cancel build: ${err}`);
161
+ }
162
+ }
163
+
164
+ private async run(request: ExecRequest): Promise<ExecResult> {
165
+ const { log } = this;
166
+ const { apiUrl, token } = this.options;
167
+
168
+ // 1. Trigger the build via POST /exec
169
+ log('debug', `POST ${apiUrl}/exec`);
170
+ let execRes: Response;
171
+ try {
172
+ execRes = await fetch(`${apiUrl}/exec`, {
173
+ method: 'POST',
174
+ headers: {
175
+ 'Content-Type': 'application/json',
176
+ Authorization: `Bearer ${token}`,
177
+ },
178
+ body: JSON.stringify(request),
179
+ signal: this.abortController.signal,
180
+ });
181
+ } catch (err) {
182
+ if (this.killed) {
183
+ this.stdout.emit('close');
184
+ this.stderr.emit('close');
185
+ for (const listener of this.exitListeners) {
186
+ listener(-1);
187
+ }
188
+ return { exitCode: -1, execId: '', status: 'CANCELLED' };
189
+ }
190
+ throw err;
191
+ }
192
+
193
+ if (!execRes.ok) {
194
+ const text = await execRes.text();
195
+ throw new Error(`exec failed: ${execRes.status} ${text}`);
196
+ }
197
+
198
+ const execData = (await execRes.json()) as { execId: string };
199
+ this.execId = execData.execId;
200
+ log('info', `Build started: ${this.execId}`);
201
+
202
+ // 2. Stream logs via SSE and wait for exit code
203
+ const eventsUrl = `${apiUrl}/exec/${this.execId}/events`;
204
+ log('debug', `GET ${eventsUrl} (SSE)`);
205
+
206
+ const timeoutMs = 3600 * 1000; // 1 hour max
207
+ let exitCode: number;
208
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
209
+ try {
210
+ exitCode = await Promise.race([
211
+ this.connectSSE(eventsUrl),
212
+ new Promise<never>((_, reject) => {
213
+ timeoutId = setTimeout(() => reject(new Error('SSE timeout')), timeoutMs);
214
+ }),
215
+ ]);
216
+ } catch {
217
+ if (this.killed) {
218
+ log('info', 'Build killed');
219
+ exitCode = -1;
220
+ } else {
221
+ log('warn', 'SSE completion timeout');
222
+ exitCode = 1;
223
+ }
224
+ } finally {
225
+ clearTimeout(timeoutId);
226
+ if (this.sseConnection) {
227
+ this.sseConnection.close();
228
+ this.sseConnection = null;
229
+ }
230
+ }
231
+
232
+ // Emit close events on streams
233
+ this.stdout.emit('close');
234
+ this.stderr.emit('close');
235
+
236
+ // Emit exit event
237
+ for (const listener of this.exitListeners) {
238
+ listener(exitCode);
239
+ }
240
+
241
+ // Determine status from exit code
242
+ const status: 'SUCCEEDED' | 'FAILED' | 'CANCELLED' =
243
+ exitCode === 0 ? 'SUCCEEDED'
244
+ : exitCode === -1 ? 'CANCELLED'
245
+ : 'FAILED';
246
+
247
+ const result: ExecResult = {
248
+ exitCode,
249
+ execId: this.execId!,
250
+ status,
251
+ };
252
+
253
+ this.log('info', `Build finished: ${result.status} (exit ${result.exitCode})`);
254
+ return result;
255
+ }
256
+
257
+ /**
258
+ * Opens an SSE connection and routes events to stdout/stderr streams.
259
+ * Resolves with the exit code when an 'exitCode' event arrives.
260
+ * Rejects when the abort signal fires (kill or cleanup).
261
+ */
262
+ private connectSSE(eventsUrl: string): Promise<number> {
263
+ return new Promise<number>((resolve, reject) => {
264
+ if (this.abortController.signal.aborted) {
265
+ reject(new Error('killed'));
266
+ return;
267
+ }
268
+
269
+ try {
270
+ const eventSource = createEventSource({
271
+ url: eventsUrl,
272
+ headers: { Authorization: `Bearer ${this.options.token}` },
273
+ onMessage: (message: EventSourceMessage) => {
274
+ const data = typeof message.data === 'string' ? message.data : String(message.data ?? '');
275
+ const eventType = message.event;
276
+ if (eventType === 'stdout') {
277
+ this.stdout.emit('data', data);
278
+ } else if (eventType === 'stderr') {
279
+ this.stderr.emit('data', data);
280
+ } else if (eventType === 'exitCode') {
281
+ const exitCode = parseInt(data, 10);
282
+ if (Number.isNaN(exitCode)) {
283
+ this.log('warn', `SSE exitCode event has invalid data: ${data}`);
284
+ return;
285
+ }
286
+ this.log('debug', `Build completed via SSE: exitCode=${exitCode}`);
287
+ resolve(exitCode);
288
+ }
289
+ },
290
+ onDisconnect: () => {
291
+ if (!this.killed) {
292
+ this.log('warn', 'SSE disconnected');
293
+ }
294
+ },
295
+ });
296
+ this.sseConnection = eventSource;
297
+
298
+ this.abortController.signal.addEventListener('abort', () => reject(new Error('killed')), {
299
+ once: true,
300
+ });
301
+ } catch (err) {
302
+ if (!this.killed) {
303
+ this.log('warn', `SSE setup failed: ${err}`);
304
+ }
305
+ reject(err);
306
+ }
307
+ });
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Execute a command on the limbuild server.
313
+ * Returns a ChildProcess-like object with stdout/stderr streams.
314
+ *
315
+ * @example
316
+ * const proc = exec({ command: 'xcodebuild' }, { apiUrl: '...', token: '...' });
317
+ *
318
+ * // Stream output
319
+ * proc.stdout.on('data', (chunk) => console.log('[stdout]', chunk));
320
+ * proc.stderr.on('data', (chunk) => console.error('[stderr]', chunk));
321
+ *
322
+ * // Wait for completion
323
+ * const { exitCode, status } = await proc;
324
+ */
325
+ export function exec(request: ExecRequest, options: ExecOptions): ExecChildProcess {
326
+ return new ExecChildProcess(request, options);
327
+ }
@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
6
6
  import { watchFolderTree } from './folder-sync-watcher';
7
7
  import { Readable } from 'stream';
8
8
  import * as zlib from 'zlib';
9
+ import ignore, { type Ignore } from 'ignore';
9
10
 
10
11
  // =============================================================================
11
12
  // Folder Sync (HTTP batch)
@@ -20,7 +21,7 @@ export type FolderSyncOptions = {
20
21
  * Used to store the last-synced “basis” copies of files (and related sync metadata) so we can compute xdelta patches
21
22
  * on subsequent syncs without re-downloading server state.
22
23
  *
23
- * Can be absolute or relative to process.cwd(). Defaults to `.lim-metadata-cache/`.
24
+ * Can be absolute or relative to process.cwd(). Defaults to `.limsync-cache/`.
24
25
  */
25
26
  basisCacheDir?: string;
26
27
  install?: boolean;
@@ -31,6 +32,21 @@ export type FolderSyncOptions = {
31
32
  maxPatchBytes?: number;
32
33
  /** Controls logging verbosity */
33
34
  log?: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
35
+ /**
36
+ * Optional filter function to include/exclude files and directories.
37
+ * Called with the relative path from localFolderPath (using forward slashes).
38
+ * For directories, the path ends with '/'.
39
+ * Return true to include, false to exclude.
40
+ *
41
+ * @example
42
+ * // Exclude build folder
43
+ * filter: (path) => !path.startsWith('build/')
44
+ *
45
+ * @example
46
+ * // Only include source files
47
+ * filter: (path) => path.startsWith('src/') || path.endsWith('.json')
48
+ */
49
+ filter?: (relativePath: string) => boolean;
34
50
  };
35
51
 
36
52
  export type SyncFolderResult = {
@@ -63,6 +79,10 @@ type FolderSyncHttpMeta = {
63
79
  type FolderSyncHttpResponse = {
64
80
  ok: boolean;
65
81
  needFull?: string[];
82
+ // Timing fields
83
+ syncDurationMs?: number;
84
+ installDurationMs?: number; // limulator only
85
+ // Install result fields (limulator only)
66
86
  installedAppPath?: string;
67
87
  bundleId?: string;
68
88
  error?: string;
@@ -123,7 +143,7 @@ async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promis
123
143
  }
124
144
 
125
145
  function folderSyncHttpUrl(apiUrl: string): string {
126
- return `${apiUrl}/folder-sync`;
146
+ return `${apiUrl}/sync`;
127
147
  }
128
148
 
129
149
  function u32be(n: number): Buffer {
@@ -226,23 +246,42 @@ async function sha256FileHex(filePath: string): Promise<string> {
226
246
  });
227
247
  }
228
248
 
229
- async function walkFiles(root: string): Promise<FileEntry[]> {
230
- const out: FileEntry[] = [];
231
- const stack: string[] = [root];
249
+ async function walkFiles(root: string, filter?: (relativePath: string) => boolean): Promise<FileEntry[]> {
232
250
  const rootResolved = path.resolve(root);
251
+
252
+ // Load .gitignore if it exists
253
+ const ig = await loadGitignore(rootResolved);
254
+
255
+ const out: FileEntry[] = [];
256
+ const stack: string[] = [rootResolved];
233
257
  while (stack.length) {
234
258
  const dir = stack.pop()!;
235
259
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });
236
260
  for (const ent of entries) {
237
- if (ent.name === '.DS_Store') continue;
261
+ // Always skip .git folder and .DS_Store
262
+ if (ent.name === '.DS_Store' || ent.name === '.git') continue;
263
+
238
264
  const abs = path.join(dir, ent.name);
265
+ const rel = path.relative(rootResolved, abs).split(path.sep).join('/');
266
+
267
+ // Check if ignored by .gitignore
268
+ if (ig.ignores(rel)) continue;
269
+
239
270
  if (ent.isDirectory()) {
271
+ // For directories, check with trailing slash
272
+ const relDir = rel + '/';
273
+ if (ig.ignores(relDir)) continue;
274
+ // Check custom filter (directories have trailing slash)
275
+ if (filter && !filter(relDir)) continue;
240
276
  stack.push(abs);
241
277
  continue;
242
278
  }
243
279
  if (!ent.isFile()) continue;
280
+
281
+ // Check custom filter for files
282
+ if (filter && !filter(rel)) continue;
283
+
244
284
  const st = await fs.promises.stat(abs);
245
- const rel = path.relative(rootResolved, abs).split(path.sep).join('/');
246
285
  const sha256 = await sha256FileHex(abs);
247
286
  // Preserve POSIX permission bits (including +x). Mask out file-type bits.
248
287
  const mode = st.mode & 0o7777;
@@ -253,6 +292,22 @@ async function walkFiles(root: string): Promise<FileEntry[]> {
253
292
  return out;
254
293
  }
255
294
 
295
+ /**
296
+ * Load and parse .gitignore file if it exists.
297
+ * Returns an Ignore instance that can be used to test paths.
298
+ */
299
+ async function loadGitignore(rootDir: string): Promise<Ignore> {
300
+ const ig = ignore();
301
+ const gitignorePath = path.join(rootDir, '.gitignore');
302
+ try {
303
+ const content = await fs.promises.readFile(gitignorePath, 'utf-8');
304
+ ig.add(content);
305
+ } catch {
306
+ // No .gitignore file, return empty ignore instance
307
+ }
308
+ return ig;
309
+ }
310
+
256
311
  let xdelta3Ready: Promise<void> | null = null;
257
312
  async function ensureXdelta3(): Promise<void> {
258
313
  if (!xdelta3Ready) {
@@ -291,7 +346,7 @@ function localBasisCacheRoot(opts: FolderSyncOptions, localFolderPath: string):
291
346
  const rootOverride =
292
347
  opts.basisCacheDir ?
293
348
  path.resolve(process.cwd(), opts.basisCacheDir)
294
- : path.join(process.cwd(), '.lim-metadata-cache');
349
+ : path.join(process.cwd(), '.limsync-cache');
295
350
  // Include folder identity to avoid collisions between different roots.
296
351
  return path.join(rootOverride, 'folder-sync', hostKey, opts.udid, `${base}-${hash}`);
297
352
  }
@@ -310,7 +365,8 @@ export type SyncAppResult = SyncFolderResult;
310
365
 
311
366
  export async function syncApp(localFolderPath: string, opts: FolderSyncOptions): Promise<SyncFolderResult> {
312
367
  if (!opts.watch) {
313
- return await syncFolderOnce(localFolderPath, opts);
368
+ const result = await syncFolderOnce(localFolderPath, opts);
369
+ return result;
314
370
  }
315
371
  // Initial sync, then watch for changes and re-run sync in the background.
316
372
  const first = await syncFolderOnce(localFolderPath, opts, 'startup');
@@ -353,14 +409,6 @@ export async function syncApp(localFolderPath: string, opts: FolderSyncOptions):
353
409
  };
354
410
  }
355
411
 
356
- // Back-compat alias (older callers)
357
- export async function syncFolder(
358
- localFolderPath: string,
359
- opts: FolderSyncOptions,
360
- ): Promise<SyncFolderResult> {
361
- return await syncApp(localFolderPath, opts);
362
- }
363
-
364
412
  async function syncFolderOnce(
365
413
  localFolderPath: string,
366
414
  opts: FolderSyncOptions,
@@ -377,7 +425,7 @@ async function syncFolderOnce(
377
425
  const tEnsureMs = nowMs() - tEnsureStart;
378
426
 
379
427
  const tWalkStart = nowMs();
380
- const files = await walkFiles(localFolderPath);
428
+ const files = await walkFiles(localFolderPath, opts.filter);
381
429
  const tWalkMs = nowMs() - tWalkStart;
382
430
  const fileMap = new Map(files.map((f) => [f.path, f]));
383
431
 
package/src/index.ts CHANGED
@@ -8,6 +8,22 @@ export { Limrun, type ClientOptions } from './client';
8
8
  export { PagePromise } from './core/pagination';
9
9
  export * from './instance-client';
10
10
  export * as Ios from './ios-client';
11
+ export {
12
+ createXCodeSandboxClient,
13
+ type XCodeSandboxClient,
14
+ type CreateXCodeSandboxClientOptions,
15
+ type SimulatorConfig,
16
+ type SyncOptions,
17
+ type SyncResult,
18
+ type XcodeBuildConfig,
19
+ } from './sandbox-client';
20
+ export {
21
+ exec,
22
+ type ExecRequest,
23
+ type ExecOptions,
24
+ type ExecResult,
25
+ type ExecChildProcess,
26
+ } from './exec-client';
11
27
  export {
12
28
  LimrunError,
13
29
  APIError,
@@ -29,6 +29,12 @@ export async function defaultParseResponse<T>(client: Limrun, props: APIResponse
29
29
  const mediaType = contentType?.split(';')[0]?.trim();
30
30
  const isJSON = mediaType?.includes('application/json') || mediaType?.endsWith('+json');
31
31
  if (isJSON) {
32
+ const contentLength = response.headers.get('content-length');
33
+ if (contentLength === '0') {
34
+ // if there is no content we can't do anything
35
+ return undefined as T;
36
+ }
37
+
32
38
  const json = await response.json();
33
39
  return json as T;
34
40
  }
package/src/ios-client.ts CHANGED
@@ -153,12 +153,22 @@ export type InstanceClient = {
153
153
  elementTree: (point?: AccessibilityPoint) => Promise<string>;
154
154
 
155
155
  /**
156
- * Tap at the specified coordinates
156
+ * Tap at the specified coordinates (uses device's native screen dimensions)
157
157
  * @param x X coordinate in points
158
158
  * @param y Y coordinate in points
159
159
  */
160
160
  tap: (x: number, y: number) => Promise<void>;
161
161
 
162
+ /**
163
+ * Tap at coordinates with explicit screen size.
164
+ * Use this when coordinates are in a different coordinate space than the device's native dimensions.
165
+ * @param x X coordinate in the provided screen coordinate space
166
+ * @param y Y coordinate in the provided screen coordinate space
167
+ * @param screenWidth Width of the coordinate space
168
+ * @param screenHeight Height of the coordinate space
169
+ */
170
+ tapWithScreenSize: (x: number, y: number, screenWidth: number, screenHeight: number) => Promise<void>;
171
+
162
172
  /**
163
173
  * Tap an accessibility element by selector
164
174
  * @param selector The selector criteria to find the element
@@ -805,9 +815,6 @@ export class LogStream extends EventEmitter {
805
815
  }
806
816
  }
807
817
 
808
- /** @deprecated Use LogStream instead */
809
- export const AppLogStream = LogStream;
810
-
811
818
  /**
812
819
  * Creates a client for interacting with a Limrun iOS instance
813
820
  * @param options Configuration options including webrtcUrl, token and log level
@@ -1159,6 +1166,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1159
1166
  screenshot,
1160
1167
  elementTree,
1161
1168
  tap,
1169
+ tapWithScreenSize,
1162
1170
  tapElement,
1163
1171
  incrementElement,
1164
1172
  decrementElement,
@@ -1202,7 +1210,29 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1202
1210
  };
1203
1211
 
1204
1212
  const tap = (x: number, y: number): Promise<void> => {
1205
- return sendRequest<void>('tap', { x, y });
1213
+ return sendRequest<void>('tap', {
1214
+ x,
1215
+ y,
1216
+ screenWidth: cachedDeviceInfo?.screenWidth,
1217
+ screenHeight: cachedDeviceInfo?.screenHeight,
1218
+ });
1219
+ };
1220
+
1221
+ /**
1222
+ * Tap at coordinates with explicit screen size.
1223
+ * Use this when coordinates are in a different coordinate space than the device's native dimensions.
1224
+ * @param x - X coordinate in the provided screen coordinate space
1225
+ * @param y - Y coordinate in the provided screen coordinate space
1226
+ * @param screenWidth - Width of the coordinate space
1227
+ * @param screenHeight - Height of the coordinate space
1228
+ */
1229
+ const tapWithScreenSize = (
1230
+ x: number,
1231
+ y: number,
1232
+ screenWidth: number,
1233
+ screenHeight: number,
1234
+ ): Promise<void> => {
1235
+ return sendRequest<void>('tap', { x, y, screenWidth, screenHeight });
1206
1236
  };
1207
1237
 
1208
1238
  const tapElement = (selector: AccessibilitySelector): Promise<TapElementResult> => {