@reactor-team/js-sdk 2.5.1 → 2.7.0

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