@reactor-team/js-sdk 2.6.0 → 2.8.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,163 @@ 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.8.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 CommandCapabilitySchema = z.object({
176
+ name: z.string(),
177
+ description: z.string(),
178
+ schema: z.record(z.string(), z.any()).optional()
97
179
  });
98
- var SessionStatusResponseSchema = z.object({
99
- session_id: z.uuidv4(),
100
- state: SessionState
180
+ var CapabilitiesSchema = z.object({
181
+ protocol_version: z.string(),
182
+ tracks: z.array(TrackCapabilitySchema),
183
+ commands: z.array(CommandCapabilitySchema).optional(),
184
+ emission_fps: z.number().nullable().optional()
101
185
  });
102
- var SessionInfoResponseSchema = SessionStatusResponseSchema.extend({
103
- session_info: CreateSessionRequestSchema.extend({
104
- session_id: z.uuidv4()
105
- })
186
+ var SessionInfoResponseSchema = z.object({
187
+ session_id: z.string(),
188
+ state: z.string(),
189
+ cluster: z.string()
106
190
  });
107
- var SDPParamsRequestSchema = z.object({
108
- sdp_offer: z.string(),
109
- extra_args: z.record(z.string(), z.any())
110
- // Dictionary
191
+ var CreateSessionResponseSchema = SessionInfoResponseSchema.extend({
192
+ model: z.object({ name: z.string(), version: z.string().optional() }),
193
+ server_info: z.object({ server_version: z.string() })
111
194
  });
112
- var SDPParamsResponseSchema = z.object({
113
- sdp_answer: z.string(),
114
- extra_args: z.record(z.string(), z.any())
115
- // Dictionary
195
+ var SessionResponseSchema = CreateSessionResponseSchema.extend({
196
+ selected_transport: TransportDeclarationSchema.optional(),
197
+ capabilities: CapabilitiesSchema.optional()
198
+ });
199
+ var TerminateSessionRequestSchema = z.object({
200
+ reason: z.string().optional()
201
+ });
202
+ var IceServerCredentialsSchema = z.object({
203
+ username: z.string(),
204
+ password: z.string()
205
+ });
206
+ var IceServerSchema = z.object({
207
+ uris: z.array(z.string()),
208
+ credentials: IceServerCredentialsSchema.optional()
116
209
  });
117
210
  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
- )
211
+ ice_servers: z.array(IceServerSchema)
212
+ });
213
+ var WebRTCSdpOfferRequestSchema = z.object({
214
+ sdp_offer: z.string(),
215
+ client_info: ClientInfoSchema.optional(),
216
+ track_mapping: z.array(TrackMappingEntrySchema)
217
+ });
218
+ var WebRTCSdpAnswerResponseSchema = z.object({
219
+ sdp_answer: z.string()
127
220
  });
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
221
 
365
222
  // 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;
223
+ var SESSION_POLL_INITIAL_BACKOFF_MS = 200;
224
+ var SESSION_POLL_MAX_BACKOFF_MS = 1e4;
225
+ var SESSION_POLL_BACKOFF_MULTIPLIER = 2;
226
+ var SESSION_POLL_DEFAULT_MAX_ATTEMPTS = 20;
370
227
  var CoordinatorClient = class {
371
228
  constructor(options) {
372
229
  this.baseUrl = options.baseUrl;
@@ -375,7 +232,7 @@ var CoordinatorClient = class {
375
232
  this.abortController = new AbortController();
376
233
  }
377
234
  /**
378
- * Aborts any in-flight HTTP requests and polling loops.
235
+ * Aborts any in-flight HTTP requests.
379
236
  * A fresh AbortController is created so the client remains reusable.
380
237
  */
381
238
  abort() {
@@ -383,69 +240,88 @@ var CoordinatorClient = class {
383
240
  this.abortController = new AbortController();
384
241
  }
385
242
  /**
386
- * The current abort signal, passed to every fetch() and sleep() call.
243
+ * The current abort signal, passed to every fetch() call.
387
244
  * Protected so subclasses can forward it to their own fetch calls.
388
245
  */
389
246
  get signal() {
390
247
  return this.abortController.signal;
391
248
  }
392
249
  /**
393
- * Returns the authorization header with JWT Bearer token
250
+ * Returns authorization + versioning headers for all coordinator requests.
394
251
  */
395
- getAuthHeaders() {
252
+ getHeaders() {
396
253
  return {
397
- Authorization: `Bearer ${this.jwtToken}`
254
+ Authorization: `Bearer ${this.jwtToken}`,
255
+ [API_VERSION_HEADER]: String(REACTOR_API_VERSION),
256
+ [API_ACCEPT_VERSION_HEADER]: String(REACTOR_API_VERSION)
398
257
  };
399
258
  }
400
259
  /**
401
- * Fetches ICE servers from the coordinator.
402
- * @returns Array of RTCIceServer objects for WebRTC peer connection configuration
260
+ * Checks an HTTP response for version mismatch errors (426, 501).
261
+ * Logs a clear message and throws with a descriptive error code.
403
262
  */
404
- getIceServers() {
263
+ checkVersionMismatch(response) {
405
264
  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}`);
265
+ if (response.status === 426) {
266
+ const msg = `Client API version (${REACTOR_API_VERSION}) is too old. Server requires a newer version. Please upgrade @reactor-team/js-sdk.`;
267
+ console.error(`[Reactor]`, msg);
268
+ throw new Error(`${VERSION_ERROR_CODES[426]}: ${msg}`);
417
269
  }
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;
270
+ if (response.status === 501) {
271
+ const msg = `Server does not support API version ${REACTOR_API_VERSION}. The server may need to be updated.`;
272
+ console.error(`[Reactor]`, msg);
273
+ throw new Error(`${VERSION_ERROR_CODES[501]}: ${msg}`);
274
+ }
275
+ });
276
+ }
277
+ sleep(ms) {
278
+ return new Promise((resolve, reject) => {
279
+ const { signal } = this;
280
+ if (signal.aborted) {
281
+ reject(new AbortError("Sleep aborted"));
282
+ return;
283
+ }
284
+ const timer = setTimeout(() => {
285
+ signal.removeEventListener("abort", onAbort);
286
+ resolve();
287
+ }, ms);
288
+ const onAbort = () => {
289
+ clearTimeout(timer);
290
+ reject(new AbortError("Sleep aborted"));
291
+ };
292
+ signal.addEventListener("abort", onAbort, { once: true });
426
293
  });
427
294
  }
428
295
  /**
429
296
  * Creates a new session with the coordinator.
430
- * Expects a 200 response and stores the session ID.
431
- * @returns The session ID
297
+ * No SDP is sent transport signaling is decoupled from session creation.
298
+ *
299
+ * The POST response is a slim acknowledgment (session_id, model name, status).
300
+ * Capabilities and transport details are populated later once the Runtime
301
+ * accepts the session — use {@link pollSessionReady} to wait for them.
432
302
  */
433
- createSession(sdp_offer) {
303
+ createSession(extraArgs) {
434
304
  return __async(this, null, function* () {
435
305
  console.debug("[CoordinatorClient] Creating session...");
436
- const requestBody = {
306
+ const requestBody = __spreadValues({
437
307
  model: { name: this.model },
438
- sdp_offer,
439
- extra_args: {}
440
- };
308
+ client_info: {
309
+ sdk_version: REACTOR_SDK_VERSION,
310
+ sdk_type: REACTOR_SDK_TYPE
311
+ },
312
+ supported_transports: [
313
+ { protocol: "webrtc", version: REACTOR_WEBRTC_VERSION }
314
+ ]
315
+ }, extraArgs ? { extra_args: extraArgs } : {});
441
316
  const response = yield fetch(`${this.baseUrl}/sessions`, {
442
317
  method: "POST",
443
- headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
318
+ headers: __spreadProps(__spreadValues({}, this.getHeaders()), {
444
319
  "Content-Type": "application/json"
445
320
  }),
446
321
  body: JSON.stringify(requestBody),
447
322
  signal: this.signal
448
323
  });
324
+ yield this.checkVersionMismatch(response);
449
325
  if (!response.ok) {
450
326
  const errorText = yield response.text();
451
327
  throw new Error(
@@ -453,339 +329,464 @@ var CoordinatorClient = class {
453
329
  );
454
330
  }
455
331
  const data = yield response.json();
456
- this.currentSessionId = data.session_id;
332
+ const parsed = CreateSessionResponseSchema.parse(data);
333
+ this.currentSessionId = parsed.session_id;
457
334
  console.debug(
458
- "[CoordinatorClient] Session created with ID:",
459
- this.currentSessionId
335
+ "[CoordinatorClient] Session created:",
336
+ this.currentSessionId,
337
+ "state:",
338
+ parsed.state
460
339
  );
461
- return data.session_id;
340
+ return parsed;
462
341
  });
463
342
  }
464
343
  /**
465
- * Gets the current session information from the coordinator.
466
- * @returns The session data (untyped for now)
344
+ * Polls GET /sessions/{id} until the Runtime has accepted the session
345
+ * and populated capabilities and selected_transport.
467
346
  */
468
- getSession() {
347
+ pollSessionReady(opts) {
469
348
  return __async(this, null, function* () {
349
+ var _a;
470
350
  if (!this.currentSessionId) {
471
351
  throw new Error("No active session. Call createSession() first.");
472
352
  }
353
+ const maxAttempts = (_a = opts == null ? void 0 : opts.maxAttempts) != null ? _a : SESSION_POLL_DEFAULT_MAX_ATTEMPTS;
354
+ let backoffMs = SESSION_POLL_INITIAL_BACKOFF_MS;
355
+ let attempt = 0;
473
356
  console.debug(
474
- "[CoordinatorClient] Getting session info for:",
475
- this.currentSessionId
357
+ "[CoordinatorClient] Polling session until capabilities are available..."
476
358
  );
477
- const response = yield fetch(
478
- `${this.baseUrl}/sessions/${this.currentSessionId}`,
479
- {
480
- method: "GET",
481
- headers: this.getAuthHeaders(),
482
- signal: this.signal
359
+ while (true) {
360
+ if (this.signal.aborted) {
361
+ throw new AbortError("Session polling aborted");
483
362
  }
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
- }
493
- /**
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)
363
+ if (attempt >= maxAttempts) {
364
+ throw new Error(
365
+ `Session polling exceeded maximum attempts (${maxAttempts}). The model may be unavailable or overloaded.`
366
+ );
367
+ }
368
+ attempt++;
369
+ const response = yield fetch(
370
+ `${this.baseUrl}/sessions/${this.currentSessionId}`,
371
+ {
372
+ method: "GET",
373
+ headers: this.getHeaders(),
374
+ signal: this.signal
375
+ }
376
+ );
377
+ yield this.checkVersionMismatch(response);
378
+ if (!response.ok) {
379
+ const errorText = yield response.text();
380
+ throw new Error(
381
+ `Failed to poll session: ${response.status} ${errorText}`
382
+ );
383
+ }
384
+ const data = yield response.json();
385
+ const partial = SessionResponseSchema.parse(data);
386
+ const terminalStates = [
387
+ "CLOSED" /* CLOSED */,
388
+ "INACTIVE" /* INACTIVE */
389
+ ];
390
+ if (terminalStates.includes(partial.state)) {
391
+ throw new Error(
392
+ `Session entered terminal state "${partial.state}" while waiting for capabilities`
393
+ );
394
+ }
395
+ if (partial.capabilities && partial.selected_transport) {
396
+ console.debug(
397
+ `[CoordinatorClient] Session ready after ${attempt} poll(s), transport: ${partial.selected_transport.protocol}, tracks: ${partial.capabilities.tracks.length}`
398
+ );
399
+ return partial;
400
+ }
401
+ console.debug(
402
+ `[CoordinatorClient] Session poll ${attempt}/${maxAttempts} \u2014 state: ${partial.state}, waiting ${backoffMs}ms...`
403
+ );
404
+ yield this.sleep(backoffMs);
405
+ backoffMs = Math.min(
406
+ backoffMs * SESSION_POLL_BACKOFF_MULTIPLIER,
407
+ SESSION_POLL_MAX_BACKOFF_MS
408
+ );
409
+ }
410
+ });
411
+ }
412
+ /**
413
+ * Gets session details from the coordinator.
414
+ * Fields like selected_transport and capabilities are only present
415
+ * after the Runtime accepts the session.
497
416
  */
498
- terminateSession() {
417
+ getSession() {
499
418
  return __async(this, null, function* () {
500
419
  if (!this.currentSessionId) {
501
- return;
420
+ throw new Error("No active session. Call createSession() first.");
502
421
  }
503
- console.debug(
504
- "[CoordinatorClient] Terminating session:",
505
- this.currentSessionId
506
- );
507
422
  const response = yield fetch(
508
423
  `${this.baseUrl}/sessions/${this.currentSessionId}`,
509
424
  {
510
- method: "DELETE",
511
- headers: this.getAuthHeaders(),
425
+ method: "GET",
426
+ headers: this.getHeaders(),
512
427
  signal: this.signal
513
428
  }
514
429
  );
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;
430
+ yield this.checkVersionMismatch(response);
431
+ if (!response.ok) {
432
+ const errorText = yield response.text();
433
+ throw new Error(`Failed to get session: ${response.status} ${errorText}`);
526
434
  }
527
- const errorText = yield response.text();
528
- throw new Error(
529
- `Failed to terminate session: ${response.status} ${errorText}`
530
- );
435
+ const data = yield response.json();
436
+ return SessionResponseSchema.parse(data);
531
437
  });
532
438
  }
533
439
  /**
534
- * Get the current session ID
440
+ * Gets lightweight session status (session_id, cluster, status).
535
441
  */
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) {
442
+ getSessionInfo() {
546
443
  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
- };
444
+ if (!this.currentSessionId) {
445
+ throw new Error("No active session. Call createSession() first.");
446
+ }
555
447
  const response = yield fetch(
556
- `${this.baseUrl}/sessions/${sessionId}/sdp_params`,
448
+ `${this.baseUrl}/sessions/${this.currentSessionId}/info`,
557
449
  {
558
- method: "PUT",
559
- headers: __spreadProps(__spreadValues({}, this.getAuthHeaders()), {
560
- "Content-Type": "application/json"
561
- }),
562
- body: JSON.stringify(requestBody),
450
+ method: "GET",
451
+ headers: this.getHeaders(),
563
452
  signal: this.signal
564
453
  }
565
454
  );
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)"
455
+ yield this.checkVersionMismatch(response);
456
+ if (!response.ok) {
457
+ const errorText = yield response.text();
458
+ throw new Error(
459
+ `Failed to get session info: ${response.status} ${errorText}`
574
460
  );
575
- return null;
576
461
  }
577
- const errorText = yield response.text();
578
- throw new Error(
579
- `Failed to send SDP offer: ${response.status} ${errorText}`
580
- );
462
+ const data = yield response.json();
463
+ return SessionInfoResponseSchema.parse(data);
581
464
  });
582
465
  }
583
466
  /**
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
467
+ * Restarts an inactive session with a different compute unit.
468
+ * The session ID is preserved but a new transport must be established.
589
469
  */
590
- pollSdpAnswer(_0) {
591
- return __async(this, arguments, function* (sessionId, maxAttempts = DEFAULT_MAX_ATTEMPTS) {
470
+ restartSession() {
471
+ return __async(this, null, function* () {
472
+ if (!this.currentSessionId) {
473
+ throw new Error("No active session. Call createSession() first.");
474
+ }
592
475
  console.debug(
593
- "[CoordinatorClient] Polling for SDP answer for session:",
594
- sessionId
476
+ "[CoordinatorClient] Restarting session:",
477
+ this.currentSessionId
595
478
  );
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;
479
+ const response = yield fetch(
480
+ `${this.baseUrl}/sessions/${this.currentSessionId}`,
481
+ {
482
+ method: "PUT",
483
+ headers: this.getHeaders(),
484
+ signal: this.signal
633
485
  }
486
+ );
487
+ yield this.checkVersionMismatch(response);
488
+ if (!response.ok) {
634
489
  const errorText = yield response.text();
635
490
  throw new Error(
636
- `Failed to poll SDP answer: ${response.status} ${errorText}`
491
+ `Failed to restart session: ${response.status} ${errorText}`
637
492
  );
638
493
  }
639
494
  });
640
495
  }
641
496
  /**
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)
497
+ * Terminates the current session by sending a DELETE request.
498
+ * No-op if no session has been created yet.
499
+ * @param reason Optional termination reason
649
500
  */
650
- connect(sessionId, sdpOffer, maxAttempts) {
501
+ terminateSession(reason) {
651
502
  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
- }
503
+ if (!this.currentSessionId) {
504
+ return;
658
505
  }
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"));
506
+ console.debug(
507
+ "[CoordinatorClient] Terminating session:",
508
+ this.currentSessionId
509
+ );
510
+ const body = reason ? { reason } : void 0;
511
+ const response = yield fetch(
512
+ `${this.baseUrl}/sessions/${this.currentSessionId}`,
513
+ __spreadProps(__spreadValues({
514
+ method: "DELETE",
515
+ headers: __spreadValues(__spreadValues({}, this.getHeaders()), body ? { "Content-Type": "application/json" } : {})
516
+ }, body ? { body: JSON.stringify(body) } : {}), {
517
+ signal: this.signal
518
+ })
519
+ );
520
+ if (response.ok) {
521
+ this.currentSessionId = void 0;
672
522
  return;
673
523
  }
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 });
524
+ if (response.status === 404) {
525
+ console.debug(
526
+ "[CoordinatorClient] Session not found on server, clearing local state:",
527
+ this.currentSessionId
528
+ );
529
+ this.currentSessionId = void 0;
530
+ return;
531
+ }
532
+ const errorText = yield response.text();
533
+ throw new Error(
534
+ `Failed to terminate session: ${response.status} ${errorText}`
535
+ );
683
536
  });
684
537
  }
538
+ getSessionId() {
539
+ return this.currentSessionId;
540
+ }
685
541
  };
686
542
 
687
543
  // src/core/LocalCoordinatorClient.ts
688
544
  var LocalCoordinatorClient = class extends CoordinatorClient {
689
- constructor(baseUrl) {
545
+ constructor(baseUrl, model) {
690
546
  super({
691
547
  baseUrl,
692
548
  jwtToken: "local",
693
- model: "local"
549
+ model
694
550
  });
695
- this.localBaseUrl = baseUrl;
551
+ }
552
+ getHeaders() {
553
+ return {
554
+ [API_VERSION_HEADER]: String(REACTOR_API_VERSION),
555
+ [API_ACCEPT_VERSION_HEADER]: String(REACTOR_API_VERSION)
556
+ };
696
557
  }
697
558
  /**
698
- * Gets ICE servers from the local HTTP runtime.
699
- * @returns The ICE server configuration
559
+ * Starts a session on the local runtime.
560
+ *
561
+ * Unlike the production coordinator, the local runtime returns the full
562
+ * response (capabilities, selected_transport) immediately — no polling needed.
700
563
  */
701
- getIceServers() {
564
+ createSession(extraArgs) {
702
565
  return __async(this, null, function* () {
703
- console.debug("[LocalCoordinatorClient] Fetching ICE servers...");
704
- const response = yield fetch(`${this.localBaseUrl}/ice_servers`, {
705
- method: "GET",
566
+ var _a, _b;
567
+ console.debug("[LocalCoordinatorClient] Starting session...");
568
+ const response = yield fetch(`${this.baseUrl}/start_session`, {
569
+ method: "POST",
570
+ headers: __spreadProps(__spreadValues({}, this.getHeaders()), {
571
+ "Content-Type": "application/json"
572
+ }),
573
+ body: JSON.stringify(__spreadValues({}, extraArgs ? { extra_args: extraArgs } : {})),
706
574
  signal: this.signal
707
575
  });
708
576
  if (!response.ok) {
709
- throw new Error("Failed to get ICE servers from local coordinator.");
577
+ const errorText = yield response.text();
578
+ throw new Error(
579
+ `Failed to start session: ${response.status} ${errorText}`
580
+ );
710
581
  }
711
582
  const data = yield response.json();
712
- const parsed = IceServersResponseSchema.parse(data);
713
- const iceServers = transformIceServers(parsed);
583
+ const session = SessionResponseSchema.parse(data);
584
+ this.cachedSessionResponse = session;
585
+ this.currentSessionId = session.session_id;
714
586
  console.debug(
715
- "[LocalCoordinatorClient] Received ICE servers:",
716
- iceServers.length
587
+ "[LocalCoordinatorClient] Session started:",
588
+ this.currentSessionId,
589
+ "transport:",
590
+ (_a = session.selected_transport) == null ? void 0 : _a.protocol,
591
+ "tracks:",
592
+ (_b = session.capabilities) == null ? void 0 : _b.tracks.length
717
593
  );
718
- return iceServers;
594
+ return CreateSessionResponseSchema.parse(data);
719
595
  });
720
596
  }
721
597
  /**
722
- * Creates a local session by posting to /start_session.
723
- * @returns always "local"
598
+ * Returns the cached full session response immediately.
599
+ * The local runtime already provided everything in start_session.
724
600
  */
725
- createSession(sdpOffer) {
601
+ pollSessionReady() {
726
602
  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.");
603
+ if (!this.cachedSessionResponse) {
604
+ throw new Error(
605
+ "No cached session response. Call createSession() first."
606
+ );
735
607
  }
736
- console.debug("[LocalCoordinatorClient] Local session created");
737
- return "local";
608
+ return this.cachedSessionResponse;
738
609
  });
739
610
  }
740
611
  /**
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)
612
+ * Stops the session on the local runtime.
746
613
  */
747
- connect(sessionId, sdpMessage) {
614
+ terminateSession() {
748
615
  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.");
616
+ if (!this.currentSessionId) {
617
+ return;
618
+ }
619
+ console.debug(
620
+ "[LocalCoordinatorClient] Stopping session:",
621
+ this.currentSessionId
622
+ );
623
+ try {
624
+ yield fetch(`${this.baseUrl}/stop_session`, {
625
+ method: "POST",
626
+ headers: this.getHeaders(),
627
+ signal: this.signal
628
+ });
629
+ } catch (error) {
630
+ console.error("[LocalCoordinatorClient] Error stopping session:", error);
768
631
  }
769
- const sdpAnswer = yield response.json();
770
- console.debug("[LocalCoordinatorClient] Received SDP answer");
771
- return { sdpAnswer: sdpAnswer.sdp, sdpPollingAttempts: 0 };
632
+ this.currentSessionId = void 0;
633
+ this.cachedSessionResponse = void 0;
772
634
  });
773
635
  }
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
- });
636
+ };
637
+
638
+ // src/utils/webrtc.ts
639
+ var DEFAULT_DATA_CHANNEL_LABEL = "data";
640
+ var FORCE_RELAY_MODE = false;
641
+ var DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024;
642
+ function createPeerConnection(config) {
643
+ return new RTCPeerConnection({
644
+ iceServers: config.iceServers,
645
+ iceTransportPolicy: FORCE_RELAY_MODE ? "relay" : "all"
646
+ });
647
+ }
648
+ function createDataChannel(pc, label) {
649
+ return pc.createDataChannel(label != null ? label : DEFAULT_DATA_CHANNEL_LABEL);
650
+ }
651
+ function createOffer(pc) {
652
+ return __async(this, null, function* () {
653
+ const offer = yield pc.createOffer();
654
+ yield pc.setLocalDescription(offer);
655
+ yield waitForIceGathering(pc);
656
+ const localDescription = pc.localDescription;
657
+ if (!localDescription) {
658
+ throw new Error("Failed to create local description");
659
+ }
660
+ return localDescription.sdp;
661
+ });
662
+ }
663
+ function setRemoteDescription(pc, sdp) {
664
+ return __async(this, null, function* () {
665
+ const sessionDescription = new RTCSessionDescription({
666
+ sdp,
667
+ type: "answer"
781
668
  });
669
+ yield pc.setRemoteDescription(sessionDescription);
670
+ });
671
+ }
672
+ function transformIceServers(response) {
673
+ return response.ice_servers.map((server) => {
674
+ const rtcServer = {
675
+ urls: server.uris
676
+ };
677
+ if (server.credentials) {
678
+ rtcServer.username = server.credentials.username;
679
+ rtcServer.credential = server.credentials.password;
680
+ }
681
+ return rtcServer;
682
+ });
683
+ }
684
+ function waitForIceGathering(pc, timeoutMs = 5e3) {
685
+ return new Promise((resolve) => {
686
+ if (pc.iceGatheringState === "complete") {
687
+ resolve();
688
+ return;
689
+ }
690
+ const onGatheringStateChange = () => {
691
+ if (pc.iceGatheringState === "complete") {
692
+ pc.removeEventListener(
693
+ "icegatheringstatechange",
694
+ onGatheringStateChange
695
+ );
696
+ resolve();
697
+ }
698
+ };
699
+ pc.addEventListener("icegatheringstatechange", onGatheringStateChange);
700
+ setTimeout(() => {
701
+ pc.removeEventListener("icegatheringstatechange", onGatheringStateChange);
702
+ resolve();
703
+ }, timeoutMs);
704
+ });
705
+ }
706
+ function sendMessage(channel, command, data, scope = "application", maxBytes = DEFAULT_MAX_MESSAGE_BYTES) {
707
+ if (channel.readyState !== "open") {
708
+ throw new Error(`Data channel not open: ${channel.readyState}`);
782
709
  }
783
- };
710
+ const jsonData = typeof data === "string" ? JSON.parse(data) : data;
711
+ const inner = { type: command, data: jsonData };
712
+ const payload = { scope, data: inner };
713
+ const serialized = JSON.stringify(payload);
714
+ const byteLength = new TextEncoder().encode(serialized).byteLength;
715
+ if (byteLength > maxBytes) {
716
+ throw new Error(
717
+ `Data channel message too large: ${byteLength} bytes exceeds limit of ${maxBytes} bytes (command: "${command}")`
718
+ );
719
+ }
720
+ channel.send(serialized);
721
+ }
722
+ function parseMessage(data) {
723
+ if (typeof data === "string") {
724
+ try {
725
+ return JSON.parse(data);
726
+ } catch (e) {
727
+ return data;
728
+ }
729
+ }
730
+ return data;
731
+ }
732
+ function closePeerConnection(pc) {
733
+ pc.close();
734
+ }
735
+ function extractConnectionStats(report) {
736
+ let rtt;
737
+ let availableOutgoingBitrate;
738
+ let localCandidateId;
739
+ let framesPerSecond;
740
+ let jitter;
741
+ let packetLossRatio;
742
+ report.forEach((stat) => {
743
+ if (stat.type === "candidate-pair" && stat.state === "succeeded") {
744
+ if (stat.currentRoundTripTime !== void 0) {
745
+ rtt = stat.currentRoundTripTime * 1e3;
746
+ }
747
+ if (stat.availableOutgoingBitrate !== void 0) {
748
+ availableOutgoingBitrate = stat.availableOutgoingBitrate;
749
+ }
750
+ localCandidateId = stat.localCandidateId;
751
+ }
752
+ if (stat.type === "inbound-rtp" && stat.kind === "video") {
753
+ if (stat.framesPerSecond !== void 0) {
754
+ framesPerSecond = stat.framesPerSecond;
755
+ }
756
+ if (stat.jitter !== void 0) {
757
+ jitter = stat.jitter;
758
+ }
759
+ if (stat.packetsReceived !== void 0 && stat.packetsLost !== void 0 && stat.packetsReceived + stat.packetsLost > 0) {
760
+ packetLossRatio = stat.packetsLost / (stat.packetsReceived + stat.packetsLost);
761
+ }
762
+ }
763
+ });
764
+ let candidateType;
765
+ if (localCandidateId) {
766
+ const localCandidate = report.get(localCandidateId);
767
+ if (localCandidate == null ? void 0 : localCandidate.candidateType) {
768
+ candidateType = localCandidate.candidateType;
769
+ }
770
+ }
771
+ return {
772
+ rtt,
773
+ candidateType,
774
+ availableOutgoingBitrate,
775
+ framesPerSecond,
776
+ packetLossRatio,
777
+ jitter,
778
+ timestamp: Date.now()
779
+ };
780
+ }
784
781
 
785
- // src/core/GPUMachineClient.ts
782
+ // src/core/WebRTCTransportClient.ts
786
783
  var PING_INTERVAL_MS = 5e3;
787
784
  var STATS_INTERVAL_MS = 2e3;
788
- var GPUMachineClient = class {
785
+ var INITIAL_BACKOFF_MS = 200;
786
+ var MAX_BACKOFF_MS = 15e3;
787
+ var BACKOFF_MULTIPLIER = 2;
788
+ var DEFAULT_MAX_POLL_ATTEMPTS = 6;
789
+ var WebRTCTransportClient = class {
789
790
  constructor(config) {
790
791
  this.eventListeners = /* @__PURE__ */ new Map();
791
792
  this.status = "disconnected";
@@ -793,11 +794,17 @@ var GPUMachineClient = class {
793
794
  this.publishedTracks = /* @__PURE__ */ new Map();
794
795
  this.peerConnected = false;
795
796
  this.dataChannelOpen = false;
796
- this.config = config;
797
+ var _a, _b;
798
+ this.baseUrl = config.baseUrl;
799
+ this.sessionId = config.sessionId;
800
+ this.jwtToken = config.jwtToken;
801
+ this.webrtcVersion = (_a = config.webrtcVersion) != null ? _a : REACTOR_WEBRTC_VERSION;
802
+ this.maxPollAttempts = (_b = config.maxPollAttempts) != null ? _b : DEFAULT_MAX_POLL_ATTEMPTS;
803
+ this.abortController = new AbortController();
797
804
  }
798
- // ─────────────────────────────────────────────────────────────────────────────
799
- // Event Emitter API
800
- // ─────────────────────────────────────────────────────────────────────────────
805
+ // ─────────────────────────────────────────────────────────────────────────
806
+ // Event Emitter
807
+ // ─────────────────────────────────────────────────────────────────────────
801
808
  on(event, handler) {
802
809
  if (!this.eventListeners.has(event)) {
803
810
  this.eventListeners.set(event, /* @__PURE__ */ new Set());
@@ -812,130 +819,241 @@ var GPUMachineClient = class {
812
819
  var _a;
813
820
  (_a = this.eventListeners.get(event)) == null ? void 0 : _a.forEach((handler) => handler(...args));
814
821
  }
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) {
822
+ // ─────────────────────────────────────────────────────────────────────────
823
+ // HTTP Helpers
824
+ // ─────────────────────────────────────────────────────────────────────────
825
+ get signal() {
826
+ return this.abortController.signal;
827
+ }
828
+ get transportBaseUrl() {
829
+ return `${this.baseUrl}/sessions/${this.sessionId}/transport/webrtc`;
830
+ }
831
+ getHeaders() {
832
+ return {
833
+ Authorization: `Bearer ${this.jwtToken}`,
834
+ [WEBRTC_VERSION_HEADER]: this.webrtcVersion
835
+ };
836
+ }
837
+ checkVersionMismatch(response) {
838
+ return __async(this, null, function* () {
839
+ if (response.status === 426) {
840
+ const msg = `Client WebRTC version (${this.webrtcVersion}) is too old. Server requires a newer version. Please upgrade @reactor-team/js-sdk.`;
841
+ console.error(`[WebRTCTransport]`, msg);
842
+ throw new Error(`${VERSION_ERROR_CODES[426]}: ${msg}`);
843
+ }
844
+ if (response.status === 501) {
845
+ const msg = `Server does not support WebRTC version ${this.webrtcVersion}. The server may need to be updated.`;
846
+ console.error(`[WebRTCTransport]`, msg);
847
+ throw new Error(`${VERSION_ERROR_CODES[501]}: ${msg}`);
848
+ }
849
+ });
850
+ }
851
+ sleep(ms) {
852
+ return new Promise((resolve, reject) => {
853
+ const { signal } = this;
854
+ if (signal.aborted) {
855
+ reject(new AbortError("Sleep aborted"));
856
+ return;
857
+ }
858
+ const timer = setTimeout(() => {
859
+ signal.removeEventListener("abort", onAbort);
860
+ resolve();
861
+ }, ms);
862
+ const onAbort = () => {
863
+ clearTimeout(timer);
864
+ reject(new AbortError("Sleep aborted"));
865
+ };
866
+ signal.addEventListener("abort", onAbort, { once: true });
867
+ });
868
+ }
869
+ // ─────────────────────────────────────────────────────────────────────────
870
+ // Transport Signaling (HTTP)
871
+ // ─────────────────────────────────────────────────────────────────────────
872
+ fetchIceServers() {
873
+ return __async(this, null, function* () {
874
+ console.debug("[WebRTCTransport] Fetching ICE servers...");
875
+ const response = yield fetch(`${this.transportBaseUrl}/ice_servers`, {
876
+ method: "GET",
877
+ headers: this.getHeaders(),
878
+ signal: this.signal
879
+ });
880
+ yield this.checkVersionMismatch(response);
881
+ if (!response.ok) {
882
+ throw new Error(`Failed to fetch ICE servers: ${response.status}`);
883
+ }
884
+ const data = yield response.json();
885
+ const parsed = IceServersResponseSchema.parse(data);
886
+ const iceServers = transformIceServers(parsed);
887
+ console.debug("[WebRTCTransport] Received ICE servers:", iceServers.length);
888
+ return iceServers;
889
+ });
890
+ }
891
+ sendSdpOffer(sdpOffer, trackMapping, method = "POST") {
892
+ return __async(this, null, function* () {
893
+ console.debug(
894
+ `[WebRTCTransport] Sending SDP offer (${method}) for session:`,
895
+ this.sessionId
896
+ );
897
+ const requestBody = {
898
+ sdp_offer: sdpOffer,
899
+ client_info: {
900
+ sdk_version: REACTOR_SDK_VERSION,
901
+ sdk_type: REACTOR_SDK_TYPE
902
+ },
903
+ track_mapping: trackMapping
904
+ };
905
+ const response = yield fetch(`${this.transportBaseUrl}/sdp_params`, {
906
+ method,
907
+ headers: __spreadProps(__spreadValues({}, this.getHeaders()), {
908
+ "Content-Type": "application/json"
909
+ }),
910
+ body: JSON.stringify(requestBody),
911
+ signal: this.signal
912
+ });
913
+ yield this.checkVersionMismatch(response);
914
+ if (response.status !== 202) {
915
+ const errorText = yield response.text();
916
+ throw new Error(
917
+ `Failed to send SDP offer: ${response.status} ${errorText}`
918
+ );
919
+ }
920
+ console.debug("[WebRTCTransport] SDP offer accepted (202)");
921
+ });
922
+ }
923
+ pollSdpAnswer() {
924
+ return __async(this, null, function* () {
925
+ console.debug("[WebRTCTransport] Polling for SDP answer...");
926
+ const pollStart = performance.now();
927
+ let backoffMs = INITIAL_BACKOFF_MS;
928
+ let attempt = 0;
929
+ while (true) {
930
+ if (this.signal.aborted) {
931
+ throw new AbortError("SDP polling aborted");
932
+ }
933
+ if (attempt >= this.maxPollAttempts) {
934
+ throw new Error(
935
+ `SDP polling exceeded maximum attempts (${this.maxPollAttempts})`
936
+ );
937
+ }
938
+ attempt++;
939
+ console.debug(
940
+ `[WebRTCTransport] SDP poll attempt ${attempt}/${this.maxPollAttempts}`
941
+ );
942
+ const response = yield fetch(`${this.transportBaseUrl}/sdp_params`, {
943
+ method: "GET",
944
+ headers: this.getHeaders(),
945
+ signal: this.signal
946
+ });
947
+ yield this.checkVersionMismatch(response);
948
+ if (response.status === 200) {
949
+ const data = yield response.json();
950
+ const parsed = WebRTCSdpAnswerResponseSchema.parse(data);
951
+ this.sdpPollingMs = performance.now() - pollStart;
952
+ this.sdpPollingAttempts = attempt;
953
+ console.debug(
954
+ `[WebRTCTransport] Received SDP answer via polling (${attempt} attempt(s), ${this.sdpPollingMs.toFixed(0)}ms)`
955
+ );
956
+ return parsed;
957
+ }
958
+ if (response.status === 202) {
959
+ console.debug(
960
+ `[WebRTCTransport] SDP answer pending, retrying in ${backoffMs}ms...`
961
+ );
962
+ yield this.sleep(backoffMs);
963
+ backoffMs = Math.min(backoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS);
964
+ continue;
965
+ }
966
+ const errorText = yield response.text();
967
+ throw new Error(
968
+ `Failed to poll SDP answer: ${response.status} ${errorText}`
969
+ );
970
+ }
971
+ });
972
+ }
973
+ // ─────────────────────────────────────────────────────────────────────────
974
+ // Connection Lifecycle
975
+ // ─────────────────────────────────────────────────────────────────────────
976
+ connect(tracks) {
831
977
  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
- );
978
+ this.setStatus("connecting");
979
+ this.resetTransportTimings();
980
+ const iceServers = yield this.fetchIceServers();
981
+ this.peerConnection = createPeerConnection({ iceServers });
982
+ this.setupPeerConnectionHandlers();
983
+ this.dataChannel = createDataChannel(this.peerConnection);
840
984
  this.setupDataChannelHandlers();
841
985
  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
986
+ for (const track of tracks) {
987
+ const transceiver = this.peerConnection.addTransceiver(track.kind, {
988
+ direction: track.direction
989
+ });
990
+ this.transceiverMap.set(track.name, {
991
+ name: track.name,
992
+ kind: track.kind,
993
+ direction: track.direction,
994
+ transceiver
846
995
  });
847
- entry.transceiver = transceiver;
848
- this.transceiverMap.set(entry.name, entry);
849
996
  console.debug(
850
- `[GPUMachineClient] Transceiver added: "${entry.name}" (${entry.kind}, ${entry.direction})`
997
+ `[WebRTCTransport] Transceiver added: "${track.name}" (${track.kind}, ${track.direction})`
851
998
  );
852
999
  }
853
- const trackNames = entries.map((e) => e.name);
854
- const { sdp, needsAnswerRestore } = yield createOffer(
1000
+ const sdpOffer = yield createOffer(this.peerConnection);
1001
+ const trackMapping = this.buildTrackMapping(tracks);
1002
+ yield this.sendSdpOffer(sdpOffer, trackMapping, "POST");
1003
+ const answerResponse = yield this.pollSdpAnswer();
1004
+ this.iceStartTime = performance.now();
1005
+ yield setRemoteDescription(
855
1006
  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)"
1007
+ answerResponse.sdp_answer
867
1008
  );
868
- return sdp;
1009
+ console.debug("[WebRTCTransport] Remote description set");
869
1010
  });
870
1011
  }
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) {
1012
+ reconnect(tracks) {
904
1013
  return __async(this, null, function* () {
905
- if (!this.peerConnection) {
906
- throw new Error(
907
- "[GPUMachineClient] Cannot connect - call createOffer() first"
908
- );
1014
+ this.setStatus("connecting");
1015
+ this.stopPing();
1016
+ this.stopStatsPolling();
1017
+ if (this.dataChannel) {
1018
+ this.dataChannel.close();
1019
+ this.dataChannel = void 0;
909
1020
  }
910
- if (this.peerConnection.signalingState !== "have-local-offer") {
911
- throw new Error(
912
- `[GPUMachineClient] Invalid signaling state: ${this.peerConnection.signalingState}`
913
- );
1021
+ if (this.peerConnection) {
1022
+ closePeerConnection(this.peerConnection);
1023
+ this.peerConnection = void 0;
914
1024
  }
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;
1025
+ this.peerConnected = false;
1026
+ this.dataChannelOpen = false;
1027
+ this.resetTransportTimings();
1028
+ const iceServers = yield this.fetchIceServers();
1029
+ this.peerConnection = createPeerConnection({ iceServers });
1030
+ this.setupPeerConnectionHandlers();
1031
+ this.dataChannel = createDataChannel(this.peerConnection);
1032
+ this.setupDataChannelHandlers();
1033
+ this.transceiverMap.clear();
1034
+ for (const track of tracks) {
1035
+ const transceiver = this.peerConnection.addTransceiver(track.kind, {
1036
+ direction: track.direction
1037
+ });
1038
+ this.transceiverMap.set(track.name, {
1039
+ name: track.name,
1040
+ kind: track.kind,
1041
+ direction: track.direction,
1042
+ transceiver
1043
+ });
933
1044
  }
1045
+ const sdpOffer = yield createOffer(this.peerConnection);
1046
+ const trackMapping = this.buildTrackMapping(tracks);
1047
+ yield this.sendSdpOffer(sdpOffer, trackMapping, "PUT");
1048
+ const answerResponse = yield this.pollSdpAnswer();
1049
+ this.iceStartTime = performance.now();
1050
+ yield setRemoteDescription(
1051
+ this.peerConnection,
1052
+ answerResponse.sdp_answer
1053
+ );
1054
+ console.debug("[WebRTCTransport] Remote description set (reconnect)");
934
1055
  });
935
1056
  }
936
- /**
937
- * Disconnects from the GPU machine and cleans up resources.
938
- */
939
1057
  disconnect() {
940
1058
  return __async(this, null, function* () {
941
1059
  this.stopPing();
@@ -952,51 +1070,56 @@ var GPUMachineClient = class {
952
1070
  this.peerConnection = void 0;
953
1071
  }
954
1072
  this.transceiverMap.clear();
955
- this.midMapping = void 0;
956
1073
  this.peerConnected = false;
957
1074
  this.dataChannelOpen = false;
958
- this.resetConnectionTimings();
1075
+ this.resetTransportTimings();
959
1076
  this.setStatus("disconnected");
960
- console.debug("[GPUMachineClient] Disconnected");
1077
+ console.debug("[WebRTCTransport] Disconnected");
961
1078
  });
962
1079
  }
963
- /**
964
- * Returns the current connection status.
965
- */
1080
+ abort() {
1081
+ this.abortController.abort();
1082
+ this.abortController = new AbortController();
1083
+ }
966
1084
  getStatus() {
967
1085
  return this.status;
968
1086
  }
1087
+ // ─────────────────────────────────────────────────────────────────────────
1088
+ // Track Mapping
1089
+ // ─────────────────────────────────────────────────────────────────────────
969
1090
  /**
970
- * Gets the current local SDP description.
1091
+ * Builds the track_mapping array from capabilities + transceiver MIDs.
1092
+ * Must be called after createOffer + setLocalDescription so that
1093
+ * transceiver.mid is assigned.
971
1094
  */
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";
1095
+ buildTrackMapping(tracks) {
1096
+ return tracks.map((track) => {
1097
+ var _a;
1098
+ const entry = this.transceiverMap.get(track.name);
1099
+ const mid = (_a = entry == null ? void 0 : entry.transceiver) == null ? void 0 : _a.mid;
1100
+ if (mid == null) {
1101
+ throw new Error(
1102
+ `Cannot build track mapping: transceiver "${track.name}" has no MID. Was createOffer() called?`
1103
+ );
1104
+ }
1105
+ return {
1106
+ mid,
1107
+ name: track.name,
1108
+ kind: track.kind,
1109
+ direction: track.direction
1110
+ };
1111
+ });
979
1112
  }
980
- // ─────────────────────────────────────────────────────────────────────────────
1113
+ // ─────────────────────────────────────────────────────────────────────────
981
1114
  // 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
- */
1115
+ // ─────────────────────────────────────────────────────────────────────────
987
1116
  get maxMessageBytes() {
988
1117
  var _a, _b, _c;
989
1118
  return (_c = (_b = (_a = this.peerConnection) == null ? void 0 : _a.sctp) == null ? void 0 : _b.maxMessageSize) != null ? _c : void 0;
990
1119
  }
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
1120
  sendCommand(command, data, scope = "application") {
998
1121
  if (!this.dataChannel) {
999
- throw new Error("[GPUMachineClient] Data channel not available");
1122
+ throw new Error("[WebRTCTransport] Data channel not available");
1000
1123
  }
1001
1124
  try {
1002
1125
  sendMessage(
@@ -1007,59 +1130,40 @@ var GPUMachineClient = class {
1007
1130
  this.maxMessageBytes
1008
1131
  );
1009
1132
  } catch (error) {
1010
- console.warn("[GPUMachineClient] Failed to send message:", error);
1133
+ console.warn("[WebRTCTransport] Failed to send message:", error);
1011
1134
  }
1012
1135
  }
1013
- // ─────────────────────────────────────────────────────────────────────────────
1136
+ // ─────────────────────────────────────────────────────────────────────────
1014
1137
  // 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
- */
1138
+ // ─────────────────────────────────────────────────────────────────────────
1022
1139
  publishTrack(name, track) {
1023
1140
  return __async(this, null, function* () {
1024
1141
  if (!this.peerConnection) {
1025
1142
  throw new Error(
1026
- `[GPUMachineClient] Cannot publish track "${name}" - not initialized`
1143
+ `[WebRTCTransport] Cannot publish track "${name}" - not initialized`
1027
1144
  );
1028
1145
  }
1029
1146
  if (this.status !== "connected") {
1030
1147
  throw new Error(
1031
- `[GPUMachineClient] Cannot publish track "${name}" - not connected`
1148
+ `[WebRTCTransport] Cannot publish track "${name}" - not connected`
1032
1149
  );
1033
1150
  }
1034
1151
  const entry = this.transceiverMap.get(name);
1035
1152
  if (!entry || !entry.transceiver) {
1036
1153
  throw new Error(
1037
- `[GPUMachineClient] Cannot publish track "${name}" - no transceiver (was it declared in tracks.send?)`
1154
+ `[WebRTCTransport] Cannot publish track "${name}" - no transceiver (was it declared in capabilities?)`
1038
1155
  );
1039
1156
  }
1040
1157
  if (entry.direction === "recvonly") {
1041
1158
  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
1159
+ `[WebRTCTransport] Cannot publish track "${name}" - transceiver is recvonly`
1055
1160
  );
1056
- throw error;
1057
1161
  }
1162
+ yield entry.transceiver.sender.replaceTrack(track);
1163
+ this.publishedTracks.set(name, track);
1164
+ console.debug(`[WebRTCTransport] Track "${name}" published successfully`);
1058
1165
  });
1059
1166
  }
1060
- /**
1061
- * Unpublishes the track with the given name.
1062
- */
1063
1167
  unpublishTrack(name) {
1064
1168
  return __async(this, null, function* () {
1065
1169
  const entry = this.transceiverMap.get(name);
@@ -1067,11 +1171,11 @@ var GPUMachineClient = class {
1067
1171
  try {
1068
1172
  yield entry.transceiver.sender.replaceTrack(null);
1069
1173
  console.debug(
1070
- `[GPUMachineClient] Track "${name}" unpublished successfully`
1174
+ `[WebRTCTransport] Track "${name}" unpublished successfully`
1071
1175
  );
1072
1176
  } catch (error) {
1073
1177
  console.error(
1074
- `[GPUMachineClient] Failed to unpublish track "${name}":`,
1178
+ `[WebRTCTransport] Failed to unpublish track "${name}":`,
1075
1179
  error
1076
1180
  );
1077
1181
  throw error;
@@ -1080,76 +1184,31 @@ var GPUMachineClient = class {
1080
1184
  }
1081
1185
  });
1082
1186
  }
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
- // ─────────────────────────────────────────────────────────────────────────────
1187
+ // ─────────────────────────────────────────────────────────────────────────
1188
+ // Stats
1189
+ // ─────────────────────────────────────────────────────────────────────────
1133
1190
  getStats() {
1134
1191
  return this.stats;
1135
1192
  }
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() {
1193
+ getTransportTimings() {
1194
+ var _a, _b;
1141
1195
  if (this.iceNegotiationMs == null || this.dataChannelMs == null) {
1142
1196
  return void 0;
1143
1197
  }
1144
1198
  return {
1199
+ protocol: "webrtc",
1200
+ sdpPollingMs: (_a = this.sdpPollingMs) != null ? _a : 0,
1201
+ sdpPollingAttempts: (_b = this.sdpPollingAttempts) != null ? _b : 0,
1145
1202
  iceNegotiationMs: this.iceNegotiationMs,
1146
1203
  dataChannelMs: this.dataChannelMs
1147
1204
  };
1148
1205
  }
1149
- resetConnectionTimings() {
1206
+ resetTransportTimings() {
1150
1207
  this.iceStartTime = void 0;
1151
1208
  this.iceNegotiationMs = void 0;
1152
1209
  this.dataChannelMs = void 0;
1210
+ this.sdpPollingMs = void 0;
1211
+ this.sdpPollingAttempts = void 0;
1153
1212
  }
1154
1213
  startStatsPolling() {
1155
1214
  this.stopStatsPolling();
@@ -1170,9 +1229,30 @@ var GPUMachineClient = class {
1170
1229
  }
1171
1230
  this.stats = void 0;
1172
1231
  }
1173
- // ─────────────────────────────────────────────────────────────────────────────
1174
- // Private Helpers
1175
- // ─────────────────────────────────────────────────────────────────────────────
1232
+ // ─────────────────────────────────────────────────────────────────────────
1233
+ // Ping (Client Liveness)
1234
+ // ─────────────────────────────────────────────────────────────────────────
1235
+ startPing() {
1236
+ this.stopPing();
1237
+ this.pingInterval = setInterval(() => {
1238
+ var _a;
1239
+ if (((_a = this.dataChannel) == null ? void 0 : _a.readyState) === "open") {
1240
+ try {
1241
+ sendMessage(this.dataChannel, "ping", {}, "runtime");
1242
+ } catch (e) {
1243
+ }
1244
+ }
1245
+ }, PING_INTERVAL_MS);
1246
+ }
1247
+ stopPing() {
1248
+ if (this.pingInterval !== void 0) {
1249
+ clearInterval(this.pingInterval);
1250
+ this.pingInterval = void 0;
1251
+ }
1252
+ }
1253
+ // ─────────────────────────────────────────────────────────────────────────
1254
+ // Internal Helpers
1255
+ // ─────────────────────────────────────────────────────────────────────────
1176
1256
  checkFullyConnected() {
1177
1257
  if (this.peerConnected && this.dataChannelOpen) {
1178
1258
  this.setStatus("connected");
@@ -1190,7 +1270,7 @@ var GPUMachineClient = class {
1190
1270
  this.peerConnection.onconnectionstatechange = () => {
1191
1271
  var _a;
1192
1272
  const state = (_a = this.peerConnection) == null ? void 0 : _a.connectionState;
1193
- console.debug("[GPUMachineClient] Connection state:", state);
1273
+ console.debug("[WebRTCTransport] Connection state:", state);
1194
1274
  if (state) {
1195
1275
  switch (state) {
1196
1276
  case "connected":
@@ -1223,21 +1303,21 @@ var GPUMachineClient = class {
1223
1303
  }
1224
1304
  trackName != null ? trackName : trackName = (_a = event.transceiver.mid) != null ? _a : `unknown-${event.track.id}`;
1225
1305
  console.debug(
1226
- `[GPUMachineClient] Track received: "${trackName}" (${event.track.kind}, mid=${event.transceiver.mid})`
1306
+ `[WebRTCTransport] Track received: "${trackName}" (${event.track.kind}, mid=${event.transceiver.mid})`
1227
1307
  );
1228
1308
  const stream = (_b = event.streams[0]) != null ? _b : new MediaStream([event.track]);
1229
1309
  this.emit("trackReceived", trackName, event.track, stream);
1230
1310
  };
1231
1311
  this.peerConnection.onicecandidate = (event) => {
1232
1312
  if (event.candidate) {
1233
- console.debug("[GPUMachineClient] ICE candidate:", event.candidate);
1313
+ console.debug("[WebRTCTransport] ICE candidate:", event.candidate);
1234
1314
  }
1235
1315
  };
1236
1316
  this.peerConnection.onicecandidateerror = (event) => {
1237
- console.warn("[GPUMachineClient] ICE candidate error:", event);
1317
+ console.warn("[WebRTCTransport] ICE candidate error:", event);
1238
1318
  };
1239
1319
  this.peerConnection.ondatachannel = (event) => {
1240
- console.debug("[GPUMachineClient] Data channel received from remote");
1320
+ console.debug("[WebRTCTransport] Data channel received from remote");
1241
1321
  this.dataChannel = event.channel;
1242
1322
  this.setupDataChannelHandlers();
1243
1323
  };
@@ -1245,7 +1325,7 @@ var GPUMachineClient = class {
1245
1325
  setupDataChannelHandlers() {
1246
1326
  if (!this.dataChannel) return;
1247
1327
  this.dataChannel.onopen = () => {
1248
- console.debug("[GPUMachineClient] Data channel open");
1328
+ console.debug("[WebRTCTransport] Data channel open");
1249
1329
  if (this.iceStartTime != null && this.dataChannelMs == null) {
1250
1330
  this.dataChannelMs = performance.now() - this.iceStartTime;
1251
1331
  }
@@ -1254,16 +1334,16 @@ var GPUMachineClient = class {
1254
1334
  this.checkFullyConnected();
1255
1335
  };
1256
1336
  this.dataChannel.onclose = () => {
1257
- console.debug("[GPUMachineClient] Data channel closed");
1337
+ console.debug("[WebRTCTransport] Data channel closed");
1258
1338
  this.dataChannelOpen = false;
1259
1339
  this.stopPing();
1260
1340
  };
1261
1341
  this.dataChannel.onerror = (error) => {
1262
- console.error("[GPUMachineClient] Data channel error:", error);
1342
+ console.error("[WebRTCTransport] Data channel error:", error);
1263
1343
  };
1264
1344
  this.dataChannel.onmessage = (event) => {
1265
1345
  const rawData = parseMessage(event.data);
1266
- console.debug("[GPUMachineClient] Received message:", rawData);
1346
+ console.debug("[WebRTCTransport] Received message:", rawData);
1267
1347
  try {
1268
1348
  if ((rawData == null ? void 0 : rawData.scope) === "application" && (rawData == null ? void 0 : rawData.data) !== void 0) {
1269
1349
  this.emit("message", rawData.data, "application");
@@ -1271,13 +1351,13 @@ var GPUMachineClient = class {
1271
1351
  this.emit("message", rawData.data, "runtime");
1272
1352
  } else {
1273
1353
  console.warn(
1274
- "[GPUMachineClient] Received message without envelope, treating as application"
1354
+ "[WebRTCTransport] Received message without envelope, treating as application"
1275
1355
  );
1276
1356
  this.emit("message", rawData, "application");
1277
1357
  }
1278
1358
  } catch (error) {
1279
1359
  console.error(
1280
- "[GPUMachineClient] Failed to parse/validate message:",
1360
+ "[WebRTCTransport] Failed to parse/validate message:",
1281
1361
  error
1282
1362
  );
1283
1363
  }
@@ -1289,46 +1369,24 @@ var GPUMachineClient = class {
1289
1369
  import { z as z2 } from "zod";
1290
1370
  var LOCAL_COORDINATOR_URL = "http://localhost:8080";
1291
1371
  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
1372
  var OptionsSchema = z2.object({
1297
1373
  apiUrl: z2.string().default(DEFAULT_BASE_URL),
1298
1374
  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([])
1375
+ local: z2.boolean().default(false)
1315
1376
  });
1316
1377
  var Reactor = class {
1317
1378
  constructor(options) {
1318
1379
  this.status = "disconnected";
1319
- // Generic event map
1380
+ this.tracks = [];
1320
1381
  this.eventListeners = /* @__PURE__ */ new Map();
1321
1382
  const validatedOptions = OptionsSchema.parse(options);
1322
1383
  this.coordinatorUrl = validatedOptions.apiUrl;
1323
1384
  this.model = validatedOptions.modelName;
1324
1385
  this.local = validatedOptions.local;
1325
- this.receive = validatedOptions.receive;
1326
- this.send = validatedOptions.send;
1327
1386
  if (this.local && options.apiUrl === void 0) {
1328
1387
  this.coordinatorUrl = LOCAL_COORDINATOR_URL;
1329
1388
  }
1330
1389
  }
1331
- // Event Emitter API
1332
1390
  on(event, handler) {
1333
1391
  if (!this.eventListeners.has(event)) {
1334
1392
  this.eventListeners.set(event, /* @__PURE__ */ new Set());
@@ -1345,10 +1403,6 @@ var Reactor = class {
1345
1403
  }
1346
1404
  /**
1347
1405
  * 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
1406
  */
1353
1407
  sendCommand(command, data, scope = "application") {
1354
1408
  return __async(this, null, function* () {
@@ -1359,7 +1413,7 @@ var Reactor = class {
1359
1413
  return;
1360
1414
  }
1361
1415
  try {
1362
- (_a = this.machineClient) == null ? void 0 : _a.sendCommand(command, data, scope);
1416
+ (_a = this.transportClient) == null ? void 0 : _a.sendCommand(command, data, scope);
1363
1417
  } catch (error) {
1364
1418
  console.error("[Reactor] Failed to send message:", error);
1365
1419
  this.createError(
@@ -1372,10 +1426,9 @@ var Reactor = class {
1372
1426
  });
1373
1427
  }
1374
1428
  /**
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.
1429
+ * Publishes a MediaStreamTrack to a named sendonly track.
1430
+ * The transceiver is already set up from capabilities — this just
1431
+ * calls replaceTrack() on the sender.
1379
1432
  */
1380
1433
  publishTrack(name, track) {
1381
1434
  return __async(this, null, function* () {
@@ -1387,7 +1440,7 @@ var Reactor = class {
1387
1440
  return;
1388
1441
  }
1389
1442
  try {
1390
- yield (_a = this.machineClient) == null ? void 0 : _a.publishTrack(name, track);
1443
+ yield (_a = this.transportClient) == null ? void 0 : _a.publishTrack(name, track);
1391
1444
  } catch (error) {
1392
1445
  console.error(`[Reactor] Failed to publish track "${name}":`, error);
1393
1446
  this.createError(
@@ -1399,16 +1452,11 @@ var Reactor = class {
1399
1452
  }
1400
1453
  });
1401
1454
  }
1402
- /**
1403
- * Unpublishes the track with the given name.
1404
- *
1405
- * @param name The declared send track name to unpublish.
1406
- */
1407
1455
  unpublishTrack(name) {
1408
1456
  return __async(this, null, function* () {
1409
1457
  var _a;
1410
1458
  try {
1411
- yield (_a = this.machineClient) == null ? void 0 : _a.unpublishTrack(name);
1459
+ yield (_a = this.transportClient) == null ? void 0 : _a.unpublishTrack(name);
1412
1460
  } catch (error) {
1413
1461
  console.error(`[Reactor] Failed to unpublish track "${name}":`, error);
1414
1462
  this.createError(
@@ -1421,8 +1469,7 @@ var Reactor = class {
1421
1469
  });
1422
1470
  }
1423
1471
  /**
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)
1472
+ * Reconnects to an existing session with a fresh transport.
1426
1473
  */
1427
1474
  reconnect(options) {
1428
1475
  return __async(this, null, function* () {
@@ -1434,31 +1481,26 @@ var Reactor = class {
1434
1481
  console.warn("[Reactor] Already connected, no need to reconnect.");
1435
1482
  return;
1436
1483
  }
1484
+ if (this.tracks.length === 0) {
1485
+ console.warn("[Reactor] No tracks available for reconnect.");
1486
+ return;
1487
+ }
1437
1488
  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
1489
  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);
1490
+ if (!this.transportClient) {
1491
+ this.transportClient = new WebRTCTransportClient({
1492
+ baseUrl: this.coordinatorUrl,
1493
+ sessionId: this.sessionId,
1494
+ jwtToken: this.local ? "local" : "",
1495
+ maxPollAttempts: options == null ? void 0 : options.maxAttempts
1496
+ });
1497
+ this.setupTransportHandlers();
1498
+ }
1499
+ yield this.transportClient.reconnect(this.tracks);
1454
1500
  } catch (error) {
1455
1501
  if (isAbortError(error)) return;
1456
- let recoverable = false;
1457
- if (error instanceof ConflictError) {
1458
- recoverable = true;
1459
- }
1460
1502
  console.error("[Reactor] Failed to reconnect:", error);
1461
- this.disconnect(recoverable);
1503
+ this.disconnect(true);
1462
1504
  this.createError(
1463
1505
  "RECONNECTION_FAILED",
1464
1506
  `Failed to reconnect: ${error}`,
@@ -1469,14 +1511,12 @@ var Reactor = class {
1469
1511
  });
1470
1512
  }
1471
1513
  /**
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)
1514
+ * Connects to the coordinator, creates a session, then establishes
1515
+ * the transport using server-declared capabilities.
1477
1516
  */
1478
1517
  connect(jwtToken, options) {
1479
1518
  return __async(this, null, function* () {
1519
+ var _a, _b;
1480
1520
  console.debug("[Reactor] Connecting, status:", this.status);
1481
1521
  if (jwtToken == void 0 && !this.local) {
1482
1522
  throw new Error("No authentication provided and not in local mode");
@@ -1487,42 +1527,55 @@ var Reactor = class {
1487
1527
  this.setStatus("connecting");
1488
1528
  this.connectStartTime = performance.now();
1489
1529
  try {
1490
- console.debug(
1491
- "[Reactor] Connecting to coordinator with authenticated URL"
1492
- );
1493
- this.coordinatorClient = this.local ? new LocalCoordinatorClient(this.coordinatorUrl) : new CoordinatorClient({
1530
+ this.coordinatorClient = this.local ? new LocalCoordinatorClient(this.coordinatorUrl, this.model) : new CoordinatorClient({
1494
1531
  baseUrl: this.coordinatorUrl,
1495
1532
  jwtToken,
1496
- // Safe: validated above
1497
1533
  model: this.model
1498
1534
  });
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
1535
  const tSession = performance.now();
1507
- const sessionId = yield this.coordinatorClient.createSession(sdpOffer);
1536
+ const initialResponse = yield this.coordinatorClient.createSession();
1508
1537
  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
1538
+ this.setSessionId(initialResponse.session_id);
1539
+ console.debug(
1540
+ "[Reactor] Session created:",
1541
+ initialResponse.session_id,
1542
+ "state:",
1543
+ initialResponse.state
1544
+ );
1545
+ this.setStatus("waiting");
1546
+ const tPoll = performance.now();
1547
+ const sessionResponse = yield this.coordinatorClient.pollSessionReady();
1548
+ const sessionPollingMs = performance.now() - tPoll;
1549
+ this.sessionResponse = sessionResponse;
1550
+ this.capabilities = sessionResponse.capabilities;
1551
+ this.tracks = sessionResponse.capabilities.tracks;
1552
+ this.emit("capabilitiesReceived", this.capabilities);
1553
+ console.debug(
1554
+ "[Reactor] Session ready, transport:",
1555
+ sessionResponse.selected_transport.protocol,
1556
+ "tracks:",
1557
+ this.tracks.length
1515
1558
  );
1516
- const sdpPollingMs = performance.now() - tSdp;
1559
+ const protocol = sessionResponse.selected_transport.protocol;
1560
+ if (protocol !== "webrtc") {
1561
+ throw new Error(`Unsupported transport protocol: ${protocol}`);
1562
+ }
1563
+ this.transportClient = new WebRTCTransportClient({
1564
+ baseUrl: this.coordinatorUrl,
1565
+ sessionId: sessionResponse.session_id,
1566
+ jwtToken: this.local ? "local" : jwtToken,
1567
+ webrtcVersion: (_b = (_a = sessionResponse.selected_transport) == null ? void 0 : _a.version) != null ? _b : REACTOR_WEBRTC_VERSION,
1568
+ maxPollAttempts: options == null ? void 0 : options.maxAttempts
1569
+ });
1570
+ this.setupTransportHandlers();
1571
+ const tTransport = performance.now();
1572
+ yield this.transportClient.connect(this.tracks);
1573
+ const transportConnectingMs = performance.now() - tTransport;
1517
1574
  this.connectionTimings = {
1518
- sessionCreationMs,
1519
- sdpPollingMs,
1520
- sdpPollingAttempts,
1521
- iceNegotiationMs: 0,
1522
- dataChannelMs: 0,
1575
+ sessionCreationMs: sessionCreationMs + sessionPollingMs,
1576
+ transportConnectingMs,
1523
1577
  totalMs: 0
1524
1578
  };
1525
- yield this.machineClient.connect(sdpAnswer);
1526
1579
  } catch (error) {
1527
1580
  if (isAbortError(error)) return;
1528
1581
  console.error("[Reactor] Connection failed:", error);
@@ -1545,18 +1598,14 @@ var Reactor = class {
1545
1598
  });
1546
1599
  }
1547
1600
  /**
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.
1601
+ * Sets up event handlers for the transport client.
1602
+ * Each handler captures the client reference to ignore stale events.
1554
1603
  */
1555
- setupMachineClientHandlers() {
1556
- if (!this.machineClient) return;
1557
- const client = this.machineClient;
1604
+ setupTransportHandlers() {
1605
+ if (!this.transportClient) return;
1606
+ const client = this.transportClient;
1558
1607
  client.on("message", (message, scope) => {
1559
- if (this.machineClient !== client) return;
1608
+ if (this.transportClient !== client) return;
1560
1609
  if (scope === "application") {
1561
1610
  this.emit("message", message);
1562
1611
  } else if (scope === "runtime") {
@@ -1564,10 +1613,10 @@ var Reactor = class {
1564
1613
  }
1565
1614
  });
1566
1615
  client.on("statusChanged", (status) => {
1567
- if (this.machineClient !== client) return;
1616
+ if (this.transportClient !== client) return;
1568
1617
  switch (status) {
1569
1618
  case "connected":
1570
- this.finalizeConnectionTimings(client);
1619
+ this.finalizeConnectionTimings();
1571
1620
  this.setStatus("ready");
1572
1621
  break;
1573
1622
  case "disconnected":
@@ -1576,7 +1625,7 @@ var Reactor = class {
1576
1625
  case "error":
1577
1626
  this.createError(
1578
1627
  "GPU_CONNECTION_ERROR",
1579
- "GPU machine connection failed",
1628
+ "Transport connection failed",
1580
1629
  "gpu",
1581
1630
  true
1582
1631
  );
@@ -1587,29 +1636,29 @@ var Reactor = class {
1587
1636
  client.on(
1588
1637
  "trackReceived",
1589
1638
  (name, track, stream) => {
1590
- if (this.machineClient !== client) return;
1639
+ if (this.transportClient !== client) return;
1591
1640
  this.emit("trackReceived", name, track, stream);
1592
1641
  }
1593
1642
  );
1594
1643
  client.on("statsUpdate", (stats) => {
1595
- if (this.machineClient !== client) return;
1644
+ if (this.transportClient !== client) return;
1596
1645
  this.emit("statsUpdate", __spreadProps(__spreadValues({}, stats), {
1597
1646
  connectionTimings: this.connectionTimings
1598
1647
  }));
1599
1648
  });
1600
1649
  }
1601
1650
  /**
1602
- * Disconnects from the coordinator and the gpu machine.
1603
- * Ensures cleanup completes even if individual disconnections fail.
1651
+ * Disconnects from both the transport and the coordinator.
1604
1652
  */
1605
1653
  disconnect(recoverable = false) {
1606
1654
  return __async(this, null, function* () {
1607
- var _a;
1655
+ var _a, _b;
1608
1656
  if (this.status === "disconnected" && !this.sessionId) {
1609
1657
  console.warn("[Reactor] Already disconnected");
1610
1658
  return;
1611
1659
  }
1612
1660
  (_a = this.coordinatorClient) == null ? void 0 : _a.abort();
1661
+ (_b = this.transportClient) == null ? void 0 : _b.abort();
1613
1662
  if (this.coordinatorClient && !recoverable) {
1614
1663
  try {
1615
1664
  yield this.coordinatorClient.terminateSession();
@@ -1618,14 +1667,14 @@ var Reactor = class {
1618
1667
  }
1619
1668
  this.coordinatorClient = void 0;
1620
1669
  }
1621
- if (this.machineClient) {
1670
+ if (this.transportClient) {
1622
1671
  try {
1623
- yield this.machineClient.disconnect();
1672
+ yield this.transportClient.disconnect();
1624
1673
  } catch (error) {
1625
- console.error("[Reactor] Error disconnecting from GPU machine:", error);
1674
+ console.error("[Reactor] Error disconnecting transport:", error);
1626
1675
  }
1627
1676
  if (!recoverable) {
1628
- this.machineClient = void 0;
1677
+ this.transportClient = void 0;
1629
1678
  }
1630
1679
  }
1631
1680
  this.setStatus("disconnected");
@@ -1633,9 +1682,45 @@ var Reactor = class {
1633
1682
  if (!recoverable) {
1634
1683
  this.setSessionExpiration(void 0);
1635
1684
  this.setSessionId(void 0);
1685
+ this.capabilities = void 0;
1686
+ this.tracks = [];
1687
+ this.sessionResponse = void 0;
1636
1688
  }
1637
1689
  });
1638
1690
  }
1691
+ // ─────────────────────────────────────────────────────────────────────────
1692
+ // Getters
1693
+ // ─────────────────────────────────────────────────────────────────────────
1694
+ getSessionId() {
1695
+ return this.sessionId;
1696
+ }
1697
+ getStatus() {
1698
+ return this.status;
1699
+ }
1700
+ getState() {
1701
+ return {
1702
+ status: this.status,
1703
+ lastError: this.lastError
1704
+ };
1705
+ }
1706
+ getLastError() {
1707
+ return this.lastError;
1708
+ }
1709
+ getCapabilities() {
1710
+ return this.capabilities;
1711
+ }
1712
+ getSessionInfo() {
1713
+ return this.sessionResponse;
1714
+ }
1715
+ getStats() {
1716
+ var _a;
1717
+ const stats = (_a = this.transportClient) == null ? void 0 : _a.getStats();
1718
+ if (!stats) return void 0;
1719
+ return __spreadProps(__spreadValues({}, stats), { connectionTimings: this.connectionTimings });
1720
+ }
1721
+ // ─────────────────────────────────────────────────────────────────────────
1722
+ // Private State Management
1723
+ // ─────────────────────────────────────────────────────────────────────────
1639
1724
  setSessionId(newSessionId) {
1640
1725
  console.debug(
1641
1726
  "[Reactor] Setting session ID:",
@@ -1648,9 +1733,6 @@ var Reactor = class {
1648
1733
  this.emit("sessionIdChanged", newSessionId);
1649
1734
  }
1650
1735
  }
1651
- getSessionId() {
1652
- return this.sessionId;
1653
- }
1654
1736
  setStatus(newStatus) {
1655
1737
  console.debug("[Reactor] Setting status:", newStatus, "from", this.status);
1656
1738
  if (this.status !== newStatus) {
@@ -1658,13 +1740,6 @@ var Reactor = class {
1658
1740
  this.emit("statusChanged", newStatus);
1659
1741
  }
1660
1742
  }
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
1743
  setSessionExpiration(newSessionExpiration) {
1669
1744
  console.debug(
1670
1745
  "[Reactor] Setting session expiration:",
@@ -1675,44 +1750,16 @@ var Reactor = class {
1675
1750
  this.emit("sessionExpirationChanged", newSessionExpiration);
1676
1751
  }
1677
1752
  }
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
1753
  resetConnectionTimings() {
1700
1754
  this.connectStartTime = void 0;
1701
1755
  this.connectionTimings = void 0;
1702
1756
  }
1703
- finalizeConnectionTimings(client) {
1704
- var _a, _b;
1757
+ finalizeConnectionTimings() {
1705
1758
  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
1759
  this.connectionTimings.totalMs = performance.now() - this.connectStartTime;
1710
1760
  this.connectStartTime = void 0;
1711
1761
  console.debug("[Reactor] Connection timings:", this.connectionTimings);
1712
1762
  }
1713
- /**
1714
- * Create and store an error
1715
- */
1716
1763
  createError(code, message, component, recoverable, retryAfter) {
1717
1764
  this.lastError = {
1718
1765
  code,
@@ -1916,7 +1963,7 @@ function ReactorProvider(_a) {
1916
1963
  console.debug("[ReactorProvider] Reactor store created successfully");
1917
1964
  }
1918
1965
  const _a2 = connectOptions != null ? connectOptions : {}, { autoConnect = false } = _a2, pollingOptions = __objRest(_a2, ["autoConnect"]);
1919
- const { apiUrl, modelName, local, receive, send } = props;
1966
+ const { apiUrl, modelName, local } = props;
1920
1967
  const maxAttempts = pollingOptions.maxAttempts;
1921
1968
  useEffect(() => {
1922
1969
  const handleBeforeUnload = () => {
@@ -1972,8 +2019,6 @@ function ReactorProvider(_a) {
1972
2019
  apiUrl,
1973
2020
  modelName,
1974
2021
  local,
1975
- receive,
1976
- send,
1977
2022
  jwtToken
1978
2023
  })
1979
2024
  );
@@ -2000,16 +2045,7 @@ function ReactorProvider(_a) {
2000
2045
  console.error("[ReactorProvider] Failed to disconnect:", error);
2001
2046
  });
2002
2047
  };
2003
- }, [
2004
- apiUrl,
2005
- modelName,
2006
- autoConnect,
2007
- local,
2008
- receive,
2009
- send,
2010
- jwtToken,
2011
- maxAttempts
2012
- ]);
2048
+ }, [apiUrl, modelName, autoConnect, local, jwtToken, maxAttempts]);
2013
2049
  return /* @__PURE__ */ jsx(ReactorContext.Provider, { value: storeRef.current, children });
2014
2050
  }
2015
2051
  function useReactorStore(selector) {
@@ -2223,31 +2259,37 @@ function ReactorController({
2223
2259
  return () => clearInterval(interval);
2224
2260
  }, [status, commands, requestCapabilities]);
2225
2261
  useReactorInternalMessage((message) => {
2262
+ var _a;
2226
2263
  if (message && typeof message === "object" && message.type === "modelCapabilities" && message.data && "commands" in message.data) {
2227
2264
  const commandsMessage = message.data;
2228
- setCommands(commandsMessage.commands);
2265
+ const commandsRecord = {};
2266
+ for (const cmd of commandsMessage.commands) {
2267
+ commandsRecord[cmd.name] = {
2268
+ description: cmd.description,
2269
+ schema: (_a = cmd.schema) != null ? _a : {}
2270
+ };
2271
+ }
2272
+ setCommands(commandsRecord);
2229
2273
  const initialValues = {};
2230
2274
  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
- }
2275
+ Object.entries(commandsRecord).forEach(([commandName, commandSchema]) => {
2276
+ initialValues[commandName] = {};
2277
+ initialExpanded[commandName] = false;
2278
+ Object.entries(commandSchema.schema).forEach(
2279
+ ([paramName, paramSchema]) => {
2280
+ var _a2, _b;
2281
+ if (paramSchema.type === "number") {
2282
+ initialValues[commandName][paramName] = (_a2 = paramSchema.minimum) != null ? _a2 : 0;
2283
+ } else if (paramSchema.type === "string") {
2284
+ initialValues[commandName][paramName] = "";
2285
+ } else if (paramSchema.type === "boolean") {
2286
+ initialValues[commandName][paramName] = false;
2287
+ } else if (paramSchema.type === "integer") {
2288
+ initialValues[commandName][paramName] = (_b = paramSchema.minimum) != null ? _b : 0;
2247
2289
  }
2248
- );
2249
- }
2250
- );
2290
+ }
2291
+ );
2292
+ });
2251
2293
  setFormValues(initialValues);
2252
2294
  setExpandedCommands(initialExpanded);
2253
2295
  }
@@ -2828,27 +2870,6 @@ function WebcamStream({
2828
2870
  }
2829
2871
  );
2830
2872
  }
2831
-
2832
- // src/utils/tokens.ts
2833
- function fetchInsecureToken(_0) {
2834
- return __async(this, arguments, function* (apiKey, apiUrl = DEFAULT_BASE_URL) {
2835
- console.warn(
2836
- "[Reactor] \u26A0\uFE0F SECURITY WARNING: fetchInsecureToken() exposes your API key in client-side code. This should ONLY be used for local development or testing. In production, fetch tokens from your server instead."
2837
- );
2838
- const response = yield fetch(`${apiUrl}/tokens`, {
2839
- method: "GET",
2840
- headers: {
2841
- "X-API-Key": apiKey
2842
- }
2843
- });
2844
- if (!response.ok) {
2845
- const error = yield response.text();
2846
- throw new Error(`Failed to create token: ${response.status} ${error}`);
2847
- }
2848
- const { jwt } = yield response.json();
2849
- return jwt;
2850
- });
2851
- }
2852
2873
  export {
2853
2874
  AbortError,
2854
2875
  ConflictError,
@@ -2858,14 +2879,11 @@ export {
2858
2879
  ReactorProvider,
2859
2880
  ReactorView,
2860
2881
  WebcamStream,
2861
- audio,
2862
- fetchInsecureToken,
2863
2882
  isAbortError,
2864
2883
  useReactor,
2865
2884
  useReactorInternalMessage,
2866
2885
  useReactorMessage,
2867
2886
  useReactorStore,
2868
- useStats,
2869
- video
2887
+ useStats
2870
2888
  };
2871
2889
  //# sourceMappingURL=index.mjs.map