@reactor-team/js-sdk 2.6.0 → 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,300 +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
- var DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024;
133
- function createPeerConnection(config) {
134
- return new RTCPeerConnection({
135
- iceServers: config.iceServers,
136
- iceTransportPolicy: FORCE_RELAY_MODE ? "relay" : "all"
137
- });
138
- }
139
- function createDataChannel(pc, label) {
140
- return pc.createDataChannel(label != null ? label : DEFAULT_DATA_CHANNEL_LABEL);
141
- }
142
- function rewriteMids(sdp, trackNames) {
143
- const lines = sdp.split("\r\n");
144
- let mediaIdx = 0;
145
- const replacements = /* @__PURE__ */ new Map();
146
- let inApplication = false;
147
- for (let i = 0; i < lines.length; i++) {
148
- if (lines[i].startsWith("m=")) {
149
- inApplication = lines[i].startsWith("m=application");
150
- }
151
- if (!inApplication && lines[i].startsWith("a=mid:")) {
152
- const oldMid = lines[i].substring("a=mid:".length);
153
- if (mediaIdx < trackNames.length) {
154
- const newMid = trackNames[mediaIdx];
155
- replacements.set(oldMid, newMid);
156
- lines[i] = `a=mid:${newMid}`;
157
- mediaIdx++;
158
- }
159
- }
160
- }
161
- for (let i = 0; i < lines.length; i++) {
162
- if (lines[i].startsWith("a=group:BUNDLE ")) {
163
- const parts = lines[i].split(" ");
164
- for (let j = 1; j < parts.length; j++) {
165
- const replacement = replacements.get(parts[j]);
166
- if (replacement !== void 0) {
167
- parts[j] = replacement;
168
- }
169
- }
170
- lines[i] = parts.join(" ");
171
- break;
172
- }
173
- }
174
- return lines.join("\r\n");
175
- }
176
- function createOffer(pc, trackNames) {
177
- return __async(this, null, function* () {
178
- const offer = yield pc.createOffer();
179
- let needsAnswerRestore = false;
180
- if (trackNames && trackNames.length > 0 && offer.sdp) {
181
- const munged = rewriteMids(offer.sdp, trackNames);
182
- try {
183
- yield pc.setLocalDescription(
184
- new RTCSessionDescription({ type: "offer", sdp: munged })
185
- );
186
- } catch (e) {
187
- yield pc.setLocalDescription(offer);
188
- needsAnswerRestore = true;
189
- }
190
- } else {
191
- yield pc.setLocalDescription(offer);
192
- }
193
- yield waitForIceGathering(pc);
194
- const localDescription = pc.localDescription;
195
- if (!localDescription) {
196
- throw new Error("Failed to create local description");
197
- }
198
- let sdp = localDescription.sdp;
199
- if (needsAnswerRestore && trackNames && trackNames.length > 0) {
200
- sdp = rewriteMids(sdp, trackNames);
201
- }
202
- return { sdp, needsAnswerRestore };
203
- });
204
- }
205
- function buildMidMapping(transceivers) {
206
- var _a;
207
- const localToRemote = /* @__PURE__ */ new Map();
208
- const remoteToLocal = /* @__PURE__ */ new Map();
209
- for (const entry of transceivers) {
210
- const mid = (_a = entry.transceiver) == null ? void 0 : _a.mid;
211
- if (mid) {
212
- localToRemote.set(mid, entry.name);
213
- remoteToLocal.set(entry.name, mid);
214
- }
215
- }
216
- return { localToRemote, remoteToLocal };
217
- }
218
- function restoreAnswerMids(sdp, remoteToLocal) {
219
- const lines = sdp.split("\r\n");
220
- for (let i = 0; i < lines.length; i++) {
221
- if (lines[i].startsWith("a=mid:")) {
222
- const remoteMid = lines[i].substring("a=mid:".length);
223
- const localMid = remoteToLocal.get(remoteMid);
224
- if (localMid !== void 0) {
225
- lines[i] = `a=mid:${localMid}`;
226
- }
227
- }
228
- if (lines[i].startsWith("a=group:BUNDLE ")) {
229
- const parts = lines[i].split(" ");
230
- for (let j = 1; j < parts.length; j++) {
231
- const localMid = remoteToLocal.get(parts[j]);
232
- if (localMid !== void 0) {
233
- parts[j] = localMid;
234
- }
235
- }
236
- lines[i] = parts.join(" ");
237
- }
238
- }
239
- return lines.join("\r\n");
240
- }
241
- function setRemoteDescription(pc, sdp) {
242
- return __async(this, null, function* () {
243
- const sessionDescription = new RTCSessionDescription({
244
- sdp,
245
- type: "answer"
246
- });
247
- yield pc.setRemoteDescription(sessionDescription);
248
- });
249
- }
250
- function getLocalDescription(pc) {
251
- const desc = pc.localDescription;
252
- if (!desc) return void 0;
253
- return desc.sdp;
254
- }
255
- function transformIceServers(response) {
256
- return response.ice_servers.map((server) => {
257
- const rtcServer = {
258
- urls: server.uris
259
- };
260
- if (server.credentials) {
261
- rtcServer.username = server.credentials.username;
262
- rtcServer.credential = server.credentials.password;
263
- }
264
- return rtcServer;
265
- });
266
- }
267
- function waitForIceGathering(pc, timeoutMs = 5e3) {
268
- return new Promise((resolve) => {
269
- if (pc.iceGatheringState === "complete") {
270
- resolve();
271
- return;
272
- }
273
- const onGatheringStateChange = () => {
274
- if (pc.iceGatheringState === "complete") {
275
- pc.removeEventListener(
276
- "icegatheringstatechange",
277
- onGatheringStateChange
278
- );
279
- resolve();
280
- }
281
- };
282
- pc.addEventListener("icegatheringstatechange", onGatheringStateChange);
283
- setTimeout(() => {
284
- pc.removeEventListener("icegatheringstatechange", onGatheringStateChange);
285
- resolve();
286
- }, timeoutMs);
287
- });
288
- }
289
- function sendMessage(channel, command, data, scope = "application", maxBytes = DEFAULT_MAX_MESSAGE_BYTES) {
290
- if (channel.readyState !== "open") {
291
- throw new Error(`Data channel not open: ${channel.readyState}`);
292
- }
293
- const jsonData = typeof data === "string" ? JSON.parse(data) : data;
294
- const inner = { type: command, data: jsonData };
295
- const payload = { scope, data: inner };
296
- const serialized = JSON.stringify(payload);
297
- const byteLength = new TextEncoder().encode(serialized).byteLength;
298
- if (byteLength > maxBytes) {
299
- throw new Error(
300
- `Data channel message too large: ${byteLength} bytes exceeds limit of ${maxBytes} bytes (command: "${command}")`
301
- );
302
- }
303
- channel.send(serialized);
304
- }
305
- function parseMessage(data) {
306
- if (typeof data === "string") {
307
- try {
308
- return JSON.parse(data);
309
- } catch (e) {
310
- return data;
311
- }
312
- }
313
- return data;
314
- }
315
- function closePeerConnection(pc) {
316
- pc.close();
317
- }
318
- function extractConnectionStats(report) {
319
- let rtt;
320
- let availableOutgoingBitrate;
321
- let localCandidateId;
322
- let framesPerSecond;
323
- let jitter;
324
- let packetLossRatio;
325
- report.forEach((stat) => {
326
- if (stat.type === "candidate-pair" && stat.state === "succeeded") {
327
- if (stat.currentRoundTripTime !== void 0) {
328
- rtt = stat.currentRoundTripTime * 1e3;
329
- }
330
- if (stat.availableOutgoingBitrate !== void 0) {
331
- availableOutgoingBitrate = stat.availableOutgoingBitrate;
332
- }
333
- localCandidateId = stat.localCandidateId;
334
- }
335
- if (stat.type === "inbound-rtp" && stat.kind === "video") {
336
- if (stat.framesPerSecond !== void 0) {
337
- framesPerSecond = stat.framesPerSecond;
338
- }
339
- if (stat.jitter !== void 0) {
340
- jitter = stat.jitter;
341
- }
342
- if (stat.packetsReceived !== void 0 && stat.packetsLost !== void 0 && stat.packetsReceived + stat.packetsLost > 0) {
343
- packetLossRatio = stat.packetsLost / (stat.packetsReceived + stat.packetsLost);
344
- }
345
- }
346
- });
347
- let candidateType;
348
- if (localCandidateId) {
349
- const localCandidate = report.get(localCandidateId);
350
- if (localCandidate == null ? void 0 : localCandidate.candidateType) {
351
- candidateType = localCandidate.candidateType;
352
- }
353
- }
354
- return {
355
- rtt,
356
- candidateType,
357
- availableOutgoingBitrate,
358
- framesPerSecond,
359
- packetLossRatio,
360
- jitter,
361
- timestamp: Date.now()
362
- };
363
- }
364
233
 
365
234
  // src/core/CoordinatorClient.ts
