@limrun/api 0.49.3 → 0.50.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 (104) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/exec-client.d.mts +25 -1
  3. package/exec-client.d.mts.map +1 -1
  4. package/exec-client.d.ts +25 -1
  5. package/exec-client.d.ts.map +1 -1
  6. package/exec-client.js +362 -197
  7. package/exec-client.js.map +1 -1
  8. package/exec-client.mjs +361 -197
  9. package/exec-client.mjs.map +1 -1
  10. package/folder-sync-ignore.d.mts +15 -12
  11. package/folder-sync-ignore.d.mts.map +1 -1
  12. package/folder-sync-ignore.d.ts +15 -12
  13. package/folder-sync-ignore.d.ts.map +1 -1
  14. package/folder-sync-ignore.js +16 -19
  15. package/folder-sync-ignore.js.map +1 -1
  16. package/folder-sync-ignore.mjs +16 -19
  17. package/folder-sync-ignore.mjs.map +1 -1
  18. package/index.d.mts +1 -1
  19. package/index.d.mts.map +1 -1
  20. package/index.d.ts +1 -1
  21. package/index.d.ts.map +1 -1
  22. package/index.js.map +1 -1
  23. package/index.mjs.map +1 -1
  24. package/instance-client.d.mts +11 -0
  25. package/instance-client.d.mts.map +1 -1
  26. package/instance-client.d.ts +11 -0
  27. package/instance-client.d.ts.map +1 -1
  28. package/instance-client.js +10 -1
  29. package/instance-client.js.map +1 -1
  30. package/instance-client.mjs +7 -0
  31. package/instance-client.mjs.map +1 -1
  32. package/ios-client.d.mts +2 -33
  33. package/ios-client.d.mts.map +1 -1
  34. package/ios-client.d.ts +2 -33
  35. package/ios-client.d.ts.map +1 -1
  36. package/ios-client.js +8 -99
  37. package/ios-client.js.map +1 -1
  38. package/ios-client.mjs +4 -95
  39. package/ios-client.mjs.map +1 -1
  40. package/log-stream.d.mts +35 -0
  41. package/log-stream.d.mts.map +1 -0
  42. package/log-stream.d.ts +35 -0
  43. package/log-stream.d.ts.map +1 -0
  44. package/log-stream.js +103 -0
  45. package/log-stream.js.map +1 -0
  46. package/log-stream.mjs +99 -0
  47. package/log-stream.mjs.map +1 -0
  48. package/logcat-process.d.mts +40 -0
  49. package/logcat-process.d.mts.map +1 -0
  50. package/logcat-process.d.ts +40 -0
  51. package/logcat-process.d.ts.map +1 -0
  52. package/logcat-process.js +122 -0
  53. package/logcat-process.js.map +1 -0
  54. package/logcat-process.mjs +118 -0
  55. package/logcat-process.mjs.map +1 -0
  56. package/mise-tools.d.mts +4 -0
  57. package/mise-tools.d.mts.map +1 -0
  58. package/mise-tools.d.ts +4 -0
  59. package/mise-tools.d.ts.map +1 -0
  60. package/mise-tools.js +63 -0
  61. package/mise-tools.js.map +1 -0
  62. package/mise-tools.mjs +58 -0
  63. package/mise-tools.mjs.map +1 -0
  64. package/package.json +32 -1
  65. package/resources/daemon-client-shared.d.mts +11 -0
  66. package/resources/daemon-client-shared.d.mts.map +1 -1
  67. package/resources/daemon-client-shared.d.ts +11 -0
  68. package/resources/daemon-client-shared.d.ts.map +1 -1
  69. package/resources/daemon-client-shared.js +23 -0
  70. package/resources/daemon-client-shared.js.map +1 -1
  71. package/resources/daemon-client-shared.mjs +22 -0
  72. package/resources/daemon-client-shared.mjs.map +1 -1
  73. package/resources/gradle-instances-helpers.d.mts +12 -2
  74. package/resources/gradle-instances-helpers.d.mts.map +1 -1
  75. package/resources/gradle-instances-helpers.d.ts +12 -2
  76. package/resources/gradle-instances-helpers.d.ts.map +1 -1
  77. package/resources/gradle-instances-helpers.js +6 -0
  78. package/resources/gradle-instances-helpers.js.map +1 -1
  79. package/resources/gradle-instances-helpers.mjs +8 -2
  80. package/resources/gradle-instances-helpers.mjs.map +1 -1
  81. package/resources/xcode-instances-helpers.d.mts +6 -15
  82. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  83. package/resources/xcode-instances-helpers.d.ts +6 -15
  84. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  85. package/resources/xcode-instances-helpers.js +3 -18
  86. package/resources/xcode-instances-helpers.js.map +1 -1
  87. package/resources/xcode-instances-helpers.mjs +5 -20
  88. package/resources/xcode-instances-helpers.mjs.map +1 -1
  89. package/src/exec-client.ts +404 -198
  90. package/src/folder-sync-ignore.ts +21 -21
  91. package/src/index.ts +1 -0
  92. package/src/instance-client.ts +16 -0
  93. package/src/ios-client.ts +4 -129
  94. package/src/log-stream.ts +132 -0
  95. package/src/logcat-process.ts +151 -0
  96. package/src/mise-tools.ts +55 -0
  97. package/src/resources/daemon-client-shared.ts +33 -0
  98. package/src/resources/gradle-instances-helpers.ts +23 -0
  99. package/src/resources/xcode-instances-helpers.ts +13 -33
  100. package/src/version.ts +1 -1
  101. package/version.d.mts +1 -1
  102. package/version.d.ts +1 -1
  103. package/version.js +1 -1
  104. package/version.mjs +1 -1
