@reactor-team/js-sdk 2.6.0 → 2.7.0

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