@reactor-team/js-sdk 2.6.0 → 2.8.0

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