@@ -8,6 +8,7 @@
8
8
  import { createEventSource, type EventSourceClient, type EventSourceMessage } from 'eventsource-client';
9
9
  import { nodeProxyTransport } from './internal/proxy-transport';
10
10
  import { sseFetch } from './internal/sse-fetch';
11
+ import { directInstanceHttpError } from './internal/direct-instance-errors';
11
12
  import type { Fetch } from './internal/builtin-types';
12
13
 
13
14
  /**
@@ -196,6 +197,7 @@ export type GradleSigningConfig = {
196
197
 
197
198
  export type GradleBuildExecRequest = {
198
199
  command: 'gradlebuild';
200
+ env?: string[];
199
201
  /**
200
202
  * Gradle tasks to run. Omit for the server default (assembleDebug, or
201
203
  * bundleRelease when signing is set).
@@ -383,6 +385,27 @@ export type AppStoreUploadConfig = {
383
385
 
384
386
  type DataListener = (chunk: string) => void;
385
387
  type CloseListener = () => void;
388
+ export type ExecLogEvent = {
389
+ id?: string;
390
+ type: string;
391
+ data: string;
392
+ };
393
+
394
+ export type ExecLogOptions = {
395
+ /** Keep streaming after replaying buffered events. Defaults to false. */
396
+ follow?: boolean;
397
+ /** Called for each replayed or live event in wire order. */
398
+ onEvent?: (event: ExecLogEvent) => void;
399
+ signal?: AbortSignal;
400
+ };
401
+
402
+ export type ExecLogResult = {
403
+ /** Concrete exec ID returned by the daemon, including when an alias was requested. */
404
+ execId: string;
405
+ status: 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
406
+ exitCode?: number;
407
+ };
408
+
386
409
  type ExitListener = (code: number) => void;
387
410
 