366
- var INITIAL_BACKOFF_MS = 500;
367
- var MAX_BACKOFF_MS = 15e3;
368
- var BACKOFF_MULTIPLIER = 2;
369
- 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;
370
239
  var CoordinatorClient = class {
371
240
  constructor(options) {
372
241
  this.baseUrl = options.baseUrl;
@@ -375,7 +244,7 @@ var CoordinatorClient = class {
375
244
  this.abortController = new AbortController();
376
245
  }
377
246
  /**
378
- * Aborts any in-flight HTTP requests and polling loops.
247
+ * Aborts any in-flight HTTP requests.
379
248
  * A fresh AbortController is created so the client remains reusable.
380
249
  */
381
250
  abort() {
@@ -383,69 +252,88 @@ var CoordinatorClient = class {
383
252
  this.abortController = new AbortController();
384
253
  }
385
254
  /**
386
- * The current abort signal, passed to every fetch() and sleep() call.
255
+ * The current abort signal, passed to every fetch() call.
387
256
  * Protected so subclasses can forward it to their own fetch calls.
388
257
  */
389
258
  get signal() {
390
259
  return this.abortController.signal;
391
260
  }
392
261
  /**
393
- * Returns the authorization header with JWT Bearer token
262
+ * Returns authorization + versioning headers for all coordinator requests.
394
263
  */
395
- getAuthHeaders() {
264
+ getHeaders() {
396
265
  return {
397
- 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)
398
269
  };
399
270
  }
400
271
  /**
401
- * Fetches ICE servers from the coordinator.
402
- * @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.
403
274
  */
404
- getIceServers() {
275
+ checkVersionMismatch(response) {
405
276
  return __async(this, null, function* () {
406
- console.debug("[CoordinatorClient] Fetching ICE servers...");
407
- const response = yield fetch(
408
- `${this.baseUrl}/ice_servers?model=${this.model}`,
409
- {
410
- method: "GET",
411
- headers: this.getAuthHeaders(),
412
- signal: this.signal
413
- }
414
- );
415
- if (!response.ok) {
416
- 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}`);
417
281
  }
418
- const data = yield response.json();
419
- const parsed = IceServersResponseSchema.parse(data);
420
- const iceServers = transformIceServers(parsed);
421
- console.debug(
422
- "[CoordinatorClient] Received ICE servers:",
423
- iceServers.length
424
- );
425
- 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 });
426
305
  });
427
306
  }
428
307
  /**
429
308
  * Creates a new session with the coordinator.
430
- * Expects a 200 response and stores the session ID.
431
- * @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.
432
314
  */
433
- createSession(sdp_offer) {
315
+ createSession(extraArgs) {
434
316
  return __async(this, null, function* () {
435
317
  console.debug("[CoordinatorClient] Creating session...");
436
- const requestBody = {
318
+ const requestBody = __spreadValues({
437
319
  model: { name: this.model },
438
- sdp_offer,
439
- extra_args: {}
440
- };
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 } : {});
441
328
  const response = yield fetch(`${this.baseUrl}/sessions`, {
442
329
  method: "POST",
443
- headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
330
+ headers: __spreadProps(__spreadValues({}, this.getHeaders()), {
444
331
  "Content-Type": "application/json"
445
332
  }),
446
333
  body: JSON.stringify(requestBody),
447
334
  signal: this.signal
448
335
  });
336
+ yield this.checkVersionMismatch(response);
449
337
  if (!response.ok) {
450
338
  const errorText = yield response.text();
451
339
  throw new Error(
@@ -453,339 +341,462 @@ var CoordinatorClient = class {
453
341
  );
454
342
  }
455
343
  const data = yield response.json();
456
- this.currentSessionId = data.session_id;
344
+ const parsed = InitialSessionResponseSchema.parse(data);
345
+ this.currentSessionId = parsed.session_id;
457
346
  console.debug(
458
- "[CoordinatorClient] Session created with ID:",
459
- this.currentSessionId
347
+ "[CoordinatorClient] Session created:",
348
+ this.currentSessionId,
349
+ "state:",
350
+ parsed.state
460
351
  );
461
- return data.session_id;
352
+ return parsed;
462
353
  });
463
354
  }
464
355
  /**
465
- * Gets the current session information from the coordinator.
466
- * @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.
467
358
  */
468
- getSession() {
359
+ pollSessionReady(opts) {
469
360
  return __async(this, null, function* () {
361
+ var _a;
470
362
  if (!this.currentSessionId) {
471
363
  throw new Error("No active session. Call createSession() first.");
472
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;
473
368
  console.debug(
474
- "[CoordinatorClient] Getting session info for:",
475
- this.currentSessionId
369
+ "[CoordinatorClient] Polling session until capabilities are available..."
476
370
  );
477
- const response = yield fetch(
478
- `${this.baseUrl}/sessions/${this.currentSessionId}`,
479
- {
480
- method: "GET",
481
- headers: this.getAuthHeaders(),
482
- signal: this.signal
371
+ while (true) {
372
+ if (this.signal.aborted) {
373
+ throw new AbortError("Session polling aborted");
483
374
  }
484
- );
485
- if (!response.ok) {
486
- const errorText = yield response.text();
487
- throw new Error(`Failed to get session: ${response.status} ${errorText}`);
488
- }
489
- const data = yield response.json();
490
- return data;
491
- });
492
- }
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;
413
+ }
414
+ console.debug(
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
421
+ );
422
+ }
423
+ });
424
+ }
493
425
  /**
494
- * Terminates the current session by sending a DELETE request to the coordinator.
495
- * No-op if no session has been created yet.
496
- * @throws Error if the request fails (except for 404, which clears local state)
426
+ * Gets full session details from the coordinator.
427
+ * Returns the same shape as the creation response but with updated state.
497
428
  */
498
- terminateSession() {
429
+ getSession() {
499
430
  return __async(this, null, function* () {
500
431
  if (!this.currentSessionId) {
501
- return;
432
+ throw new Error("No active session. Call createSession() first.");
502
433
  }
503
- console.debug(
504
- "[CoordinatorClient] Terminating session:",
505
- this.currentSessionId
506
- );
507
434
  const response = yield fetch(
508
435
  `${this.baseUrl}/sessions/${this.currentSessionId}`,
509
436
  {
510
- method: "DELETE",
511
- headers: this.getAuthHeaders(),
437
+ method: "GET",
438
+ headers: this.getHeaders(),
512
439
  signal: this.signal
513
440
  }
514
441
  );
515
- if (response.ok) {
516
- this.currentSessionId = void 0;
517
- return;
518
- }
519
- if (response.status === 404) {
520
- console.debug(
521
- "[CoordinatorClient] Session not found on server, clearing local state:",
522
- this.currentSessionId
523
- );
524
- this.currentSessionId = void 0;
525
- return;
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}`);
526
446
  }
527
- const errorText = yield response.text();
528
- throw new Error(
529
- `Failed to terminate session: ${response.status} ${errorText}`
530
- );
447
+ const data = yield response.json();
448
+ return CreateSessionResponseSchema.parse(data);
531
449
  });
532
450
  }
533
451
  /**
534
- * Get the current session ID
452
+ * Gets lightweight session status (session_id, cluster, status).
535
453
  */
536
- getSessionId() {
537
- return this.currentSessionId;
538
- }
539
- /**
540
- * Sends an SDP offer to the server for reconnection.
541
- * @param sessionId - The session ID to connect to
542
- * @param sdpOffer - The SDP offer from the local WebRTC peer connection
543
- * @returns The SDP answer if ready (200), or null if pending (202)
544
- */
545
- sendSdpOffer(sessionId, sdpOffer) {
454
+ getSessionInfo() {
546
455
  return __async(this, null, function* () {
547
- console.debug(
548
- "[CoordinatorClient] Sending SDP offer for session:",
549
- sessionId
550
- );
551
- const requestBody = {
552
- sdp_offer: sdpOffer,
553
- extra_args: {}
554
- };
456
+ if (!this.currentSessionId) {
457
+ throw new Error("No active session. Call createSession() first.");
458
+ }
555
459
  const response = yield fetch(
556
- `${this.baseUrl}/sessions/${sessionId}/sdp_params`,
460
+ `${this.baseUrl}/sessions/${this.currentSessionId}/info`,
557
461
  {
558
- method: "PUT",
559
- headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
560
- "Content-Type": "application/json"
561
- }),
562
- body: JSON.stringify(requestBody),
462
+ method: "GET",
463
+ headers: this.getHeaders(),
563
464
  signal: this.signal
564
465
  }
565
466
  );
566
- if (response.status === 200) {
567
- const answerData = yield response.json();
568
- console.debug("[CoordinatorClient] Received SDP answer immediately");
569
- return answerData.sdp_answer;
570
- }
571
- if (response.status === 202) {
572
- console.debug(
573
- "[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}`
574
472
  );
575
- return null;
576
473
  }
577
- const errorText = yield response.text();
578
- throw new Error(
579
- `Failed to send SDP offer: ${response.status} ${errorText}`
580
- );
474
+ const data = yield response.json();
475
+ return SessionInfoResponseSchema.parse(data);
581
476
  });
582
477
  }
583
478
  /**
584
- * Polls for the SDP answer with exponential backoff.
585
- * Used for async reconnection when the answer is not immediately available.
586
- * @param sessionId - The session ID to poll for
587
- * @param maxAttempts - Optional maximum number of polling attempts before giving up
588
- * @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.
589
481
  */
590
- pollSdpAnswer(_0) {
591
- 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
+ }
592
487
  console.debug(
593
- "[CoordinatorClient] Polling for SDP answer for session:",
594
- sessionId
488
+ "[CoordinatorClient] Restarting session:",
489
+ this.currentSessionId
595
490
  );
596
- let backoffMs = INITIAL_BACKOFF_MS;
597
- let attempt = 0;
598
- while (true) {
599
- if (this.signal.aborted) {
600
- throw new AbortError("SDP polling aborted");
601
- }
602
- if (attempt >= maxAttempts) {
603
- throw new Error(
604
- `SDP polling exceeded maximum attempts (${maxAttempts}) for session ${sessionId}`
605
- );
606
- }
607
- attempt++;
608
- console.debug(
609
- `[CoordinatorClient] SDP poll attempt ${attempt}/${maxAttempts} for session ${sessionId}`
610
- );
611
- const response = yield fetch(
612
- `${this.baseUrl}/sessions/${sessionId}/sdp_params`,
613
- {
614
- method: "GET",
615
- headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
616
- "Content-Type": "application/json"
617
- }),
618
- signal: this.signal
619
- }
620
- );
621
- if (response.status === 200) {
622
- const answerData = yield response.json();
623
- console.debug("[CoordinatorClient] Received SDP answer via polling");
624
- return { sdpAnswer: answerData.sdp_answer, attempts: attempt };
625
- }
626
- if (response.status === 202) {
627
- console.warn(
628
- `[CoordinatorClient] SDP answer pending (202), retrying in ${backoffMs}ms...`
629
- );
630
- yield this.sleep(backoffMs);
631
- backoffMs = Math.min(backoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS);
632
- continue;
491
+ const response = yield fetch(
492
+ `${this.baseUrl}/sessions/${this.currentSessionId}`,
493
+ {
494
+ method: "PUT",
495
+ headers: this.getHeaders(),
496
+ signal: this.signal
633
497
  }
498
+ );
499
+ yield this.checkVersionMismatch(response);
500
+ if (!response.ok) {
634
501
  const errorText = yield response.text();
635
502
  throw new Error(
636
- `Failed to poll SDP answer: ${response.status} ${errorText}`
503
+ `Failed to restart session: ${response.status} ${errorText}`
637
504
  );
638
505
  }
639
506
  });
