@reactor-team/js-sdk 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -79,15 +79,17 @@ var __async = (__this, __arguments, generator) => {
79
79
  // src/index.ts
80
80
  var index_exports = {};
81
81
  __export(index_exports, {
82
+ AbortError: () => AbortError,
82
83
  ConflictError: () => ConflictError,
83
- PROD_COORDINATOR_URL: () => PROD_COORDINATOR_URL,
84
+ DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
84
85
  Reactor: () => Reactor,
85
86
  ReactorController: () => ReactorController,
86
87
  ReactorProvider: () => ReactorProvider,
87
88
  ReactorView: () => ReactorView,
88
89
  WebcamStream: () => WebcamStream,
89
90
  audio: () => audio,
90
- fetchInsecureJwtToken: () => fetchInsecureJwtToken,
91
+ fetchInsecureToken: () => fetchInsecureToken,
92
+ isAbortError: () => isAbortError,
91
93
  useReactor: () => useReactor,
92
94
  useReactorInternalMessage: () => useReactorInternalMessage,
93
95
  useReactorMessage: () => useReactorMessage,
@@ -109,6 +111,14 @@ var ConflictError = class extends Error {
109
111
  super(message);
110
112
  }
111
113
  };
114
+ var AbortError = class extends Error {
115
+ constructor(message) {
116
+ super(message);
117
+ }
118
+ };
119
+ function isAbortError(error) {
120
+ return error instanceof AbortError || error instanceof Error && error.name === "AbortError";
121
+ }
112
122
 
113
123
  // src/core/types.ts
114
124
  var import_zod = require("zod");
@@ -168,6 +178,7 @@ var IceServersResponseSchema = import_zod.z.object({
168
178
  // src/utils/webrtc.ts
169
179
  var DEFAULT_DATA_CHANNEL_LABEL = "data";
170
180
  var FORCE_RELAY_MODE = false;
181
+ var DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024;
171
182
  function createPeerConnection(config) {
172
183
  return new RTCPeerConnection({
173
184
  iceServers: config.iceServers,
@@ -214,13 +225,17 @@ function rewriteMids(sdp, trackNames) {
214
225
  function createOffer(pc, trackNames) {
215
226
  return __async(this, null, function* () {
216
227
  const offer = yield pc.createOffer();
228
+ let needsAnswerRestore = false;
217
229
  if (trackNames && trackNames.length > 0 && offer.sdp) {
218
230
  const munged = rewriteMids(offer.sdp, trackNames);
219
- const mungedOffer = new RTCSessionDescription({
220
- type: "offer",
221
- sdp: munged
222
- });
223
- yield pc.setLocalDescription(mungedOffer);
231
+ try {
232
+ yield pc.setLocalDescription(
233
+ new RTCSessionDescription({ type: "offer", sdp: munged })
234
+ );
235
+ } catch (e) {
236
+ yield pc.setLocalDescription(offer);
237
+ needsAnswerRestore = true;
238
+ }
224
239
  } else {
225
240
  yield pc.setLocalDescription(offer);
226
241
  }
@@ -229,9 +244,49 @@ function createOffer(pc, trackNames) {
229
244
  if (!localDescription) {
230
245
  throw new Error("Failed to create local description");
231
246
  }
232
- return localDescription.sdp;
247
+ let sdp = localDescription.sdp;
248
+ if (needsAnswerRestore && trackNames && trackNames.length > 0) {
249
+ sdp = rewriteMids(sdp, trackNames);
250
+ }
251
+ return { sdp, needsAnswerRestore };
233
252
  });
234
253
  }
254
+ function buildMidMapping(transceivers) {
255
+ var _a;
256
+ const localToRemote = /* @__PURE__ */ new Map();
257
+ const remoteToLocal = /* @__PURE__ */ new Map();
258
+ for (const entry of transceivers) {
259
+ const mid = (_a = entry.transceiver) == null ? void 0 : _a.mid;
260
+ if (mid) {
261
+ localToRemote.set(mid, entry.name);
262
+ remoteToLocal.set(entry.name, mid);
263
+ }
264
+ }
265
+ return { localToRemote, remoteToLocal };
266
+ }
267
+ function restoreAnswerMids(sdp, remoteToLocal) {
268
+ const lines = sdp.split("\r\n");
269
+ for (let i = 0; i < lines.length; i++) {
270
+ if (lines[i].startsWith("a=mid:")) {
271
+ const remoteMid = lines[i].substring("a=mid:".length);
272
+ const localMid = remoteToLocal.get(remoteMid);
273
+ if (localMid !== void 0) {
274
+ lines[i] = `a=mid:${localMid}`;
275
+ }
276
+ }
277
+ if (lines[i].startsWith("a=group:BUNDLE ")) {
278
+ const parts = lines[i].split(" ");
279
+ for (let j = 1; j < parts.length; j++) {
280
+ const localMid = remoteToLocal.get(parts[j]);
281
+ if (localMid !== void 0) {
282
+ parts[j] = localMid;
283
+ }
284
+ }
285
+ lines[i] = parts.join(" ");
286
+ }
287
+ }
288
+ return lines.join("\r\n");
289
+ }
235
290
  function setRemoteDescription(pc, sdp) {
236
291
  return __async(this, null, function* () {
237
292
  const sessionDescription = new RTCSessionDescription({
@@ -280,14 +335,21 @@ function waitForIceGathering(pc, timeoutMs = 5e3) {
280
335
  }, timeoutMs);
281
336
  });
282
337
  }
283
- function sendMessage(channel, command, data, scope = "application") {
338
+ function sendMessage(channel, command, data, scope = "application", maxBytes = DEFAULT_MAX_MESSAGE_BYTES) {
284
339
  if (channel.readyState !== "open") {
285
340
  throw new Error(`Data channel not open: ${channel.readyState}`);
286
341
  }
287
342
  const jsonData = typeof data === "string" ? JSON.parse(data) : data;
288
343
  const inner = { type: command, data: jsonData };
289
344
  const payload = { scope, data: inner };
290
- channel.send(JSON.stringify(payload));
345
+ const serialized = JSON.stringify(payload);
346
+ const byteLength = new TextEncoder().encode(serialized).byteLength;
347
+ if (byteLength > maxBytes) {
348
+ throw new Error(
349
+ `Data channel message too large: ${byteLength} bytes exceeds limit of ${maxBytes} bytes (command: "${command}")`
350
+ );
351
+ }
352
+ channel.send(serialized);
291
353
  }
292
354
  function parseMessage(data) {
293
355
  if (typeof data === "string") {
@@ -359,6 +421,22 @@ var CoordinatorClient = class {
359
421
  this.baseUrl = options.baseUrl;
360
422
  this.jwtToken = options.jwtToken;
361
423
  this.model = options.model;
424
+ this.abortController = new AbortController();
425
+ }
426
+ /**
427
+ * Aborts any in-flight HTTP requests and polling loops.
428
+ * A fresh AbortController is created so the client remains reusable.
429
+ */
430
+ abort() {
431
+ this.abortController.abort();
432
+ this.abortController = new AbortController();
433
+ }
434
+ /**
435
+ * The current abort signal, passed to every fetch() and sleep() call.
436
+ * Protected so subclasses can forward it to their own fetch calls.
437
+ */
438
+ get signal() {
439
+ return this.abortController.signal;
362
440
  }
363
441
  /**
364
442
  * Returns the authorization header with JWT Bearer token
@@ -379,7 +457,8 @@ var CoordinatorClient = class {
379
457
  `${this.baseUrl}/ice_servers?model=${this.model}`,
380
458
  {
381
459
  method: "GET",
382
- headers: this.getAuthHeaders()
460
+ headers: this.getAuthHeaders(),
461
+ signal: this.signal
383
462
  }
384
463
  );
385
464
  if (!response.ok) {
@@ -413,7 +492,8 @@ var CoordinatorClient = class {
413
492
  headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
414
493
  "Content-Type": "application/json"
415
494
  }),
416
- body: JSON.stringify(requestBody)
495
+ body: JSON.stringify(requestBody),
496
+ signal: this.signal
417
497
  });
418
498
  if (!response.ok) {
419
499
  const errorText = yield response.text();
@@ -447,7 +527,8 @@ var CoordinatorClient = class {
447
527
  `${this.baseUrl}/sessions/${this.currentSessionId}`,
448
528
  {
449
529
  method: "GET",
450
- headers: this.getAuthHeaders()
530
+ headers: this.getAuthHeaders(),
531
+ signal: this.signal
451
532
  }
452
533
  );
453
534
  if (!response.ok) {
@@ -460,12 +541,13 @@ var CoordinatorClient = class {
460
541
  }
461
542
  /**
462
543
  * Terminates the current session by sending a DELETE request to the coordinator.
463
- * @throws Error if no active session exists or if the request fails (except for 404)
544
+ * No-op if no session has been created yet.
545
+ * @throws Error if the request fails (except for 404, which clears local state)
464
546
  */
465
547
  terminateSession() {
466
548
  return __async(this, null, function* () {
467
549
  if (!this.currentSessionId) {
468
- throw new Error("No active session. Call createSession() first.");
550
+ return;
469
551
  }
470
552
  console.debug(
471
553
  "[CoordinatorClient] Terminating session:",
@@ -475,7 +557,8 @@ var CoordinatorClient = class {
475
557
  `${this.baseUrl}/sessions/${this.currentSessionId}`,
476
558
  {
477
559
  method: "DELETE",
478
- headers: this.getAuthHeaders()
560
+ headers: this.getAuthHeaders(),
561
+ signal: this.signal
479
562
  }
480
563
  );
481
564
  if (response.ok) {
@@ -525,7 +608,8 @@ var CoordinatorClient = class {
525
608
  headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
526
609
  "Content-Type": "application/json"
527
610
  }),
528
- body: JSON.stringify(requestBody)
611
+ body: JSON.stringify(requestBody),
612
+ signal: this.signal
529
613
  }
530
614
  );
531
615
  if (response.status === 200) {
@@ -561,6 +645,9 @@ var CoordinatorClient = class {
561
645
  let backoffMs = INITIAL_BACKOFF_MS;
562
646
  let attempt = 0;
563
647
  while (true) {
648
+ if (this.signal.aborted) {
649
+ throw new AbortError("SDP polling aborted");
650
+ }
564
651
  if (attempt >= maxAttempts) {
565
652
  throw new Error(
566
653
  `SDP polling exceeded maximum attempts (${maxAttempts}) for session ${sessionId}`
@@ -576,13 +663,14 @@ var CoordinatorClient = class {
576
663
  method: "GET",
577
664
  headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
578
665
  "Content-Type": "application/json"
579
- })
666
+ }),
667
+ signal: this.signal
580
668
  }
581
669
  );
582
670
  if (response.status === 200) {
583
671
  const answerData = yield response.json();
584
672
  console.debug("[CoordinatorClient] Received SDP answer via polling");
585
- return answerData.sdp_answer;
673
+ return { sdpAnswer: answerData.sdp_answer, attempts: attempt };
586
674
  }
587
675
  if (response.status === 202) {
588
676
  console.warn(
@@ -606,7 +694,7 @@ var CoordinatorClient = class {
606
694
  * @param sessionId - The session ID to connect to
607
695
  * @param sdpOffer - Optional SDP offer from the local WebRTC peer connection
608
696
  * @param maxAttempts - Optional maximum number of polling attempts before giving up
609
- * @returns The SDP answer from the server
697
+ * @returns The SDP answer and the number of polling attempts made (0 if answered immediately via PUT)
610
698
  */
611
699
  connect(sessionId, sdpOffer, maxAttempts) {
612
700
  return __async(this, null, function* () {
@@ -614,17 +702,34 @@ var CoordinatorClient = class {
614
702
  if (sdpOffer) {
615
703
  const answer = yield this.sendSdpOffer(sessionId, sdpOffer);
616
704
  if (answer !== null) {
617
- return answer;
705
+ return { sdpAnswer: answer, sdpPollingAttempts: 0 };
618
706
  }
619
707
  }
620
- return this.pollSdpAnswer(sessionId, maxAttempts);
708
+ const result = yield this.pollSdpAnswer(sessionId, maxAttempts);
709
+ return { sdpAnswer: result.sdpAnswer, sdpPollingAttempts: result.attempts };
621
710
  });
622
711
  }
623
712
  /**
624
- * Utility function to sleep for a given number of milliseconds
713
+ * Abort-aware sleep. Resolves after `ms` milliseconds unless the
714
+ * abort signal fires first, in which case it rejects with AbortError.
625
715
  */
626
716
  sleep(ms) {
627
- return new Promise((resolve) => setTimeout(resolve, ms));
717
+ return new Promise((resolve, reject) => {
718
+ const { signal } = this;
719
+ if (signal.aborted) {
720
+ reject(new AbortError("Sleep aborted"));
721
+ return;
722
+ }
723
+ const timer = setTimeout(() => {
724
+ signal.removeEventListener("abort", onAbort);
725
+ resolve();
726
+ }, ms);
727
+ const onAbort = () => {
728
+ clearTimeout(timer);
729
+ reject(new AbortError("Sleep aborted"));
730
+ };
731
+ signal.addEventListener("abort", onAbort, { once: true });
732
+ });
628
733
  }
629
734
  };
630
735
 
@@ -646,7 +751,8 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
646
751
  return __async(this, null, function* () {
647
752
  console.debug("[LocalCoordinatorClient] Fetching ICE servers...");
648
753
  const response = yield fetch(`${this.localBaseUrl}/ice_servers`, {
649
- method: "GET"
754
+ method: "GET",
755
+ signal: this.signal
650
756
  });
651
757
  if (!response.ok) {
652
758
  throw new Error("Failed to get ICE servers from local coordinator.");
@@ -670,7 +776,8 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
670
776
  console.debug("[LocalCoordinatorClient] Creating local session...");
671
777
  this.sdpOffer = sdpOffer;
672
778
  const response = yield fetch(`${this.localBaseUrl}/start_session`, {
673
- method: "POST"
779
+ method: "POST",
780
+ signal: this.signal
674
781
  });
675
782
  if (!response.ok) {
676
783
  throw new Error("Failed to send local start session command.");
@@ -681,9 +788,10 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
681
788
  }
682
789
  /**
683
790
  * Connects to the local session by posting SDP params to /sdp_params.
791
+ * Local connections are always immediate (no polling).
684
792
  * @param sessionId - The session ID (ignored for local)
685
793
  * @param sdpMessage - The SDP offer from the local WebRTC peer connection
686
- * @returns The SDP answer from the server
794
+ * @returns The SDP answer and polling attempts (always 0 for local)
687
795
  */
688
796
  connect(sessionId, sdpMessage) {
689
797
  return __async(this, null, function* () {
@@ -698,7 +806,8 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
698
806
  headers: {
699
807
  "Content-Type": "application/json"
700
808
  },
701
- body: JSON.stringify(sdpBody)
809
+ body: JSON.stringify(sdpBody),
810
+ signal: this.signal
702
811
  });
703
812
  if (!response.ok) {
704
813
  if (response.status === 409) {
@@ -708,14 +817,15 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
708
817
  }
709
818
  const sdpAnswer = yield response.json();
710
819
  console.debug("[LocalCoordinatorClient] Received SDP answer");
711
- return sdpAnswer.sdp;
820
+ return { sdpAnswer: sdpAnswer.sdp, sdpPollingAttempts: 0 };
712
821
  });
713
822
  }
714
823
  terminateSession() {
715
824
  return __async(this, null, function* () {
716
825
  console.debug("[LocalCoordinatorClient] Stopping local session...");
717
826
  yield fetch(`${this.localBaseUrl}/stop_session`, {
718
- method: "POST"
827
+ method: "POST",
828
+ signal: this.signal
719
829
  });
720
830
  });
721
831
  }
@@ -790,12 +900,21 @@ var GPUMachineClient = class {
790
900
  );
791
901
  }
792
902
  const trackNames = entries.map((e) => e.name);
793
- const offer = yield createOffer(this.peerConnection, trackNames);
903
+ const { sdp, needsAnswerRestore } = yield createOffer(
904
+ this.peerConnection,
905
+ trackNames
906
+ );
907
+ if (needsAnswerRestore) {
908
+ this.midMapping = buildMidMapping(entries);
909
+ } else {
910
+ this.midMapping = void 0;
911
+ }
794
912
  console.debug(
795
913
  "[GPUMachineClient] Created SDP offer with MIDs:",
796
- trackNames
914
+ trackNames,
915
+ needsAnswerRestore ? "(needs answer restore)" : "(native munging)"
797
916
  );
798
- return offer;
917
+ return sdp;
799
918
  });
800
919
  }
801
920
  /**
@@ -843,8 +962,18 @@ var GPUMachineClient = class {
843
962
  );
844
963
  }
845
964
  this.setStatus("connecting");
965
+ this.iceStartTime = performance.now();
966
+ this.iceNegotiationMs = void 0;
967
+ this.dataChannelMs = void 0;
846
968
  try {
847
- yield setRemoteDescription(this.peerConnection, sdpAnswer);
969
+ let answer = sdpAnswer;
970
+ if (this.midMapping) {
971
+ answer = restoreAnswerMids(
972
+ answer,
973
+ this.midMapping.remoteToLocal
974
+ );
975
+ }
976
+ yield setRemoteDescription(this.peerConnection, answer);
848
977
  console.debug("[GPUMachineClient] Remote description set");
849
978
  } catch (error) {
850
979
  console.error("[GPUMachineClient] Failed to connect:", error);
@@ -872,8 +1001,10 @@ var GPUMachineClient = class {
872
1001
  this.peerConnection = void 0;
873
1002
  }
874
1003
  this.transceiverMap.clear();
1004
+ this.midMapping = void 0;
875
1005
  this.peerConnected = false;
876
1006
  this.dataChannelOpen = false;
1007
+ this.resetConnectionTimings();
877
1008
  this.setStatus("disconnected");
878
1009
  console.debug("[GPUMachineClient] Disconnected");
879
1010
  });
@@ -898,6 +1029,14 @@ var GPUMachineClient = class {
898
1029
  // ─────────────────────────────────────────────────────────────────────────────
899
1030
  // Messaging
900
1031
  // ─────────────────────────────────────────────────────────────────────────────
1032
+ /**
1033
+ * Returns the negotiated SCTP max message size (bytes) if available,
1034
+ * otherwise `undefined` so `sendMessage` falls back to its built-in default.
1035
+ */
1036
+ get maxMessageBytes() {
1037
+ var _a, _b, _c;
1038
+ return (_c = (_b = (_a = this.peerConnection) == null ? void 0 : _a.sctp) == null ? void 0 : _b.maxMessageSize) != null ? _c : void 0;
1039
+ }
901
1040
  /**
902
1041
  * Sends a command to the GPU machine via the data channel.
903
1042
  * @param command The command to send
@@ -909,7 +1048,13 @@ var GPUMachineClient = class {
909
1048
  throw new Error("[GPUMachineClient] Data channel not available");
910
1049
  }
911
1050
  try {
912
- sendMessage(this.dataChannel, command, data, scope);
1051
+ sendMessage(
1052
+ this.dataChannel,
1053
+ command,
1054
+ data,
1055
+ scope,
1056
+ this.maxMessageBytes
1057
+ );
913
1058
  } catch (error) {
914
1059
  console.warn("[GPUMachineClient] Failed to send message:", error);
915
1060
  }
@@ -1037,6 +1182,24 @@ var GPUMachineClient = class {
1037
1182
  getStats() {
1038
1183
  return this.stats;
1039
1184
  }
1185
+ /**
1186
+ * Returns the ICE/data-channel durations recorded during the last connect(),
1187
+ * or undefined if no connection has completed yet.
1188
+ */
1189
+ getConnectionTimings() {
1190
+ if (this.iceNegotiationMs == null || this.dataChannelMs == null) {
1191
+ return void 0;
1192
+ }
1193
+ return {
1194
+ iceNegotiationMs: this.iceNegotiationMs,
1195
+ dataChannelMs: this.dataChannelMs
1196
+ };
1197
+ }
1198
+ resetConnectionTimings() {
1199
+ this.iceStartTime = void 0;
1200
+ this.iceNegotiationMs = void 0;
1201
+ this.dataChannelMs = void 0;
1202
+ }
1040
1203
  startStatsPolling() {
1041
1204
  this.stopStatsPolling();
1042
1205
  this.statsInterval = setInterval(() => __async(this, null, function* () {
@@ -1080,6 +1243,9 @@ var GPUMachineClient = class {
1080
1243
  if (state) {
1081
1244
  switch (state) {
1082
1245
  case "connected":
1246
+ if (this.iceStartTime != null && this.iceNegotiationMs == null) {
1247
+ this.iceNegotiationMs = performance.now() - this.iceStartTime;
1248
+ }
1083
1249
  this.peerConnected = true;
1084
1250
  this.checkFullyConnected();
1085
1251
  break;
@@ -1096,13 +1262,19 @@ var GPUMachineClient = class {
1096
1262
  }
1097
1263
  };
1098
1264
  this.peerConnection.ontrack = (event) => {
1099
- var _a;
1100
- const mid = event.transceiver.mid;
1101
- const trackName = mid != null ? mid : `unknown-${event.track.id}`;
1265
+ var _a, _b;
1266
+ let trackName;
1267
+ for (const [name, entry] of this.transceiverMap) {
1268
+ if (entry.transceiver === event.transceiver) {
1269
+ trackName = name;
1270
+ break;
1271
+ }
1272
+ }
1273
+ trackName != null ? trackName : trackName = (_a = event.transceiver.mid) != null ? _a : `unknown-${event.track.id}`;
1102
1274
  console.debug(
1103
- `[GPUMachineClient] Track received: "${trackName}" (${event.track.kind}, mid=${mid})`
1275
+ `[GPUMachineClient] Track received: "${trackName}" (${event.track.kind}, mid=${event.transceiver.mid})`
1104
1276
  );
1105
- const stream = (_a = event.streams[0]) != null ? _a : new MediaStream([event.track]);
1277
+ const stream = (_b = event.streams[0]) != null ? _b : new MediaStream([event.track]);
1106
1278
  this.emit("trackReceived", trackName, event.track, stream);
1107
1279
  };
1108
1280
  this.peerConnection.onicecandidate = (event) => {
@@ -1123,6 +1295,9 @@ var GPUMachineClient = class {
1123
1295
  if (!this.dataChannel) return;
1124
1296
  this.dataChannel.onopen = () => {
1125
1297
  console.debug("[GPUMachineClient] Data channel open");
1298
+ if (this.iceStartTime != null && this.dataChannelMs == null) {
1299
+ this.dataChannelMs = performance.now() - this.iceStartTime;
1300
+ }
1126
1301
  this.dataChannelOpen = true;
1127
1302
  this.startPing();
1128
1303
  this.checkFullyConnected();
@@ -1162,13 +1337,13 @@ var GPUMachineClient = class {
1162
1337
  // src/core/Reactor.ts
1163
1338
  var import_zod2 = require("zod");
1164
1339
  var LOCAL_COORDINATOR_URL = "http://localhost:8080";
1165
- var PROD_COORDINATOR_URL = "https://api.reactor.inc";
1340
+ var DEFAULT_BASE_URL = "https://api.reactor.inc";
1166
1341
  var TrackConfigSchema = import_zod2.z.object({
1167
1342
  name: import_zod2.z.string(),
1168
1343
  kind: import_zod2.z.enum(["audio", "video"])
1169
1344
  });
1170
1345
  var OptionsSchema = import_zod2.z.object({
1171
- coordinatorUrl: import_zod2.z.string().default(PROD_COORDINATOR_URL),
1346
+ apiUrl: import_zod2.z.string().default(DEFAULT_BASE_URL),
1172
1347
  modelName: import_zod2.z.string(),
1173
1348
  local: import_zod2.z.boolean().default(false),
1174
1349
  /**
@@ -1193,12 +1368,12 @@ var Reactor = class {
1193
1368
  // Generic event map
1194
1369
  this.eventListeners = /* @__PURE__ */ new Map();
1195
1370
  const validatedOptions = OptionsSchema.parse(options);
1196
- this.coordinatorUrl = validatedOptions.coordinatorUrl;
1371
+ this.coordinatorUrl = validatedOptions.apiUrl;
1197
1372
  this.model = validatedOptions.modelName;
1198
1373
  this.local = validatedOptions.local;
1199
1374
  this.receive = validatedOptions.receive;
1200
1375
  this.send = validatedOptions.send;
1201
- if (this.local && options.coordinatorUrl === void 0) {
1376
+ if (this.local && options.apiUrl === void 0) {
1202
1377
  this.coordinatorUrl = LOCAL_COORDINATOR_URL;
1203
1378
  }
1204
1379
  }
@@ -1319,14 +1494,14 @@ var Reactor = class {
1319
1494
  receive: this.receive
1320
1495
  });
1321
1496
  try {
1322
- const sdpAnswer = yield this.coordinatorClient.connect(
1497
+ const { sdpAnswer } = yield this.coordinatorClient.connect(
1323
1498
  this.sessionId,
1324
1499
  sdpOffer,
1325
1500
  options == null ? void 0 : options.maxAttempts
1326
1501
  );
1327
1502
  yield this.machineClient.connect(sdpAnswer);
1328
- this.setStatus("ready");
1329
1503
  } catch (error) {
1504
+ if (isAbortError(error)) return;
1330
1505
  let recoverable = false;
1331
1506
  if (error instanceof ConflictError) {
1332
1507
  recoverable = true;
@@ -1336,7 +1511,7 @@ var Reactor = class {
1336
1511
  this.createError(
1337
1512
  "RECONNECTION_FAILED",
1338
1513
  `Failed to reconnect: ${error}`,
1339
- "coordinator",
1514
+ "api",
1340
1515
  true
1341
1516
  );
1342
1517
  }
@@ -1359,6 +1534,7 @@ var Reactor = class {
1359
1534
  throw new Error("Already connected or connecting");
1360
1535
  }
1361
1536
  this.setStatus("connecting");
1537
+ this.connectStartTime = performance.now();
1362
1538
  try {
1363
1539
  console.debug(
1364
1540
  "[Reactor] Connecting to coordinator with authenticated URL"
@@ -1376,20 +1552,33 @@ var Reactor = class {
1376
1552
  send: this.send,
1377
1553
  receive: this.receive
1378
1554
  });
1555
+ const tSession = performance.now();
1379
1556
  const sessionId = yield this.coordinatorClient.createSession(sdpOffer);
1557
+ const sessionCreationMs = performance.now() - tSession;
1380
1558
  this.setSessionId(sessionId);
1381
- const sdpAnswer = yield this.coordinatorClient.connect(
1559
+ const tSdp = performance.now();
1560
+ const { sdpAnswer, sdpPollingAttempts } = yield this.coordinatorClient.connect(
1382
1561
  sessionId,
1383
1562
  void 0,
1384
1563
  options == null ? void 0 : options.maxAttempts
1385
1564
  );
1565
+ const sdpPollingMs = performance.now() - tSdp;
1566
+ this.connectionTimings = {
1567
+ sessionCreationMs,
1568
+ sdpPollingMs,
1569
+ sdpPollingAttempts,
1570
+ iceNegotiationMs: 0,
1571
+ dataChannelMs: 0,
1572
+ totalMs: 0
1573
+ };
1386
1574
  yield this.machineClient.connect(sdpAnswer);
1387
1575
  } catch (error) {
1576
+ if (isAbortError(error)) return;
1388
1577
  console.error("[Reactor] Connection failed:", error);
1389
1578
  this.createError(
1390
1579
  "CONNECTION_FAILED",
1391
1580
  `Connection failed: ${error}`,
1392
- "coordinator",
1581
+ "api",
1393
1582
  true
1394
1583
  );
1395
1584
  try {
@@ -1406,19 +1595,28 @@ var Reactor = class {
1406
1595
  }
1407
1596
  /**
1408
1597
  * Sets up event handlers for the machine client.
1598
+ *
1599
+ * Each handler captures the client reference at registration time and
1600
+ * ignores events if this.machineClient has since changed (e.g. after
1601
+ * disconnect + reconnect), preventing stale WebRTC teardown events from
1602
+ * interfering with a new connection.
1409
1603
  */
1410
1604
  setupMachineClientHandlers() {
1411
1605
  if (!this.machineClient) return;
1412
- this.machineClient.on("message", (message, scope) => {
1606
+ const client = this.machineClient;
1607
+ client.on("message", (message, scope) => {
1608
+ if (this.machineClient !== client) return;
1413
1609
  if (scope === "application") {
1414
1610
  this.emit("message", message);
1415
1611
  } else if (scope === "runtime") {
1416
1612
  this.emit("runtimeMessage", message);
1417
1613
  }
1418
1614
  });
1419
- this.machineClient.on("statusChanged", (status) => {
1615
+ client.on("statusChanged", (status) => {
1616
+ if (this.machineClient !== client) return;
1420
1617
  switch (status) {
1421
1618
  case "connected":
1619
+ this.finalizeConnectionTimings(client);
1422
1620
  this.setStatus("ready");
1423
1621
  break;
1424
1622
  case "disconnected":
@@ -1435,14 +1633,18 @@ var Reactor = class {
1435
1633
  break;
1436
1634
  }
1437
1635
  });
1438
- this.machineClient.on(
1636
+ client.on(
1439
1637
  "trackReceived",
1440
1638
  (name, track, stream) => {
1639
+ if (this.machineClient !== client) return;
1441
1640
  this.emit("trackReceived", name, track, stream);
1442
1641
  }
1443
1642
  );
1444
- this.machineClient.on("statsUpdate", (stats) => {
1445
- this.emit("statsUpdate", stats);
1643
+ client.on("statsUpdate", (stats) => {
1644
+ if (this.machineClient !== client) return;
1645
+ this.emit("statsUpdate", __spreadProps(__spreadValues({}, stats), {
1646
+ connectionTimings: this.connectionTimings
1647
+ }));
1446
1648
  });
1447
1649
  }
1448
1650
  /**
@@ -1451,10 +1653,12 @@ var Reactor = class {
1451
1653
  */
1452
1654
  disconnect(recoverable = false) {
1453
1655
  return __async(this, null, function* () {
1656
+ var _a;
1454
1657
  if (this.status === "disconnected" && !this.sessionId) {
1455
1658
  console.warn("[Reactor] Already disconnected");
1456
1659
  return;
1457
1660
  }
1661
+ (_a = this.coordinatorClient) == null ? void 0 : _a.abort();
1458
1662
  if (this.coordinatorClient && !recoverable) {
1459
1663
  try {
1460
1664
  yield this.coordinatorClient.terminateSession();
@@ -1474,6 +1678,7 @@ var Reactor = class {
1474
1678
  }
1475
1679
  }
1476
1680
  this.setStatus("disconnected");
1681
+ this.resetConnectionTimings();
1477
1682
  if (!recoverable) {
1478
1683
  this.setSessionExpiration(void 0);
1479
1684
  this.setSessionId(void 0);
@@ -1536,7 +1741,23 @@ var Reactor = class {
1536
1741
  }
1537
1742
  getStats() {
1538
1743
  var _a;
1539
- return (_a = this.machineClient) == null ? void 0 : _a.getStats();
1744
+ const stats = (_a = this.machineClient) == null ? void 0 : _a.getStats();
1745
+ if (!stats) return void 0;
1746
+ return __spreadProps(__spreadValues({}, stats), { connectionTimings: this.connectionTimings });
1747
+ }
1748
+ resetConnectionTimings() {
1749
+ this.connectStartTime = void 0;
1750
+ this.connectionTimings = void 0;
1751
+ }
1752
+ finalizeConnectionTimings(client) {
1753
+ var _a, _b;
1754
+ if (!this.connectionTimings || this.connectStartTime == null) return;
1755
+ const webrtcTimings = client.getConnectionTimings();
1756
+ this.connectionTimings.iceNegotiationMs = (_a = webrtcTimings == null ? void 0 : webrtcTimings.iceNegotiationMs) != null ? _a : 0;
1757
+ this.connectionTimings.dataChannelMs = (_b = webrtcTimings == null ? void 0 : webrtcTimings.dataChannelMs) != null ? _b : 0;
1758
+ this.connectionTimings.totalMs = performance.now() - this.connectStartTime;
1759
+ this.connectStartTime = void 0;
1760
+ console.debug("[Reactor] Connection timings:", this.connectionTimings);
1540
1761
  }
1541
1762
  /**
1542
1763
  * Create and store an error
@@ -1576,7 +1797,7 @@ var initReactorStore = (props) => {
1576
1797
  };
1577
1798
  var createReactorStore = (initProps, publicState = defaultInitState) => {
1578
1799
  console.debug("[ReactorStore] Creating store", {
1579
- coordinatorUrl: initProps.coordinatorUrl,
1800
+ apiUrl: initProps.apiUrl,
1580
1801
  jwtToken: initProps.jwtToken,
1581
1802
  initialState: publicState
1582
1803
  });
@@ -1744,7 +1965,7 @@ function ReactorProvider(_a) {
1744
1965
  console.debug("[ReactorProvider] Reactor store created successfully");
1745
1966
  }
1746
1967
  const _a2 = connectOptions != null ? connectOptions : {}, { autoConnect = false } = _a2, pollingOptions = __objRest(_a2, ["autoConnect"]);
1747
- const { coordinatorUrl, modelName, local, receive, send } = props;
1968
+ const { apiUrl, modelName, local, receive, send } = props;
1748
1969
  const maxAttempts = pollingOptions.maxAttempts;
1749
1970
  (0, import_react3.useEffect)(() => {
1750
1971
  const handleBeforeUnload = () => {
@@ -1797,7 +2018,7 @@ function ReactorProvider(_a) {
1797
2018
  console.debug("[ReactorProvider] Updating reactor store");
1798
2019
  storeRef.current = createReactorStore(
1799
2020
  initReactorStore({
1800
- coordinatorUrl,
2021
+ apiUrl,
1801
2022
  modelName,
1802
2023
  local,
1803
2024
  receive,
@@ -1829,7 +2050,7 @@ function ReactorProvider(_a) {
1829
2050
  });
1830
2051
  };
1831
2052
  }, [
1832
- coordinatorUrl,
2053
+ apiUrl,
1833
2054
  modelName,
1834
2055
  autoConnect,
1835
2056
  local,
@@ -2658,12 +2879,12 @@ function WebcamStream({
2658
2879
  }
2659
2880
 
2660
2881
  // src/utils/tokens.ts
2661
- function fetchInsecureJwtToken(_0) {
2662
- return __async(this, arguments, function* (apiKey, coordinatorUrl = PROD_COORDINATOR_URL) {
2882
+ function fetchInsecureToken(_0) {
2883
+ return __async(this, arguments, function* (apiKey, apiUrl = DEFAULT_BASE_URL) {
2663
2884
  console.warn(
2664
- "[Reactor] \u26A0\uFE0F SECURITY WARNING: fetchInsecureJwtToken() exposes your API key in client-side code. This should ONLY be used for local development or testing. In production, fetch tokens from your server instead."
2885
+ "[Reactor] \u26A0\uFE0F SECURITY WARNING: fetchInsecureToken() exposes your API key in client-side code. This should ONLY be used for local development or testing. In production, fetch tokens from your server instead."
2665
2886
  );
2666
- const response = yield fetch(`${coordinatorUrl}/tokens`, {
2887
+ const response = yield fetch(`${apiUrl}/tokens`, {
2667
2888
  method: "GET",
2668
2889
  headers: {
2669
2890
  "X-API-Key": apiKey
@@ -2679,15 +2900,17 @@ function fetchInsecureJwtToken(_0) {
2679
2900
  }
2680
2901
  // Annotate the CommonJS export names for ESM import in node:
2681
2902
  0 && (module.exports = {
2903
+ AbortError,
2682
2904
  ConflictError,
2683
- PROD_COORDINATOR_URL,
2905
+ DEFAULT_BASE_URL,
2684
2906
  Reactor,
2685
2907
  ReactorController,
2686
2908
  ReactorProvider,
2687
2909
  ReactorView,
2688
2910
  WebcamStream,
2689
2911
  audio,
2690
- fetchInsecureJwtToken,
2912
+ fetchInsecureToken,
2913
+ isAbortError,
2691
2914
  useReactor,
2692
2915
  useReactorInternalMessage,
2693
2916
  useReactorMessage,