388
411
  /**
@@ -418,6 +441,195 @@ export class ReadableStream {
418
441
  }
419
442
  }
420
443
 
444
+ type FollowExecEventStreamOptions = {
445
+ eventsUrl: string;
446
+ token: string;
447
+ signal?: AbortSignal;
448
+ abortError: () => Error;
449
+ onEvent: (event: ExecLogEvent) => void;
450
+ onStreamDeadSince?: (since: number) => void;
451
+ failOnClientError?: boolean;
452
+ log?: ExecOptions['log'];
453
+ };
454
+
455
+ /** Follow execution events until exitCode, with bounded retries and replay deduplication. */
456
+ function followExecEventStream(options: FollowExecEventStreamOptions): {
457
+ connection: EventSourceClient | null;
458
+ result: Promise<number>;
459
+ } {
460
+ let connection: EventSourceClient | null = null;
461
+ const { eventsUrl, signal } = options;
462
+ const result = new Promise<number>((resolve, reject) => {
463
+ if (signal?.aborted) {
464
+ reject(options.abortError());
465
+ return;
466
+ }
467
+ // Settle-once pair. Both paths close the source: the EventSource
468
+ // reconnects on its own otherwise, even after the promise is done.
469
+ let settled = false;
470
+ let lastSeenEventId = -1;
471
+ let streamDeadSince = 0;
472
+ let eventSource: EventSourceClient | null = null;
473
+ let connectedAt = 0;
474
+ let lastCycleAt = 0;
475
+ let lastCycleMono = 0;
476
+ let proofOfLifeThisCycle = false;
477
+ let cleanResponseThisCycle = false;
478
+ let lastStreamError: Error | undefined;
479
+ const cleanup = () => {
480
+ eventSource?.close();
481
+ signal?.removeEventListener('abort', onAbort);
482
+ };
483
+ const succeed = (exitCode: number) => {
484
+ if (settled) return;
485
+ settled = true;
486
+ cleanup();
487
+ resolve(exitCode);
488
+ };
489
+ const fail = (error: unknown) => {
490
+ if (settled) return;
491
+ settled = true;
492
+ cleanup();
493
+ reject(error);
494
+ };
495
+ const onAbort = () => fail(options.abortError());
496
+
497
+ // Stream health is judged at the fetch surface, where the response
498
+ // status is visible; the library fires onConnect for error responses
499
+ // too, so an error page held open must not read as a healthy
500
+ // connection. An HTTP 204 makes the EventSource close for good
501
+ // without reporting anything, so it must fail here. A rejected fetch
502
+ // never reaches onDisconnect (the hazard sseFetch exists for):
503
+ // capture it for the give-up message and let the clock decide, so
504
+ // one transient refusal does not kill a live build.
505
+ const fetchWithStreamPolicy: Fetch = async (input, init) => {
506
+ const response = await nodeProxyTransport.fetch(input, init);
507
+ if (response.status === 204) {
508
+ fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
509
+ } else if (
510
+ options.failOnClientError &&
511
+ response.status >= 400 &&
512
+ response.status < 500 &&
513
+ response.status !== 408 &&
514
+ response.status !== 429
515
+ ) {
516
+ fail(
517
+ directInstanceHttpError('GET exec logs', response.status, await response.text(), response.headers),
518
+ );
519
+ } else if (!response.ok) {
520
+ lastStreamError = new Error(`server answered HTTP ${response.status}`);
521
+ }
522
+ cleanResponseThisCycle = response.ok;
523
+ return response;
524
+ };
525
+
526
+ eventSource = createEventSource({
527
+ url: eventsUrl,
528
+ fetch: sseFetch(fetchWithStreamPolicy, (err) => {
529
+ lastStreamError = err instanceof Error ? err : new Error(String(err));
530
+ }),
531
+ headers: { Authorization: `Bearer ${options.token}` },
532
+ onConnect: () => {
533
+ connectedAt = Date.now();
534
+ },
535
+ // Comments count as proof of life, so a server-side keepalive
536
+ // works without a client change (onMessage never sees them).
537
+ onComment: () => {
538
+ proofOfLifeThisCycle = true;
539
+ },
540
+ // Fires once per broken cycle, before the retry timer is armed, on
541
+ // both failure paths (request rejected, stream ended). This is
542
+ // where the give-up clock runs.
543
+ onScheduleReconnect: () => {
544
+ if (settled || signal?.aborted) {
545
+ return;
546
+ }
547
+ const now = Date.now();
548
+ const mono = performance.now();
549
+ const livedMs = connectedAt > 0 ? now - connectedAt : 0;
550
+ connectedAt = 0;
551
+ // Date.now() is wall clock: a laptop waking from sleep (or a
552
+ // clock step) would arrive with the whole window already
553
+ // "elapsed" and fail on its first attempt. Sleep is the wall
554
+ // clock advancing while the monotonic clock stands still; a
555
+ // large drift between the two restarts the streak. Long server
556
+ // retry delays and hanging connects advance both clocks equally
557
+ // and keep counting.
558
+ if (streamDeadSince > 0 && lastCycleAt > 0) {
559
+ const wallGapMs = now - lastCycleAt;
560
+ const monoGapMs = mono - lastCycleMono;
561
+ if (wallGapMs - monoGapMs > 30_000) {
562
+ streamDeadSince = now;
563
+ options.onStreamDeadSince?.(streamDeadSince);
564
+ }
565
+ }
566
+ lastCycleAt = now;
567
+ lastCycleMono = mono;
568
+ const healthy =
569
+ proofOfLifeThisCycle || (cleanResponseThisCycle && livedMs >= sseStreamPolicy.healthyConnectionMs);
570
+ proofOfLifeThisCycle = false;
571
+ cleanResponseThisCycle = false;
572
+ if (healthy) {
573
+ streamDeadSince = 0;
574
+ options.onStreamDeadSince?.(0);
575
+ lastStreamError = undefined;
576
+ return;
577
+ }
578
+ if (streamDeadSince === 0) {
579
+ streamDeadSince = now;
580
+ options.onStreamDeadSince?.(streamDeadSince);
581
+ options.log?.('warn', 'SSE disconnected; reconnecting');
582
+ return;
583
+ }
584
+ if (now - streamDeadSince >= sseStreamPolicy.giveUpAfterMs) {
585
+ const seconds = Math.round((now - streamDeadSince) / 1000);
586
+ const cause = lastStreamError ? `; last error: ${lastStreamError.message}` : '';
587
+ fail(
588
+ new ExecStreamLostError(
589
+ `event stream to ${eventsUrl} kept failing for ${seconds}s without delivering events${cause}; ` +
590
+ 'the execution may no longer exist (instance deleted or record expired)',
591
+ ),
592
+ );
593
+ }
594
+ },
595
+ onMessage: (message: EventSourceMessage) => {
596
+ if (settled) return;
597
+ streamDeadSince = 0;
598
+ options.onStreamDeadSince?.(0);
599
+ proofOfLifeThisCycle = true;
600
+ lastStreamError = undefined;
601
+ const eventId = message.id !== undefined ? Number.parseInt(message.id, 10) : NaN;
602
+ if (!Number.isNaN(eventId)) {
603
+ if (eventId <= lastSeenEventId) return;
604
+ lastSeenEventId = eventId;
605
+ }
606
+ try {
607
+ const event: ExecLogEvent = {
608
+ ...(message.id !== undefined && { id: message.id }),
609
+ type: message.event || 'message',
610
+ data: typeof message.data === 'string' ? message.data : String(message.data ?? ''),
611
+ };
612
+ options.onEvent(event);
613
+ if (event.type === 'exitCode') {
614
+ const exitCode = parseExitCode(event.data);
615
+ options.log?.('debug', `Execution completed via SSE: exitCode=${exitCode}`);
616
+ succeed(exitCode);
617
+ }
618
+ } catch (error) {
619
+ fail(error);
620
+ }
621
+ },
622
+ });
623
+ connection = eventSource;
624
+ if (settled) {
625
+ eventSource.close();
626
+ } else {
627
+ signal?.addEventListener('abort', onAbort, { once: true });
628
+ }
629
+ });
630
+ return { connection, result };
631
+ }
632
+
421
633
  /**
422
634
  * A ChildProcess-like object similar to Node.js's ChildProcess.
423
635
  *
@@ -464,11 +676,6 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
464
676
  private playstoreEvent: PlaystoreEvent | null = null;
465
677
  private xctestCases: XctestCaseEvent[] = [];
466
678
  private xctestSummary: XctestSummaryEvent | null = null;
467
- // Highest server-assigned event id already dispatched. The server numbers
468
- // every frame and replays the stream from the start on reconnect; skipping
469
- // ids at or below this mark drops the replayed prefix for every event type
470
- // at once, and stays correct if the server ever honors Last-Event-ID.
471
- private lastSeenEventId = -1;
472
679
  private readonly options: ExecOptions;
473
680
  private readonly log: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
474
681
 
@@ -743,208 +950,207 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
743
950
  * closes the stream for good, or when the give-up clock runs out.
744
951
  */