640
507
  }
641
508
  /**
642
- * Connects to the session by sending an SDP offer and receiving an SDP answer.
643
- * If sdpOffer is provided, sends it first. If the answer is pending (202),
644
- * falls back to polling. If no sdpOffer is provided, goes directly to polling.
645
- * @param sessionId - The session ID to connect to
646
- * @param sdpOffer - Optional SDP offer from the local WebRTC peer connection
647
- * @param maxAttempts - Optional maximum number of polling attempts before giving up
648
- * @returns The SDP answer and the number of polling attempts made (0 if answered immediately via PUT)
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
649
512
  */
650
- connect(sessionId, sdpOffer, maxAttempts) {
513
+ terminateSession(reason) {
651
514
  return __async(this, null, function* () {
652
- console.debug("[CoordinatorClient] Connecting to session:", sessionId);
653
- if (sdpOffer) {
654
- const answer = yield this.sendSdpOffer(sessionId, sdpOffer);
655
- if (answer !== null) {
656
- return { sdpAnswer: answer, sdpPollingAttempts: 0 };
657
- }
515
+ if (!this.currentSessionId) {
516
+ return;
658
517
  }
659
- const result = yield this.pollSdpAnswer(sessionId, maxAttempts);
660
- return { sdpAnswer: result.sdpAnswer, sdpPollingAttempts: result.attempts };
661
- });
662
- }
663
- /**
664
- * Abort-aware sleep. Resolves after `ms` milliseconds unless the
665
- * abort signal fires first, in which case it rejects with AbortError.
666
- */
667
- sleep(ms) {
668
- return new Promise((resolve, reject) => {
669
- const { signal } = this;
670
- if (signal.aborted) {
671
- 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;
672
534
  return;
673
535
  }
674
- const timer = setTimeout(() => {
675
- signal.removeEventListener("abort", onAbort);
676
- resolve();
677
- }, ms);
678
- const onAbort = () => {
679
- clearTimeout(timer);
680
- reject(new AbortError("Sleep aborted"));
681
- };
682
- signal.addEventListener("abort", onAbort, { once: true });
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
+ );
683
548
  });
684
549
  }
550
+ getSessionId() {
551
+ return this.currentSessionId;
552
+ }
685
553
  };
686
554
 
687
555
  // src/core/LocalCoordinatorClient.ts
688
556
  var LocalCoordinatorClient = class extends CoordinatorClient {
689
- constructor(baseUrl) {
557
+ constructor(baseUrl, model) {
690
558
  super({
691
559
  baseUrl,
692
560
  jwtToken: "local",
693
- model: "local"
561
+ model
694
562
  });
695
- 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
+ };
696
569
  }
697
570
  /**
698
- * Gets ICE servers from the local HTTP runtime.
699
- * @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.
700
575
  */
701
- getIceServers() {
576
+ createSession(extraArgs) {
702
577
  return __async(this, null, function* () {
703
- console.debug("[LocalCoordinatorClient] Fetching ICE servers...");
704
- const response = yield fetch(`${this.localBaseUrl}/ice_servers`, {
705
- 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 } : {})),
706
585
  signal: this.signal
707
586
  });
708
587
  if (!response.ok) {
709
- 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
+ );
710
592
  }
711
593
  const data = yield response.json();
712
- const parsed = IceServersResponseSchema.parse(data);
713
- const iceServers = transformIceServers(parsed);
594
+ this.cachedSessionResponse = CreateSessionResponseSchema.parse(data);
595
+ this.currentSessionId = this.cachedSessionResponse.session_id;
714
596
  console.debug(
715
- "[LocalCoordinatorClient] Received ICE servers:",
716
- 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
717
603
  );
718
- return iceServers;
604
+ return InitialSessionResponseSchema.parse(data);
719
605
  });
720
606
  }
721
607
  /**
722
- * Creates a local session by posting to /start_session.
723
- * @returns always "local"
608
+ * Returns the cached full session response immediately.
609
+ * The local runtime already provided everything in start_session.
724
610
  */
725
- createSession(sdpOffer) {
611
+ pollSessionReady() {
726
612
  return __async(this, null, function* () {
727
- console.debug("[LocalCoordinatorClient] Creating local session...");
728
- this.sdpOffer = sdpOffer;
729
- const response = yield fetch(`${this.localBaseUrl}/start_session`, {
730
- method: "POST",
731
- signal: this.signal
732
- });
733
- if (!response.ok) {
734
- 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
+ );
735
617
  }
736
- console.debug("[LocalCoordinatorClient] Local session created");
737
- return "local";
618
+ return this.cachedSessionResponse;
738
619
  });
739
620
  }
740
621
  /**
741
- * Connects to the local session by posting SDP params to /sdp_params.
742
- * Local connections are always immediate (no polling).
743
- * @param sessionId - The session ID (ignored for local)
744
- * @param sdpMessage - The SDP offer from the local WebRTC peer connection
745
- * @returns The SDP answer and polling attempts (always 0 for local)
622
+ * Stops the session on the local runtime.
746
623
  */
