@reactor-team/js-sdk 2.5.1 → 2.7.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
@@ -51,12 +51,6 @@ var __async = (__this, __arguments, generator) => {
51
51
  };
52
52
 
53
53
  // src/types.ts
54
- function video(name, _options) {
55
- return { name, kind: "video" };
56
- }
57
- function audio(name, _options) {
58
- return { name, kind: "audio" };
59
- }
60
54
  var ConflictError = class extends Error {
61
55
  constructor(message) {
62
56
  super(message);
@@ -73,292 +67,175 @@ function isAbortError(error) {
73
67
 
74
68
  // src/core/types.ts
75
69
  import { z } from "zod";
76
- var SessionState = /* @__PURE__ */ ((SessionState2) => {
77
- SessionState2[SessionState2["CREATED"] = 0] = "CREATED";
78
- SessionState2[SessionState2["PENDING"] = 1] = "PENDING";
79
- SessionState2[SessionState2["SUSPENDED"] = 2] = "SUSPENDED";
80
- SessionState2[SessionState2["WAITING"] = 3] = "WAITING";
81
- SessionState2[SessionState2["ACTIVE"] = 4] = "ACTIVE";
82
- SessionState2[SessionState2["INACTIVE"] = 5] = "INACTIVE";
83
- SessionState2[SessionState2["CLOSED"] = 6] = "CLOSED";
84
- return SessionState2;
85
- })(SessionState || {});
86
- var ModelSchema = z.object({
87
- name: z.string()
70
+
71
+ // package.json
72
+ var package_default = {
73
+ name: "@reactor-team/js-sdk",
74
+ version: "2.7.0",
75
+ description: "Reactor JavaScript frontend SDK",
76
+ main: "dist/index.js",
77
+ module: "dist/index.mjs",
78
+ types: "dist/index.d.ts",
79
+ exports: {
80
+ ".": {
81
+ types: "./dist/index.d.ts",
82
+ import: "./dist/index.mjs",
83
+ require: "./dist/index.js"
84
+ }
85
+ },
86
+ files: [
87
+ "dist",
88
+ "README.md"
89
+ ],
90
+ scripts: {
91
+ build: "tsup",
92
+ dev: "tsup --watch",
93
+ test: "vitest run",
94
+ "test:watch": "vitest",
95
+ "test:unit": "vitest run __tests__/unit",
96
+ "test:integration": "vitest run __tests__/integration",
97
+ format: "prettier --write .",
98
+ "format:check": "prettier --check ."
99
+ },
100
+ keywords: [
101
+ "reactor",
102
+ "frontend",
103
+ "sdk"
104
+ ],
105
+ author: "Reactor Technologies, Inc.",
106
+ reactor: {
107
+ apiVersion: 1,
108
+ webrtcVersion: "1.0"
109
+ },
110
+ license: "MIT",
111
+ repository: {
112
+ type: "git",
113
+ url: "https://github.com/reactor-team/js-sdk"
114
+ },
115
+ packageManager: "pnpm@10.12.1",
116
+ devDependencies: {
117
+ "@roamhq/wrtc": "^0.8.0",
118
+ "@types/inquirer": "^9.0.9",
119
+ "@types/node": "^20",
120
+ "@types/react": "^18.2.8",
121
+ "@types/ws": "^8.18.1",
122
+ prettier: "^3.6.2",
123
+ tsup: "^8.5.0",
124
+ typescript: "^5.8.3",
125
+ vitest: "^3.0.0"
126
+ },
127
+ dependencies: {
128
+ "@bufbuild/protobuf": "^2.0.0",
129
+ chalk: "^5.3.0",
130
+ inquirer: "^9.3.0",
131
+ "simple-git": "^3.24.0",
132
+ ws: "^8.18.3",
133
+ zod: "^4.0.5"
134
+ },
135
+ peerDependencies: {
136
+ react: "^17.0.0 || ^18.0.0 || ^19.0.0",
137
+ zustand: "^5.0.6"
138
+ }
139
+ };
140
+
141
+ // src/core/types.ts
142
+ var REACTOR_SDK_VERSION = package_default.version;
143
+ var REACTOR_API_VERSION = package_default.reactor.apiVersion;
144
+ var REACTOR_WEBRTC_VERSION = package_default.reactor.webrtcVersion;
145
+ var REACTOR_SDK_TYPE = "js";
146
+ var API_VERSION_HEADER = "Reactor-API-Version";
147
+ var API_ACCEPT_VERSION_HEADER = "Reactor-API-Accept-Version";
148
+ var WEBRTC_VERSION_HEADER = "Reactor-WebRTC-Version";
149
+ var VERSION_ERROR_CODES = {
150
+ 426: "CLIENT_VERSION_TOO_OLD",
151
+ 501: "SERVER_VERSION_TOO_OLD"
152
+ };
153
+ var ClientInfoSchema = z.object({
154
+ sdk_version: z.string(),
155
+ sdk_type: z.literal("js")
156
+ });
157
+ var TransportDeclarationSchema = z.object({
158
+ protocol: z.string(),
159
+ version: z.string()
160
+ });
161
+ var TrackCapabilitySchema = z.object({
162
+ name: z.string(),
163
+ kind: z.enum(["video", "audio"]),
164
+ direction: z.enum(["recvonly", "sendonly"])
165
+ });
166
+ var TrackMappingEntrySchema = TrackCapabilitySchema.extend({
167
+ mid: z.string()
88
168
  });
89
169
  var CreateSessionRequestSchema = z.object({
90
- model: ModelSchema,
91
- sdp_offer: z.string(),
92
- extra_args: z.record(z.string(), z.any())
93
- // Dictionary
170
+ model: z.object({ name: z.string() }),
171
+ client_info: ClientInfoSchema,
172
+ supported_transports: z.array(TransportDeclarationSchema),
173
+ extra_args: z.record(z.string(), z.any()).optional()
94
174
  });
95
- var CreateSessionResponseSchema = z.object({
96
- session_id: z.uuidv4()
175
+ var InitialSessionResponseSchema = z.object({
176
+ session_id: z.string(),
177
+ model: z.object({ name: z.string() }),
178
+ server_info: z.object({ server_version: z.string() }).optional(),
179
+ state: z.string(),
180
+ cluster: z.string().optional()
97
181
  });
98
- var SessionStatusResponseSchema = z.object({
99
- session_id: z.uuidv4(),
100
- state: SessionState
182
+ var CommandCapabilitySchema = z.object({
183
+ name: z.string(),
184
+ description: z.string(),
185
+ schema: z.record(z.string(), z.any()).optional()
101
186
  });
102
- var SessionInfoResponseSchema = SessionStatusResponseSchema.extend({
103
- session_info: CreateSessionRequestSchema.extend({
104
- session_id: z.uuidv4()
105
- })
187
+ var CapabilitiesSchema = z.object({
188
+ protocol_version: z.string(),
189
+ tracks: z.array(TrackCapabilitySchema),
190
+ commands: z.array(CommandCapabilitySchema).optional(),
191
+ emission_fps: z.number().nullable().optional()
106
192
  });
107
- var SDPParamsRequestSchema = z.object({
108
- sdp_offer: z.string(),
109
- extra_args: z.record(z.string(), z.any())
110
- // Dictionary
193
+ var SessionResponseSchema = z.object({
194
+ session_id: z.string(),
195
+ server_info: z.object({ server_version: z.string() }).optional(),
196
+ selected_transport: TransportDeclarationSchema.optional(),
197
+ model: z.object({ name: z.string(), version: z.string().optional() }),
198
+ capabilities: CapabilitiesSchema.optional(),
199
+ state: z.string(),
200
+ cluster: z.string().optional()
201
+ });
202
+ var CreateSessionResponseSchema = SessionResponseSchema.extend({
203
+ selected_transport: TransportDeclarationSchema,
204
+ capabilities: CapabilitiesSchema
205
+ });
206
+ var SessionInfoResponseSchema = z.object({
207
+ session_id: z.string(),
208
+ cluster: z.string().optional(),
209
+ state: z.string()
210
+ });
211
+ var TerminateSessionRequestSchema = z.object({
212
+ reason: z.string().optional()
213
+ });
214
+ var IceServerCredentialsSchema = z.object({
215
+ username: z.string(),
216
+ password: z.string()
111
217
  });
112
- var SDPParamsResponseSchema = z.object({
113
- sdp_answer: z.string(),
114
- extra_args: z.record(z.string(), z.any())
115
- // Dictionary
218
+ var IceServerSchema = z.object({
219
+ uris: z.array(z.string()),
220
+ credentials: IceServerCredentialsSchema.optional()
116
221
  });
117
222
  var IceServersResponseSchema = z.object({
118
- ice_servers: z.array(
119
- z.object({
120
- uris: z.array(z.string()),
121
- credentials: z.object({
122
- username: z.string(),
123
- password: z.string()
124
- }).optional()
125
- })
126
- )
223
+ ice_servers: z.array(IceServerSchema)
224
+ });
225
+ var WebRTCSdpOfferRequestSchema = z.object({
226
+ sdp_offer: z.string(),
227
+ client_info: ClientInfoSchema.optional(),
228
+ track_mapping: z.array(TrackMappingEntrySchema)
229
+ });
230
+ var WebRTCSdpAnswerResponseSchema = z.object({
231
+ sdp_answer: z.string()
127
232
  });
128
-
129
- // src/utils/webrtc.ts
130
- var DEFAULT_DATA_CHANNEL_LABEL = "data";
131
- var FORCE_RELAY_MODE = false;
132
- function createPeerConnection(config) {
133
- return new RTCPeerConnection({
134
- iceServers: config.iceServers,
135
- iceTransportPolicy: FORCE_RELAY_MODE ? "relay" : "all"
136
- });
137
- }
138
- function createDataChannel(pc, label) {
139
- return pc.createDataChannel(label != null ? label : DEFAULT_DATA_CHANNEL_LABEL);
140
- }
141
- function rewriteMids(sdp, trackNames) {
142
- const lines = sdp.split("\r\n");
143
- let mediaIdx = 0;
144
- const replacements = /* @__PURE__ */ new Map();
145
- let inApplication = false;
146
- for (let i = 0; i < lines.length; i++) {
147
- if (lines[i].startsWith("m=")) {
148
- inApplication = lines[i].startsWith("m=application");
149
- }
150
- if (!inApplication && lines[i].startsWith("a=mid:")) {
151
- const oldMid = lines[i].substring("a=mid:".length);
152
- if (mediaIdx < trackNames.length) {
153
- const newMid = trackNames[mediaIdx];
154
- replacements.set(oldMid, newMid);
155
- lines[i] = `a=mid:${newMid}`;
156
- mediaIdx++;
157
- }
158
- }
159
- }
160
- for (let i = 0; i < lines.length; i++) {
161
- if (lines[i].startsWith("a=group:BUNDLE ")) {
162
- const parts = lines[i].split(" ");
163
- for (let j = 1; j < parts.length; j++) {
164
- const replacement = replacements.get(parts[j]);
165
- if (replacement !== void 0) {
166
- parts[j] = replacement;
167
- }
168
- }
169
- lines[i] = parts.join(" ");
170
- break;
171
- }
172
- }
173
- return lines.join("\r\n");
174
- }
175
- function createOffer(pc, trackNames) {
176
- return __async(this, null, function* () {
177
- const offer = yield pc.createOffer();
178
- let needsAnswerRestore = false;
179
- if (trackNames && trackNames.length > 0 && offer.sdp) {
180
- const munged = rewriteMids(offer.sdp, trackNames);
181
- try {
182
- yield pc.setLocalDescription(
183
- new RTCSessionDescription({ type: "offer", sdp: munged })
184
- );
185
- } catch (e) {
186
- yield pc.setLocalDescription(offer);
187
- needsAnswerRestore = true;
188
- }
189
- } else {
190
- yield pc.setLocalDescription(offer);
191
- }
192
- yield waitForIceGathering(pc);
193
- const localDescription = pc.localDescription;
194
- if (!localDescription) {
195
- throw new Error("Failed to create local description");
196
- }
197
- let sdp = localDescription.sdp;
198
- if (needsAnswerRestore && trackNames && trackNames.length > 0) {
199
- sdp = rewriteMids(sdp, trackNames);
200
- }
201
- return { sdp, needsAnswerRestore };
202
- });
203
- }
204
- function buildMidMapping(transceivers) {
205
- var _a;
206
- const localToRemote = /* @__PURE__ */ new Map();
207
- const remoteToLocal = /* @__PURE__ */ new Map();
208
- for (const entry of transceivers) {
209
- const mid = (_a = entry.transceiver) == null ? void 0 : _a.mid;
210
- if (mid) {
211
- localToRemote.set(mid, entry.name);
212
- remoteToLocal.set(entry.name, mid);
213
- }
214
- }
215
- return { localToRemote, remoteToLocal };
216
- }
217
- function restoreAnswerMids(sdp, remoteToLocal) {
218
- const lines = sdp.split("\r\n");
219
- for (let i = 0; i < lines.length; i++) {
220
- if (lines[i].startsWith("a=mid:")) {
221
- const remoteMid = lines[i].substring("a=mid:".length);
222
- const localMid = remoteToLocal.get(remoteMid);
223
- if (localMid !== void 0) {
224
- lines[i] = `a=mid:${localMid}`;
225
- }
226
- }
227
- if (lines[i].startsWith("a=group:BUNDLE ")) {
228
- const parts = lines[i].split(" ");
229
- for (let j = 1; j < parts.length; j++) {
230
- const localMid = remoteToLocal.get(parts[j]);
231
- if (localMid !== void 0) {
232
- parts[j] = localMid;
233
- }
234
- }
235
- lines[i] = parts.join(" ");
236
- }
237
- }
238
- return lines.join("\r\n");
239
- }
240
- function setRemoteDescription(pc, sdp) {
241
- return __async(this, null, function* () {
242
- const sessionDescription = new RTCSessionDescription({
243
- sdp,
244
- type: "answer"
245
- });
246
- yield pc.setRemoteDescription(sessionDescription);
247
- });
248
- }
249
- function getLocalDescription(pc) {
250
- const desc = pc.localDescription;
251
- if (!desc) return void 0;
252
- return desc.sdp;
253
- }
254
- function transformIceServers(response) {
255
- return response.ice_servers.map((server) => {
256
- const rtcServer = {
257
- urls: server.uris
258
- };
259
- if (server.credentials) {
260
- rtcServer.username = server.credentials.username;
261
- rtcServer.credential = server.credentials.password;
262
- }
263
- return rtcServer;
264
- });
265
- }
266
- function waitForIceGathering(pc, timeoutMs = 5e3) {
267
- return new Promise((resolve) => {
268
- if (pc.iceGatheringState === "complete") {
269
- resolve();
270
- return;
271
- }
272
- const onGatheringStateChange = () => {
273
- if (pc.iceGatheringState === "complete") {
274
- pc.removeEventListener(
275
- "icegatheringstatechange",
276
- onGatheringStateChange
277
- );
278
- resolve();
279
- }
280
- };
281
- pc.addEventListener("icegatheringstatechange", onGatheringStateChange);
282
- setTimeout(() => {
283
- pc.removeEventListener("icegatheringstatechange", onGatheringStateChange);
284
- resolve();
285
- }, timeoutMs);
286
- });
287
- }
288
- function sendMessage(channel, command, data, scope = "application") {
289
- if (channel.readyState !== "open") {
290
- throw new Error(`Data channel not open: ${channel.readyState}`);
291
- }
292
- const jsonData = typeof data === "string" ? JSON.parse(data) : data;
293
- const inner = { type: command, data: jsonData };
294
- const payload = { scope, data: inner };
295
- channel.send(JSON.stringify(payload));
296
- }
297
- function parseMessage(data) {
298
- if (typeof data === "string") {
299
- try {
300
- return JSON.parse(data);
301
- } catch (e) {
302
- return data;
303
- }
304
- }
305
- return data;
306
- }
307
- function closePeerConnection(pc) {
308
- pc.close();
309
- }
310
- function extractConnectionStats(report) {
311
- let rtt;
312
- let availableOutgoingBitrate;
313
- let localCandidateId;
314
- let framesPerSecond;
315
- let jitter;
316
- let packetLossRatio;
317
- report.forEach((stat) => {
318
- if (stat.type === "candidate-pair" && stat.state === "succeeded") {
319
- if (stat.currentRoundTripTime !== void 0) {
320
- rtt = stat.currentRoundTripTime * 1e3;
321
- }
322
- if (stat.availableOutgoingBitrate !== void 0) {
323
- availableOutgoingBitrate = stat.availableOutgoingBitrate;
324
- }
325
- localCandidateId = stat.localCandidateId;
326
- }
327
- if (stat.type === "inbound-rtp" && stat.kind === "video") {
328
- if (stat.framesPerSecond !== void 0) {
329
- framesPerSecond = stat.framesPerSecond;
330
- }
331
- if (stat.jitter !== void 0) {
332
- jitter = stat.jitter;
333
- }
334
- if (stat.packetsReceived !== void 0 && stat.packetsLost !== void 0 && stat.packetsReceived + stat.packetsLost > 0) {
335
- packetLossRatio = stat.packetsLost / (stat.packetsReceived + stat.packetsLost);
336
- }
337
- }
338
- });
339
- let candidateType;
340
- if (localCandidateId) {
341
- const localCandidate = report.get(localCandidateId);
342
- if (localCandidate == null ? void 0 : localCandidate.candidateType) {
343
- candidateType = localCandidate.candidateType;
344
- }
345
- }
346
- return {
347
- rtt,
348
- candidateType,
349
- availableOutgoingBitrate,
350
- framesPerSecond,
351
- packetLossRatio,
352
- jitter,
353
- timestamp: Date.now()
354
- };
355
- }
356
233
 
357
234
  // src/core/CoordinatorClient.ts
358
- var INITIAL_BACKOFF_MS = 500;
359
- var MAX_BACKOFF_MS = 15e3;
360
- var BACKOFF_MULTIPLIER = 2;
361
- var DEFAULT_MAX_ATTEMPTS = 6;
235
+ var SESSION_POLL_INITIAL_BACKOFF_MS = 200;
236
+ var SESSION_POLL_MAX_BACKOFF_MS = 1e4;
237
+ var SESSION_POLL_BACKOFF_MULTIPLIER = 2;
238
+ var SESSION_POLL_DEFAULT_MAX_ATTEMPTS = 20;
362
239
  var CoordinatorClient = class {
363
240
  constructor(options) {
364
241
  this.baseUrl = options.baseUrl;
@@ -367,7 +244,7 @@ var CoordinatorClient = class {
367
244
  this.abortController = new AbortController();
368
245
  }
369
246
  /**
370
- * Aborts any in-flight HTTP requests and polling loops.
247
+ * Aborts any in-flight HTTP requests.
371
248
  * A fresh AbortController is created so the client remains reusable.
372
249
  */
373
250
  abort() {
@@ -375,69 +252,88 @@ var CoordinatorClient = class {
375
252
  this.abortController = new AbortController();
376
253
  }
377
254
  /**
378
- * The current abort signal, passed to every fetch() and sleep() call.
255
+ * The current abort signal, passed to every fetch() call.
379
256
  * Protected so subclasses can forward it to their own fetch calls.
380
257
  */
381
258
  get signal() {
382
259
  return this.abortController.signal;
383
260
  }
384
261
  /**
385
- * Returns the authorization header with JWT Bearer token
262
+ * Returns authorization + versioning headers for all coordinator requests.
386
263
  */
387
- getAuthHeaders() {
264
+ getHeaders() {
388
265
  return {
389
- Authorization: `Bearer ${this.jwtToken}`
266
+ Authorization: `Bearer ${this.jwtToken}`,
267
+ [API_VERSION_HEADER]: String(REACTOR_API_VERSION),
268
+ [API_ACCEPT_VERSION_HEADER]: String(REACTOR_API_VERSION)
390
269
  };
391
270
  }
392
271
  /**
393
- * Fetches ICE servers from the coordinator.
394
- * @returns Array of RTCIceServer objects for WebRTC peer connection configuration
272
+ * Checks an HTTP response for version mismatch errors (426, 501).
273
+ * Logs a clear message and throws with a descriptive error code.
395
274
  */
396
- getIceServers() {
275
+ checkVersionMismatch(response) {
397
276
  return __async(this, null, function* () {
398
- console.debug("[CoordinatorClient] Fetching ICE servers...");
399
- const response = yield fetch(
400
- `${this.baseUrl}/ice_servers?model=${this.model}`,
401
- {
402
- method: "GET",
403
- headers: this.getAuthHeaders(),
404
- signal: this.signal
405
- }
406
- );
407
- if (!response.ok) {
408
- throw new Error(`Failed to fetch ICE servers: ${response.status}`);
277
+ if (response.status === 426) {
278
+ const msg = `Client API version (${REACTOR_API_VERSION}) is too old. Server requires a newer version. Please upgrade @reactor-team/js-sdk.`;
279
+ console.error(`[Reactor]`, msg);
280
+ throw new Error(`${VERSION_ERROR_CODES[426]}: ${msg}`);
409
281
  }
410
- const data = yield response.json();
411
- const parsed = IceServersResponseSchema.parse(data);
412
- const iceServers = transformIceServers(parsed);
413
- console.debug(
414
- "[CoordinatorClient] Received ICE servers:",
415
- iceServers.length
416
- );
417
- return iceServers;
282
+ if (response.status === 501) {
283
+ const msg = `Server does not support API version ${REACTOR_API_VERSION}. The server may need to be updated.`;
284
+ console.error(`[Reactor]`, msg);
285
+ throw new Error(`${VERSION_ERROR_CODES[501]}: ${msg}`);
286
+ }
287
+ });
288
+ }
289
+ sleep(ms) {
290
+ return new Promise((resolve, reject) => {
291
+ const { signal } = this;
292
+ if (signal.aborted) {
293
+ reject(new AbortError("Sleep aborted"));
294
+ return;
295
+ }
296
+ const timer = setTimeout(() => {
297
+ signal.removeEventListener("abort", onAbort);
298
+ resolve();
299
+ }, ms);
300
+ const onAbort = () => {
301
+ clearTimeout(timer);
302
+ reject(new AbortError("Sleep aborted"));
303
+ };
304
+ signal.addEventListener("abort", onAbort, { once: true });
418
305
  });
419
306
  }
420
307
  /**
421
308
  * Creates a new session with the coordinator.
422
- * Expects a 200 response and stores the session ID.
423
- * @returns The session ID
309
+ * No SDP is sent transport signaling is decoupled from session creation.
310
+ *
311
+ * The POST response is a slim acknowledgment (session_id, model name, status).
312
+ * Capabilities and transport details are populated later once the Runtime
313
+ * accepts the session — use {@link pollSessionReady} to wait for them.
424
314
  */
425
- createSession(sdp_offer) {
315
+ createSession(extraArgs) {
426
316
  return __async(this, null, function* () {
427
317
  console.debug("[CoordinatorClient] Creating session...");
428
- const requestBody = {
318
+ const requestBody = __spreadValues({
429
319
  model: { name: this.model },
430
- sdp_offer,
431
- extra_args: {}
432
- };
320
+ client_info: {
321
+ sdk_version: REACTOR_SDK_VERSION,
322
+ sdk_type: REACTOR_SDK_TYPE
323
+ },
324
+ supported_transports: [
325
+ { protocol: "webrtc", version: REACTOR_WEBRTC_VERSION }
326
+ ]
327
+ }, extraArgs ? { extra_args: extraArgs } : {});
433
328
  const response = yield fetch(`${this.baseUrl}/sessions`, {
434
329
  method: "POST",
435
- headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
330
+ headers: __spreadProps(__spreadValues({}, this.getHeaders()), {
436
331
  "Content-Type": "application/json"
437
332
  }),
438
333
  body: JSON.stringify(requestBody),
439
334
  signal: this.signal
440
335
  });
336
+ yield this.checkVersionMismatch(response);
441
337
  if (!response.ok) {
442
338
  const errorText = yield response.text();
443
339
  throw new Error(
@@ -445,337 +341,462 @@ var CoordinatorClient = class {
445
341
  );
446
342
  }
447
343
  const data = yield response.json();
448
- this.currentSessionId = data.session_id;
344
+ const parsed = InitialSessionResponseSchema.parse(data);
345
+ this.currentSessionId = parsed.session_id;
449
346
  console.debug(
450
- "[CoordinatorClient] Session created with ID:",
451
- this.currentSessionId
347
+ "[CoordinatorClient] Session created:",
348
+ this.currentSessionId,
349
+ "state:",
350
+ parsed.state
452
351
  );
453
- return data.session_id;
352
+ return parsed;
454
353
  });
455
354
  }
456
355
  /**
457
- * Gets the current session information from the coordinator.
458
- * @returns The session data (untyped for now)
356
+ * Polls GET /sessions/{id} until the Runtime has accepted the session
357
+ * and populated capabilities and selected_transport.
459
358
  */
460
- getSession() {
359
+ pollSessionReady(opts) {
461
360
  return __async(this, null, function* () {
361
+ var _a;
462
362
  if (!this.currentSessionId) {
463
363
  throw new Error("No active session. Call createSession() first.");
464
364
  }
365
+ const maxAttempts = (_a = opts == null ? void 0 : opts.maxAttempts) != null ? _a : SESSION_POLL_DEFAULT_MAX_ATTEMPTS;
366
+ let backoffMs = SESSION_POLL_INITIAL_BACKOFF_MS;
367
+ let attempt = 0;
465
368
  console.debug(
466
- "[CoordinatorClient] Getting session info for:",
467
- this.currentSessionId
369
+ "[CoordinatorClient] Polling session until capabilities are available..."
468
370
  );
469
- const response = yield fetch(
470
- `${this.baseUrl}/sessions/${this.currentSessionId}`,
471
- {
472
- method: "GET",
473
- headers: this.getAuthHeaders(),
474
- signal: this.signal
371
+ while (true) {
372
+ if (this.signal.aborted) {
373
+ throw new AbortError("Session polling aborted");
475
374
  }
476
- );
477
- if (!response.ok) {
478
- const errorText = yield response.text();
479
- throw new Error(`Failed to get session: ${response.status} ${errorText}`);
480
- }
481
- const data = yield response.json();
482
- return data;
483
- });
484
- }
485
- /**
486
- * Terminates the current session by sending a DELETE request to the coordinator.
487
- * No-op if no session has been created yet.
488
- * @throws Error if the request fails (except for 404, which clears local state)
489
- */
490
- terminateSession() {
491
- return __async(this, null, function* () {
492
- if (!this.currentSessionId) {
493
- return;
494
- }
495
- console.debug(
496
- "[CoordinatorClient] Terminating session:",
497
- this.currentSessionId
498
- );
499
- const response = yield fetch(
500
- `${this.baseUrl}/sessions/${this.currentSessionId}`,
501
- {
502
- method: "DELETE",
503
- headers: this.getAuthHeaders(),
504
- signal: this.signal
375
+ if (attempt >= maxAttempts) {
376
+ throw new Error(
377
+ `Session polling exceeded maximum attempts (${maxAttempts}). The model may be unavailable or overloaded.`
378
+ );
379
+ }
380
+ attempt++;
381
+ const response = yield fetch(
382
+ `${this.baseUrl}/sessions/${this.currentSessionId}`,
383
+ {
384
+ method: "GET",
385
+ headers: this.getHeaders(),
386
+ signal: this.signal
387
+ }
388
+ );
389
+ yield this.checkVersionMismatch(response);
390
+ if (!response.ok) {
391
+ const errorText = yield response.text();
392
+ throw new Error(
393
+ `Failed to poll session: ${response.status} ${errorText}`
394
+ );
395
+ }
396
+ const data = yield response.json();
397
+ const partial = SessionResponseSchema.parse(data);
398
+ const terminalStates = [
399
+ "CLOSED" /* CLOSED */,
400
+ "INACTIVE" /* INACTIVE */
401
+ ];
402
+ if (terminalStates.includes(partial.state)) {
403
+ throw new Error(
404
+ `Session entered terminal state "${partial.state}" while waiting for capabilities`
405
+ );
406
+ }
407
+ if (partial.capabilities && partial.selected_transport) {
408
+ const full = CreateSessionResponseSchema.parse(data);
409
+ console.debug(
410
+ `[CoordinatorClient] Session ready after ${attempt} poll(s), transport: ${full.selected_transport.protocol}, tracks: ${full.capabilities.tracks.length}`
411
+ );
412
+ return full;
505
413
  }
506
- );
507
- if (response.ok) {
508
- this.currentSessionId = void 0;
509
- return;
510
- }
511
- if (response.status === 404) {
512
414
  console.debug(
513
- "[CoordinatorClient] Session not found on server, clearing local state:",
514
- this.currentSessionId
415
+ `[CoordinatorClient] Session poll ${attempt}/${maxAttempts} \u2014 state: ${partial.state}, waiting ${backoffMs}ms...`
416
+ );
417
+ yield this.sleep(backoffMs);
418
+ backoffMs = Math.min(
419
+ backoffMs * SESSION_POLL_BACKOFF_MULTIPLIER,
420
+ SESSION_POLL_MAX_BACKOFF_MS
515
421
  );
516
- this.currentSessionId = void 0;
517
- return;
518
422
  }
519
- const errorText = yield response.text();
520
- throw new Error(
521
- `Failed to terminate session: ${response.status} ${errorText}`
522
- );
523
423
  });
524
424
  }
525
425
  /**
526
- * Get the current session ID
426
+ * Gets full session details from the coordinator.
427
+ * Returns the same shape as the creation response but with updated state.
527
428
  */
528
- getSessionId() {
529
- return this.currentSessionId;
429
+ getSession() {
430
+ return __async(this, null, function* () {
431
+ if (!this.currentSessionId) {
432
+ throw new Error("No active session. Call createSession() first.");
433
+ }
434
+ const response = yield fetch(
435
+ `${this.baseUrl}/sessions/${this.currentSessionId}`,
436
+ {
437
+ method: "GET",
438
+ headers: this.getHeaders(),
439
+ signal: this.signal
440
+ }
441
+ );
442
+ yield this.checkVersionMismatch(response);
443
+ if (!response.ok) {
444
+ const errorText = yield response.text();
445
+ throw new Error(`Failed to get session: ${response.status} ${errorText}`);
446
+ }
447
+ const data = yield response.json();
448
+ return CreateSessionResponseSchema.parse(data);
449
+ });
530
450
  }
531
451
  /**
532
- * Sends an SDP offer to the server for reconnection.
533
- * @param sessionId - The session ID to connect to
534
- * @param sdpOffer - The SDP offer from the local WebRTC peer connection
535
- * @returns The SDP answer if ready (200), or null if pending (202)
452
+ * Gets lightweight session status (session_id, cluster, status).
536
453
  */
537
- sendSdpOffer(sessionId, sdpOffer) {
454
+ getSessionInfo() {
538
455
  return __async(this, null, function* () {
539
- console.debug(
540
- "[CoordinatorClient] Sending SDP offer for session:",
541
- sessionId
542
- );
543
- const requestBody = {
544
- sdp_offer: sdpOffer,
545
- extra_args: {}
546
- };
456
+ if (!this.currentSessionId) {
457
+ throw new Error("No active session. Call createSession() first.");
458
+ }
547
459
  const response = yield fetch(
548
- `${this.baseUrl}/sessions/${sessionId}/sdp_params`,
460
+ `${this.baseUrl}/sessions/${this.currentSessionId}/info`,
549
461
  {
550
- method: "PUT",
551
- headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
552
- "Content-Type": "application/json"
553
- }),
554
- body: JSON.stringify(requestBody),
462
+ method: "GET",
463
+ headers: this.getHeaders(),
555
464
  signal: this.signal
556
465
  }
557
466
  );
558
- if (response.status === 200) {
559
- const answerData = yield response.json();
560
- console.debug("[CoordinatorClient] Received SDP answer immediately");
561
- return answerData.sdp_answer;
562
- }
563
- if (response.status === 202) {
564
- console.debug(
565
- "[CoordinatorClient] SDP offer accepted, answer pending (202)"
467
+ yield this.checkVersionMismatch(response);
468
+ if (!response.ok) {
469
+ const errorText = yield response.text();
470
+ throw new Error(
471
+ `Failed to get session info: ${response.status} ${errorText}`
566
472
  );
567
- return null;
568
473
  }
569
- const errorText = yield response.text();
570
- throw new Error(
571
- `Failed to send SDP offer: ${response.status} ${errorText}`
572
- );
474
+ const data = yield response.json();
475
+ return SessionInfoResponseSchema.parse(data);
573
476
  });
574
477
  }
575
478
  /**
576
- * Polls for the SDP answer with exponential backoff.
577
- * Used for async reconnection when the answer is not immediately available.
578
- * @param sessionId - The session ID to poll for
579
- * @param maxAttempts - Optional maximum number of polling attempts before giving up
580
- * @returns The SDP answer from the server
479
+ * Restarts an inactive session with a different compute unit.
480
+ * The session ID is preserved but a new transport must be established.
581
481
  */
582
- pollSdpAnswer(_0) {
583
- return __async(this, arguments, function* (sessionId, maxAttempts = DEFAULT_MAX_ATTEMPTS) {
482
+ restartSession() {
483
+ return __async(this, null, function* () {
484
+ if (!this.currentSessionId) {
485
+ throw new Error("No active session. Call createSession() first.");
486
+ }
584
487
  console.debug(
585
- "[CoordinatorClient] Polling for SDP answer for session:",
586
- sessionId
488
+ "[CoordinatorClient] Restarting session:",
489
+ this.currentSessionId
587
490
  );
588
- let backoffMs = INITIAL_BACKOFF_MS;
589
- let attempt = 0;
590
- while (true) {
591
- if (this.signal.aborted) {
592
- throw new AbortError("SDP polling aborted");
593
- }
594
- if (attempt >= maxAttempts) {
595
- throw new Error(
596
- `SDP polling exceeded maximum attempts (${maxAttempts}) for session ${sessionId}`
597
- );
598
- }
599
- attempt++;
600
- console.debug(
601
- `[CoordinatorClient] SDP poll attempt ${attempt}/${maxAttempts} for session ${sessionId}`
602
- );
603
- const response = yield fetch(
604
- `${this.baseUrl}/sessions/${sessionId}/sdp_params`,
605
- {
606
- method: "GET",
607
- headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
608
- "Content-Type": "application/json"
609
- }),
610
- signal: this.signal
611
- }
612
- );
613
- if (response.status === 200) {
614
- const answerData = yield response.json();
615
- console.debug("[CoordinatorClient] Received SDP answer via polling");
616
- return answerData.sdp_answer;
617
- }
618
- if (response.status === 202) {
619
- console.warn(
620
- `[CoordinatorClient] SDP answer pending (202), retrying in ${backoffMs}ms...`
621
- );
622
- yield this.sleep(backoffMs);
623
- backoffMs = Math.min(backoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS);
624
- continue;
491
+ const response = yield fetch(
492
+ `${this.baseUrl}/sessions/${this.currentSessionId}`,
493
+ {
494
+ method: "PUT",
495
+ headers: this.getHeaders(),
496
+ signal: this.signal
625
497
  }
498
+ );
499
+ yield this.checkVersionMismatch(response);
500
+ if (!response.ok) {
626
501
  const errorText = yield response.text();
627
502
  throw new Error(
628
- `Failed to poll SDP answer: ${response.status} ${errorText}`
503
+ `Failed to restart session: ${response.status} ${errorText}`
629
504
  );
630
505
  }
631
506
  });
632
507
  }
633
508
  /**
634
- * Connects to the session by sending an SDP offer and receiving an SDP answer.
635
- * If sdpOffer is provided, sends it first. If the answer is pending (202),
636
- * falls back to polling. If no sdpOffer is provided, goes directly to polling.
637
- * @param sessionId - The session ID to connect to
638
- * @param sdpOffer - Optional SDP offer from the local WebRTC peer connection
639
- * @param maxAttempts - Optional maximum number of polling attempts before giving up
640
- * @returns The SDP answer from the server
509
+ * Terminates the current session by sending a DELETE request.
510
+ * No-op if no session has been created yet.
511
+ * @param reason Optional termination reason
641
512
  */
642
- connect(sessionId, sdpOffer, maxAttempts) {
513
+ terminateSession(reason) {
643
514
  return __async(this, null, function* () {
644
- console.debug("[CoordinatorClient] Connecting to session:", sessionId);
645
- if (sdpOffer) {
646
- const answer = yield this.sendSdpOffer(sessionId, sdpOffer);
647
- if (answer !== null) {
648
- return answer;
649
- }
515
+ if (!this.currentSessionId) {
516
+ return;
650
517
  }
651
- return this.pollSdpAnswer(sessionId, maxAttempts);
652
- });
653
- }
654
- /**
655
- * Abort-aware sleep. Resolves after `ms` milliseconds unless the
656
- * abort signal fires first, in which case it rejects with AbortError.
657
- */
658
- sleep(ms) {
659
- return new Promise((resolve, reject) => {
660
- const { signal } = this;
661
- if (signal.aborted) {
662
- reject(new AbortError("Sleep aborted"));
518
+ console.debug(
519
+ "[CoordinatorClient] Terminating session:",
520
+ this.currentSessionId
521
+ );
522
+ const body = reason ? { reason } : void 0;
523
+ const response = yield fetch(
524
+ `${this.baseUrl}/sessions/${this.currentSessionId}`,
525
+ __spreadProps(__spreadValues({
526
+ method: "DELETE",
527
+ headers: __spreadValues(__spreadValues({}, this.getHeaders()), body ? { "Content-Type": "application/json" } : {})
528
+ }, body ? { body: JSON.stringify(body) } : {}), {
529
+ signal: this.signal
530
+ })
531
+ );
532
+ if (response.ok) {
533
+ this.currentSessionId = void 0;
663
534
  return;
664
535
  }
665
- const timer = setTimeout(() => {
666
- signal.removeEventListener("abort", onAbort);
667
- resolve();
668
- }, ms);
669
- const onAbort = () => {
670
- clearTimeout(timer);
671
- reject(new AbortError("Sleep aborted"));
672
- };
673
- signal.addEventListener("abort", onAbort, { once: true });
536
+ if (response.status === 404) {
537
+ console.debug(
538
+ "[CoordinatorClient] Session not found on server, clearing local state:",
539
+ this.currentSessionId
540
+ );
541
+ this.currentSessionId = void 0;
542
+ return;
543
+ }
544
+ const errorText = yield response.text();
545
+ throw new Error(
546
+ `Failed to terminate session: ${response.status} ${errorText}`
547
+ );
674
548
  });
675
549
  }
550
+ getSessionId() {
551
+ return this.currentSessionId;
552
+ }
676
553
  };
677
554
 
678
555
  // src/core/LocalCoordinatorClient.ts
679
556
  var LocalCoordinatorClient = class extends CoordinatorClient {
680
- constructor(baseUrl) {
557
+ constructor(baseUrl, model) {
681
558
  super({
682
559
  baseUrl,
683
560
  jwtToken: "local",
684
- model: "local"
561
+ model
685
562
  });
686
- this.localBaseUrl = baseUrl;
563
+ }
564
+ getHeaders() {
565
+ return {
566
+ [API_VERSION_HEADER]: String(REACTOR_API_VERSION),
567
+ [API_ACCEPT_VERSION_HEADER]: String(REACTOR_API_VERSION)
568
+ };
687
569
  }
688
570
  /**
689
- * Gets ICE servers from the local HTTP runtime.
690
- * @returns The ICE server configuration
571
+ * Starts a session on the local runtime.
572
+ *
573
+ * Unlike the production coordinator, the local runtime returns the full
574
+ * response (capabilities, selected_transport) immediately — no polling needed.
691
575
  */
692
- getIceServers() {
576
+ createSession(extraArgs) {
693
577
  return __async(this, null, function* () {
694
- console.debug("[LocalCoordinatorClient] Fetching ICE servers...");
695
- const response = yield fetch(`${this.localBaseUrl}/ice_servers`, {
696
- method: "GET",
578
+ console.debug("[LocalCoordinatorClient] Starting session...");
579
+ const response = yield fetch(`${this.baseUrl}/start_session`, {
580
+ method: "POST",
581
+ headers: __spreadProps(__spreadValues({}, this.getHeaders()), {
582
+ "Content-Type": "application/json"
583
+ }),
584
+ body: JSON.stringify(__spreadValues({}, extraArgs ? { extra_args: extraArgs } : {})),
697
585
  signal: this.signal
698
586
  });
699
587
  if (!response.ok) {
700
- throw new Error("Failed to get ICE servers from local coordinator.");
588
+ const errorText = yield response.text();
589
+ throw new Error(
590
+ `Failed to start session: ${response.status} ${errorText}`
591
+ );
701
592
  }
702
593
  const data = yield response.json();
703
- const parsed = IceServersResponseSchema.parse(data);
704
- const iceServers = transformIceServers(parsed);
594
+ this.cachedSessionResponse = CreateSessionResponseSchema.parse(data);
595
+ this.currentSessionId = this.cachedSessionResponse.session_id;
705
596
  console.debug(
706
- "[LocalCoordinatorClient] Received ICE servers:",
707
- iceServers.length
597
+ "[LocalCoordinatorClient] Session started:",
598
+ this.currentSessionId,
599
+ "transport:",
600
+ this.cachedSessionResponse.selected_transport.protocol,
601
+ "tracks:",
602
+ this.cachedSessionResponse.capabilities.tracks.length
708
603
  );
709
- return iceServers;
604
+ return InitialSessionResponseSchema.parse(data);
710
605
  });
711
606
  }
712
607
  /**
713
- * Creates a local session by posting to /start_session.
714
- * @returns always "local"
608
+ * Returns the cached full session response immediately.
609
+ * The local runtime already provided everything in start_session.
715
610
  */
716
- createSession(sdpOffer) {
611
+ pollSessionReady() {
717
612
  return __async(this, null, function* () {
718
- console.debug("[LocalCoordinatorClient] Creating local session...");
719
- this.sdpOffer = sdpOffer;
720
- const response = yield fetch(`${this.localBaseUrl}/start_session`, {
721
- method: "POST",
722
- signal: this.signal
723
- });
724
- if (!response.ok) {
725
- throw new Error("Failed to send local start session command.");
613
+ if (!this.cachedSessionResponse) {
614
+ throw new Error(
615
+ "No cached session response. Call createSession() first."
616
+ );
726
617
  }
727
- console.debug("[LocalCoordinatorClient] Local session created");
728
- return "local";
618
+ return this.cachedSessionResponse;
729
619
  });
730
620
  }
731
621
  /**
732
- * Connects to the local session by posting SDP params to /sdp_params.
733
- * @param sessionId - The session ID (ignored for local)
734
- * @param sdpMessage - The SDP offer from the local WebRTC peer connection
735
- * @returns The SDP answer from the server
622
+ * Stops the session on the local runtime.
736
623
  */
737
- connect(sessionId, sdpMessage) {
624
+ terminateSession() {
738
625
  return __async(this, null, function* () {
739
- this.sdpOffer = sdpMessage || this.sdpOffer;
740
- console.debug("[LocalCoordinatorClient] Connecting to local session...");
741
- const sdpBody = {
742
- sdp: this.sdpOffer,
743
- type: "offer"
744
- };
745
- const response = yield fetch(`${this.localBaseUrl}/sdp_params`, {
746
- method: "POST",
747
- headers: {
748
- "Content-Type": "application/json"
749
- },
750
- body: JSON.stringify(sdpBody),
751
- signal: this.signal
752
- });
753
- if (!response.ok) {
754
- if (response.status === 409) {
755
- throw new ConflictError("Connection superseded by newer request");
756
- }
757
- throw new Error("Failed to get SDP answer from local coordinator.");
626
+ if (!this.currentSessionId) {
627
+ return;
758
628
  }
759
- const sdpAnswer = yield response.json();
760
- console.debug("[LocalCoordinatorClient] Received SDP answer");
761
- return sdpAnswer.sdp;
629
+ console.debug(
630
+ "[LocalCoordinatorClient] Stopping session:",
631
+ this.currentSessionId
632
+ );
633
+ try {
634
+ yield fetch(`${this.baseUrl}/stop_session`, {
635
+ method: "POST",
636
+ headers: this.getHeaders(),
637
+ signal: this.signal
638
+ });
639
+ } catch (error) {
640
+ console.error("[LocalCoordinatorClient] Error stopping session:", error);
641
+ }
642
+ this.currentSessionId = void 0;
643
+ this.cachedSessionResponse = void 0;
762
644
  });
763
645
  }
764
- terminateSession() {
765
- return __async(this, null, function* () {
766
- console.debug("[LocalCoordinatorClient] Stopping local session...");
767
- yield fetch(`${this.localBaseUrl}/stop_session`, {
768
- method: "POST",
769
- signal: this.signal
770
- });
646
+ };
647
+
648
+ // src/utils/webrtc.ts
649
+ var DEFAULT_DATA_CHANNEL_LABEL = "data";
650
+ var FORCE_RELAY_MODE = false;
651
+ var DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024;
652
+ function createPeerConnection(config) {
653
+ return new RTCPeerConnection({
654
+ iceServers: config.iceServers,
655
+ iceTransportPolicy: FORCE_RELAY_MODE ? "relay" : "all"
656
+ });
657
+ }
658
+ function createDataChannel(pc, label) {
659
+ return pc.createDataChannel(label != null ? label : DEFAULT_DATA_CHANNEL_LABEL);
660
+ }
661
+ function createOffer(pc) {
662
+ return __async(this, null, function* () {
663
+ const offer = yield pc.createOffer();
664
+ yield pc.setLocalDescription(offer);
665
+ yield waitForIceGathering(pc);
666
+ const localDescription = pc.localDescription;
667
+ if (!localDescription) {
668
+ throw new Error("Failed to create local description");
669
+ }
670
+ return localDescription.sdp;
671
+ });
672
+ }
673
+ function setRemoteDescription(pc, sdp) {
674
+ return __async(this, null, function* () {
675
+ const sessionDescription = new RTCSessionDescription({
676
+ sdp,
677
+ type: "answer"
771
678
  });
679
+ yield pc.setRemoteDescription(sessionDescription);
680
+ });
681
+ }
682
+ function transformIceServers(response) {
683
+ return response.ice_servers.map((server) => {
684
+ const rtcServer = {
685
+ urls: server.uris
686
+ };
687
+ if (server.credentials) {
688
+ rtcServer.username = server.credentials.username;
689
+ rtcServer.credential = server.credentials.password;
690
+ }
691
+ return rtcServer;
692
+ });
693
+ }
694
+ function waitForIceGathering(pc, timeoutMs = 5e3) {
695
+ return new Promise((resolve) => {
696
+ if (pc.iceGatheringState === "complete") {
697
+ resolve();
698
+ return;
699
+ }
700
+ const onGatheringStateChange = () => {
701
+ if (pc.iceGatheringState === "complete") {
702
+ pc.removeEventListener(
703
+ "icegatheringstatechange",
704
+ onGatheringStateChange
705
+ );
706
+ resolve();
707
+ }
708
+ };
709
+ pc.addEventListener("icegatheringstatechange", onGatheringStateChange);
710
+ setTimeout(() => {
711
+ pc.removeEventListener("icegatheringstatechange", onGatheringStateChange);
712
+ resolve();
713
+ }, timeoutMs);
714
+ });
715
+ }
716
+ function sendMessage(channel, command, data, scope = "application", maxBytes = DEFAULT_MAX_MESSAGE_BYTES) {
717
+ if (channel.readyState !== "open") {
718
+ throw new Error(`Data channel not open: ${channel.readyState}`);
772
719
  }
773
- };
720
+ const jsonData = typeof data === "string" ? JSON.parse(data) : data;
721
+ const inner = { type: command, data: jsonData };
722
+ const payload = { scope, data: inner };
723
+ const serialized = JSON.stringify(payload);
724
+ const byteLength = new TextEncoder().encode(serialized).byteLength;
725
+ if (byteLength > maxBytes) {
726
+ throw new Error(
727
+ `Data channel message too large: ${byteLength} bytes exceeds limit of ${maxBytes} bytes (command: "${command}")`
728
+ );
729
+ }
730
+ channel.send(serialized);
731
+ }
732
+ function parseMessage(data) {
733
+ if (typeof data === "string") {
734
+ try {
735
+ return JSON.parse(data);
736
+ } catch (e) {
737
+ return data;
738
+ }
739
+ }
740
+ return data;
741
+ }
742
+ function closePeerConnection(pc) {
743
+ pc.close();
744
+ }
745
+ function extractConnectionStats(report) {
746
+ let rtt;
747
+ let availableOutgoingBitrate;
748
+ let localCandidateId;
749
+ let framesPerSecond;
750
+ let jitter;
751
+ let packetLossRatio;
752
+ report.forEach((stat) => {
753
+ if (stat.type === "candidate-pair" && stat.state === "succeeded") {
754
+ if (stat.currentRoundTripTime !== void 0) {
755
+ rtt = stat.currentRoundTripTime * 1e3;
756
+ }
757
+ if (stat.availableOutgoingBitrate !== void 0) {
758
+ availableOutgoingBitrate = stat.availableOutgoingBitrate;
759
+ }
760
+ localCandidateId = stat.localCandidateId;
761
+ }
762
+ if (stat.type === "inbound-rtp" && stat.kind === "video") {
763
+ if (stat.framesPerSecond !== void 0) {
764
+ framesPerSecond = stat.framesPerSecond;
765
+ }
766
+ if (stat.jitter !== void 0) {
767
+ jitter = stat.jitter;
768
+ }
769
+ if (stat.packetsReceived !== void 0 && stat.packetsLost !== void 0 && stat.packetsReceived + stat.packetsLost > 0) {
770
+ packetLossRatio = stat.packetsLost / (stat.packetsReceived + stat.packetsLost);
771
+ }
772
+ }
773
+ });
774
+ let candidateType;
775
+ if (localCandidateId) {
776
+ const localCandidate = report.get(localCandidateId);
777
+ if (localCandidate == null ? void 0 : localCandidate.candidateType) {
778
+ candidateType = localCandidate.candidateType;
779
+ }
780
+ }
781
+ return {
782
+ rtt,
783
+ candidateType,
784
+ availableOutgoingBitrate,
785
+ framesPerSecond,
786
+ packetLossRatio,
787
+ jitter,
788
+ timestamp: Date.now()
789
+ };
790
+ }
774
791
 
775
- // src/core/GPUMachineClient.ts
792
+ // src/core/WebRTCTransportClient.ts
776
793
  var PING_INTERVAL_MS = 5e3;
777
794
  var STATS_INTERVAL_MS = 2e3;
778
- var GPUMachineClient = class {
795
+ var INITIAL_BACKOFF_MS = 200;
796
+ var MAX_BACKOFF_MS = 15e3;
797
+ var BACKOFF_MULTIPLIER = 2;
798
+ var DEFAULT_MAX_POLL_ATTEMPTS = 6;
799
+ var WebRTCTransportClient = class {
779
800
  constructor(config) {
780
801
  this.eventListeners = /* @__PURE__ */ new Map();
781
802
  this.status = "disconnected";
@@ -783,11 +804,17 @@ var GPUMachineClient = class {
783
804
  this.publishedTracks = /* @__PURE__ */ new Map();
784
805
  this.peerConnected = false;
785
806
  this.dataChannelOpen = false;
786
- this.config = config;
807
+ var _a, _b;
808
+ this.baseUrl = config.baseUrl;
809
+ this.sessionId = config.sessionId;
810
+ this.jwtToken = config.jwtToken;
811
+ this.webrtcVersion = (_a = config.webrtcVersion) != null ? _a : REACTOR_WEBRTC_VERSION;
812
+ this.maxPollAttempts = (_b = config.maxPollAttempts) != null ? _b : DEFAULT_MAX_POLL_ATTEMPTS;
813
+ this.abortController = new AbortController();
787
814
  }
788
- // ─────────────────────────────────────────────────────────────────────────────
789
- // Event Emitter API
790
- // ─────────────────────────────────────────────────────────────────────────────
815
+ // ─────────────────────────────────────────────────────────────────────────
816
+ // Event Emitter
817
+ // ─────────────────────────────────────────────────────────────────────────
791
818
  on(event, handler) {
792
819
  if (!this.eventListeners.has(event)) {
793
820
  this.eventListeners.set(event, /* @__PURE__ */ new Set());
@@ -802,127 +829,241 @@ var GPUMachineClient = class {
802
829
  var _a;
803
830
  (_a = this.eventListeners.get(event)) == null ? void 0 : _a.forEach((handler) => handler(...args));
804
831
  }
805
- // ─────────────────────────────────────────────────────────────────────────────
806
- // SDP & Connection
807
- // ─────────────────────────────────────────────────────────────────────────────
808
- /**
809
- * Creates an SDP offer based on the declared tracks.
810
- *
811
- * **RECEIVE** = client receives from the model (model → client) → `recvonly`
812
- * **SEND** = client sends to the model (client → model) → `sendonly`
813
- *
814
- * Track names must be unique across both arrays. A name that appears in
815
- * both `receive` and `send` will throw — use distinct names instead.
816
- *
817
- * The data channel is always created first (before transceivers).
818
- * Must be called before connect().
819
- */
820
- createOffer(tracks) {
832
+ // ─────────────────────────────────────────────────────────────────────────
833
+ // HTTP Helpers
834
+ // ─────────────────────────────────────────────────────────────────────────
835
+ get signal() {
836
+ return this.abortController.signal;
837
+ }
838
+ get transportBaseUrl() {
839
+ return `${this.baseUrl}/sessions/${this.sessionId}/transport/webrtc`;
840
+ }
841
+ getHeaders() {
842
+ return {
843
+ Authorization: `Bearer ${this.jwtToken}`,
844
+ [WEBRTC_VERSION_HEADER]: this.webrtcVersion
845
+ };
846
+ }
847
+ checkVersionMismatch(response) {
848
+ return __async(this, null, function* () {
849
+ if (response.status === 426) {
850
+ const msg = `Client WebRTC version (${this.webrtcVersion}) is too old. Server requires a newer version. Please upgrade @reactor-team/js-sdk.`;
851
+ console.error(`[WebRTCTransport]`, msg);
852
+ throw new Error(`${VERSION_ERROR_CODES[426]}: ${msg}`);
853
+ }
854
+ if (response.status === 501) {
855
+ const msg = `Server does not support WebRTC version ${this.webrtcVersion}. The server may need to be updated.`;
856
+ console.error(`[WebRTCTransport]`, msg);
857
+ throw new Error(`${VERSION_ERROR_CODES[501]}: ${msg}`);
858
+ }
859
+ });
860
+ }
861
+ sleep(ms) {
862
+ return new Promise((resolve, reject) => {
863
+ const { signal } = this;
864
+ if (signal.aborted) {
865
+ reject(new AbortError("Sleep aborted"));
866
+ return;
867
+ }
868
+ const timer = setTimeout(() => {
869
+ signal.removeEventListener("abort", onAbort);
870
+ resolve();
871
+ }, ms);
872
+ const onAbort = () => {
873
+ clearTimeout(timer);
874
+ reject(new AbortError("Sleep aborted"));
875
+ };
876
+ signal.addEventListener("abort", onAbort, { once: true });
877
+ });
878
+ }
879
+ // ─────────────────────────────────────────────────────────────────────────
880
+ // Transport Signaling (HTTP)
881
+ // ─────────────────────────────────────────────────────────────────────────
882
+ fetchIceServers() {
883
+ return __async(this, null, function* () {
884
+ console.debug("[WebRTCTransport] Fetching ICE servers...");
885
+ const response = yield fetch(`${this.transportBaseUrl}/ice_servers`, {
886
+ method: "GET",
887
+ headers: this.getHeaders(),
888
+ signal: this.signal
889
+ });
890
+ yield this.checkVersionMismatch(response);
891
+ if (!response.ok) {
892
+ throw new Error(`Failed to fetch ICE servers: ${response.status}`);
893
+ }
894
+ const data = yield response.json();
895
+ const parsed = IceServersResponseSchema.parse(data);
896
+ const iceServers = transformIceServers(parsed);
897
+ console.debug("[WebRTCTransport] Received ICE servers:", iceServers.length);
898
+ return iceServers;
899
+ });
900
+ }
901
+ sendSdpOffer(sdpOffer, trackMapping, method = "POST") {
902
+ return __async(this, null, function* () {
903
+ console.debug(
904
+ `[WebRTCTransport] Sending SDP offer (${method}) for session:`,
905
+ this.sessionId
906
+ );
907
+ const requestBody = {
908
+ sdp_offer: sdpOffer,
909
+ client_info: {
910
+ sdk_version: REACTOR_SDK_VERSION,
911
+ sdk_type: REACTOR_SDK_TYPE
912
+ },
913
+ track_mapping: trackMapping
914
+ };
915
+ const response = yield fetch(`${this.transportBaseUrl}/sdp_params`, {
916
+ method,
917
+ headers: __spreadProps(__spreadValues({}, this.getHeaders()), {
918
+ "Content-Type": "application/json"
919
+ }),
920
+ body: JSON.stringify(requestBody),
921
+ signal: this.signal
922
+ });
923
+ yield this.checkVersionMismatch(response);
924
+ if (response.status !== 202) {
925
+ const errorText = yield response.text();
926
+ throw new Error(
927
+ `Failed to send SDP offer: ${response.status} ${errorText}`
928
+ );
929
+ }
930
+ console.debug("[WebRTCTransport] SDP offer accepted (202)");
931
+ });
932
+ }
933
+ pollSdpAnswer() {
934
+ return __async(this, null, function* () {
935
+ console.debug("[WebRTCTransport] Polling for SDP answer...");
936
+ const pollStart = performance.now();
937
+ let backoffMs = INITIAL_BACKOFF_MS;
938
+ let attempt = 0;
939
+ while (true) {
940
+ if (this.signal.aborted) {
941
+ throw new AbortError("SDP polling aborted");
942
+ }
943
+ if (attempt >= this.maxPollAttempts) {
944
+ throw new Error(
945
+ `SDP polling exceeded maximum attempts (${this.maxPollAttempts})`
946
+ );
947
+ }
948
+ attempt++;
949
+ console.debug(
950
+ `[WebRTCTransport] SDP poll attempt ${attempt}/${this.maxPollAttempts}`
951
+ );
952
+ const response = yield fetch(`${this.transportBaseUrl}/sdp_params`, {
953
+ method: "GET",
954
+ headers: this.getHeaders(),
955
+ signal: this.signal
956
+ });
957
+ yield this.checkVersionMismatch(response);
958
+ if (response.status === 200) {
959
+ const data = yield response.json();
960
+ const parsed = WebRTCSdpAnswerResponseSchema.parse(data);
961
+ this.sdpPollingMs = performance.now() - pollStart;
962
+ this.sdpPollingAttempts = attempt;
963
+ console.debug(
964
+ `[WebRTCTransport] Received SDP answer via polling (${attempt} attempt(s), ${this.sdpPollingMs.toFixed(0)}ms)`
965
+ );
966
+ return parsed;
967
+ }
968
+ if (response.status === 202) {
969
+ console.debug(
970
+ `[WebRTCTransport] SDP answer pending, retrying in ${backoffMs}ms...`
971
+ );
972
+ yield this.sleep(backoffMs);
973
+ backoffMs = Math.min(backoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS);
974
+ continue;
975
+ }
976
+ const errorText = yield response.text();
977
+ throw new Error(
978
+ `Failed to poll SDP answer: ${response.status} ${errorText}`
979
+ );
980
+ }
981
+ });
982
+ }
983
+ // ─────────────────────────────────────────────────────────────────────────
984
+ // Connection Lifecycle
985
+ // ─────────────────────────────────────────────────────────────────────────
986
+ connect(tracks) {
821
987
  return __async(this, null, function* () {
822
- if (!this.peerConnection) {
823
- this.peerConnection = createPeerConnection(this.config);
824
- this.setupPeerConnectionHandlers();
825
- }
826
- this.dataChannel = createDataChannel(
827
- this.peerConnection,
828
- this.config.dataChannelLabel
829
- );
988
+ this.setStatus("connecting");
989
+ this.resetTransportTimings();
990
+ const iceServers = yield this.fetchIceServers();
991
+ this.peerConnection = createPeerConnection({ iceServers });
992
+ this.setupPeerConnectionHandlers();
993
+ this.dataChannel = createDataChannel(this.peerConnection);
830
994
  this.setupDataChannelHandlers();
831
995
  this.transceiverMap.clear();
832
- const entries = this.buildTransceiverEntries(tracks);
833
- for (const entry of entries) {
834
- const transceiver = this.peerConnection.addTransceiver(entry.kind, {
835
- direction: entry.direction
996
+ for (const track of tracks) {
997
+ const transceiver = this.peerConnection.addTransceiver(track.kind, {
998
+ direction: track.direction
999
+ });
1000
+ this.transceiverMap.set(track.name, {
1001
+ name: track.name,
1002
+ kind: track.kind,
1003
+ direction: track.direction,
1004
+ transceiver
836
1005
  });
837
- entry.transceiver = transceiver;
838
- this.transceiverMap.set(entry.name, entry);
839
1006
  console.debug(
840
- `[GPUMachineClient] Transceiver added: "${entry.name}" (${entry.kind}, ${entry.direction})`
1007
+ `[WebRTCTransport] Transceiver added: "${track.name}" (${track.kind}, ${track.direction})`
841
1008
  );
842
1009
  }
843
- const trackNames = entries.map((e) => e.name);
844
- const { sdp, needsAnswerRestore } = yield createOffer(
1010
+ const sdpOffer = yield createOffer(this.peerConnection);
1011
+ const trackMapping = this.buildTrackMapping(tracks);
1012
+ yield this.sendSdpOffer(sdpOffer, trackMapping, "POST");
1013
+ const answerResponse = yield this.pollSdpAnswer();
1014
+ this.iceStartTime = performance.now();
1015
+ yield setRemoteDescription(
845
1016
  this.peerConnection,
846
- trackNames
1017
+ answerResponse.sdp_answer
847
1018
  );
848
- if (needsAnswerRestore) {
849
- this.midMapping = buildMidMapping(entries);
850
- } else {
851
- this.midMapping = void 0;
852
- }
853
- console.debug(
854
- "[GPUMachineClient] Created SDP offer with MIDs:",
855
- trackNames,
856
- needsAnswerRestore ? "(needs answer restore)" : "(native munging)"
857
- );
858
- return sdp;
1019
+ console.debug("[WebRTCTransport] Remote description set");
859
1020
  });
860
1021
  }
861
- /**
862
- * Builds an ordered list of transceiver entries from the receive/send arrays.
863
- *
864
- * Each track produces exactly one transceiver — `recvonly` for receive,
865
- * `sendonly` for send. Bidirectional (`sendrecv`) transceivers are not
866
- * supported; the same track name in both arrays is an error.
867
- */
868
- buildTransceiverEntries(tracks) {
869
- const map = /* @__PURE__ */ new Map();
870
- for (const t of tracks.receive) {
871
- if (map.has(t.name)) {
872
- throw new Error(
873
- `Duplicate receive track name "${t.name}". Track names must be unique.`
874
- );
875
- }
876
- map.set(t.name, { name: t.name, kind: t.kind, direction: "recvonly" });
877
- }
878
- for (const t of tracks.send) {
879
- if (map.has(t.name)) {
880
- throw new Error(
881
- `Track name "${t.name}" appears in both receive and send. Bidirectional tracks are not supported \u2014 use distinct names for the inbound and outbound directions (e.g. "${t.name}_in" and "${t.name}_out").`
882
- );
883
- }
884
- map.set(t.name, { name: t.name, kind: t.kind, direction: "sendonly" });
885
- }
886
- return Array.from(map.values());
887
- }
888
- /**
889
- * Connects to the GPU machine using the provided SDP answer.
890
- * createOffer() must be called first.
891
- * @param sdpAnswer The SDP answer from the GPU machine
892
- */
893
- connect(sdpAnswer) {
1022
+ reconnect(tracks) {
894
1023
  return __async(this, null, function* () {
895
- if (!this.peerConnection) {
896
- throw new Error(
897
- "[GPUMachineClient] Cannot connect - call createOffer() first"
898
- );
1024
+ this.setStatus("connecting");
1025
+ this.stopPing();
1026
+ this.stopStatsPolling();
1027
+ if (this.dataChannel) {
1028
+ this.dataChannel.close();
1029
+ this.dataChannel = void 0;
899
1030
  }
900
- if (this.peerConnection.signalingState !== "have-local-offer") {
901
- throw new Error(
902
- `[GPUMachineClient] Invalid signaling state: ${this.peerConnection.signalingState}`
903
- );
1031
+ if (this.peerConnection) {
1032
+ closePeerConnection(this.peerConnection);
1033
+ this.peerConnection = void 0;
904
1034
  }
905
- this.setStatus("connecting");
906
- try {
907
- let answer = sdpAnswer;
908
- if (this.midMapping) {
909
- answer = restoreAnswerMids(
910
- answer,
911
- this.midMapping.remoteToLocal
912
- );
913
- }
914
- yield setRemoteDescription(this.peerConnection, answer);
915
- console.debug("[GPUMachineClient] Remote description set");
916
- } catch (error) {
917
- console.error("[GPUMachineClient] Failed to connect:", error);
918
- this.setStatus("error");
919
- throw error;
1035
+ this.peerConnected = false;
1036
+ this.dataChannelOpen = false;
1037
+ this.resetTransportTimings();
1038
+ const iceServers = yield this.fetchIceServers();
1039
+ this.peerConnection = createPeerConnection({ iceServers });
1040
+ this.setupPeerConnectionHandlers();
1041
+ this.dataChannel = createDataChannel(this.peerConnection);
1042
+ this.setupDataChannelHandlers();
1043
+ this.transceiverMap.clear();
1044
+ for (const track of tracks) {
1045
+ const transceiver = this.peerConnection.addTransceiver(track.kind, {
1046
+ direction: track.direction
1047
+ });
1048
+ this.transceiverMap.set(track.name, {
1049
+ name: track.name,
1050
+ kind: track.kind,
1051
+ direction: track.direction,
1052
+ transceiver
1053
+ });
920
1054
  }
1055
+ const sdpOffer = yield createOffer(this.peerConnection);
1056
+ const trackMapping = this.buildTrackMapping(tracks);
1057
+ yield this.sendSdpOffer(sdpOffer, trackMapping, "PUT");
1058
+ const answerResponse = yield this.pollSdpAnswer();
1059
+ this.iceStartTime = performance.now();
1060
+ yield setRemoteDescription(
1061
+ this.peerConnection,
1062
+ answerResponse.sdp_answer
1063
+ );
1064
+ console.debug("[WebRTCTransport] Remote description set (reconnect)");
921
1065
  });
922
1066
  }
923
- /**
924
- * Disconnects from the GPU machine and cleans up resources.
925
- */
926
1067
  disconnect() {
927
1068
  return __async(this, null, function* () {
928
1069
  this.stopPing();
@@ -939,99 +1080,100 @@ var GPUMachineClient = class {
939
1080
  this.peerConnection = void 0;
940
1081
  }
941
1082
  this.transceiverMap.clear();
942
- this.midMapping = void 0;
943
1083
  this.peerConnected = false;
944
1084
  this.dataChannelOpen = false;
1085
+ this.resetTransportTimings();
945
1086
  this.setStatus("disconnected");
946
- console.debug("[GPUMachineClient] Disconnected");
1087
+ console.debug("[WebRTCTransport] Disconnected");
947
1088
  });
948
1089
  }
949
- /**
950
- * Returns the current connection status.
951
- */
1090
+ abort() {
1091
+ this.abortController.abort();
1092
+ this.abortController = new AbortController();
1093
+ }
952
1094
  getStatus() {
953
1095
  return this.status;
954
1096
  }
1097
+ // ─────────────────────────────────────────────────────────────────────────
1098
+ // Track Mapping
1099
+ // ─────────────────────────────────────────────────────────────────────────
955
1100
  /**
956
- * Gets the current local SDP description.
1101
+ * Builds the track_mapping array from capabilities + transceiver MIDs.
1102
+ * Must be called after createOffer + setLocalDescription so that
1103
+ * transceiver.mid is assigned.
957
1104
  */
958
- getLocalSDP() {
959
- if (!this.peerConnection) return void 0;
960
- return getLocalDescription(this.peerConnection);
961
- }
962
- isOfferStillValid() {
963
- if (!this.peerConnection) return false;
964
- return this.peerConnection.signalingState === "have-local-offer";
1105
+ buildTrackMapping(tracks) {
1106
+ return tracks.map((track) => {
1107
+ var _a;
1108
+ const entry = this.transceiverMap.get(track.name);
1109
+ const mid = (_a = entry == null ? void 0 : entry.transceiver) == null ? void 0 : _a.mid;
1110
+ if (mid == null) {
1111
+ throw new Error(
1112
+ `Cannot build track mapping: transceiver "${track.name}" has no MID. Was createOffer() called?`
1113
+ );
1114
+ }
1115
+ return {
1116
+ mid,
1117
+ name: track.name,
1118
+ kind: track.kind,
1119
+ direction: track.direction
1120
+ };
1121
+ });
965
1122
  }
966
- // ─────────────────────────────────────────────────────────────────────────────
1123
+ // ─────────────────────────────────────────────────────────────────────────
967
1124
  // Messaging
968
- // ─────────────────────────────────────────────────────────────────────────────
969
- /**
970
- * Sends a command to the GPU machine via the data channel.
971
- * @param command The command to send
972
- * @param data The data to send with the command. These are the parameters for the command, matching the schema in the capabilities dictionary.
973
- * @param scope The message scope – "application" (default) for model commands, "runtime" for platform-level messages.
974
- */
1125
+ // ─────────────────────────────────────────────────────────────────────────
1126
+ get maxMessageBytes() {
1127
+ var _a, _b, _c;
1128
+ return (_c = (_b = (_a = this.peerConnection) == null ? void 0 : _a.sctp) == null ? void 0 : _b.maxMessageSize) != null ? _c : void 0;
1129
+ }
975
1130
  sendCommand(command, data, scope = "application") {
976
1131
  if (!this.dataChannel) {
977
- throw new Error("[GPUMachineClient] Data channel not available");
1132
+ throw new Error("[WebRTCTransport] Data channel not available");
978
1133
  }
979
1134
  try {
980
- sendMessage(this.dataChannel, command, data, scope);
1135
+ sendMessage(
1136
+ this.dataChannel,
1137
+ command,
1138
+ data,
1139
+ scope,
1140
+ this.maxMessageBytes
1141
+ );
981
1142
  } catch (error) {
982
- console.warn("[GPUMachineClient] Failed to send message:", error);
1143
+ console.warn("[WebRTCTransport] Failed to send message:", error);
983
1144
  }
984
1145
  }
985
- // ─────────────────────────────────────────────────────────────────────────────
1146
+ // ─────────────────────────────────────────────────────────────────────────
986
1147
  // Track Publishing
987
- // ─────────────────────────────────────────────────────────────────────────────
988
- /**
989
- * Publishes a MediaStreamTrack to the named send track.
990
- *
991
- * @param name The declared track name (must exist in transceiverMap with a sendable direction).
992
- * @param track The MediaStreamTrack to publish.
993
- */
1148
+ // ─────────────────────────────────────────────────────────────────────────
994
1149
  publishTrack(name, track) {
995
1150
  return __async(this, null, function* () {
996
1151
  if (!this.peerConnection) {
997
1152
  throw new Error(
998
- `[GPUMachineClient] Cannot publish track "${name}" - not initialized`
1153
+ `[WebRTCTransport] Cannot publish track "${name}" - not initialized`
999
1154
  );
1000
1155
  }
1001
1156
  if (this.status !== "connected") {
1002
1157
  throw new Error(
1003
- `[GPUMachineClient] Cannot publish track "${name}" - not connected`
1158
+ `[WebRTCTransport] Cannot publish track "${name}" - not connected`
1004
1159
  );
1005
1160
  }
1006
1161
  const entry = this.transceiverMap.get(name);
1007
1162
  if (!entry || !entry.transceiver) {
1008
1163
  throw new Error(
1009
- `[GPUMachineClient] Cannot publish track "${name}" - no transceiver (was it declared in tracks.send?)`
1164
+ `[WebRTCTransport] Cannot publish track "${name}" - no transceiver (was it declared in capabilities?)`
1010
1165
  );
1011
1166
  }
1012
1167
  if (entry.direction === "recvonly") {
1013
1168
  throw new Error(
1014
- `[GPUMachineClient] Cannot publish track "${name}" - transceiver is recvonly`
1015
- );
1016
- }
1017
- try {
1018
- yield entry.transceiver.sender.replaceTrack(track);
1019
- this.publishedTracks.set(name, track);
1020
- console.debug(
1021
- `[GPUMachineClient] Track "${name}" published successfully`
1022
- );
1023
- } catch (error) {
1024
- console.error(
1025
- `[GPUMachineClient] Failed to publish track "${name}":`,
1026
- error
1169
+ `[WebRTCTransport] Cannot publish track "${name}" - transceiver is recvonly`
1027
1170
  );
1028
- throw error;
1029
1171
  }
1172
+ yield entry.transceiver.sender.replaceTrack(track);
1173
+ this.publishedTracks.set(name, track);
1174
+ console.debug(`[WebRTCTransport] Track "${name}" published successfully`);
1030
1175
  });
1031
1176
  }
1032
- /**
1033
- * Unpublishes the track with the given name.
1034
- */
1035
1177
  unpublishTrack(name) {
1036
1178
  return __async(this, null, function* () {
1037
1179
  const entry = this.transceiverMap.get(name);
@@ -1039,11 +1181,11 @@ var GPUMachineClient = class {
1039
1181
  try {
1040
1182
  yield entry.transceiver.sender.replaceTrack(null);
1041
1183
  console.debug(
1042
- `[GPUMachineClient] Track "${name}" unpublished successfully`
1184
+ `[WebRTCTransport] Track "${name}" unpublished successfully`
1043
1185
  );
1044
1186
  } catch (error) {
1045
1187
  console.error(
1046
- `[GPUMachineClient] Failed to unpublish track "${name}":`,
1188
+ `[WebRTCTransport] Failed to unpublish track "${name}":`,
1047
1189
  error
1048
1190
  );
1049
1191
  throw error;
@@ -1052,58 +1194,31 @@ var GPUMachineClient = class {
1052
1194
  }
1053
1195
  });
1054
1196
  }
1055
- /**
1056
- * Returns the currently published track for the given name.
1057
- */
1058
- getPublishedTrack(name) {
1059
- return this.publishedTracks.get(name);
1060
- }
1061
- // ─────────────────────────────────────────────────────────────────────────────
1062
- // Getters
1063
- // ─────────────────────────────────────────────────────────────────────────────
1064
- /**
1065
- * Returns the remote media stream from the GPU machine.
1066
- */
1067
- getRemoteStream() {
1068
- if (!this.peerConnection) return void 0;
1069
- const receivers = this.peerConnection.getReceivers();
1070
- const tracks = receivers.map((r) => r.track).filter((t) => t !== null);
1071
- if (tracks.length === 0) return void 0;
1072
- return new MediaStream(tracks);
1073
- }
1074
- // ─────────────────────────────────────────────────────────────────────────────
1075
- // Ping (Client Liveness)
1076
- // ─────────────────────────────────────────────────────────────────────────────
1077
- /**
1078
- * Starts sending periodic "ping" messages on the runtime channel so the
1079
- * server can detect stale connections quickly.
1080
- */
1081
- startPing() {
1082
- this.stopPing();
1083
- this.pingInterval = setInterval(() => {
1084
- var _a;
1085
- if (((_a = this.dataChannel) == null ? void 0 : _a.readyState) === "open") {
1086
- try {
1087
- sendMessage(this.dataChannel, "ping", {}, "runtime");
1088
- } catch (e) {
1089
- }
1090
- }
1091
- }, PING_INTERVAL_MS);
1197
+ // ─────────────────────────────────────────────────────────────────────────
1198
+ // Stats
1199
+ // ─────────────────────────────────────────────────────────────────────────
1200
+ getStats() {
1201
+ return this.stats;
1092
1202
  }
1093
- /**
1094
- * Stops the periodic ping.
1095
- */
1096
- stopPing() {
1097
- if (this.pingInterval !== void 0) {
1098
- clearInterval(this.pingInterval);
1099
- this.pingInterval = void 0;
1203
+ getTransportTimings() {
1204
+ var _a, _b;
1205
+ if (this.iceNegotiationMs == null || this.dataChannelMs == null) {
1206
+ return void 0;
1100
1207
  }
1208
+ return {
1209
+ protocol: "webrtc",
1210
+ sdpPollingMs: (_a = this.sdpPollingMs) != null ? _a : 0,
1211
+ sdpPollingAttempts: (_b = this.sdpPollingAttempts) != null ? _b : 0,
1212
+ iceNegotiationMs: this.iceNegotiationMs,
1213
+ dataChannelMs: this.dataChannelMs
1214
+ };
1101
1215
  }
1102
- // ─────────────────────────────────────────────────────────────────────────────
1103
- // Stats Polling (RTT)
1104
- // ─────────────────────────────────────────────────────────────────────────────
1105
- getStats() {
1106
- return this.stats;
1216
+ resetTransportTimings() {
1217
+ this.iceStartTime = void 0;
1218
+ this.iceNegotiationMs = void 0;
1219
+ this.dataChannelMs = void 0;
1220
+ this.sdpPollingMs = void 0;
1221
+ this.sdpPollingAttempts = void 0;
1107
1222
  }
1108
1223
  startStatsPolling() {
1109
1224
  this.stopStatsPolling();
@@ -1124,9 +1239,30 @@ var GPUMachineClient = class {
1124
1239
  }
1125
1240
  this.stats = void 0;
1126
1241
  }
1127
- // ─────────────────────────────────────────────────────────────────────────────
1128
- // Private Helpers
1129
- // ─────────────────────────────────────────────────────────────────────────────
1242
+ // ─────────────────────────────────────────────────────────────────────────
1243
+ // Ping (Client Liveness)
1244
+ // ─────────────────────────────────────────────────────────────────────────
1245
+ startPing() {
1246
+ this.stopPing();
1247
+ this.pingInterval = setInterval(() => {
1248
+ var _a;
1249
+ if (((_a = this.dataChannel) == null ? void 0 : _a.readyState) === "open") {
1250
+ try {
1251
+ sendMessage(this.dataChannel, "ping", {}, "runtime");
1252
+ } catch (e) {
1253
+ }
1254
+ }
1255
+ }, PING_INTERVAL_MS);
1256
+ }
1257
+ stopPing() {
1258
+ if (this.pingInterval !== void 0) {
1259
+ clearInterval(this.pingInterval);
1260
+ this.pingInterval = void 0;
1261
+ }
1262
+ }
1263
+ // ─────────────────────────────────────────────────────────────────────────
1264
+ // Internal Helpers
1265
+ // ─────────────────────────────────────────────────────────────────────────
1130
1266
  checkFullyConnected() {
1131
1267
  if (this.peerConnected && this.dataChannelOpen) {
1132
1268
  this.setStatus("connected");
@@ -1144,10 +1280,13 @@ var GPUMachineClient = class {
1144
1280
  this.peerConnection.onconnectionstatechange = () => {
1145
1281
  var _a;
1146
1282
  const state = (_a = this.peerConnection) == null ? void 0 : _a.connectionState;
1147
- console.debug("[GPUMachineClient] Connection state:", state);
1283
+ console.debug("[WebRTCTransport] Connection state:", state);
1148
1284
  if (state) {
1149
1285
  switch (state) {
1150
1286
  case "connected":
1287
+ if (this.iceStartTime != null && this.iceNegotiationMs == null) {
1288
+ this.iceNegotiationMs = performance.now() - this.iceStartTime;
1289
+ }
1151
1290
  this.peerConnected = true;
1152
1291
  this.checkFullyConnected();
1153
1292
  break;
@@ -1174,21 +1313,21 @@ var GPUMachineClient = class {
1174
1313
  }
1175
1314
  trackName != null ? trackName : trackName = (_a = event.transceiver.mid) != null ? _a : `unknown-${event.track.id}`;
1176
1315
  console.debug(
1177
- `[GPUMachineClient] Track received: "${trackName}" (${event.track.kind}, mid=${event.transceiver.mid})`
1316
+ `[WebRTCTransport] Track received: "${trackName}" (${event.track.kind}, mid=${event.transceiver.mid})`
1178
1317
  );
1179
1318
  const stream = (_b = event.streams[0]) != null ? _b : new MediaStream([event.track]);
1180
1319
  this.emit("trackReceived", trackName, event.track, stream);
1181
1320
  };
1182
1321
  this.peerConnection.onicecandidate = (event) => {
1183
1322
  if (event.candidate) {
1184
- console.debug("[GPUMachineClient] ICE candidate:", event.candidate);
1323
+ console.debug("[WebRTCTransport] ICE candidate:", event.candidate);
1185
1324
  }
1186
1325
  };
1187
1326
  this.peerConnection.onicecandidateerror = (event) => {
1188
- console.warn("[GPUMachineClient] ICE candidate error:", event);
1327
+ console.warn("[WebRTCTransport] ICE candidate error:", event);
1189
1328
  };
1190
1329
  this.peerConnection.ondatachannel = (event) => {
1191
- console.debug("[GPUMachineClient] Data channel received from remote");
1330
+ console.debug("[WebRTCTransport] Data channel received from remote");
1192
1331
  this.dataChannel = event.channel;
1193
1332
  this.setupDataChannelHandlers();
1194
1333
  };
@@ -1196,22 +1335,25 @@ var GPUMachineClient = class {
1196
1335
  setupDataChannelHandlers() {
1197
1336
  if (!this.dataChannel) return;
1198
1337
  this.dataChannel.onopen = () => {
1199
- console.debug("[GPUMachineClient] Data channel open");
1338
+ console.debug("[WebRTCTransport] Data channel open");
1339
+ if (this.iceStartTime != null && this.dataChannelMs == null) {
1340
+ this.dataChannelMs = performance.now() - this.iceStartTime;
1341
+ }
1200
1342
  this.dataChannelOpen = true;
1201
1343
  this.startPing();
1202
1344
  this.checkFullyConnected();
1203
1345
  };
1204
1346
  this.dataChannel.onclose = () => {
1205
- console.debug("[GPUMachineClient] Data channel closed");
1347
+ console.debug("[WebRTCTransport] Data channel closed");
1206
1348
  this.dataChannelOpen = false;
1207
1349
  this.stopPing();
1208
1350
  };
1209
1351
  this.dataChannel.onerror = (error) => {
1210
- console.error("[GPUMachineClient] Data channel error:", error);
1352
+ console.error("[WebRTCTransport] Data channel error:", error);
1211
1353
  };
1212
1354
  this.dataChannel.onmessage = (event) => {
1213
1355
  const rawData = parseMessage(event.data);
1214
- console.debug("[GPUMachineClient] Received message:", rawData);
1356
+ console.debug("[WebRTCTransport] Received message:", rawData);
1215
1357
  try {
1216
1358
  if ((rawData == null ? void 0 : rawData.scope) === "application" && (rawData == null ? void 0 : rawData.data) !== void 0) {
1217
1359
  this.emit("message", rawData.data, "application");
@@ -1219,13 +1361,13 @@ var GPUMachineClient = class {
1219
1361
  this.emit("message", rawData.data, "runtime");
1220
1362
  } else {
1221
1363
  console.warn(
1222
- "[GPUMachineClient] Received message without envelope, treating as application"
1364
+ "[WebRTCTransport] Received message without envelope, treating as application"
1223
1365
  );
1224
1366
  this.emit("message", rawData, "application");
1225
1367
  }
1226
1368
  } catch (error) {
1227
1369
  console.error(
1228
- "[GPUMachineClient] Failed to parse/validate message:",
1370
+ "[WebRTCTransport] Failed to parse/validate message:",
1229
1371
  error
1230
1372
  );
1231
1373
  }
@@ -1236,47 +1378,25 @@ var GPUMachineClient = class {
1236
1378
  // src/core/Reactor.ts
1237
1379
  import { z as z2 } from "zod";
1238
1380
  var LOCAL_COORDINATOR_URL = "http://localhost:8080";
1239
- var PROD_COORDINATOR_URL = "https://api.reactor.inc";
1240
- var TrackConfigSchema = z2.object({
1241
- name: z2.string(),
1242
- kind: z2.enum(["audio", "video"])
1243
- });
1381
+ var DEFAULT_BASE_URL = "https://api.reactor.inc";
1244
1382
  var OptionsSchema = z2.object({
1245
- coordinatorUrl: z2.string().default(PROD_COORDINATOR_URL),
1383
+ apiUrl: z2.string().default(DEFAULT_BASE_URL),
1246
1384
  modelName: z2.string(),
1247
- local: z2.boolean().default(false),
1248
- /**
1249
- * Tracks the client **RECEIVES** from the model (model → client).
1250
- * Each entry produces a `recvonly` transceiver.
1251
- * Names must be unique across both `receive` and `send`.
1252
- *
1253
- * When omitted, defaults to a single video track named `"main_video"`.
1254
- * Pass an explicit empty array to opt out of the default.
1255
- */
1256
- receive: z2.array(TrackConfigSchema).default([{ name: "main_video", kind: "video" }]),
1257
- /**
1258
- * Tracks the client **SENDS** to the model (client → model).
1259
- * Each entry produces a `sendonly` transceiver.
1260
- * Names must be unique across both `receive` and `send`.
1261
- */
1262
- send: z2.array(TrackConfigSchema).default([])
1385
+ local: z2.boolean().default(false)
1263
1386
  });
1264
1387
  var Reactor = class {
1265
1388
  constructor(options) {
1266
1389
  this.status = "disconnected";
1267
- // Generic event map
1390
+ this.tracks = [];
1268
1391
  this.eventListeners = /* @__PURE__ */ new Map();
1269
1392
  const validatedOptions = OptionsSchema.parse(options);
1270
- this.coordinatorUrl = validatedOptions.coordinatorUrl;
1393
+ this.coordinatorUrl = validatedOptions.apiUrl;
1271
1394
  this.model = validatedOptions.modelName;
1272
1395
  this.local = validatedOptions.local;
1273
- this.receive = validatedOptions.receive;
1274
- this.send = validatedOptions.send;
1275
- if (this.local && options.coordinatorUrl === void 0) {
1396
+ if (this.local && options.apiUrl === void 0) {
1276
1397
  this.coordinatorUrl = LOCAL_COORDINATOR_URL;
1277
1398
  }
1278
1399
  }
1279
- // Event Emitter API
1280
1400
  on(event, handler) {
1281
1401
  if (!this.eventListeners.has(event)) {
1282
1402
  this.eventListeners.set(event, /* @__PURE__ */ new Set());
@@ -1293,10 +1413,6 @@ var Reactor = class {
1293
1413
  }
1294
1414
  /**
1295
1415
  * Sends a command to the model via the data channel.
1296
- *
1297
- * @param command The command name.
1298
- * @param data The command payload.
1299
- * @param scope "application" (default) for model commands, "runtime" for platform messages.
1300
1416
  */
1301
1417
  sendCommand(command, data, scope = "application") {
1302
1418
  return __async(this, null, function* () {
@@ -1307,7 +1423,7 @@ var Reactor = class {
1307
1423
  return;
1308
1424
  }
1309
1425
  try {
1310
- (_a = this.machineClient) == null ? void 0 : _a.sendCommand(command, data, scope);
1426
+ (_a = this.transportClient) == null ? void 0 : _a.sendCommand(command, data, scope);
1311
1427
  } catch (error) {
1312
1428
  console.error("[Reactor] Failed to send message:", error);
1313
1429
  this.createError(
@@ -1320,10 +1436,9 @@ var Reactor = class {
1320
1436
  });
1321
1437
  }
1322
1438
  /**
1323
- * Publishes a MediaStreamTrack to a named send track.
1324
- *
1325
- * @param name The declared send track name (e.g. "webcam").
1326
- * @param track The MediaStreamTrack to publish.
1439
+ * Publishes a MediaStreamTrack to a named sendonly track.
1440
+ * The transceiver is already set up from capabilities — this just
1441
+ * calls replaceTrack() on the sender.
1327
1442
  */
1328
1443
  publishTrack(name, track) {
1329
1444
  return __async(this, null, function* () {
@@ -1335,7 +1450,7 @@ var Reactor = class {
1335
1450
  return;
1336
1451
  }
1337
1452
  try {
1338
- yield (_a = this.machineClient) == null ? void 0 : _a.publishTrack(name, track);
1453
+ yield (_a = this.transportClient) == null ? void 0 : _a.publishTrack(name, track);
1339
1454
  } catch (error) {
1340
1455
  console.error(`[Reactor] Failed to publish track "${name}":`, error);
1341
1456
  this.createError(
@@ -1347,16 +1462,11 @@ var Reactor = class {
1347
1462
  }
1348
1463
  });
1349
1464
  }
1350
- /**
1351
- * Unpublishes the track with the given name.
1352
- *
1353
- * @param name The declared send track name to unpublish.
1354
- */
1355
1465
  unpublishTrack(name) {
1356
1466
  return __async(this, null, function* () {
1357
1467
  var _a;
1358
1468
  try {
1359
- yield (_a = this.machineClient) == null ? void 0 : _a.unpublishTrack(name);
1469
+ yield (_a = this.transportClient) == null ? void 0 : _a.unpublishTrack(name);
1360
1470
  } catch (error) {
1361
1471
  console.error(`[Reactor] Failed to unpublish track "${name}":`, error);
1362
1472
  this.createError(
@@ -1369,8 +1479,7 @@ var Reactor = class {
1369
1479
  });
1370
1480
  }
1371
1481
  /**
1372
- * Public method for reconnecting to an existing session, that may have been interrupted but can be recovered.
1373
- * @param options Optional connect options (e.g. maxAttempts for SDP polling)
1482
+ * Reconnects to an existing session with a fresh transport.
1374
1483
  */
1375
1484
  reconnect(options) {
1376
1485
  return __async(this, null, function* () {
@@ -1382,49 +1491,42 @@ var Reactor = class {
1382
1491
  console.warn("[Reactor] Already connected, no need to reconnect.");
1383
1492
  return;
1384
1493
  }
1494
+ if (this.tracks.length === 0) {
1495
+ console.warn("[Reactor] No tracks available for reconnect.");
1496
+ return;
1497
+ }
1385
1498
  this.setStatus("connecting");
1386
- if (!this.machineClient) {
1387
- const iceServers = yield this.coordinatorClient.getIceServers();
1388
- this.machineClient = new GPUMachineClient({ iceServers });
1389
- this.setupMachineClientHandlers();
1390
- }
1391
- const sdpOffer = yield this.machineClient.createOffer({
1392
- send: this.send,
1393
- receive: this.receive
1394
- });
1395
1499
  try {
1396
- const sdpAnswer = yield this.coordinatorClient.connect(
1397
- this.sessionId,
1398
- sdpOffer,
1399
- options == null ? void 0 : options.maxAttempts
1400
- );
1401
- yield this.machineClient.connect(sdpAnswer);
1500
+ if (!this.transportClient) {
1501
+ this.transportClient = new WebRTCTransportClient({
1502
+ baseUrl: this.coordinatorUrl,
1503
+ sessionId: this.sessionId,
1504
+ jwtToken: this.local ? "local" : "",
1505
+ maxPollAttempts: options == null ? void 0 : options.maxAttempts
1506
+ });
1507
+ this.setupTransportHandlers();
1508
+ }
1509
+ yield this.transportClient.reconnect(this.tracks);
1402
1510
  } catch (error) {
1403
1511
  if (isAbortError(error)) return;
1404
- let recoverable = false;
1405
- if (error instanceof ConflictError) {
1406
- recoverable = true;
1407
- }
1408
1512
  console.error("[Reactor] Failed to reconnect:", error);
1409
- this.disconnect(recoverable);
1513
+ this.disconnect(true);
1410
1514
  this.createError(
1411
1515
  "RECONNECTION_FAILED",
1412
1516
  `Failed to reconnect: ${error}`,
1413
- "coordinator",
1517
+ "api",
1414
1518
  true
1415
1519
  );
1416
1520
  }
1417
1521
  });
1418
1522
  }
1419
1523
  /**
1420
- * Connects to the coordinator and waits for a GPU to be assigned.
1421
- * Once a GPU is assigned, the Reactor will connect to the gpu machine via WebRTC.
1422
- * If no authentication is provided and not in local mode, an error is thrown.
1423
- * @param jwtToken Optional JWT token for authentication
1424
- * @param options Optional connect options (e.g. maxAttempts for SDP polling)
1524
+ * Connects to the coordinator, creates a session, then establishes
1525
+ * the transport using server-declared capabilities.
1425
1526
  */
1426
1527
  connect(jwtToken, options) {
1427
1528
  return __async(this, null, function* () {
1529
+ var _a;
1428
1530
  console.debug("[Reactor] Connecting, status:", this.status);
1429
1531
  if (jwtToken == void 0 && !this.local) {
1430
1532
  throw new Error("No authentication provided and not in local mode");
@@ -1433,38 +1535,64 @@ var Reactor = class {
1433
1535
  throw new Error("Already connected or connecting");
1434
1536
  }
1435
1537
  this.setStatus("connecting");
1538
+ this.connectStartTime = performance.now();
1436
1539
  try {
1437
- console.debug(
1438
- "[Reactor] Connecting to coordinator with authenticated URL"
1439
- );
1440
- this.coordinatorClient = this.local ? new LocalCoordinatorClient(this.coordinatorUrl) : new CoordinatorClient({
1540
+ this.coordinatorClient = this.local ? new LocalCoordinatorClient(this.coordinatorUrl, this.model) : new CoordinatorClient({
1441
1541
  baseUrl: this.coordinatorUrl,
1442
1542
  jwtToken,
1443
- // Safe: validated above
1444
1543
  model: this.model
1445
1544
  });
1446
- const iceServers = yield this.coordinatorClient.getIceServers();
1447
- this.machineClient = new GPUMachineClient({ iceServers });
1448
- this.setupMachineClientHandlers();
1449
- const sdpOffer = yield this.machineClient.createOffer({
1450
- send: this.send,
1451
- receive: this.receive
1452
- });
1453
- const sessionId = yield this.coordinatorClient.createSession(sdpOffer);
1454
- this.setSessionId(sessionId);
1455
- const sdpAnswer = yield this.coordinatorClient.connect(
1456
- sessionId,
1457
- void 0,
1458
- options == null ? void 0 : options.maxAttempts
1545
+ const tSession = performance.now();
1546
+ const initialResponse = yield this.coordinatorClient.createSession();
1547
+ const sessionCreationMs = performance.now() - tSession;
1548
+ this.setSessionId(initialResponse.session_id);
1549
+ console.debug(
1550
+ "[Reactor] Session created:",
1551
+ initialResponse.session_id,
1552
+ "state:",
1553
+ initialResponse.state
1554
+ );
1555
+ this.setStatus("waiting");
1556
+ const tPoll = performance.now();
1557
+ const sessionResponse = yield this.coordinatorClient.pollSessionReady();
1558
+ const sessionPollingMs = performance.now() - tPoll;
1559
+ this.sessionResponse = sessionResponse;
1560
+ this.capabilities = sessionResponse.capabilities;
1561
+ this.tracks = sessionResponse.capabilities.tracks;
1562
+ this.emit("capabilitiesReceived", this.capabilities);
1563
+ console.debug(
1564
+ "[Reactor] Session ready, transport:",
1565
+ sessionResponse.selected_transport.protocol,
1566
+ "tracks:",
1567
+ this.tracks.length
1459
1568
  );
1460
- yield this.machineClient.connect(sdpAnswer);
1569
+ const protocol = sessionResponse.selected_transport.protocol;
1570
+ if (protocol !== "webrtc") {
1571
+ throw new Error(`Unsupported transport protocol: ${protocol}`);
1572
+ }
1573
+ this.transportClient = new WebRTCTransportClient({
1574
+ baseUrl: this.coordinatorUrl,
1575
+ sessionId: sessionResponse.session_id,
1576
+ jwtToken: this.local ? "local" : jwtToken,
1577
+ webrtcVersion: (_a = sessionResponse.selected_transport.version) != null ? _a : REACTOR_WEBRTC_VERSION,
1578
+ maxPollAttempts: options == null ? void 0 : options.maxAttempts
1579
+ });
1580
+ this.setupTransportHandlers();
1581
+ const tTransport = performance.now();
1582
+ yield this.transportClient.connect(this.tracks);
1583
+ const transportConnectingMs = performance.now() - tTransport;
1584
+ this.connectionTimings = {
1585
+ sessionCreationMs: sessionCreationMs + sessionPollingMs,
1586
+ transportConnectingMs,
1587
+ totalMs: 0
1588
+ };
1461
1589
  } catch (error) {
1462
1590
  if (isAbortError(error)) return;
1463
1591
  console.error("[Reactor] Connection failed:", error);
1464
1592
  this.createError(
1465
1593
  "CONNECTION_FAILED",
1466
1594
  `Connection failed: ${error}`,
1467
- "coordinator",
1595
+ "api",
1468
1596
  true
1469
1597
  );
1470
1598
  try {
@@ -1480,18 +1608,14 @@ var Reactor = class {
1480
1608
  });
1481
1609
  }
1482
1610
  /**
1483
- * Sets up event handlers for the machine client.
1484
- *
1485
- * Each handler captures the client reference at registration time and
1486
- * ignores events if this.machineClient has since changed (e.g. after
1487
- * disconnect + reconnect), preventing stale WebRTC teardown events from
1488
- * interfering with a new connection.
1611
+ * Sets up event handlers for the transport client.
1612
+ * Each handler captures the client reference to ignore stale events.
1489
1613
  */
1490
- setupMachineClientHandlers() {
1491
- if (!this.machineClient) return;
1492
- const client = this.machineClient;
1614
+ setupTransportHandlers() {
1615
+ if (!this.transportClient) return;
1616
+ const client = this.transportClient;
1493
1617
  client.on("message", (message, scope) => {
1494
- if (this.machineClient !== client) return;
1618
+ if (this.transportClient !== client) return;
1495
1619
  if (scope === "application") {
1496
1620
  this.emit("message", message);
1497
1621
  } else if (scope === "runtime") {
@@ -1499,9 +1623,10 @@ var Reactor = class {
1499
1623
  }
1500
1624
  });
1501
1625
  client.on("statusChanged", (status) => {
1502
- if (this.machineClient !== client) return;
1626
+ if (this.transportClient !== client) return;
1503
1627
  switch (status) {
1504
1628
  case "connected":
1629
+ this.finalizeConnectionTimings();
1505
1630
  this.setStatus("ready");
1506
1631
  break;
1507
1632
  case "disconnected":
@@ -1510,7 +1635,7 @@ var Reactor = class {
1510
1635
  case "error":
1511
1636
  this.createError(
1512
1637
  "GPU_CONNECTION_ERROR",
1513
- "GPU machine connection failed",
1638
+ "Transport connection failed",
1514
1639
  "gpu",
1515
1640
  true
1516
1641
  );
@@ -1521,27 +1646,29 @@ var Reactor = class {
1521
1646
  client.on(
1522
1647
  "trackReceived",
1523
1648
  (name, track, stream) => {
1524
- if (this.machineClient !== client) return;
1649
+ if (this.transportClient !== client) return;
1525
1650
  this.emit("trackReceived", name, track, stream);
1526
1651
  }
1527
1652
  );
1528
1653
  client.on("statsUpdate", (stats) => {
1529
- if (this.machineClient !== client) return;
1530
- this.emit("statsUpdate", stats);
1654
+ if (this.transportClient !== client) return;
1655
+ this.emit("statsUpdate", __spreadProps(__spreadValues({}, stats), {
1656
+ connectionTimings: this.connectionTimings
1657
+ }));
1531
1658
  });
1532
1659
  }
1533
1660
  /**
1534
- * Disconnects from the coordinator and the gpu machine.
1535
- * Ensures cleanup completes even if individual disconnections fail.
1661
+ * Disconnects from both the transport and the coordinator.
1536
1662
  */
1537
1663
  disconnect(recoverable = false) {
1538
1664
  return __async(this, null, function* () {
1539
- var _a;
1665
+ var _a, _b;
1540
1666
  if (this.status === "disconnected" && !this.sessionId) {
1541
1667
  console.warn("[Reactor] Already disconnected");
1542
1668
  return;
1543
1669
  }
1544
1670
  (_a = this.coordinatorClient) == null ? void 0 : _a.abort();
1671
+ (_b = this.transportClient) == null ? void 0 : _b.abort();
1545
1672
  if (this.coordinatorClient && !recoverable) {
1546
1673
  try {
1547
1674
  yield this.coordinatorClient.terminateSession();
@@ -1550,23 +1677,60 @@ var Reactor = class {
1550
1677
  }
1551
1678
  this.coordinatorClient = void 0;
1552
1679
  }
1553
- if (this.machineClient) {
1680
+ if (this.transportClient) {
1554
1681
  try {
1555
- yield this.machineClient.disconnect();
1682
+ yield this.transportClient.disconnect();
1556
1683
  } catch (error) {
1557
- console.error("[Reactor] Error disconnecting from GPU machine:", error);
1684
+ console.error("[Reactor] Error disconnecting transport:", error);
1558
1685
  }
1559
1686
  if (!recoverable) {
1560
- this.machineClient = void 0;
1687
+ this.transportClient = void 0;
1561
1688
  }
1562
1689
  }
1563
1690
  this.setStatus("disconnected");
1691
+ this.resetConnectionTimings();
1564
1692
  if (!recoverable) {
1565
1693
  this.setSessionExpiration(void 0);
1566
1694
  this.setSessionId(void 0);
1695
+ this.capabilities = void 0;
1696
+ this.tracks = [];
1697
+ this.sessionResponse = void 0;
1567
1698
  }
1568
1699
  });
1569
1700
  }
1701
+ // ─────────────────────────────────────────────────────────────────────────
1702
+ // Getters
1703
+ // ─────────────────────────────────────────────────────────────────────────
1704
+ getSessionId() {
1705
+ return this.sessionId;
1706
+ }
1707
+ getStatus() {
1708
+ return this.status;
1709
+ }
1710
+ getState() {
1711
+ return {
1712
+ status: this.status,
1713
+ lastError: this.lastError
1714
+ };
1715
+ }
1716
+ getLastError() {
1717
+ return this.lastError;
1718
+ }
1719
+ getCapabilities() {
1720
+ return this.capabilities;
1721
+ }
1722
+ getSessionInfo() {
1723
+ return this.sessionResponse;
1724
+ }
1725
+ getStats() {
1726
+ var _a;
1727
+ const stats = (_a = this.transportClient) == null ? void 0 : _a.getStats();
1728
+ if (!stats) return void 0;
1729
+ return __spreadProps(__spreadValues({}, stats), { connectionTimings: this.connectionTimings });
1730
+ }
1731
+ // ─────────────────────────────────────────────────────────────────────────
1732
+ // Private State Management
1733
+ // ─────────────────────────────────────────────────────────────────────────
1570
1734
  setSessionId(newSessionId) {
1571
1735
  console.debug(
1572
1736
  "[Reactor] Setting session ID:",
@@ -1579,9 +1743,6 @@ var Reactor = class {
1579
1743
  this.emit("sessionIdChanged", newSessionId);
1580
1744
  }
1581
1745
  }
1582
- getSessionId() {
1583
- return this.sessionId;
1584
- }
1585
1746
  setStatus(newStatus) {
1586
1747
  console.debug("[Reactor] Setting status:", newStatus, "from", this.status);
1587
1748
  if (this.status !== newStatus) {
@@ -1589,13 +1750,6 @@ var Reactor = class {
1589
1750
  this.emit("statusChanged", newStatus);
1590
1751
  }
1591
1752
  }
1592
- getStatus() {
1593
- return this.status;
1594
- }
1595
- /**
1596
- * Set the session expiration time.
1597
- * @param newSessionExpiration The new session expiration time in seconds.
1598
- */
1599
1753
  setSessionExpiration(newSessionExpiration) {
1600
1754
  console.debug(
1601
1755
  "[Reactor] Setting session expiration:",
@@ -1606,28 +1760,16 @@ var Reactor = class {
1606
1760
  this.emit("sessionExpirationChanged", newSessionExpiration);
1607
1761
  }
1608
1762
  }
1609
- /**
1610
- * Get the current state including status, error, and waiting info
1611
- */
1612
- getState() {
1613
- return {
1614
- status: this.status,
1615
- lastError: this.lastError
1616
- };
1617
- }
1618
- /**
1619
- * Get the last error that occurred
1620
- */
1621
- getLastError() {
1622
- return this.lastError;
1763
+ resetConnectionTimings() {
1764
+ this.connectStartTime = void 0;
1765
+ this.connectionTimings = void 0;
1623
1766
  }
1624
- getStats() {
1625
- var _a;
1626
- return (_a = this.machineClient) == null ? void 0 : _a.getStats();
1767
+ finalizeConnectionTimings() {
1768
+ if (!this.connectionTimings || this.connectStartTime == null) return;
1769
+ this.connectionTimings.totalMs = performance.now() - this.connectStartTime;
1770
+ this.connectStartTime = void 0;
1771
+ console.debug("[Reactor] Connection timings:", this.connectionTimings);
1627
1772
  }
1628
- /**
1629
- * Create and store an error
1630
- */
1631
1773
  createError(code, message, component, recoverable, retryAfter) {
1632
1774
  this.lastError = {
1633
1775
  code,
@@ -1663,7 +1805,7 @@ var initReactorStore = (props) => {
1663
1805
  };
1664
1806
  var createReactorStore = (initProps, publicState = defaultInitState) => {
1665
1807
  console.debug("[ReactorStore] Creating store", {
1666
- coordinatorUrl: initProps.coordinatorUrl,
1808
+ apiUrl: initProps.apiUrl,
1667
1809
  jwtToken: initProps.jwtToken,
1668
1810
  initialState: publicState
1669
1811
  });
@@ -1831,7 +1973,7 @@ function ReactorProvider(_a) {
1831
1973
  console.debug("[ReactorProvider] Reactor store created successfully");
1832
1974
  }
1833
1975
  const _a2 = connectOptions != null ? connectOptions : {}, { autoConnect = false } = _a2, pollingOptions = __objRest(_a2, ["autoConnect"]);
1834
- const { coordinatorUrl, modelName, local, receive, send } = props;
1976
+ const { apiUrl, modelName, local } = props;
1835
1977
  const maxAttempts = pollingOptions.maxAttempts;
1836
1978
  useEffect(() => {
1837
1979
  const handleBeforeUnload = () => {
@@ -1884,11 +2026,9 @@ function ReactorProvider(_a) {
1884
2026
  console.debug("[ReactorProvider] Updating reactor store");
1885
2027
  storeRef.current = createReactorStore(
1886
2028
  initReactorStore({
1887
- coordinatorUrl,
2029
+ apiUrl,
1888
2030
  modelName,
1889
2031
  local,
1890
- receive,
1891
- send,
1892
2032
  jwtToken
1893
2033
  })
1894
2034
  );
@@ -1915,16 +2055,7 @@ function ReactorProvider(_a) {
1915
2055
  console.error("[ReactorProvider] Failed to disconnect:", error);
1916
2056
  });
1917
2057
  };
1918
- }, [
1919
- coordinatorUrl,
1920
- modelName,
1921
- autoConnect,
1922
- local,
1923
- receive,
1924
- send,
1925
- jwtToken,
1926
- maxAttempts
1927
- ]);
2058
+ }, [apiUrl, modelName, autoConnect, local, jwtToken, maxAttempts]);
1928
2059
  return /* @__PURE__ */ jsx(ReactorContext.Provider, { value: storeRef.current, children });
1929
2060
  }
1930
2061
  function useReactorStore(selector) {
@@ -2138,31 +2269,37 @@ function ReactorController({
2138
2269
  return () => clearInterval(interval);
2139
2270
  }, [status, commands, requestCapabilities]);
2140
2271
  useReactorInternalMessage((message) => {
2272
+ var _a;
2141
2273
  if (message && typeof message === "object" && message.type === "modelCapabilities" && message.data && "commands" in message.data) {
2142
2274
  const commandsMessage = message.data;
2143
- setCommands(commandsMessage.commands);
2275
+ const commandsRecord = {};
2276
+ for (const cmd of commandsMessage.commands) {
2277
+ commandsRecord[cmd.name] = {
2278
+ description: cmd.description,
2279
+ schema: (_a = cmd.schema) != null ? _a : {}
2280
+ };
2281
+ }
2282
+ setCommands(commandsRecord);
2144
2283
  const initialValues = {};
2145
2284
  const initialExpanded = {};
2146
- Object.entries(commandsMessage.commands).forEach(
2147
- ([commandName, commandSchema]) => {
2148
- initialValues[commandName] = {};
2149
- initialExpanded[commandName] = false;
2150
- Object.entries(commandSchema.schema).forEach(
2151
- ([paramName, paramSchema]) => {
2152
- var _a, _b;
2153
- if (paramSchema.type === "number") {
2154
- initialValues[commandName][paramName] = (_a = paramSchema.minimum) != null ? _a : 0;
2155
- } else if (paramSchema.type === "string") {
2156
- initialValues[commandName][paramName] = "";
2157
- } else if (paramSchema.type === "boolean") {
2158
- initialValues[commandName][paramName] = false;
2159
- } else if (paramSchema.type === "integer") {
2160
- initialValues[commandName][paramName] = (_b = paramSchema.minimum) != null ? _b : 0;
2161
- }
2285
+ Object.entries(commandsRecord).forEach(([commandName, commandSchema]) => {
2286
+ initialValues[commandName] = {};
2287
+ initialExpanded[commandName] = false;
2288
+ Object.entries(commandSchema.schema).forEach(
2289
+ ([paramName, paramSchema]) => {
2290
+ var _a2, _b;
2291
+ if (paramSchema.type === "number") {
2292
+ initialValues[commandName][paramName] = (_a2 = paramSchema.minimum) != null ? _a2 : 0;
2293
+ } else if (paramSchema.type === "string") {
2294
+ initialValues[commandName][paramName] = "";
2295
+ } else if (paramSchema.type === "boolean") {
2296
+ initialValues[commandName][paramName] = false;
2297
+ } else if (paramSchema.type === "integer") {
2298
+ initialValues[commandName][paramName] = (_b = paramSchema.minimum) != null ? _b : 0;
2162
2299
  }
2163
- );
2164
- }
2165
- );
2300
+ }
2301
+ );
2302
+ });
2166
2303
  setFormValues(initialValues);
2167
2304
  setExpandedCommands(initialExpanded);
2168
2305
  }
@@ -2745,15 +2882,15 @@ function WebcamStream({
2745
2882
  }
2746
2883
 
2747
2884
  // src/utils/tokens.ts
2748
- function fetchInsecureJwtToken(_0) {
2749
- return __async(this, arguments, function* (apiKey, coordinatorUrl = PROD_COORDINATOR_URL) {
2885
+ function fetchInsecureToken(_0) {
2886
+ return __async(this, arguments, function* (apiKey, apiUrl = DEFAULT_BASE_URL) {
2750
2887
  console.warn(
2751
- "[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."
2888
+ "[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."
2752
2889
  );
2753
- const response = yield fetch(`${coordinatorUrl}/tokens`, {
2890
+ const response = yield fetch(`${apiUrl}/tokens`, {
2754
2891
  method: "GET",
2755
2892
  headers: {
2756
- "X-API-Key": apiKey
2893
+ "Reactor-API-Key": apiKey
2757
2894
  }
2758
2895
  });
2759
2896
  if (!response.ok) {
@@ -2767,20 +2904,19 @@ function fetchInsecureJwtToken(_0) {
2767
2904
  export {
2768
2905
  AbortError,
2769
2906
  ConflictError,
2770
- PROD_COORDINATOR_URL,
2907
+ DEFAULT_BASE_URL,
2771
2908
  Reactor,
2772
2909
  ReactorController,
2773
2910
  ReactorProvider,
2774
2911
  ReactorView,
2912
+ WebRTCTransportClient,
2775
2913
  WebcamStream,
2776
- audio,
2777
- fetchInsecureJwtToken,
2914
+ fetchInsecureToken,
2778
2915
  isAbortError,
2779
2916
  useReactor,
2780
2917
  useReactorInternalMessage,
2781
2918
  useReactorMessage,
2782
2919
  useReactorStore,
2783
- useStats,
2784
- video
2920
+ useStats
2785
2921
  };
2786
2922
  //# sourceMappingURL=index.mjs.map