745
952
  private connectSSE(eventsUrl: string): Promise<number> {
746
- return new Promise<number>((resolve, reject) => {
747
- const signal = this.abortController.signal;
748
- if (signal.aborted) {
749
- reject(new Error('killed'));
750
- return;
751
- }
752
-
753
- // Settle-once pair. Both paths close the source: the EventSource
754
- // reconnects on its own otherwise, even after the promise is done.
755
- let settled = false;
756
- let connectedAt = 0;
757
- let lastCycleAt = 0;
758
- let lastCycleMono = 0;
759
- let proofOfLifeThisCycle = false;
760
- let cleanResponseThisCycle = false;
761
- let lastStreamError: Error | undefined;
762
- const cleanup = () => {
763
- eventSource.close();
764
- signal.removeEventListener('abort', onAbort);
765
- };
766
- const succeed = (exitCode: number) => {
767
- if (settled) return;
768
- settled = true;
769
- cleanup();
770
- resolve(exitCode);
771
- };
772
- const fail = (error: Error) => {
773
- if (settled) return;
774
- settled = true;
775
- cleanup();
776
- reject(error);
777
- };
778
- const onAbort = () => fail(new Error('killed'));
779
-
780
- // Stream health is judged at the fetch surface, where the response
781
- // status is visible; the library fires onConnect for error responses
782
- // too, so an error page held open must not read as a healthy
783
- // connection. An HTTP 204 makes the EventSource close for good
784
- // without reporting anything, so it must fail here. A rejected fetch
785
- // never reaches onDisconnect (the hazard sseFetch exists for):
786
- // capture it for the give-up message and let the clock decide, so
787
- // one transient refusal does not kill a live build.
788
- const fetchWithStreamPolicy: Fetch = async (input, init) => {
789
- const response = await nodeProxyTransport.fetch(input, init);
790
- if (response.status === 204) {
791
- fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
792
- } else if (!response.ok) {
793
- lastStreamError = new Error(`server answered HTTP ${response.status}`);
794
- }
795
- cleanResponseThisCycle = response.ok;
796
- return response;
797
- };
798
-
799
- const eventSource = createEventSource({
800
- url: eventsUrl,
801
- fetch: sseFetch(fetchWithStreamPolicy, (err) => {
802
- lastStreamError = err instanceof Error ? err : new Error(String(err));
803
- }),
804
- headers: { Authorization: `Bearer ${this.options.token}` },
805
- onConnect: () => {
806
- connectedAt = Date.now();
807
- },
808
- // Comments count as proof of life, so a server-side keepalive
809
- // works without a client change (onMessage never sees them).
810
- onComment: () => {
811
- proofOfLifeThisCycle = true;
812
- },
813
- // Fires once per broken cycle, before the retry timer is armed, on
814
- // both failure paths (request rejected, stream ended). This is
815
- // where the give-up clock runs.
816
- onScheduleReconnect: () => {
817
- if (settled || this.killed) {
818
- return;
819
- }
820
- const now = Date.now();
821
- const mono = performance.now();
822
- const livedMs = connectedAt > 0 ? now - connectedAt : 0;
823
- connectedAt = 0;
824
- // Date.now() is wall clock: a laptop waking from sleep (or a
825
- // clock step) would arrive with the whole window already
826
- // "elapsed" and fail on its first attempt. Sleep is the wall
827
- // clock advancing while the monotonic clock stands still; a
828
- // large drift between the two restarts the streak. Long server
829
- // retry delays and hanging connects advance both clocks equally
830
- // and keep counting.
831
- if (this.streamDeadSince > 0 && lastCycleAt > 0) {
832
- const wallGapMs = now - lastCycleAt;
833
- const monoGapMs = mono - lastCycleMono;
834
- if (wallGapMs - monoGapMs > 30_000) {
835
- this.streamDeadSince = now;
836
- }
837
- }
838
- lastCycleAt = now;
839
- lastCycleMono = mono;
840
- const healthy =
841
- proofOfLifeThisCycle ||
842
- (cleanResponseThisCycle && livedMs >= sseStreamPolicy.healthyConnectionMs);
843
- proofOfLifeThisCycle = false;
844
- cleanResponseThisCycle = false;
845
- if (healthy) {
846
- this.streamDeadSince = 0;
847
- lastStreamError = undefined;
848
- return;
953
+ const stream = followExecEventStream({
954
+ eventsUrl,
955
+ token: this.options.token,
956
+ signal: this.abortController.signal,
957
+ abortError: () => new Error('killed'),
958
+ log: this.log,
959
+ onStreamDeadSince: (since) => {
960
+ this.streamDeadSince = since;
961
+ },
962
+ onEvent: ({ type: eventType, data }) => {
963
+ if (eventType === 'command') {
964
+ this.command.emit('data', data);
965
+ } else if (eventType === 'stdout') {
966
+ this.stdout.emit('data', data);
967
+ } else if (eventType === 'stderr') {
968
+ this.stderr.emit('data', data);
969
+ } else if (eventType === 'testflight') {
970
+ try {
971
+ this.appStoreEvent = JSON.parse(data) as AppStoreEvent;
972
+ } catch {
973
+ // The wire event itself proves the server ran the App Store upload,
974
+ // so never let a payload glitch look like a missing feature.
975
+ this.appStoreEvent = { state: 'unknown' };
976
+ this.log('warn', `SSE testflight event has invalid data: ${data}`);
849
977
  }
850
- if (this.streamDeadSince === 0) {
851
- this.streamDeadSince = now;
852
- this.log('warn', 'SSE disconnected; reconnecting');
853
- return;
978
+ } else if (eventType === 'playstore') {
979
+ try {
980
+ this.playstoreEvent = JSON.parse(data) as PlaystoreEvent;
981
+ } catch {
982
+ // Same contract as the App Store upload event: its presence proves the server
983
+ // ran the Play Store step, so a payload glitch must not
984
+ // read as a missing feature.
985
+ this.playstoreEvent = { state: 'unknown' };
986
+ this.log('warn', `SSE playstore event has invalid data: ${data}`);
854
987
  }
855
- if (now - this.streamDeadSince >= sseStreamPolicy.giveUpAfterMs) {
856
- const seconds = Math.round((now - this.streamDeadSince) / 1000);
857
- const cause = lastStreamError ? `; last error: ${lastStreamError.message}` : '';
858
- fail(
859
- new ExecStreamLostError(
860
- `event stream to ${eventsUrl} kept failing for ${seconds}s without delivering events${cause}; ` +
861
- 'the execution may no longer exist (instance deleted or record expired)',
862
- ),
863
- );
864
- }
865
- },
866
- onMessage: (message: EventSourceMessage) => {
867
- this.streamDeadSince = 0;
868
- proofOfLifeThisCycle = true;
869
- lastStreamError = undefined;
870
- const eventId = message.id !== undefined ? parseInt(message.id, 10) : NaN;
871
- if (!Number.isNaN(eventId)) {
872
- if (eventId <= this.lastSeenEventId) {
873
- // A reconnect replays the stream from the start; this frame was
874
- // already dispatched on an earlier connection.
875
- return;
988
+ } else if (eventType === 'xctest') {
989
+ let event: XctestEvent | undefined;
990
+ try {
991
+ const parsed = JSON.parse(data) as XctestEvent;
992
+ if (parsed?.type === 'case') {
993
+ this.xctestCases.push(parsed);
994
+ event = parsed;
995
+ } else if (parsed?.type === 'summary') {
996
+ this.xctestSummary = parsed;
997
+ event = parsed;
998
+ } else {
999
+ // The simulator owns the frame vocabulary and may grow it; the
1000
+ // callback's contract is cases and summaries only.
1001
+ this.log('debug', `SSE xctest event of unknown type ignored: ${data}`);
876
1002
  }
877
- this.lastSeenEventId = eventId;
1003
+ } catch {
1004
+ this.log('warn', `SSE xctest event has invalid data: ${data}`);
878
1005
  }
879
- const data = typeof message.data === 'string' ? message.data : String(message.data ?? '');
880
- const eventType = message.event;
881
- if (eventType === 'command') {
882
- this.command.emit('data', data);
883
- } else if (eventType === 'stdout') {
884
- this.stdout.emit('data', data);
885
- } else if (eventType === 'stderr') {
886
- this.stderr.emit('data', data);
887
- } else if (eventType === 'testflight') {
888
- try {
889
- this.appStoreEvent = JSON.parse(data) as AppStoreEvent;
890
- } catch {
891
- // The wire event itself proves the server ran the App Store upload,
892
- // so never let a payload glitch look like a missing feature.
893
- this.appStoreEvent = { state: 'unknown' };
894
- this.log('warn', `SSE testflight event has invalid data: ${data}`);
895
- }
896
- } else if (eventType === 'playstore') {
897
- try {
898
- this.playstoreEvent = JSON.parse(data) as PlaystoreEvent;
899
- } catch {
900
- // Same contract as the App Store upload event: its presence proves the server
901
- // ran the Play Store step, so a payload glitch must not
902
- // read as a missing feature.
903
- this.playstoreEvent = { state: 'unknown' };
904
- this.log('warn', `SSE playstore event has invalid data: ${data}`);
905
- }
906
- } else if (eventType === 'xctest') {
907
- let event: XctestEvent | undefined;
1006
+ if (event) {
908
1007
  try {
909
- const parsed = JSON.parse(data) as XctestEvent;
910
- if (parsed?.type === 'case') {
911
- this.xctestCases.push(parsed);
912
- event = parsed;
913
- } else if (parsed?.type === 'summary') {
914
- this.xctestSummary = parsed;
915
- event = parsed;
916
- } else {
917
- // The simulator owns the frame vocabulary and may grow it; the
918
- // callback's contract is cases and summaries only.
919
- this.log('debug', `SSE xctest event of unknown type ignored: ${data}`);
920
- }
921
- } catch {
922
- this.log('warn', `SSE xctest event has invalid data: ${data}`);
923
- }
924
- if (event) {
925
- try {
926
- this.options.onXctestEvent?.(event);
927
- } catch (err) {
928
- // The consumer's callback, not the frame: the run must not be
929
- // derailed and the log must not blame the payload.
930
- this.log('warn', `onXctestEvent callback threw: ${String(err)}`);
931
- }
932
- }
933
- } else if (eventType === 'exitCode') {
934
- const exitCode = parseInt(data, 10);
935
- if (Number.isNaN(exitCode)) {
936
- this.log('warn', `SSE exitCode event has invalid data: ${data}`);
937
- return;
1008
+ this.options.onXctestEvent?.(event);
1009
+ } catch (err) {
1010
+ // The consumer's callback, not the frame: the run must not be
1011
+ // derailed and the log must not blame the payload.
1012
+ this.log('warn', `onXctestEvent callback threw: ${String(err)}`);
938
1013
  }
939
- this.log('debug', `Execution completed via SSE: exitCode=${exitCode}`);
940
- succeed(exitCode);
941
1014
  }
942
- },
943
- });
944
- this.sseConnection = eventSource;
945
- signal.addEventListener('abort', onAbort, { once: true });
1015
+ }
1016
+ },
1017
+ });
1018
+ this.sseConnection = stream.connection;
1019
+ return stream.result;
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Replay logs for an existing execution, optionally following it to completion.
1025
+ * Snapshot mode uses a finite SSE response; follow mode consumes the same
1026
+ * replayable stream until its terminal exitCode event.
1027
+ */
1028
+ export async function observeExecLogs(
1029
+ execId: string,
1030
+ options: ExecOptions & ExecLogOptions,
1031
+ ): Promise<ExecLogResult> {
1032
+ if (!execId.trim()) {
1033
+ throw new Error('execId must not be empty');
1034
+ }
1035
+ const follow = options.follow ?? false;
1036
+ // Resolve mutable aliases once so a reconnect cannot switch to another build.
1037
+ if (follow && (execId === 'active' || execId === 'latest')) {
1038
+ const snapshotEvents: ExecLogEvent[] = [];
1039
+ const snapshot = await observeExecLogs(execId, {
1040
+ ...options,
1041
+ follow: false,
1042
+ onEvent: (event) => snapshotEvents.push(event),
946
1043
  });
1044
+ if (snapshot.status !== 'RUNNING') {
1045
+ for (const event of snapshotEvents) options.onEvent?.(event);
1046
+ return snapshot;
1047
+ }
1048
+ if (snapshot.execId === execId) {
1049
+ throw new Error(`The server did not resolve the ${execId} build alias.`);
1050
+ }
1051
+ return observeExecLogs(snapshot.execId, options);
947
1052
  }
1053
+ let apiUrlEnd = options.apiUrl.length;
1054
+ while (options.apiUrl[apiUrlEnd - 1] === '/') apiUrlEnd--;
1055
+ const eventsUrl = new URL(
1056
+ `${options.apiUrl.slice(0, apiUrlEnd)}/exec/${encodeURIComponent(execId)}/events`,
1057
+ );
1058
+ eventsUrl.searchParams.set('follow', String(follow));
1059
+
1060
+ if (!follow) {
1061
+ const response = await nodeProxyTransport.fetch(eventsUrl, {
1062
+ method: 'GET',
1063
+ headers: { Authorization: `Bearer ${options.token}` },
1064
+ ...(options.signal && { signal: options.signal }),
1065
+ });
1066
+ if (!response.ok) {
1067
+ const text = await response.text();
1068
+ throw directInstanceHttpError('GET exec logs', response.status, text, response.headers);
1069
+ }
1070
+ const events = parseSSE(await response.text());
1071
+ let exitCode: number | undefined;
1072
+ let resolvedExecId = execId;
1073
+ for (const event of events) {
1074
+ resolvedExecId = execIdFromMeta(event, resolvedExecId);
1075
+ options.onEvent?.(event);
1076
+ if (event.type === 'exitCode') {
1077
+ exitCode = parseExitCode(event.data);
1078
+ }
1079
+ }
1080
+ return buildExecLogResult(resolvedExecId, exitCode);
1081
+ }
1082
+
1083
+ let resolvedExecId = execId;
1084
+ const stream = followExecEventStream({
1085
+ eventsUrl: eventsUrl.toString(),
1086
+ token: options.token,
1087
+ ...(options.signal && { signal: options.signal }),
1088
+ failOnClientError: true,
1089
+ log: options.log,
1090
+ abortError: () => new Error(`exec log stream for ${execId} was aborted`),
1091
+ onEvent: (event) => {
1092
+ resolvedExecId = execIdFromMeta(event, resolvedExecId);
1093
+ options.onEvent?.(event);
1094
+ },
1095
+ });
1096
+ const exitCode = await stream.result;
1097
+ return buildExecLogResult(resolvedExecId, exitCode);
1098
+ }
1099
+
1100
+ function parseSSE(body: string): ExecLogEvent[] {
1101
+ const normalized = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1102
+ const events: ExecLogEvent[] = [];
1103
+ for (const block of normalized.split('\n\n')) {
1104
+ if (!block.trim()) continue;
1105
+ let id: string | undefined;
1106
+ let type = 'message';
1107
+ const data: string[] = [];
1108
+ for (const line of block.split('\n')) {
1109
+ if (line.startsWith('id:')) {
1110
+ id = line.slice(3).trimStart();
1111
+ } else if (line.startsWith('event:')) {
1112
+ type = line.slice(6).trimStart();
1113
+ } else if (line.startsWith('data:')) {
1114
+ data.push(line.slice(5).replace(/^ /, ''));
1115
+ }
1116
+ }
1117
+ if (data.length > 0 || type !== 'message' || id !== undefined) {
1118
+ events.push({ ...(id !== undefined && { id }), type, data: data.join('\n') });
1119
+ }
1120
+ }
1121
+ return events;
1122
+ }
1123
+
1124
+ function parseExitCode(data: string): number {
1125
+ const exitCode = Number.parseInt(data, 10);
1126
+ if (Number.isNaN(exitCode)) {
1127
+ throw new Error(`invalid exec exit code: ${data}`);
1128
+ }
1129
+ return exitCode;
1130
+ }
1131
+
1132
+ function execIdFromMeta(event: ExecLogEvent, fallback: string): string {
1133
+ if (event.type !== 'meta') return fallback;
1134
+ try {
1135
+ const meta = JSON.parse(event.data) as { id?: unknown };
1136
+ return typeof meta.id === 'string' && meta.id ? meta.id : fallback;
1137
+ } catch {
1138
+ return fallback;
1139
+ }
1140
+ }
1141
+
1142
+ function buildExecLogResult(execId: string, exitCode: number | undefined): ExecLogResult {
1143
+ if (exitCode === undefined) {
1144
+ return { execId, status: 'RUNNING' };
1145
+ }
1146
+ return {
1147
+ execId,
1148
+ exitCode,
1149
+ status:
1150
+ exitCode === 0 ? 'SUCCEEDED'
1151
+ : exitCode === -1 ? 'CANCELLED'
1152
+ : 'FAILED',
1153
+ };
948
1154
  }
949
1155
 
950
1156
  /**