747
- connect(sessionId, sdpMessage) {
624
+ terminateSession() {
748
625
  return __async(this, null, function* () {
749
- this.sdpOffer = sdpMessage || this.sdpOffer;
750
- console.debug("[LocalCoordinatorClient] Connecting to local session...");
751
- const sdpBody = {
752
- sdp: this.sdpOffer,
753
- type: "offer"
754
- };
755
- const response = yield fetch(`${this.localBaseUrl}/sdp_params`, {
756
- method: "POST",
757
- headers: {
758
- "Content-Type": "application/json"
759
- },
760
- body: JSON.stringify(sdpBody),
761
- signal: this.signal
762
- });
763
- if (!response.ok) {
764
- if (response.status === 409) {
765
- throw new ConflictError("Connection superseded by newer request");
766
- }
767
- throw new Error("Failed to get SDP answer from local coordinator.");
626
+ if (!this.currentSessionId) {
627
+ return;
768
628
  }
769
- const sdpAnswer = yield response.json();
770
- console.debug("[LocalCoordinatorClient] Received SDP answer");
771
- return { sdpAnswer: sdpAnswer.sdp, sdpPollingAttempts: 0 };
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;
772
644
  });
773
645
  }
774
- terminateSession() {
775
- return __async(this, null, function* () {
776
- console.debug("[LocalCoordinatorClient] Stopping local session...");
777
- yield fetch(`${this.localBaseUrl}/stop_session`, {
778
- method: "POST",
779
- signal: this.signal
780
- });
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"
781
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}`);
782
719
  }
783
- };
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
+ }
784
791
 
785
- // src/core/GPUMachineClient.ts
792
+ // src/core/WebRTCTransportClient.ts
786
793
  var PING_INTERVAL_MS = 5e3;
787
794
  var STATS_INTERVAL_MS = 2e3;
788
- 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 {
789
800
  constructor(config) {
790
801
  this.eventListeners = /* @__PURE__ */ new Map();
791
802
  this.status = "disconnected";
@@ -793,11 +804,17 @@ var GPUMachineClient = class {
793
804
  this.publishedTracks = /* @__PURE__ */ new Map();
794
805
  this.peerConnected = false;
795
806
  this.dataChannelOpen = false;
796
- 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();
797
814
  }
798
- // ─────────────────────────────────────────────────────────────────────────────
799
- // Event Emitter API
800
- // ─────────────────────────────────────────────────────────────────────────────
815
+ // ─────────────────────────────────────────────────────────────────────────
816
+ // Event Emitter
817
+ // ─────────────────────────────────────────────────────────────────────────
801
818
  on(event, handler) {
802
819
  if (!this.eventListeners.has(event)) {
803
820
  this.eventListeners.set(event, /* @__PURE__ */ new Set());
@@ -812,130 +829,241 @@ var GPUMachineClient = class {
812
829
  var _a;
813
830
  (_a = this.eventListeners.get(event)) == null ? void 0 : _a.forEach((handler) => handler(...args));
814
831
  }
815
- // ─────────────────────────────────────────────────────────────────────────────
816
- // SDP & Connection
817
- // ─────────────────────────────────────────────────────────────────────────────
818
- /**
819
- * Creates an SDP offer based on the declared tracks.
820
- *
821
- * **RECEIVE** = client receives from the model (model → client) → `recvonly`
822
- * **SEND** = client sends to the model (client → model) → `sendonly`
823
- *
824
- * Track names must be unique across both arrays. A name that appears in
825
- * both `receive` and `send` will throw — use distinct names instead.
826
- *
827
- * The data channel is always created first (before transceivers).
828
- * Must be called before connect().
829
- */
830
- 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) {
831
987
  return __async(this, null, function* () {
832
- if (!this.peerConnection) {
833
- this.peerConnection = createPeerConnection(this.config);
834
- this.setupPeerConnectionHandlers();
835
- }
836
- this.dataChannel = createDataChannel(
837
- this.peerConnection,
838
- this.config.dataChannelLabel
839
- );
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);
840
994
  this.setupDataChannelHandlers();
841
995
  this.transceiverMap.clear();
842
- const entries = this.buildTransceiverEntries(tracks);
843
- for (const entry of entries) {
844
- const transceiver = this.peerConnection.addTransceiver(entry.kind, {
845
- 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
846
1005
  });
847
- entry.transceiver = transceiver;
848
- this.transceiverMap.set(entry.name, entry);
849
1006
  console.debug(
850
- `[GPUMachineClient] Transceiver added: "${entry.name}" (${entry.kind}, ${entry.direction})`
1007
+ `[WebRTCTransport] Transceiver added: "${track.name}" (${track.kind}, ${track.direction})`
851
1008
  );
852
1009
  }
853
- const trackNames = entries.map((e) => e.name);
854
- 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(
855
1016
  this.peerConnection,
856
- trackNames
857
- );
858
- if (needsAnswerRestore) {
859
- this.midMapping = buildMidMapping(entries);
860
- } else {
861
- this.midMapping = void 0;
862
- }
863
- console.debug(
864
- "[GPUMachineClient] Created SDP offer with MIDs:",
865
- trackNames,
866
- needsAnswerRestore ? "(needs answer restore)" : "(native munging)"
1017
+ answerResponse.sdp_answer
867
1018
  );
868
- return sdp;
1019
+ console.debug("[WebRTCTransport] Remote description set");
869
1020
  });
870
1021
  }
871
- /**
872
- * Builds an ordered list of transceiver entries from the receive/send arrays.
873
- *
874
- * Each track produces exactly one transceiver — `recvonly` for receive,
875
- * `sendonly` for send. Bidirectional (`sendrecv`) transceivers are not
876
- * supported; the same track name in both arrays is an error.
877
- */
878
- buildTransceiverEntries(tracks) {
879
- const map = /* @__PURE__ */ new Map();
880
- for (const t of tracks.receive) {
881
- if (map.has(t.name)) {
882
- throw new Error(
883
- `Duplicate receive track name "${t.name}". Track names must be unique.`
884
- );
885
- }
886
- map.set(t.name, { name: t.name, kind: t.kind, direction: "recvonly" });
887
- }
888
- for (const t of tracks.send) {
889
- if (map.has(t.name)) {
890
- throw new Error(
891
- `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").`
892
- );
893
- }
894
- map.set(t.name, { name: t.name, kind: t.kind, direction: "sendonly" });
895
- }
896
- return Array.from(map.values());
897
- }
898
- /**
899
- * Connects to the GPU machine using the provided SDP answer.
900
- * createOffer() must be called first.
901
- * @param sdpAnswer The SDP answer from the GPU machine
902
- */
903
- connect(sdpAnswer) {
1022
+ reconnect(tracks) {
904
1023
  return __async(this, null, function* () {
905
- if (!this.peerConnection) {
906
- throw new Error(
907
- "[GPUMachineClient] Cannot connect - call createOffer() first"
908
- );
1024
+ this.setStatus("connecting");
1025
+ this.stopPing();
1026
+ this.stopStatsPolling();
1027
+ if (this.dataChannel) {
1028
+ this.dataChannel.close();
1029
+ this.dataChannel = void 0;
909
1030
  }
910
- if (this.peerConnection.signalingState !== "have-local-offer") {
911
- throw new Error(
912
- `[GPUMachineClient] Invalid signaling state: ${this.peerConnection.signalingState}`
913
- );
1031
+ if (this.peerConnection) {
1032
+ closePeerConnection(this.peerConnection);
1033
+ this.peerConnection = void 0;
914
1034
  }
915
- this.setStatus("connecting");
916
- this.iceStartTime = performance.now();
917
- this.iceNegotiationMs = void 0;
918
- this.dataChannelMs = void 0;
919
- try {
920
- let answer = sdpAnswer;
921
- if (this.midMapping) {
922
- answer = restoreAnswerMids(
923
- answer,
924
- this.midMapping.remoteToLocal
925
- );
926
- }
927
- yield setRemoteDescription(this.peerConnection, answer);
928
- console.debug("[GPUMachineClient] Remote description set");
929
- } catch (error) {
930
- console.error("[GPUMachineClient] Failed to connect:", error);
931
- this.setStatus("error");
932
- 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
+ });
933
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)");
934
1065
  });
935
1066
  }
936
- /**
937
- * Disconnects from the GPU machine and cleans up resources.
938
- */
939
1067
  disconnect() {
940
1068
  return __async(this, null, function* () {
941
1069
  this.stopPing();
@@ -952,51 +1080,56 @@ var GPUMachineClient = class {
952
1080
  this.peerConnection = void 0;
953
1081
  }
954
1082
  this.transceiverMap.clear();
955
- this.midMapping = void 0;
956
1083
  this.peerConnected = false;
957
1084
  this.dataChannelOpen = false;
958
- this.resetConnectionTimings();
1085
+ this.resetTransportTimings();
959
1086
  this.setStatus("disconnected");
960
- console.debug("[GPUMachineClient] Disconnected");
1087
+ console.debug("[WebRTCTransport] Disconnected");
961
1088
  });
962
1089
  }
963
- /**
964
- * Returns the current connection status.
965
- */
1090
+ abort() {
1091
+ this.abortController.abort();
1092
+ this.abortController = new AbortController();
1093
+ }
966
1094
  getStatus() {
967
1095
  return this.status;
968
1096
  }
1097
+ // ─────────────────────────────────────────────────────────────────────────
1098
+ // Track Mapping
1099
+ // ─────────────────────────────────────────────────────────────────────────
969
1100
  /**
970
- * 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.
971
1104
  */
972
- getLocalSDP() {
973
- if (!this.peerConnection) return void 0;
974
- return getLocalDescription(this.peerConnection);
975
- }
976
- isOfferStillValid() {
977
- if (!this.peerConnection) return false;
978
- 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
+ });
979
1122
  }
980
- // ─────────────────────────────────────────────────────────────────────────────
1123
+ // ─────────────────────────────────────────────────────────────────────────
981
1124
  // Messaging
982
- // ─────────────────────────────────────────────────────────────────────────────
983
- /**
984
- * Returns the negotiated SCTP max message size (bytes) if available,
985
- * otherwise `undefined` so `sendMessage` falls back to its built-in default.
986
- */
1125
+ // ─────────────────────────────────────────────────────────────────────────
987
1126
  get maxMessageBytes() {
988
1127
  var _a, _b, _c;
989
1128
  return (_c = (_b = (_a = this.peerConnection) == null ? void 0 : _a.sctp) == null ? void 0 : _b.maxMessageSize) != null ? _c : void 0;
990
1129
  }
