@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.mjs CHANGED
@@ -62,6 +62,14 @@ var ConflictError = class extends Error {
62
62
  super(message);
63
63
  }
64
64
  };
65
+ var AbortError = class extends Error {
66
+ constructor(message) {
67
+ super(message);
68
+ }
69
+ };
70
+ function isAbortError(error) {
71
+ return error instanceof AbortError || error instanceof Error && error.name === "AbortError";
72
+ }
65
73
 
66
74
  // src/core/types.ts
67
75
  import { z } from "zod";
@@ -121,6 +129,7 @@ var IceServersResponseSchema = z.object({
121
129
  // src/utils/webrtc.ts
122
130
  var DEFAULT_DATA_CHANNEL_LABEL = "data";
123
131
  var FORCE_RELAY_MODE = false;
132
+ var DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024;
124
133
  function createPeerConnection(config) {
125
134
  return new RTCPeerConnection({
126
135
  iceServers: config.iceServers,
@@ -167,13 +176,17 @@ function rewriteMids(sdp, trackNames) {
167
176
  function createOffer(pc, trackNames) {
168
177
  return __async(this, null, function* () {
169
178
  const offer = yield pc.createOffer();
179
+ let needsAnswerRestore = false;
170
180
  if (trackNames && trackNames.length > 0 && offer.sdp) {
171
181
  const munged = rewriteMids(offer.sdp, trackNames);
172
- const mungedOffer = new RTCSessionDescription({
173
- type: "offer",
174
- sdp: munged
175
- });
176
- yield pc.setLocalDescription(mungedOffer);
182
+ try {
183
+ yield pc.setLocalDescription(
184
+ new RTCSessionDescription({ type: "offer", sdp: munged })
185
+ );
186
+ } catch (e) {
187
+ yield pc.setLocalDescription(offer);
188
+ needsAnswerRestore = true;
189
+ }
177
190
  } else {
178
191
  yield pc.setLocalDescription(offer);
179
192
  }
@@ -182,9 +195,49 @@ function createOffer(pc, trackNames) {
182
195
  if (!localDescription) {
183
196
  throw new Error("Failed to create local description");
184
197
  }
185
- return localDescription.sdp;
198
+ let sdp = localDescription.sdp;
199
+ if (needsAnswerRestore && trackNames && trackNames.length > 0) {
200
+ sdp = rewriteMids(sdp, trackNames);
201
+ }
202
+ return { sdp, needsAnswerRestore };
186
203
  });
187
204
  }
205
+ function buildMidMapping(transceivers) {
206
+ var _a;
207
+ const localToRemote = /* @__PURE__ */ new Map();
208
+ const remoteToLocal = /* @__PURE__ */ new Map();
209
+ for (const entry of transceivers) {
210
+ const mid = (_a = entry.transceiver) == null ? void 0 : _a.mid;
211
+ if (mid) {
212
+ localToRemote.set(mid, entry.name);
213
+ remoteToLocal.set(entry.name, mid);
214
+ }
215
+ }
216
+ return { localToRemote, remoteToLocal };
217
+ }
218
+ function restoreAnswerMids(sdp, remoteToLocal) {
219
+ const lines = sdp.split("\r\n");
220
+ for (let i = 0; i < lines.length; i++) {
221
+ if (lines[i].startsWith("a=mid:")) {
222
+ const remoteMid = lines[i].substring("a=mid:".length);
223
+ const localMid = remoteToLocal.get(remoteMid);
224
+ if (localMid !== void 0) {
225
+ lines[i] = `a=mid:${localMid}`;
226
+ }
227
+ }
228
+ if (lines[i].startsWith("a=group:BUNDLE ")) {
229
+ const parts = lines[i].split(" ");
230
+ for (let j = 1; j < parts.length; j++) {
231
+ const localMid = remoteToLocal.get(parts[j]);
232
+ if (localMid !== void 0) {
233
+ parts[j] = localMid;
234
+ }
235
+ }
236
+ lines[i] = parts.join(" ");
237
+ }
238
+ }
239
+ return lines.join("\r\n");
240
+ }
188
241
  function setRemoteDescription(pc, sdp) {
189
242
  return __async(this, null, function* () {
190
243
  const sessionDescription = new RTCSessionDescription({
@@ -233,14 +286,21 @@ function waitForIceGathering(pc, timeoutMs = 5e3) {
233
286
  }, timeoutMs);
234
287
  });
235
288
  }
236
- function sendMessage(channel, command, data, scope = "application") {
289
+ function sendMessage(channel, command, data, scope = "application", maxBytes = DEFAULT_MAX_MESSAGE_BYTES) {
237
290
  if (channel.readyState !== "open") {
238
291
  throw new Error(`Data channel not open: ${channel.readyState}`);
239
292
  }
240
293
  const jsonData = typeof data === "string" ? JSON.parse(data) : data;
241
294
  const inner = { type: command, data: jsonData };
242
295
  const payload = { scope, data: inner };
243
- channel.send(JSON.stringify(payload));
296
+ const serialized = JSON.stringify(payload);
297
+ const byteLength = new TextEncoder().encode(serialized).byteLength;
298
+ if (byteLength > maxBytes) {
299
+ throw new Error(
300
+ `Data channel message too large: ${byteLength} bytes exceeds limit of ${maxBytes} bytes (command: "${command}")`
301
+ );
302
+ }
303
+ channel.send(serialized);
244
304
  }
245
305
  function parseMessage(data) {
246
306
  if (typeof data === "string") {
@@ -312,6 +372,22 @@ var CoordinatorClient = class {
312
372
  this.baseUrl = options.baseUrl;
313
373
  this.jwtToken = options.jwtToken;
314
374
  this.model = options.model;
375
+ this.abortController = new AbortController();
376
+ }
377
+ /**
378
+ * Aborts any in-flight HTTP requests and polling loops.
379
+ * A fresh AbortController is created so the client remains reusable.
380
+ */
381
+ abort() {
382
+ this.abortController.abort();
383
+ this.abortController = new AbortController();
384
+ }
385
+ /**
386
+ * The current abort signal, passed to every fetch() and sleep() call.
387
+ * Protected so subclasses can forward it to their own fetch calls.
388
+ */
389
+ get signal() {
390
+ return this.abortController.signal;
315
391
  }
316
392
  /**
317
393
  * Returns the authorization header with JWT Bearer token
@@ -332,7 +408,8 @@ var CoordinatorClient = class {
332
408
  `${this.baseUrl}/ice_servers?model=${this.model}`,
333
409
  {
334
410
  method: "GET",
335
- headers: this.getAuthHeaders()
411
+ headers: this.getAuthHeaders(),
412
+ signal: this.signal
336
413
  }
337
414
  );
338
415
  if (!response.ok) {
@@ -366,7 +443,8 @@ var CoordinatorClient = class {
366
443
  headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
367
444
  "Content-Type": "application/json"
368
445
  }),
369
- body: JSON.stringify(requestBody)
446
+ body: JSON.stringify(requestBody),
447
+ signal: this.signal
370
448
  });
371
449
  if (!response.ok) {
372
450
  const errorText = yield response.text();
@@ -400,7 +478,8 @@ var CoordinatorClient = class {
400
478
  `${this.baseUrl}/sessions/${this.currentSessionId}`,
401
479
  {
402
480
  method: "GET",
403
- headers: this.getAuthHeaders()
481
+ headers: this.getAuthHeaders(),
482
+ signal: this.signal
404
483
  }
405
484
  );
406
485
  if (!response.ok) {
@@ -413,12 +492,13 @@ var CoordinatorClient = class {
413
492
  }
414
493
  /**
415
494
  * Terminates the current session by sending a DELETE request to the coordinator.
416
- * @throws Error if no active session exists or if the request fails (except for 404)
495
+ * No-op if no session has been created yet.
496
+ * @throws Error if the request fails (except for 404, which clears local state)
417
497
  */
418
498
  terminateSession() {
419
499
  return __async(this, null, function* () {
420
500
  if (!this.currentSessionId) {
421
- throw new Error("No active session. Call createSession() first.");
501
+ return;
422
502
  }
423
503
  console.debug(
424
504
  "[CoordinatorClient] Terminating session:",
@@ -428,7 +508,8 @@ var CoordinatorClient = class {
428
508
  `${this.baseUrl}/sessions/${this.currentSessionId}`,
429
509
  {
430
510
  method: "DELETE",
431
- headers: this.getAuthHeaders()
511
+ headers: this.getAuthHeaders(),
512
+ signal: this.signal
432
513
  }
433
514
  );
434
515
  if (response.ok) {
@@ -478,7 +559,8 @@ var CoordinatorClient = class {
478
559
  headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
479
560
  "Content-Type": "application/json"
480
561
  }),
481
- body: JSON.stringify(requestBody)
562
+ body: JSON.stringify(requestBody),
563
+ signal: this.signal
482
564
  }
483
565
  );
484
566
  if (response.status === 200) {
@@ -514,6 +596,9 @@ var CoordinatorClient = class {
514
596
  let backoffMs = INITIAL_BACKOFF_MS;
515
597
  let attempt = 0;
516
598
  while (true) {
599
+ if (this.signal.aborted) {
600
+ throw new AbortError("SDP polling aborted");
601
+ }
517
602
  if (attempt >= maxAttempts) {
518
603
  throw new Error(
519
604
  `SDP polling exceeded maximum attempts (${maxAttempts}) for session ${sessionId}`
@@ -529,13 +614,14 @@ var CoordinatorClient = class {
529
614
  method: "GET",
530
615
  headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
531
616
  "Content-Type": "application/json"
532
- })
617
+ }),
618
+ signal: this.signal
533
619
  }
534
620
  );
535
621
  if (response.status === 200) {
536
622
  const answerData = yield response.json();
537
623
  console.debug("[CoordinatorClient] Received SDP answer via polling");
538
- return answerData.sdp_answer;
624
+ return { sdpAnswer: answerData.sdp_answer, attempts: attempt };
539
625
  }
540
626
  if (response.status === 202) {
541
627
  console.warn(
@@ -559,7 +645,7 @@ var CoordinatorClient = class {
559
645
  * @param sessionId - The session ID to connect to
560
646
  * @param sdpOffer - Optional SDP offer from the local WebRTC peer connection
561
647
  * @param maxAttempts - Optional maximum number of polling attempts before giving up
562
- * @returns The SDP answer from the server
648
+ * @returns The SDP answer and the number of polling attempts made (0 if answered immediately via PUT)
563
649
  */
564
650
  connect(sessionId, sdpOffer, maxAttempts) {
565
651
  return __async(this, null, function* () {
@@ -567,17 +653,34 @@ var CoordinatorClient = class {
567
653
  if (sdpOffer) {
568
654
  const answer = yield this.sendSdpOffer(sessionId, sdpOffer);
569
655
  if (answer !== null) {
570
- return answer;
656
+ return { sdpAnswer: answer, sdpPollingAttempts: 0 };
571
657
  }
572
658
  }
573
- return this.pollSdpAnswer(sessionId, maxAttempts);
659
+ const result = yield this.pollSdpAnswer(sessionId, maxAttempts);
660
+ return { sdpAnswer: result.sdpAnswer, sdpPollingAttempts: result.attempts };
574
661
  });
575
662
  }
576
663
  /**
577
- * Utility function to sleep for a given number of milliseconds
664
+ * Abort-aware sleep. Resolves after `ms` milliseconds unless the
665
+ * abort signal fires first, in which case it rejects with AbortError.
578
666
  */
579
667
  sleep(ms) {
580
- return new Promise((resolve) => setTimeout(resolve, ms));
668
+ return new Promise((resolve, reject) => {
669
+ const { signal } = this;
670
+ if (signal.aborted) {
671
+ reject(new AbortError("Sleep aborted"));
672
+ return;
673
+ }
674
+ const timer = setTimeout(() => {
675
+ signal.removeEventListener("abort", onAbort);
676
+ resolve();
677
+ }, ms);
678
+ const onAbort = () => {
679
+ clearTimeout(timer);
680
+ reject(new AbortError("Sleep aborted"));
681
+ };
682
+ signal.addEventListener("abort", onAbort, { once: true });
683
+ });
581
684
  }
582
685
  };
583
686
 
@@ -599,7 +702,8 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
599
702
  return __async(this, null, function* () {
600
703
  console.debug("[LocalCoordinatorClient] Fetching ICE servers...");
601
704
  const response = yield fetch(`${this.localBaseUrl}/ice_servers`, {
602
- method: "GET"
705
+ method: "GET",
706
+ signal: this.signal
603
707
  });
604
708
  if (!response.ok) {
605
709
  throw new Error("Failed to get ICE servers from local coordinator.");
@@ -623,7 +727,8 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
623
727
  console.debug("[LocalCoordinatorClient] Creating local session...");
624
728
  this.sdpOffer = sdpOffer;
625
729
  const response = yield fetch(`${this.localBaseUrl}/start_session`, {
626
- method: "POST"
730
+ method: "POST",
731
+ signal: this.signal
627
732
  });
628
733
  if (!response.ok) {
629
734
  throw new Error("Failed to send local start session command.");
@@ -634,9 +739,10 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
634
739
  }
635
740
  /**
636
741
  * Connects to the local session by posting SDP params to /sdp_params.
742
+ * Local connections are always immediate (no polling).
637
743
  * @param sessionId - The session ID (ignored for local)
638
744
  * @param sdpMessage - The SDP offer from the local WebRTC peer connection
639
- * @returns The SDP answer from the server
745
+ * @returns The SDP answer and polling attempts (always 0 for local)
640
746
  */
641
747
  connect(sessionId, sdpMessage) {
642
748
  return __async(this, null, function* () {
@@ -651,7 +757,8 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
651
757
  headers: {
652
758
  "Content-Type": "application/json"
653
759
  },
654
- body: JSON.stringify(sdpBody)
760
+ body: JSON.stringify(sdpBody),
761
+ signal: this.signal
655
762
  });
656
763
  if (!response.ok) {
657
764
  if (response.status === 409) {
@@ -661,14 +768,15 @@ var LocalCoordinatorClient = class extends CoordinatorClient {
661
768
  }
662
769
  const sdpAnswer = yield response.json();
663
770
  console.debug("[LocalCoordinatorClient] Received SDP answer");
664
- return sdpAnswer.sdp;
771
+ return { sdpAnswer: sdpAnswer.sdp, sdpPollingAttempts: 0 };
665
772
  });
666
773
  }
667
774
  terminateSession() {
668
775
  return __async(this, null, function* () {
669
776
  console.debug("[LocalCoordinatorClient] Stopping local session...");
670
777
  yield fetch(`${this.localBaseUrl}/stop_session`, {
671
- method: "POST"
778
+ method: "POST",
779
+ signal: this.signal
672
780
  });
673
781
  });
674
782
  }
@@ -743,12 +851,21 @@ var GPUMachineClient = class {
743
851
  );
744
852
  }
745
853
  const trackNames = entries.map((e) => e.name);
746
- const offer = yield createOffer(this.peerConnection, trackNames);
854
+ const { sdp, needsAnswerRestore } = yield createOffer(
855
+ this.peerConnection,
856
+ trackNames
857
+ );
858
+ if (needsAnswerRestore) {
859
+ this.midMapping = buildMidMapping(entries);
860
+ } else {
861
+ this.midMapping = void 0;
862
+ }
747
863
  console.debug(
748
864
  "[GPUMachineClient] Created SDP offer with MIDs:",
749
- trackNames
865
+ trackNames,
866
+ needsAnswerRestore ? "(needs answer restore)" : "(native munging)"
750
867
  );
751
- return offer;
868
+ return sdp;
752
869
  });
753
870
  }
754
871
  /**
@@ -796,8 +913,18 @@ var GPUMachineClient = class {
796
913
  );
797
914
  }
798
915
  this.setStatus("connecting");
916
+ this.iceStartTime = performance.now();
917
+ this.iceNegotiationMs = void 0;
918
+ this.dataChannelMs = void 0;
799
919
  try {
800
- yield setRemoteDescription(this.peerConnection, sdpAnswer);
920
+ let answer = sdpAnswer;
921
+ if (this.midMapping) {
922
+ answer = restoreAnswerMids(
923
+ answer,
924
+ this.midMapping.remoteToLocal
925
+ );
926
+ }
927
+ yield setRemoteDescription(this.peerConnection, answer);
801
928
  console.debug("[GPUMachineClient] Remote description set");
802
929
  } catch (error) {
803
930
  console.error("[GPUMachineClient] Failed to connect:", error);
@@ -825,8 +952,10 @@ var GPUMachineClient = class {
825
952
  this.peerConnection = void 0;
826
953
  }
827
954
  this.transceiverMap.clear();
955
+ this.midMapping = void 0;
828
956
  this.peerConnected = false;
829
957
  this.dataChannelOpen = false;
958
+ this.resetConnectionTimings();
830
959
  this.setStatus("disconnected");
831
960
  console.debug("[GPUMachineClient] Disconnected");
832
961
  });
@@ -851,6 +980,14 @@ var GPUMachineClient = class {
851
980
  // ─────────────────────────────────────────────────────────────────────────────
852
981
  // Messaging
853
982
  // ─────────────────────────────────────────────────────────────────────────────
983
+ /**
984
+ * Returns the negotiated SCTP max message size (bytes) if available,
985
+ * otherwise `undefined` so `sendMessage` falls back to its built-in default.
986
+ */
987
+ get maxMessageBytes() {
988
+ var _a, _b, _c;
989
+ return (_c = (_b = (_a = this.peerConnection) == null ? void 0 : _a.sctp) == null ? void 0 : _b.maxMessageSize) != null ? _c : void 0;
990
+ }
854
991
  /**
855
992
  * Sends a command to the GPU machine via the data channel.
856
993
  * @param command The command to send
@@ -862,7 +999,13 @@ var GPUMachineClient = class {
862
999
  throw new Error("[GPUMachineClient] Data channel not available");
863
1000
  }
864
1001
  try {
865
- sendMessage(this.dataChannel, command, data, scope);
1002
+ sendMessage(
1003
+ this.dataChannel,
1004
+ command,
1005
+ data,
1006
+ scope,
1007
+ this.maxMessageBytes
1008
+ );
866
1009
  } catch (error) {
867
1010
  console.warn("[GPUMachineClient] Failed to send message:", error);
868
1011
  }
@@ -990,6 +1133,24 @@ var GPUMachineClient = class {
990
1133
  getStats() {
991
1134
  return this.stats;
992
1135
  }
1136
+ /**
1137
+ * Returns the ICE/data-channel durations recorded during the last connect(),
1138
+ * or undefined if no connection has completed yet.
1139
+ */
1140
+ getConnectionTimings() {
1141
+ if (this.iceNegotiationMs == null || this.dataChannelMs == null) {
1142
+ return void 0;
1143
+ }
1144
+ return {
1145
+ iceNegotiationMs: this.iceNegotiationMs,
1146
+ dataChannelMs: this.dataChannelMs
1147
+ };
1148
+ }
1149
+ resetConnectionTimings() {
1150
+ this.iceStartTime = void 0;
1151
+ this.iceNegotiationMs = void 0;
1152
+ this.dataChannelMs = void 0;
1153
+ }
993
1154
  startStatsPolling() {
994
1155
  this.stopStatsPolling();
995
1156
  this.statsInterval = setInterval(() => __async(this, null, function* () {
@@ -1033,6 +1194,9 @@ var GPUMachineClient = class {
1033
1194
  if (state) {
1034
1195
  switch (state) {
1035
1196
  case "connected":
1197
+ if (this.iceStartTime != null && this.iceNegotiationMs == null) {
1198
+ this.iceNegotiationMs = performance.now() - this.iceStartTime;
1199
+ }
1036
1200
  this.peerConnected = true;
1037
1201
  this.checkFullyConnected();
1038
1202
  break;
@@ -1049,13 +1213,19 @@ var GPUMachineClient = class {
1049
1213
  }
1050
1214
  };
1051
1215
  this.peerConnection.ontrack = (event) => {
1052
- var _a;
1053
- const mid = event.transceiver.mid;
1054
- const trackName = mid != null ? mid : `unknown-${event.track.id}`;
1216
+ var _a, _b;
1217
+ let trackName;
1218
+ for (const [name, entry] of this.transceiverMap) {
1219
+ if (entry.transceiver === event.transceiver) {
1220
+ trackName = name;
1221
+ break;
1222
+ }
1223
+ }
1224
+ trackName != null ? trackName : trackName = (_a = event.transceiver.mid) != null ? _a : `unknown-${event.track.id}`;
1055
1225
  console.debug(
1056
- `[GPUMachineClient] Track received: "${trackName}" (${event.track.kind}, mid=${mid})`
1226
+ `[GPUMachineClient] Track received: "${trackName}" (${event.track.kind}, mid=${event.transceiver.mid})`
1057
1227
  );
1058
- const stream = (_a = event.streams[0]) != null ? _a : new MediaStream([event.track]);
1228
+ const stream = (_b = event.streams[0]) != null ? _b : new MediaStream([event.track]);
1059
1229
  this.emit("trackReceived", trackName, event.track, stream);
1060
1230
  };
1061
1231
  this.peerConnection.onicecandidate = (event) => {
@@ -1076,6 +1246,9 @@ var GPUMachineClient = class {
1076
1246
  if (!this.dataChannel) return;
1077
1247
  this.dataChannel.onopen = () => {
1078
1248
  console.debug("[GPUMachineClient] Data channel open");
1249
+ if (this.iceStartTime != null && this.dataChannelMs == null) {
1250
+ this.dataChannelMs = performance.now() - this.iceStartTime;
1251
+ }
1079
1252
  this.dataChannelOpen = true;
1080
1253
  this.startPing();
1081
1254
  this.checkFullyConnected();
@@ -1115,13 +1288,13 @@ var GPUMachineClient = class {
1115
1288
  // src/core/Reactor.ts
1116
1289
  import { z as z2 } from "zod";
1117
1290
  var LOCAL_COORDINATOR_URL = "http://localhost:8080";
1118
- var PROD_COORDINATOR_URL = "https://api.reactor.inc";
1291
+ var DEFAULT_BASE_URL = "https://api.reactor.inc";
1119
1292
  var TrackConfigSchema = z2.object({
1120
1293
  name: z2.string(),
1121
1294
  kind: z2.enum(["audio", "video"])
1122
1295
  });
1123
1296
  var OptionsSchema = z2.object({
1124
- coordinatorUrl: z2.string().default(PROD_COORDINATOR_URL),
1297
+ apiUrl: z2.string().default(DEFAULT_BASE_URL),
1125
1298
  modelName: z2.string(),
1126
1299
  local: z2.boolean().default(false),
1127
1300
  /**
@@ -1146,12 +1319,12 @@ var Reactor = class {
1146
1319
  // Generic event map
1147
1320
  this.eventListeners = /* @__PURE__ */ new Map();
1148
1321
  const validatedOptions = OptionsSchema.parse(options);
1149
- this.coordinatorUrl = validatedOptions.coordinatorUrl;
1322
+ this.coordinatorUrl = validatedOptions.apiUrl;
1150
1323
  this.model = validatedOptions.modelName;
1151
1324
  this.local = validatedOptions.local;
1152
1325
  this.receive = validatedOptions.receive;
1153
1326
  this.send = validatedOptions.send;
1154
- if (this.local && options.coordinatorUrl === void 0) {
1327
+ if (this.local && options.apiUrl === void 0) {
1155
1328
  this.coordinatorUrl = LOCAL_COORDINATOR_URL;
1156
1329
  }
1157
1330
  }
@@ -1272,14 +1445,14 @@ var Reactor = class {
1272
1445
  receive: this.receive
1273
1446
  });
1274
1447
  try {
1275
- const sdpAnswer = yield this.coordinatorClient.connect(
1448
+ const { sdpAnswer } = yield this.coordinatorClient.connect(
1276
1449
  this.sessionId,
1277
1450
  sdpOffer,
1278
1451
  options == null ? void 0 : options.maxAttempts
1279
1452
  );
1280
1453
  yield this.machineClient.connect(sdpAnswer);
1281
- this.setStatus("ready");
1282
1454
  } catch (error) {
1455
+ if (isAbortError(error)) return;
1283
1456
  let recoverable = false;
1284
1457
  if (error instanceof ConflictError) {
1285
1458
  recoverable = true;
@@ -1289,7 +1462,7 @@ var Reactor = class {
1289
1462
  this.createError(
1290
1463
  "RECONNECTION_FAILED",
1291
1464
  `Failed to reconnect: ${error}`,
1292
- "coordinator",
1465
+ "api",
1293
1466
  true
1294
1467
  );
1295
1468
  }
@@ -1312,6 +1485,7 @@ var Reactor = class {
1312
1485
  throw new Error("Already connected or connecting");
1313
1486
  }
1314
1487
  this.setStatus("connecting");
1488
+ this.connectStartTime = performance.now();
1315
1489
  try {
1316
1490
  console.debug(
1317
1491
  "[Reactor] Connecting to coordinator with authenticated URL"
@@ -1329,20 +1503,33 @@ var Reactor = class {
1329
1503
  send: this.send,
1330
1504
  receive: this.receive
1331
1505
  });
1506
+ const tSession = performance.now();
1332
1507
  const sessionId = yield this.coordinatorClient.createSession(sdpOffer);
1508
+ const sessionCreationMs = performance.now() - tSession;
1333
1509
  this.setSessionId(sessionId);
1334
- const sdpAnswer = yield this.coordinatorClient.connect(
1510
+ const tSdp = performance.now();
1511
+ const { sdpAnswer, sdpPollingAttempts } = yield this.coordinatorClient.connect(
1335
1512
  sessionId,
1336
1513
  void 0,
1337
1514
  options == null ? void 0 : options.maxAttempts
1338
1515
  );
1516
+ const sdpPollingMs = performance.now() - tSdp;
1517
+ this.connectionTimings = {
1518
+ sessionCreationMs,
1519
+ sdpPollingMs,
1520
+ sdpPollingAttempts,
1521
+ iceNegotiationMs: 0,
1522
+ dataChannelMs: 0,
1523
+ totalMs: 0
1524
+ };
1339
1525
  yield this.machineClient.connect(sdpAnswer);
1340
1526
  } catch (error) {
1527
+ if (isAbortError(error)) return;
1341
1528
  console.error("[Reactor] Connection failed:", error);
1342
1529
  this.createError(
1343
1530
  "CONNECTION_FAILED",
1344
1531
  `Connection failed: ${error}`,
1345
- "coordinator",
1532
+ "api",
1346
1533
  true
1347
1534
  );
1348
1535
  try {
@@ -1359,19 +1546,28 @@ var Reactor = class {
1359
1546
  }
1360
1547
  /**
1361
1548
  * Sets up event handlers for the machine client.
1549
+ *
1550
+ * Each handler captures the client reference at registration time and
1551
+ * ignores events if this.machineClient has since changed (e.g. after
1552
+ * disconnect + reconnect), preventing stale WebRTC teardown events from
1553
+ * interfering with a new connection.
1362
1554
  */
1363
1555
  setupMachineClientHandlers() {
1364
1556
  if (!this.machineClient) return;
1365
- this.machineClient.on("message", (message, scope) => {
1557
+ const client = this.machineClient;
1558
+ client.on("message", (message, scope) => {
1559
+ if (this.machineClient !== client) return;
1366
1560
  if (scope === "application") {
1367
1561
  this.emit("message", message);
1368
1562
  } else if (scope === "runtime") {
1369
1563
  this.emit("runtimeMessage", message);
1370
1564
  }
1371
1565
  });
1372
- this.machineClient.on("statusChanged", (status) => {
1566
+ client.on("statusChanged", (status) => {
1567
+ if (this.machineClient !== client) return;
1373
1568
  switch (status) {
1374
1569
  case "connected":
1570
+ this.finalizeConnectionTimings(client);
1375
1571
  this.setStatus("ready");
1376
1572
  break;
1377
1573
  case "disconnected":
@@ -1388,14 +1584,18 @@ var Reactor = class {
1388
1584
  break;
1389
1585
  }
1390
1586
  });
1391
- this.machineClient.on(
1587
+ client.on(
1392
1588
  "trackReceived",
1393
1589
  (name, track, stream) => {
1590
+ if (this.machineClient !== client) return;
1394
1591
  this.emit("trackReceived", name, track, stream);
1395
1592
  }
1396
1593
  );
1397
- this.machineClient.on("statsUpdate", (stats) => {
1398
- this.emit("statsUpdate", stats);
1594
+ client.on("statsUpdate", (stats) => {
1595
+ if (this.machineClient !== client) return;
1596
+ this.emit("statsUpdate", __spreadProps(__spreadValues({}, stats), {
1597
+ connectionTimings: this.connectionTimings
1598
+ }));
1399
1599
  });
1400
1600
  }
1401
1601
  /**
@@ -1404,10 +1604,12 @@ var Reactor = class {
1404
1604
  */
1405
1605
  disconnect(recoverable = false) {
1406
1606
  return __async(this, null, function* () {
1607
+ var _a;
1407
1608
  if (this.status === "disconnected" && !this.sessionId) {
1408
1609
  console.warn("[Reactor] Already disconnected");
1409
1610
  return;
1410
1611
  }
1612
+ (_a = this.coordinatorClient) == null ? void 0 : _a.abort();
1411
1613
  if (this.coordinatorClient && !recoverable) {
1412
1614
  try {
1413
1615
  yield this.coordinatorClient.terminateSession();
@@ -1427,6 +1629,7 @@ var Reactor = class {
1427
1629
  }
1428
1630
  }
1429
1631
  this.setStatus("disconnected");
1632
+ this.resetConnectionTimings();
1430
1633
  if (!recoverable) {
1431
1634
  this.setSessionExpiration(void 0);
1432
1635
  this.setSessionId(void 0);
@@ -1489,7 +1692,23 @@ var Reactor = class {
1489
1692
  }
1490
1693
  getStats() {
1491
1694
  var _a;
1492
- return (_a = this.machineClient) == null ? void 0 : _a.getStats();
1695
+ const stats = (_a = this.machineClient) == null ? void 0 : _a.getStats();
1696
+ if (!stats) return void 0;
1697
+ return __spreadProps(__spreadValues({}, stats), { connectionTimings: this.connectionTimings });
1698
+ }
1699
+ resetConnectionTimings() {
1700
+ this.connectStartTime = void 0;
1701
+ this.connectionTimings = void 0;
1702
+ }
1703
+ finalizeConnectionTimings(client) {
1704
+ var _a, _b;
1705
+ if (!this.connectionTimings || this.connectStartTime == null) return;
1706
+ const webrtcTimings = client.getConnectionTimings();
1707
+ this.connectionTimings.iceNegotiationMs = (_a = webrtcTimings == null ? void 0 : webrtcTimings.iceNegotiationMs) != null ? _a : 0;
1708
+ this.connectionTimings.dataChannelMs = (_b = webrtcTimings == null ? void 0 : webrtcTimings.dataChannelMs) != null ? _b : 0;
1709
+ this.connectionTimings.totalMs = performance.now() - this.connectStartTime;
1710
+ this.connectStartTime = void 0;
1711
+ console.debug("[Reactor] Connection timings:", this.connectionTimings);
1493
1712
  }
1494
1713
  /**
1495
1714
  * Create and store an error
@@ -1529,7 +1748,7 @@ var initReactorStore = (props) => {
1529
1748
  };
1530
1749
  var createReactorStore = (initProps, publicState = defaultInitState) => {
1531
1750
  console.debug("[ReactorStore] Creating store", {
1532
- coordinatorUrl: initProps.coordinatorUrl,
1751
+ apiUrl: initProps.apiUrl,
1533
1752
  jwtToken: initProps.jwtToken,
1534
1753
  initialState: publicState
1535
1754
  });
@@ -1697,7 +1916,7 @@ function ReactorProvider(_a) {
1697
1916
  console.debug("[ReactorProvider] Reactor store created successfully");
1698
1917
  }
1699
1918
  const _a2 = connectOptions != null ? connectOptions : {}, { autoConnect = false } = _a2, pollingOptions = __objRest(_a2, ["autoConnect"]);
1700
- const { coordinatorUrl, modelName, local, receive, send } = props;
1919
+ const { apiUrl, modelName, local, receive, send } = props;
1701
1920
  const maxAttempts = pollingOptions.maxAttempts;
1702
1921
  useEffect(() => {
1703
1922
  const handleBeforeUnload = () => {
@@ -1750,7 +1969,7 @@ function ReactorProvider(_a) {
1750
1969
  console.debug("[ReactorProvider] Updating reactor store");
1751
1970
  storeRef.current = createReactorStore(
1752
1971
  initReactorStore({
1753
- coordinatorUrl,
1972
+ apiUrl,
1754
1973
  modelName,
1755
1974
  local,
1756
1975
  receive,
@@ -1782,7 +2001,7 @@ function ReactorProvider(_a) {
1782
2001
  });
1783
2002
  };
1784
2003
  }, [
1785
- coordinatorUrl,
2004
+ apiUrl,
1786
2005
  modelName,
1787
2006
  autoConnect,
1788
2007
  local,
@@ -2611,12 +2830,12 @@ function WebcamStream({
2611
2830
  }
2612
2831
 
2613
2832
  // src/utils/tokens.ts
2614
- function fetchInsecureJwtToken(_0) {
2615
- return __async(this, arguments, function* (apiKey, coordinatorUrl = PROD_COORDINATOR_URL) {
2833
+ function fetchInsecureToken(_0) {
2834
+ return __async(this, arguments, function* (apiKey, apiUrl = DEFAULT_BASE_URL) {
2616
2835
  console.warn(
2617
- "[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."
2836
+ "[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."
2618
2837
  );
2619
- const response = yield fetch(`${coordinatorUrl}/tokens`, {
2838
+ const response = yield fetch(`${apiUrl}/tokens`, {
2620
2839
  method: "GET",
2621
2840
  headers: {
2622
2841
  "X-API-Key": apiKey
@@ -2631,15 +2850,17 @@ function fetchInsecureJwtToken(_0) {
2631
2850
  });
2632
2851
  }
2633
2852
  export {
2853
+ AbortError,
2634
2854
  ConflictError,
2635
- PROD_COORDINATOR_URL,
2855
+ DEFAULT_BASE_URL,
2636
2856
  Reactor,
2637
2857
  ReactorController,
2638
2858
  ReactorProvider,
2639
2859
  ReactorView,
2640
2860
  WebcamStream,
2641
2861
  audio,
2642
- fetchInsecureJwtToken,
2862
+ fetchInsecureToken,
2863
+ isAbortError,
2643
2864
  useReactor,
2644
2865
  useReactorInternalMessage,
2645
2866
  useReactorMessage,