991
- /**
992
- * Sends a command to the GPU machine via the data channel.
993
- * @param command The command to send
994
- * @param data The data to send with the command. These are the parameters for the command, matching the schema in the capabilities dictionary.
995
- * @param scope The message scope – "application" (default) for model commands, "runtime" for platform-level messages.
996
- */
997
1130
  sendCommand(command, data, scope = "application") {
998
1131
  if (!this.dataChannel) {
999
- throw new Error("[GPUMachineClient] Data channel not available");
1132
+ throw new Error("[WebRTCTransport] Data channel not available");
1000
1133
  }
1001
1134
  try {
1002
1135
  sendMessage(
@@ -1007,59 +1140,40 @@ var GPUMachineClient = class {
1007
1140
  this.maxMessageBytes
1008
1141
  );
1009
1142
  } catch (error) {
1010
- console.warn("[GPUMachineClient] Failed to send message:", error);
1143
+ console.warn("[WebRTCTransport] Failed to send message:", error);
1011
1144
  }
1012
1145
  }
1013
- // ─────────────────────────────────────────────────────────────────────────────
1146
+ // ─────────────────────────────────────────────────────────────────────────
1014
1147
  // Track Publishing
1015
- // ─────────────────────────────────────────────────────────────────────────────
1016
- /**
1017
- * Publishes a MediaStreamTrack to the named send track.
1018
- *
1019
- * @param name The declared track name (must exist in transceiverMap with a sendable direction).
1020
- * @param track The MediaStreamTrack to publish.
1021
- */
1148
+ // ─────────────────────────────────────────────────────────────────────────
1022
1149
  publishTrack(name, track) {
1023
1150
  return __async(this, null, function* () {
1024
1151
  if (!this.peerConnection) {
1025
1152
  throw new Error(
1026
- `[GPUMachineClient] Cannot publish track "${name}" - not initialized`
1153
+ `[WebRTCTransport] Cannot publish track "${name}" - not initialized`
1027
1154
  );
1028
1155
  }
1029
1156
  if (this.status !== "connected") {
1030
1157
  throw new Error(
1031
- `[GPUMachineClient] Cannot publish track "${name}" - not connected`
1158
+ `[WebRTCTransport] Cannot publish track "${name}" - not connected`
1032
1159
  );
1033
1160
  }
1034
1161
  const entry = this.transceiverMap.get(name);
1035
1162
  if (!entry || !entry.transceiver) {
1036
1163
  throw new Error(
1037
- `[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?)`
1038
1165
  );
1039
1166
  }
1040
1167
  if (entry.direction === "recvonly") {
1041
1168
  throw new Error(
1042
- `[GPUMachineClient] Cannot publish track "${name}" - transceiver is recvonly`
1043
- );
1044
- }
1045
- try {
1046
- yield entry.transceiver.sender.replaceTrack(track);
1047
- this.publishedTracks.set(name, track);
1048
- console.debug(
1049
- `[GPUMachineClient] Track "${name}" published successfully`
1050
- );
1051
- } catch (error) {
1052
- console.error(
1053
- `[GPUMachineClient] Failed to publish track "${name}":`,
1054
- error
1169
+ `[WebRTCTransport] Cannot publish track "${name}" - transceiver is recvonly`
1055
1170
  );
1056
- throw error;
1057
1171
  }
1172
+ yield entry.transceiver.sender.replaceTrack(track);
1173
+ this.publishedTracks.set(name, track);
1174
+ console.debug(`[WebRTCTransport] Track "${name}" published successfully`);
1058
1175
  });
1059
1176
  }
1060
- /**
1061
- * Unpublishes the track with the given name.
1062
- */
1063
1177
  unpublishTrack(name) {
1064
1178
  return __async(this, null, function* () {
1065
1179
  const entry = this.transceiverMap.get(name);
@@ -1067,11 +1181,11 @@ var GPUMachineClient = class {
1067
1181
  try {
1068
1182
  yield entry.transceiver.sender.replaceTrack(null);
1069
1183
  console.debug(
1070
- `[GPUMachineClient] Track "${name}" unpublished successfully`
1184
+ `[WebRTCTransport] Track "${name}" unpublished successfully`
1071
1185
  );
1072
1186
  } catch (error) {
1073
1187
  console.error(
1074
- `[GPUMachineClient] Failed to unpublish track "${name}":`,
1188
+ `[WebRTCTransport] Failed to unpublish track "${name}":`,
1075
1189
  error
1076
1190
  );
1077
1191
  throw error;
@@ -1080,76 +1194,31 @@ var GPUMachineClient = class {
1080
1194
  }
1081
1195
  });
1082
1196
  }
1083
- /**
1084
- * Returns the currently published track for the given name.
1085
- */
1086
- getPublishedTrack(name) {
1087
- return this.publishedTracks.get(name);
1088
- }
1089
- // ─────────────────────────────────────────────────────────────────────────────
1090
- // Getters
1091
- // ─────────────────────────────────────────────────────────────────────────────
1092
- /**
1093
- * Returns the remote media stream from the GPU machine.
1094
- */
1095
- getRemoteStream() {
1096
- if (!this.peerConnection) return void 0;
1097
- const receivers = this.peerConnection.getReceivers();
1098
- const tracks = receivers.map((r) => r.track).filter((t) => t !== null);
1099
- if (tracks.length === 0) return void 0;
1100
- return new MediaStream(tracks);
1101
- }
1102
- // ─────────────────────────────────────────────────────────────────────────────
1103
- // Ping (Client Liveness)
1104
- // ─────────────────────────────────────────────────────────────────────────────
1105
- /**
1106
- * Starts sending periodic "ping" messages on the runtime channel so the
1107
- * server can detect stale connections quickly.
1108
- */
1109
- startPing() {
1110
- this.stopPing();
1111
- this.pingInterval = setInterval(() => {
1112
- var _a;
1113
- if (((_a = this.dataChannel) == null ? void 0 : _a.readyState) === "open") {
1114
- try {
1115
- sendMessage(this.dataChannel, "ping", {}, "runtime");
1116
- } catch (e) {
1117
- }
1118
- }
1119
- }, PING_INTERVAL_MS);
1120
- }
1121
- /**
1122
- * Stops the periodic ping.
1123
- */
1124
- stopPing() {
1125
- if (this.pingInterval !== void 0) {
1126
- clearInterval(this.pingInterval);
1127
- this.pingInterval = void 0;
1128
- }
1129
- }
1130
- // ─────────────────────────────────────────────────────────────────────────────
1131
- // Stats Polling (RTT)
1132
- // ─────────────────────────────────────────────────────────────────────────────
1197
+ // ─────────────────────────────────────────────────────────────────────────
1198
+ // Stats
1199
+ // ─────────────────────────────────────────────────────────────────────────
1133
1200
  getStats() {
1134
1201
  return this.stats;
1135
1202
  }
1136
- /**
1137
- * Returns the ICE/data-channel durations recorded during the last connect(),
1138
- * or undefined if no connection has completed yet.
1139
- */
1140
- getConnectionTimings() {
1203
+ getTransportTimings() {
1204
+ var _a, _b;
1141
1205
  if (this.iceNegotiationMs == null || this.dataChannelMs == null) {
1142
1206
  return void 0;
1143
1207
  }
1144
1208
  return {
1209
+ protocol: "webrtc",
1210
+ sdpPollingMs: (_a = this.sdpPollingMs) != null ? _a : 0,
1211
+ sdpPollingAttempts: (_b = this.sdpPollingAttempts) != null ? _b : 0,
1145
1212
  iceNegotiationMs: this.iceNegotiationMs,
1146
1213
  dataChannelMs: this.dataChannelMs
1147
1214
  };
1148
1215
  }
1149
- resetConnectionTimings() {
1216
+ resetTransportTimings() {
1150
1217
  this.iceStartTime = void 0;
1151
1218
  this.iceNegotiationMs = void 0;
1152
1219
  this.dataChannelMs = void 0;
1220
+ this.sdpPollingMs = void 0;
1221
+ this.sdpPollingAttempts = void 0;
1153
1222
  }
1154
1223
  startStatsPolling() {
1155
1224
  this.stopStatsPolling();
@@ -1170,9 +1239,30 @@ var GPUMachineClient = class {
1170
1239
  }
1171
1240
  this.stats = void 0;
1172
1241
  }
1173
- // ─────────────────────────────────────────────────────────────────────────────
1174
- // Private Helpers
1175
- // ─────────────────────────────────────────────────────────────────────────────
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
+ // ─────────────────────────────────────────────────────────────────────────
1176
1266
  checkFullyConnected() {
1177
1267
  if (this.peerConnected && this.dataChannelOpen) {
1178
1268
  this.setStatus("connected");
@@ -1190,7 +1280,7 @@ var GPUMachineClient = class {
1190
1280
  this.peerConnection.onconnectionstatechange = () => {
1191
1281
  var _a;
1192
1282
  const state = (_a = this.peerConnection) == null ? void 0 : _a.connectionState;
1193
- console.debug("[GPUMachineClient] Connection state:", state);
1283
+ console.debug("[WebRTCTransport] Connection state:", state);
1194
1284
  if (state) {
1195
1285
  switch (state) {
1196
1286
  case "connected":
@@ -1223,21 +1313,21 @@ var GPUMachineClient = class {
1223
1313
  }
1224
1314
  trackName != null ? trackName : trackName = (_a = event.transceiver.mid) != null ? _a : `unknown-${event.track.id}`;
1225
1315
  console.debug(
1226
- `[GPUMachineClient] Track received: "${trackName}" (${event.track.kind}, mid=${event.transceiver.mid})`
1316
+ `[WebRTCTransport] Track received: "${trackName}" (${event.track.kind}, mid=${event.transceiver.mid})`
1227
1317
  );
1228
1318
  const stream = (_b = event.streams[0]) != null ? _b : new MediaStream([event.track]);
1229
1319
  this.emit("trackReceived", trackName, event.track, stream);
1230
1320
  };
1231
1321
  this.peerConnection.onicecandidate = (event) => {
1232
1322
  if (event.candidate) {
1233
- console.debug("[GPUMachineClient] ICE candidate:", event.candidate);
1323
+ console.debug("[WebRTCTransport] ICE candidate:", event.candidate);
1234
1324
  }
1235
1325
  };
1236
1326
  this.peerConnection.onicecandidateerror = (event) => {
1237
- console.warn("[GPUMachineClient] ICE candidate error:", event);
1327
+ console.warn("[WebRTCTransport] ICE candidate error:", event);
1238
1328
  };
1239
1329
  this.peerConnection.ondatachannel = (event) => {
1240
- console.debug("[GPUMachineClient] Data channel received from remote");
1330
+ console.debug("[WebRTCTransport] Data channel received from remote");
1241
1331
  this.dataChannel = event.channel;
1242
1332
  this.setupDataChannelHandlers();
1243
1333
  };
@@ -1245,7 +1335,7 @@ var GPUMachineClient = class {
1245
1335
  setupDataChannelHandlers() {
1246
1336
  if (!this.dataChannel) return;
1247
1337
  this.dataChannel.onopen = () => {
1248
- console.debug("[GPUMachineClient] Data channel open");
1338
+ console.debug("[WebRTCTransport] Data channel open");
1249
1339
  if (this.iceStartTime != null && this.dataChannelMs == null) {
1250
1340
  this.dataChannelMs = performance.now() - this.iceStartTime;
1251
1341
  }
@@ -1254,16 +1344,16 @@ var GPUMachineClient = class {
1254
1344
  this.checkFullyConnected();
1255
1345
  };
1256
1346
  this.dataChannel.onclose = () => {
1257
- console.debug("[GPUMachineClient] Data channel closed");
1347
+ console.debug("[WebRTCTransport] Data channel closed");
1258
1348
  this.dataChannelOpen = false;
1259
1349
  this.stopPing();
1260
1350
  };
1261
1351
  this.dataChannel.onerror = (error) => {
1262
- console.error("[GPUMachineClient] Data channel error:", error);
1352
+ console.error("[WebRTCTransport] Data channel error:", error);
1263
1353
  };
1264
1354
  this.dataChannel.onmessage = (event) => {
1265
1355
  const rawData = parseMessage(event.data);
1266
- console.debug("[GPUMachineClient] Received message:", rawData);
1356
+ console.debug("[WebRTCTransport] Received message:", rawData);
1267
1357
  try {
1268
1358
  if ((rawData == null ? void 0 : rawData.scope) === "application" && (rawData == null ? void 0 : rawData.data) !== void 0) {
1269
1359
  this.emit("message", rawData.data, "application");
@@ -1271,13 +1361,13 @@ var GPUMachineClient = class {
1271
1361
  this.emit("message", rawData.data, "runtime");
1272
1362
  } else {
1273
1363
  console.warn(
1274
- "[GPUMachineClient] Received message without envelope, treating as application"
1364
+ "[WebRTCTransport] Received message without envelope, treating as application"
1275
1365
  );
1276
1366
  this.emit("message", rawData, "application");
1277
1367
  }
1278
1368
  } catch (error) {
1279
1369
  console.error(
1280
- "[GPUMachineClient] Failed to parse/validate message:",
1370
+ "[WebRTCTransport] Failed to parse/validate message:",
1281
1371
  error
1282
1372
  );
1283
1373
  }
@@ -1289,46 +1379,24 @@ var GPUMachineClient = class {
1289
1379
  import { z as z2 } from "zod";
1290
1380
  var LOCAL_COORDINATOR_URL = "http://localhost:8080";
1291
1381
  var DEFAULT_BASE_URL = "https://api.reactor.inc";
1292
- var TrackConfigSchema = z2.object({
1293
- name: z2.string(),
1294
- kind: z2.enum(["audio", "video"])
1295
- });
1296
1382
  var OptionsSchema = z2.object({
1297
1383
  apiUrl: z2.string().default(DEFAULT_BASE_URL),
1298
1384
  modelName: z2.string(),
1299
- local: z2.boolean().default(false),
1300
- /**
1301
- * Tracks the client **RECEIVES** from the model (model → client).
1302
- * Each entry produces a `recvonly` transceiver.
1303
- * Names must be unique across both `receive` and `send`.
1304
- *
1305
- * When omitted, defaults to a single video track named `"main_video"`.
1306
- * Pass an explicit empty array to opt out of the default.
1307
- */
1308
- receive: z2.array(TrackConfigSchema).default([{ name: "main_video", kind: "video" }]),
1309
- /**
1310
- * Tracks the client **SENDS** to the model (client → model).
1311
- * Each entry produces a `sendonly` transceiver.
1312
- * Names must be unique across both `receive` and `send`.
1313
- */
1314
- send: z2.array(TrackConfigSchema).default([])
1385
+ local: z2.boolean().default(false)
1315
1386
  });
1316
1387
  var Reactor = class {
1317
1388
  constructor(options) {
1318
1389
  this.status = "disconnected";
1319
- // Generic event map
1390
+ this.tracks = [];
1320
1391
  this.eventListeners = /* @__PURE__ */ new Map();
1321
1392
  const validatedOptions = OptionsSchema.parse(options);
1322
1393
  this.coordinatorUrl = validatedOptions.apiUrl;
1323
1394
  this.model = validatedOptions.modelName;
1324
1395
  this.local = validatedOptions.local;
1325
- this.receive = validatedOptions.receive;
1326
- this.send = validatedOptions.send;
1327
1396
  if (this.local && options.apiUrl === void 0) {
1328
1397
  this.coordinatorUrl = LOCAL_COORDINATOR_URL;
1329
1398
  }
1330
1399
  }
1331
- // Event Emitter API
1332
1400
  on(event, handler) {
1333
1401
  if (!this.eventListeners.has(event)) {
1334
1402
  this.eventListeners.set(event, /* @__PURE__ */ new Set());
@@ -1345,10 +1413,6 @@ var Reactor = class {
1345
1413
  }
1346
1414
  /**
1347
1415
  * Sends a command to the model via the data channel.
1348
- *
1349
- * @param command The command name.
1350
- * @param data The command payload.
1351
- * @param scope "application" (default) for model commands, "runtime" for platform messages.
1352
1416
  */
1353
1417
  sendCommand(command, data, scope = "application") {
1354
1418
  return __async(this, null, function* () {
@@ -1359,7 +1423,7 @@ var Reactor = class {
1359
1423
  return;
1360
1424
  }
1361
1425
  try {
1362
- (_a = this.machineClient) == null ? void 0 : _a.sendCommand(command, data, scope);
1426
+ (_a = this.transportClient) == null ? void 0 : _a.sendCommand(command, data, scope);
1363
1427
  } catch (error) {
1364
1428
  console.error("[Reactor] Failed to send message:", error);
1365
1429
  this.createError(
@@ -1372,10 +1436,9 @@ var Reactor = class {
1372
1436
  });
1373
1437
  }
1374
1438
  /**
1375
- * Publishes a MediaStreamTrack to a named send track.
1376
- *
1377
- * @param name The declared send track name (e.g. "webcam").
1378
- * @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.
1379
1442
  */
1380
1443
  publishTrack(name, track) {
1381
1444
  return __async(this, null, function* () {
@@ -1387,7 +1450,7 @@ var Reactor = class {
1387
1450
  return;
1388
1451
  }
1389
1452
  try {
1390
- yield (_a = this.machineClient) == null ? void 0 : _a.publishTrack(name, track);
1453
+ yield (_a = this.transportClient) == null ? void 0 : _a.publishTrack(name, track);
1391
1454
  } catch (error) {
1392
1455
  console.error(`[Reactor] Failed to publish track "${name}":`, error);
1393
1456
  this.createError(
@@ -1399,16 +1462,11 @@ var Reactor = class {
1399
1462
  }
1400
1463
  });
1401
1464
  }
1402
- /**
1403
- * Unpublishes the track with the given name.
1404
- *
1405
- * @param name The declared send track name to unpublish.
1406
- */
1407
1465
  unpublishTrack(name) {
1408
1466
  return __async(this, null, function* () {
1409
1467
  var _a;
1410
1468
  try {
1411
- yield (_a = this.machineClient) == null ? void 0 : _a.unpublishTrack(name);
1469
+ yield (_a = this.transportClient) == null ? void 0 : _a.unpublishTrack(name);
1412
1470
  } catch (error) {
1413
1471
  console.error(`[Reactor] Failed to unpublish track "${name}":`, error);
1414
1472
  this.createError(
@@ -1421,8 +1479,7 @@ var Reactor = class {
1421
1479
  });
1422
1480
  }
1423
1481
  /**
1424
- * Public method for reconnecting to an existing session, that may have been interrupted but can be recovered.
1425
- * @param options Optional connect options (e.g. maxAttempts for SDP polling)
1482
+ * Reconnects to an existing session with a fresh transport.
1426
1483
  */
1427
1484
  reconnect(options) {
1428
1485
  return __async(this, null, function* () {
@@ -1434,31 +1491,26 @@ var Reactor = class {
1434
1491
  console.warn("[Reactor] Already connected, no need to reconnect.");
1435
1492
  return;
1436
1493
  }
1494
+ if (this.tracks.length === 0) {
1495
+ console.warn("[Reactor] No tracks available for reconnect.");
1496
+ return;
1497
+ }
1437
1498
  this.setStatus("connecting");
1438
- if (!this.machineClient) {
1439
- const iceServers = yield this.coordinatorClient.getIceServers();
1440
- this.machineClient = new GPUMachineClient({ iceServers });
1441
- this.setupMachineClientHandlers();
1442
- }
1443
- const sdpOffer = yield this.machineClient.createOffer({
1444
- send: this.send,
1445
- receive: this.receive
1446
- });
1447
1499
  try {
1448
- const { sdpAnswer } = yield this.coordinatorClient.connect(
1449
- this.sessionId,
1450
- sdpOffer,
1451
- options == null ? void 0 : options.maxAttempts
1452
- );
1453
- 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);
1454
1510
  } catch (error) {
1455
1511
  if (isAbortError(error)) return;
1456
- let recoverable = false;
1457
- if (error instanceof ConflictError) {
1458
- recoverable = true;
1459
- }
1460
1512
  console.error("[Reactor] Failed to reconnect:", error);
1461
- this.disconnect(recoverable);
1513
+ this.disconnect(true);
1462
1514
  this.createError(
1463
1515
  "RECONNECTION_FAILED",
1464
1516
  `Failed to reconnect: ${error}`,
@@ -1469,14 +1521,12 @@ var Reactor = class {
1469
1521
  });
1470
1522
  }
1471
1523
  /**
1472
- * Connects to the coordinator and waits for a GPU to be assigned.
1473
- * Once a GPU is assigned, the Reactor will connect to the gpu machine via WebRTC.
1474
- * If no authentication is provided and not in local mode, an error is thrown.
1475
- * @param jwtToken Optional JWT token for authentication
1476
- * @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.
1477
1526
  */
1478
1527
  connect(jwtToken, options) {
1479
1528
  return __async(this, null, function* () {
1529
+ var _a;
1480
1530
  console.debug("[Reactor] Connecting, status:", this.status);
1481
1531
  if (jwtToken == void 0 && !this.local) {
1482
1532
  throw new Error("No authentication provided and not in local mode");
@@ -1487,42 +1537,55 @@ var Reactor = class {
1487
1537
  this.setStatus("connecting");
1488
1538
  this.connectStartTime = performance.now();
1489
1539
  try {
1490
- console.debug(
1491
- "[Reactor] Connecting to coordinator with authenticated URL"
1492
- );
1493
- this.coordinatorClient = this.local ? new LocalCoordinatorClient(this.coordinatorUrl) : new CoordinatorClient({
1540
+ this.coordinatorClient = this.local ? new LocalCoordinatorClient(this.coordinatorUrl, this.model) : new CoordinatorClient({
1494
1541
  baseUrl: this.coordinatorUrl,
1495
1542
  jwtToken,
1496
- // Safe: validated above
1497
1543
  model: this.model
1498
1544
  });
1499
- const iceServers = yield this.coordinatorClient.getIceServers();
1500
- this.machineClient = new GPUMachineClient({ iceServers });
1501
- this.setupMachineClientHandlers();
1502
- const sdpOffer = yield this.machineClient.createOffer({
1503
- send: this.send,
1504
- receive: this.receive
1505
- });
1506
1545
  const tSession = performance.now();
1507
- const sessionId = yield this.coordinatorClient.createSession(sdpOffer);
1546
+ const initialResponse = yield this.coordinatorClient.createSession();
1508
1547
  const sessionCreationMs = performance.now() - tSession;
1509
- this.setSessionId(sessionId);
1510
- const tSdp = performance.now();
1511
- const { sdpAnswer, sdpPollingAttempts } = yield this.coordinatorClient.connect(
1512
- sessionId,
1513
- void 0,
1514
- options == null ? void 0 : options.maxAttempts
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
1515
1568
  );
1516
- const sdpPollingMs = performance.now() - tSdp;
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;
1517
1584
  this.connectionTimings = {
1518
- sessionCreationMs,
1519
- sdpPollingMs,
1520
- sdpPollingAttempts,
1521
- iceNegotiationMs: 0,
1522
- dataChannelMs: 0,
1585
+ sessionCreationMs: sessionCreationMs + sessionPollingMs,
1586
+ transportConnectingMs,
1523
1587
  totalMs: 0
1524
1588
  };
1525
- yield this.machineClient.connect(sdpAnswer);
1526
1589
  } catch (error) {
1527
1590
  if (isAbortError(error)) return;
1528
1591
  console.error("[Reactor] Connection failed:", error);
@@ -1545,18 +1608,14 @@ var Reactor = class {
1545
1608
  });
1546
1609
  }
1547
1610
  /**
1548
- * Sets up event handlers for the machine client.
1549
- *
1550
- * Each handler captures the client reference at registration time and
1551
- * ignores events if this.machineClient has since changed (e.g. after
1552
- * disconnect + reconnect), preventing stale WebRTC teardown events from
1553
- * interfering with a new connection.
1611
+ * Sets up event handlers for the transport client.
1612
+ * Each handler captures the client reference to ignore stale events.
1554
1613
  */
1555
- setupMachineClientHandlers() {
1556
- if (!this.machineClient) return;
1557
- const client = this.machineClient;
1614
+ setupTransportHandlers() {
1615
+ if (!this.transportClient) return;
1616
+ const client = this.transportClient;
1558
1617
  client.on("message", (message, scope) => {
1559
- if (this.machineClient !== client) return;
1618
+ if (this.transportClient !== client) return;
1560
1619
  if (scope === "application") {
1561
1620
  this.emit("message", message);
1562
1621
  } else if (scope === "runtime") {
@@ -1564,10 +1623,10 @@ var Reactor = class {
1564
1623
  }
1565
1624
  });
1566
1625
  client.on("statusChanged", (status) => {
1567
- if (this.machineClient !== client) return;
1626
+ if (this.transportClient !== client) return;
1568
1627
  switch (status) {
1569
1628
  case "connected":
1570
- this.finalizeConnectionTimings(client);
1629
+ this.finalizeConnectionTimings();
1571
1630
  this.setStatus("ready");
1572
1631
  break;
1573
1632
  case "disconnected":
@@ -1576,7 +1635,7 @@ var Reactor = class {
1576
1635
  case "error":
1577
1636
  this.createError(
1578
1637
  "GPU_CONNECTION_ERROR",
1579
- "GPU machine connection failed",
1638
+ "Transport connection failed",
1580
1639
  "gpu",
1581
1640
  true
1582
1641
  );
@@ -1587,29 +1646,29 @@ var Reactor = class {
1587
1646
  client.on(
1588
1647
  "trackReceived",
1589
1648
  (name, track, stream) => {
1590
- if (this.machineClient !== client) return;
1649
+ if (this.transportClient !== client) return;
1591
1650
  this.emit("trackReceived", name, track, stream);
1592
1651
  }
1593
1652
  );
1594
1653
  client.on("statsUpdate", (stats) => {
1595
- if (this.machineClient !== client) return;
1654
+ if (this.transportClient !== client) return;
1596
1655
  this.emit("statsUpdate", __spreadProps(__spreadValues({}, stats), {
1597
1656
  connectionTimings: this.connectionTimings
1598
1657
  }));
1599
1658
  });
1600
1659
  }
1601
1660
  /**
1602
- * Disconnects from the coordinator and the gpu machine.
1603
- * Ensures cleanup completes even if individual disconnections fail.
1661
+ * Disconnects from both the transport and the coordinator.
1604
1662
  */
1605
1663
  disconnect(recoverable = false) {
1606
1664
  return __async(this, null, function* () {
1607
- var _a;
1665
+ var _a, _b;
1608
1666
  if (this.status === "disconnected" && !this.sessionId) {
1609
1667
  console.warn("[Reactor] Already disconnected");
1610
1668
  return;
1611
1669
  }
1612
1670
  (_a = this.coordinatorClient) == null ? void 0 : _a.abort();
1671
+ (_b = this.transportClient) == null ? void 0 : _b.abort();
1613
1672
  if (this.coordinatorClient && !recoverable) {
1614
1673
  try {
1615
1674
  yield this.coordinatorClient.terminateSession();
@@ -1618,14 +1677,14 @@ var Reactor = class {
1618
1677
  }
1619
1678
  this.coordinatorClient = void 0;
1620
1679
  }
1621
- if (this.machineClient) {
1680
+ if (this.transportClient) {
1622
1681
  try {
1623
- yield this.machineClient.disconnect();
1682
+ yield this.transportClient.disconnect();
1624
1683
  } catch (error) {
1625
- console.error("[Reactor] Error disconnecting from GPU machine:", error);
1684
+ console.error("[Reactor] Error disconnecting transport:", error);
1626
1685
  }
1627
1686
  if (!recoverable) {
1628
- this.machineClient = void 0;
1687
+ this.transportClient = void 0;
1629
1688
  }
1630
1689
  }
1631
1690
  this.setStatus("disconnected");
@@ -1633,9 +1692,45 @@ var Reactor = class {
1633
1692
  if (!recoverable) {
1634
1693
  this.setSessionExpiration(void 0);
1635
1694
  this.setSessionId(void 0);
1695
+ this.capabilities = void 0;
1696
+ this.tracks = [];
1697
+ this.sessionResponse = void 0;
1636
1698
  }
1637
1699
  });
1638
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
+ // ─────────────────────────────────────────────────────────────────────────
1639
1734
  setSessionId(newSessionId) {
1640
1735
  console.debug(
1641
1736
  "[Reactor] Setting session ID:",
@@ -1648,9 +1743,6 @@ var Reactor = class {
1648
1743
  this.emit("sessionIdChanged", newSessionId);
1649
1744
  }
1650
1745
  }
1651
- getSessionId() {
1652
- return this.sessionId;
1653
- }
1654
1746
  setStatus(newStatus) {
1655
1747
  console.debug("[Reactor] Setting status:", newStatus, "from", this.status);
1656
1748
  if (this.status !== newStatus) {
@@ -1658,13 +1750,6 @@ var Reactor = class {
1658
1750
  this.emit("statusChanged", newStatus);
1659
1751
  }
1660
1752
  }
1661
- getStatus() {
1662
- return this.status;
1663
- }
1664
- /**
1665
- * Set the session expiration time.
1666
- * @param newSessionExpiration The new session expiration time in seconds.
1667
- */
1668
1753
  setSessionExpiration(newSessionExpiration) {
1669
1754
  console.debug(
1670
1755
  "[Reactor] Setting session expiration:",
@@ -1675,44 +1760,16 @@ var Reactor = class {
1675
1760
  this.emit("sessionExpirationChanged", newSessionExpiration);
1676
1761
  }
1677
1762
  }
1678
- /**
1679
- * Get the current state including status, error, and waiting info
1680
- */
1681
- getState() {
1682
- return {
1683
- status: this.status,
1684
- lastError: this.lastError
1685
- };
1686
- }
1687
- /**
1688
- * Get the last error that occurred
1689
- */
1690
- getLastError() {
1691
- return this.lastError;
1692
- }
1693
- getStats() {
1694
- var _a;
1695
- const stats = (_a = this.machineClient) == null ? void 0 : _a.getStats();
1696
- if (!stats) return void 0;
1697
- return __spreadProps(__spreadValues({}, stats), { connectionTimings: this.connectionTimings });
1698
- }
1699
1763
  resetConnectionTimings() {
1700
1764
  this.connectStartTime = void 0;
1701
1765
  this.connectionTimings = void 0;
1702
1766
  }
1703
- finalizeConnectionTimings(client) {
1704
- var _a, _b;
1767
+ finalizeConnectionTimings() {
1705
1768
  if (!this.connectionTimings || this.connectStartTime == null) return;
1706
- const webrtcTimings = client.getConnectionTimings();
1707
- this.connectionTimings.iceNegotiationMs = (_a = webrtcTimings == null ? void 0 : webrtcTimings.iceNegotiationMs) != null ? _a : 0;
1708
- this.connectionTimings.dataChannelMs = (_b = webrtcTimings == null ? void 0 : webrtcTimings.dataChannelMs) != null ? _b : 0;
1709
1769
  this.connectionTimings.totalMs = performance.now() - this.connectStartTime;
1710
1770
  this.connectStartTime = void 0;
1711
1771
  console.debug("[Reactor] Connection timings:", this.connectionTimings);
1712
1772
  }
1713
- /**
1714
- * Create and store an error
1715
- */
1716
1773
  createError(code, message, component, recoverable, retryAfter) {
1717
1774
  this.lastError = {
1718
1775
  code,
@@ -1916,7 +1973,7 @@ function ReactorProvider(_a) {
1916
1973
  console.debug("[ReactorProvider] Reactor store created successfully");
1917
1974
  }
1918
1975
  const _a2 = connectOptions != null ? connectOptions : {}, { autoConnect = false } = _a2, pollingOptions = __objRest(_a2, ["autoConnect"]);
1919
- const { apiUrl, modelName, local, receive, send } = props;
1976
+ const { apiUrl, modelName, local } = props;
1920
1977
  const maxAttempts = pollingOptions.maxAttempts;
1921
1978
  useEffect(() => {
1922
1979
  const handleBeforeUnload = () => {
@@ -1972,8 +2029,6 @@ function ReactorProvider(_a) {
1972
2029
  apiUrl,
1973
2030
  modelName,
1974
2031
  local,
1975
- receive,
1976
- send,
1977
2032
  jwtToken
1978
2033
  })
1979
2034
  );
@@ -2000,16 +2055,7 @@ function ReactorProvider(_a) {
2000
2055
  console.error("[ReactorProvider] Failed to disconnect:", error);
2001
2056
  });
2002
2057
  };
2003
- }, [
2004
- apiUrl,
2005
- modelName,
2006
- autoConnect,
2007
- local,
2008
- receive,
2009
- send,
2010
- jwtToken,
2011
- maxAttempts
2012
- ]);
2058
+ }, [apiUrl, modelName, autoConnect, local, jwtToken, maxAttempts]);
2013
2059
  return /* @__PURE__ */ jsx(ReactorContext.Provider, { value: storeRef.current, children });
2014
2060
  }
2015
2061
  function useReactorStore(selector) {
@@ -2223,31 +2269,37 @@ function ReactorController({
2223
2269
  return () => clearInterval(interval);
2224
2270
  }, [status, commands, requestCapabilities]);
2225
2271
  useReactorInternalMessage((message) => {
2272
+ var _a;
2226
2273
  if (message && typeof message === "object" && message.type === "modelCapabilities" && message.data && "commands" in message.data) {
2227
2274
  const commandsMessage = message.data;
2228
- 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);
2229
2283
  const initialValues = {};
2230
2284
  const initialExpanded = {};
2231
- Object.entries(commandsMessage.commands).forEach(
2232
- ([commandName, commandSchema]) => {
2233
- initialValues[commandName] = {};
2234
- initialExpanded[commandName] = false;
2235
- Object.entries(commandSchema.schema).forEach(
2236
- ([paramName, paramSchema]) => {
2237
- var _a, _b;
2238
- if (paramSchema.type === "number") {
2239
- initialValues[commandName][paramName] = (_a = paramSchema.minimum) != null ? _a : 0;
2240
- } else if (paramSchema.type === "string") {
2241
- initialValues[commandName][paramName] = "";
2242
- } else if (paramSchema.type === "boolean") {
2243
- initialValues[commandName][paramName] = false;
2244
- } else if (paramSchema.type === "integer") {
2245
- initialValues[commandName][paramName] = (_b = paramSchema.minimum) != null ? _b : 0;
2246
- }
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;
2247
2299
  }
2248
- );
2249
- }
2250
- );
2300
+ }
2301
+ );
2302
+ });
2251
2303
  setFormValues(initialValues);
2252
2304
  setExpandedCommands(initialExpanded);
2253
2305
  }
@@ -2838,7 +2890,7 @@ function fetchInsecureToken(_0) {
2838
2890
  const response = yield fetch(`${apiUrl}/tokens`, {
2839
2891
  method: "GET",
2840
2892
  headers: {
2841
- "X-API-Key": apiKey
2893
+ "Reactor-API-Key": apiKey
2842
2894
  }
2843
2895
  });
2844
2896
  if (!response.ok) {
@@ -2857,15 +2909,14 @@ export {
2857
2909
  ReactorController,
2858
2910
  ReactorProvider,
2859
2911
  ReactorView,
2912
+ WebRTCTransportClient,
2860
2913
  WebcamStream,
2861
- audio,
2862
2914
  fetchInsecureToken,
2863
2915
  isAbortError,
2864
2916
  useReactor,
2865
2917
  useReactorInternalMessage,
2866
2918
  useReactorMessage,
2867
2919
  useReactorStore,
2868
- useStats,
2869
- video
2920
+ useStats
2870
2921
  };
2871
2922
  //# sourceMappingURL=index.mjs.map