@relaymessenger/livekit 0.1.0-staging.5 → 0.1.0-staging.6

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/transport.js CHANGED
@@ -5,10 +5,32 @@ export const DEFAULT_INBOUND_AUDIO = Object.freeze({
5
5
  });
6
6
  const INBOUND_SAMPLE_RATES = new Set([8_000, 12_000, 16_000, 24_000, 48_000]);
7
7
  const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 10_000;
8
- const DEFAULT_CONNECTION_TIMEOUT_MS = 15_000;
9
8
  const AUDIO_SLICE_MS = 10;
10
9
  const STALL_CHECK_MS = 500;
11
10
  const STALL_AFTER_MS = 2_000;
11
+ /**
12
+ * Restart rule, copied from PartyTracks and Cloudflare (PROTOCOL.md section 4):
13
+ * not `connected` 5 s after the SFU answer (Cloudflare's echo example waits
14
+ * 5000 ms; SFU operations block up to 5 s awaiting `connected`), `failed`, or
15
+ * `disconnected` for 7 s (PartyTracks.ts `timeoutSeconds = 7`). Backoff 250 ms
16
+ * x1.1 per attempt, capped at 10 s (PartyTracks `retryWithBackoff`
17
+ * `backoffFactor: 1.1`, rxjs-helpers.ts defaults). werift never reports
18
+ * `failed` on a session whose checks go unanswered (ice.js:983-986), so the
19
+ * 5 s timer is the trigger that fires in practice.
20
+ */
21
+ export const RESTART_CONNECT_TIMEOUT_MS = 5_000;
22
+ export const RESTART_DISCONNECTED_MS = 7_000;
23
+ export const RESTART_INITIAL_DELAY_MS = 250;
24
+ export const RESTART_BACKOFF_FACTOR = 1.1;
25
+ export const RESTART_MAX_DELAY_MS = 10_000;
26
+ /**
27
+ * Backoff before restart number `attempt` (1-based) counted since the last
28
+ * session that connected. PartyTracks passes `resetOnSuccess: true` to rxjs
29
+ * `retry` (rxjs-helpers.ts:18, :39); this transport reads "success" as a
30
+ * session reaching `connected`.
31
+ */
32
+ export const restartDelayMs = (attempt) => Math.min(RESTART_INITIAL_DELAY_MS * RESTART_BACKOFF_FACTOR ** (attempt - 1), RESTART_MAX_DELAY_MS);
33
+ const ACTIVE_CALL_STATUSES = new Set(["ringing", "in-progress"]);
12
34
  export class RelayCallTransportError extends Error {
13
35
  code;
14
36
  constructor(message, code) {
@@ -83,7 +105,13 @@ const summarizeRoom = (room) => {
83
105
  return `room: ${parts.join(", ")}`;
84
106
  };
85
107
  const summarize = (diagnostics) => `${summarizeIce(diagnostics)}; ${summarizeInbound(diagnostics.inbound)}; `
86
- + `${summarizeOutbound(diagnostics.outbound)}; ${summarizeRoom(diagnostics.room)}`;
108
+ + `${summarizeOutbound(diagnostics.outbound)}; ${summarizeRoom(diagnostics.room)}`
109
+ + (diagnostics.restarts ? `; restarts ${diagnostics.restarts}` : "");
110
+ const copyIceServers = (servers) => servers.map((server) => ({
111
+ urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
112
+ ...(server.username === undefined ? {} : { username: server.username }),
113
+ ...(server.credential === undefined ? {} : { credential: server.credential }),
114
+ }));
87
115
  const cloneSamples = (samples) => {
88
116
  const copy = new Int16Array(samples.length);
89
117
  copy.set(samples);
@@ -129,6 +157,13 @@ class WrtcAudioSource {
129
157
  this.#runAcceptedMs = 0;
130
158
  }
131
159
  }
160
+ /** werift's `close()` is async; a rejection there must not become an unhandled one. */
161
+ const closePeer = (peer) => {
162
+ try {
163
+ void Promise.resolve(peer.close()).catch(() => undefined);
164
+ }
165
+ catch { /* already closed */ }
166
+ };
132
167
  const delay = (milliseconds) => new Promise((resolve) => {
133
168
  const timer = setTimeout(resolve, milliseconds);
134
169
  timer.unref?.();
@@ -137,15 +172,19 @@ const delay = (milliseconds) => new Promise((resolve) => {
137
172
  * Provider-neutral Node WebRTC bridge for Relay Call rooms.
138
173
  *
139
174
  * The class owns Relay media negotiation internally. Higher-level adapters only
140
- * exchange PCM16 frames and call lifecycle events.
175
+ * exchange PCM16 frames and call lifecycle events. When an SFU session never
176
+ * connects or dies, the transport builds a new peer connection on a new
177
+ * session (PROTOCOL.md section 4); the audio source and the `audio` events
178
+ * carry on across the swap, so adapters only hear silence.
141
179
  */
142
180
  export class RelayCallTransport {
143
181
  #room;
144
182
  #providedFactory;
145
183
  #engine;
146
184
  #iceGatheringTimeoutMs;
147
- #connectionTimeoutMs;
148
- #peerConfig;
185
+ #sessionConnectTimeoutMs;
186
+ #iceServers;
187
+ #iceTransportPolicy;
149
188
  #inboundAudio;
150
189
  #onWarning;
151
190
  #connectStartedAt = 0;
@@ -158,6 +197,8 @@ export class RelayCallTransport {
158
197
  /** Engine stats frozen when media shuts down, so diagnostics survive the call's end. */
159
198
  #finalSinkStats;
160
199
  #finalSourceStats;
200
+ /** Inbound counts from sinks of peers already replaced by a restart. */
201
+ #retiredSinkStats;
161
202
  #stallTimer;
162
203
  #stallSince;
163
204
  #stallWarned = false;
@@ -167,6 +208,10 @@ export class RelayCallTransport {
167
208
  #listeners = new Map();
168
209
  #factory;
169
210
  #peer;
211
+ /** Bumped for every peer built; work started for an older peer stops when it sees a newer one. */
212
+ #peerGeneration = 0;
213
+ /** The current peer has reached `connected` since it was built. */
214
+ #peerConnected = false;
170
215
  #audioSource;
171
216
  #localTrack;
172
217
  #remoteSink;
@@ -174,6 +219,20 @@ export class RelayCallTransport {
174
219
  #publishFrame;
175
220
  #initialAnswerSdp;
176
221
  #negotiationTail = Promise.resolve();
222
+ #restarts = 0;
223
+ /** Restarts since the last `connected`; sets the backoff. */
224
+ #failedAttempts = 0;
225
+ #restartPending = false;
226
+ #wakeRestart;
227
+ #connectTimer;
228
+ #disconnectTimer;
229
+ #callStatus;
230
+ #remoteVideo = false;
231
+ #personConnected = false;
232
+ #peerAudioArrived = false;
233
+ #peerAudioReady = false;
234
+ #peerAudioWaiters = new Set();
235
+ #ended = false;
177
236
  #audioGeneration = 0;
178
237
  /** `waitForPlayout()` callers released early by `clearAudio()` or `close()`. */
179
238
  #playoutWaiters = new Set();
@@ -210,26 +269,22 @@ export class RelayCallTransport {
210
269
  }
211
270
  this.#inboundAudio = { sampleRate: inbound.sampleRate, channelCount: inbound.channelCount };
212
271
  this.#iceGatheringTimeoutMs = options.iceGatheringTimeoutMs ?? DEFAULT_ICE_GATHERING_TIMEOUT_MS;
213
- this.#connectionTimeoutMs = options.mediaConnectTimeoutMs
272
+ this.#sessionConnectTimeoutMs = options.sessionConnectTimeoutMs
273
+ ?? options.mediaConnectTimeoutMs
214
274
  ?? options.connectionTimeoutMs
215
- ?? DEFAULT_CONNECTION_TIMEOUT_MS;
275
+ ?? RESTART_CONNECT_TIMEOUT_MS;
216
276
  const policy = options.iceTransportPolicy ?? "all";
217
277
  if (policy !== "all" && policy !== "relay") {
218
278
  throw new Error('iceTransportPolicy must be "all" or "relay".');
219
279
  }
220
- this.#peerConfig = {
221
- iceServers: (options.iceServers ?? []).map((server) => ({
222
- urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
223
- ...(server.username === undefined ? {} : { username: server.username }),
224
- ...(server.credential === undefined ? {} : { credential: server.credential }),
225
- })),
226
- iceTransportPolicy: policy,
227
- };
280
+ this.#iceTransportPolicy = policy;
281
+ const iceServers = options.iceServers ?? [];
282
+ this.#iceServers = typeof iceServers === "function" ? iceServers : copyIceServers(iceServers);
228
283
  if (!Number.isFinite(this.#iceGatheringTimeoutMs) || this.#iceGatheringTimeoutMs <= 0) {
229
284
  throw new Error("iceGatheringTimeoutMs must be greater than zero.");
230
285
  }
231
- if (!Number.isFinite(this.#connectionTimeoutMs) || this.#connectionTimeoutMs <= 0) {
232
- throw new Error("mediaConnectTimeoutMs must be greater than zero.");
286
+ if (!Number.isFinite(this.#sessionConnectTimeoutMs) || this.#sessionConnectTimeoutMs <= 0) {
287
+ throw new Error("sessionConnectTimeoutMs must be greater than zero.");
233
288
  }
234
289
  this.#ready = new Promise((resolve, reject) => {
235
290
  this.#readyResolve = resolve;
@@ -250,13 +305,37 @@ export class RelayCallTransport {
250
305
  this.#listeners.get(event)?.delete(listener);
251
306
  return this;
252
307
  }
253
- async connect() {
308
+ /**
309
+ * Join the room, publish, and resolve on the first `connected`. Dead SFU
310
+ * sessions are replaced as they are found (PROTOCOL.md section 4), with no
311
+ * overall deadline: this rejects only when the Call ends, the room or
312
+ * transport closes, the room reports an error, or `signal` aborts (which
313
+ * also closes the transport).
314
+ */
315
+ async connect(options = {}) {
254
316
  if (this.#closed)
255
317
  throw new Error("Relay Call transport is closed.");
318
+ const { signal } = options;
319
+ const aborted = () => new RelayCallTransportError("Relay Call connect was aborted.", "aborted");
320
+ if (signal?.aborted)
321
+ throw aborted();
322
+ const abort = () => {
323
+ this.#rejectReady(aborted());
324
+ this.close();
325
+ };
326
+ signal?.addEventListener("abort", abort, { once: true });
327
+ try {
328
+ await this.#connect();
329
+ }
330
+ finally {
331
+ signal?.removeEventListener("abort", abort);
332
+ }
333
+ }
334
+ async #connect() {
256
335
  this.#attachRoomHandlers();
257
336
  await this.#room.connect();
258
- if (this.#peer) {
259
- await this.#waitForConnection();
337
+ if (this.#factory) {
338
+ await this.#ready;
260
339
  return;
261
340
  }
262
341
  this.#factory = this.#providedFactory ?? await loadWebRTCFactory(this.#engine);
@@ -266,18 +345,9 @@ export class RelayCallTransport {
266
345
  throw new RelayCallTransportError("The WebRTC binding created a non-audio Relay track.");
267
346
  }
268
347
  this.#connectStartedAt = Date.now();
269
- const peer = this.#factory.createPeerConnection(this.#peerConfig);
270
- this.#peer = peer;
271
- this.#publishTransceiver = peer.addTransceiver(this.#localTrack, { direction: "sendonly" });
272
- this.#observeIce(peer);
273
- peer.onconnectionstatechange = () => {
274
- this.#recordTransition("connection", peer.connectionState);
275
- this.#connectionStateChanged();
276
- };
277
- peer.ontrack = (event) => this.#remoteTrack(event.track);
278
348
  try {
279
- await this.#publishLocalAudio();
280
- await this.#waitForConnection();
349
+ await this.#startPeer();
350
+ await this.#ready;
281
351
  }
282
352
  catch (error) {
283
353
  this.close();
@@ -294,15 +364,15 @@ export class RelayCallTransport {
294
364
  this.#room.send(this.#publishFrame);
295
365
  }
296
366
  /**
297
- * ICE candidates, state transitions, packet counts in both directions and
298
- * room frame counts recorded for this call, with a one-line summary.
367
+ * ICE candidates, state transitions, packet counts in both directions,
368
+ * room frame counts and restarts recorded for this call, with a one-line summary.
299
369
  */
300
370
  diagnostics() {
301
371
  const snapshot = {
302
372
  local: { ...this.#iceLocal },
303
373
  remote: this.#iceRemote.map((candidate) => ({ ...candidate })),
304
374
  transitions: this.#iceTransitions.map((transition) => ({ ...transition })),
305
- connected: this.#reportedConnected,
375
+ connected: this.#peerConnected,
306
376
  inbound: this.#inboundDiagnostics(),
307
377
  outbound: this.#outboundDiagnostics(),
308
378
  room: {
@@ -311,14 +381,31 @@ export class RelayCallTransport {
311
381
  endedReason: this.#endedReason,
312
382
  errors: [...this.#roomErrors],
313
383
  },
384
+ restarts: this.#restarts,
314
385
  };
315
386
  return { ...snapshot, summary: summarize(snapshot) };
316
387
  }
317
388
  #sinceConnect(epochMs) {
318
389
  return epochMs === undefined ? undefined : epochMs - this.#connectStartedAt;
319
390
  }
391
+ /** The live sink's counts added to those of sinks retired by restarts. */
392
+ #sinkStats() {
393
+ const current = this.#remoteSink?.stats?.();
394
+ const retired = this.#retiredSinkStats;
395
+ if (!retired)
396
+ return current;
397
+ if (!current)
398
+ return { ...retired, recentRtpPackets: 0 };
399
+ return {
400
+ rtpPackets: retired.rtpPackets + current.rtpPackets,
401
+ decodeFailures: retired.decodeFailures + current.decodeFailures,
402
+ firstRtpAt: retired.firstRtpAt ?? current.firstRtpAt,
403
+ lastRtpAt: current.lastRtpAt ?? retired.lastRtpAt,
404
+ recentRtpPackets: current.recentRtpPackets,
405
+ };
406
+ }
320
407
  #inboundDiagnostics() {
321
- const stats = this.#remoteSink?.stats?.() ?? this.#finalSinkStats;
408
+ const stats = this.#finalSinkStats ?? this.#sinkStats();
322
409
  return {
323
410
  rtpPackets: stats?.rtpPackets ?? 0,
324
411
  decodeFailures: stats?.decodeFailures ?? 0,
@@ -431,6 +518,41 @@ export class RelayCallTransport {
431
518
  this.#audioSource?.clear?.();
432
519
  this.#releasePlayoutWaiters();
433
520
  }
521
+ /**
522
+ * Resolves once `peerAudio` has fired (at once if it already has). Rejects
523
+ * after `timeoutMs`, or when the Call ends or the transport closes first.
524
+ */
525
+ waitForPeerAudio(timeoutMs) {
526
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
527
+ return Promise.reject(new Error("waitForPeerAudio timeoutMs must be greater than zero."));
528
+ }
529
+ if (this.#peerAudioReady)
530
+ return Promise.resolve();
531
+ if (this.#closed || this.#ended) {
532
+ return Promise.reject(new RelayCallTransportError("Relay Call ended before the person's audio arrived."));
533
+ }
534
+ return new Promise((resolve, reject) => {
535
+ const waiter = {
536
+ resolve: () => { clearTimeout(timer); this.#peerAudioWaiters.delete(waiter); resolve(); },
537
+ reject: (error) => { clearTimeout(timer); this.#peerAudioWaiters.delete(waiter); reject(error); },
538
+ };
539
+ const timer = setTimeout(() => waiter.reject(new RelayCallTransportError(`Timed out waiting for the person's audio (${this.diagnostics().summary})`)), timeoutMs);
540
+ timer.unref?.();
541
+ this.#peerAudioWaiters.add(waiter);
542
+ });
543
+ }
544
+ #checkPeerAudio() {
545
+ if (this.#peerAudioReady || !this.#peerAudioArrived || !this.#personConnected)
546
+ return;
547
+ this.#peerAudioReady = true;
548
+ for (const waiter of [...this.#peerAudioWaiters])
549
+ waiter.resolve();
550
+ this.#emit("peerAudio");
551
+ }
552
+ #rejectPeerAudio(error) {
553
+ for (const waiter of [...this.#peerAudioWaiters])
554
+ waiter.reject(error);
555
+ }
434
556
  setMuted(muted) {
435
557
  this.#room.userUpdate({ muted });
436
558
  }
@@ -442,17 +564,54 @@ export class RelayCallTransport {
442
564
  return;
443
565
  this.#closed = true;
444
566
  this.#rejectReady(new RelayCallTransportError("Relay Call transport closed before media connected."));
567
+ this.#rejectPeerAudio(new RelayCallTransportError("Relay Call transport closed before the person's audio arrived."));
445
568
  this.#audioGeneration += 1;
446
569
  this.#shutdownMedia();
447
570
  this.#releasePlayoutWaiters();
448
571
  this.#room.close();
449
572
  }
450
- async #publishLocalAudio() {
451
- const peer = this.#requirePeer();
573
+ /**
574
+ * Build a peer connection around the one local track and publish it. The
575
+ * first peer sends a plain `offer`; every later one is a restart onto a new
576
+ * SFU session (`restart: true`) with ICE servers fetched again.
577
+ */
578
+ async #startPeer() {
579
+ const factory = this.#factory;
580
+ const track = this.#localTrack;
581
+ if (!factory || !track)
582
+ throw new Error("Relay Call transport is not connected.");
583
+ const restarts = this.#restarts;
584
+ const generation = ++this.#peerGeneration;
585
+ const iceServers = typeof this.#iceServers === "function"
586
+ ? copyIceServers(await this.#iceServers({ restarts }))
587
+ : copyIceServers(this.#iceServers);
588
+ if (this.#closed || this.#ended || generation !== this.#peerGeneration)
589
+ return;
590
+ const peer = factory.createPeerConnection({ iceServers, iceTransportPolicy: this.#iceTransportPolicy });
591
+ this.#peer = peer;
592
+ this.#peerConnected = false;
593
+ this.#initialAnswerSdp = undefined;
594
+ this.#publishTransceiver = peer.addTransceiver(track, { direction: "sendonly" });
595
+ this.#observeIce(peer);
596
+ peer.onconnectionstatechange = () => {
597
+ if (this.#peer !== peer)
598
+ return;
599
+ this.#recordTransition("connection", peer.connectionState);
600
+ this.#connectionStateChanged(peer);
601
+ };
602
+ peer.ontrack = (event) => {
603
+ if (this.#peer === peer)
604
+ this.#remoteTrack(event.track);
605
+ };
606
+ await this.#publishLocalAudio(peer, restarts > 0);
607
+ }
608
+ async #publishLocalAudio(peer, restart) {
452
609
  const offer = await peer.createOffer();
453
610
  await peer.setLocalDescription(offer);
454
611
  await this.#waitForIceGathering(peer);
455
- const description = this.#localDescription("offer");
612
+ if (this.#peer !== peer)
613
+ return;
614
+ const description = this.#localDescription(peer, "offer");
456
615
  const mid = this.#publishTransceiver?.mid;
457
616
  if (!mid)
458
617
  throw new RelayCallTransportError("Relay audio publication has no WebRTC MID.");
@@ -460,6 +619,7 @@ export class RelayCallTransport {
460
619
  type: "offer",
461
620
  session_description: description,
462
621
  tracks: [{ mid, name: "audio" }],
622
+ ...(restart ? { restart: true } : {}),
463
623
  };
464
624
  this.#room.send(this.#publishFrame);
465
625
  }
@@ -476,7 +636,18 @@ export class RelayCallTransport {
476
636
  });
477
637
  this.#room.on("roomState", (frame) => {
478
638
  this.#roomStates += 1;
639
+ this.#callStatus = frame.call?.status;
640
+ // A Call has exactly one agent; the transport is that agent, so the
641
+ // person is the other participant.
642
+ const person = frame.participants?.find((participant) => participant.kind === "user");
643
+ const video = person?.video === true;
644
+ const videoChanged = video !== this.#remoteVideo;
645
+ this.#remoteVideo = video;
646
+ this.#personConnected = person?.connected === true;
479
647
  this.#emit("roomState", frame);
648
+ if (videoChanged)
649
+ this.#emit("remoteVideo", video);
650
+ this.#checkPeerAudio();
480
651
  });
481
652
  this.#room.on("error", (error) => {
482
653
  if (error instanceof Error) {
@@ -487,8 +658,10 @@ export class RelayCallTransport {
487
658
  this.#serverError(error);
488
659
  });
489
660
  this.#room.on("ended", (frame) => {
661
+ this.#ended = true;
490
662
  this.#endedReason = frame.reason;
491
663
  this.#rejectReady(new RelayCallTransportError(`Relay Call ended before media connected (${frame.reason}).`));
664
+ this.#rejectPeerAudio(new RelayCallTransportError(`Relay Call ended before the person's audio arrived (${frame.reason}).`));
492
665
  this.clearAudio();
493
666
  this.#shutdownMedia();
494
667
  this.#emit("ended", frame);
@@ -509,25 +682,42 @@ export class RelayCallTransport {
509
682
  });
510
683
  }
511
684
  async #serverAnswer(frame) {
512
- const peer = this.#requirePeer();
685
+ // No peer: the answer is for a session a restart already replaced.
686
+ const peer = this.#peer;
687
+ if (!peer)
688
+ return;
513
689
  const sdp = frame.session_description.sdp;
514
690
  // Reconnecting the signaling socket replays the exact initial offer. Relay
515
691
  // returns its cached answer; applying that answer again in stable state is
516
692
  // invalid WebRTC signaling, so recognize and ignore the replay.
517
693
  if (peer.signalingState === "stable" && this.#initialAnswerSdp === sdp)
518
694
  return;
519
- if (this.#initialAnswerSdp === undefined)
695
+ const initial = this.#initialAnswerSdp === undefined;
696
+ if (initial)
520
697
  this.#recordRemoteCandidates(sdp);
521
698
  await peer.setRemoteDescription(frame.session_description);
699
+ if (this.#peer !== peer)
700
+ return;
522
701
  this.#initialAnswerSdp ??= sdp;
702
+ if (initial && !this.#peerConnected)
703
+ this.#armConnectTimer(peer);
523
704
  }
524
705
  async #serverOffer(frame) {
525
- const peer = this.#requirePeer();
706
+ const peer = this.#peer;
707
+ // A pull offer that arrives while this participant's restart offer is
708
+ // unanswered was sent for the replaced session: the room clears those
709
+ // pulls on restart and pulls again after the new session connects.
710
+ if (!peer || peer.signalingState === "have-local-offer")
711
+ return;
712
+ // `video` m-lines are answered receive-only by the engine and never decoded
713
+ // (`#remoteTrack` takes audio only).
526
714
  await peer.setRemoteDescription(frame.session_description);
527
715
  const answer = await peer.createAnswer();
528
716
  await peer.setLocalDescription(answer);
529
717
  await this.#waitForIceGathering(peer);
530
- this.#room.send({ type: "answer", session_description: this.#localDescription("answer") });
718
+ if (this.#peer !== peer)
719
+ return;
720
+ this.#room.send({ type: "answer", session_description: this.#localDescription(peer, "answer") });
531
721
  }
532
722
  #serverError(frame) {
533
723
  this.#roomErrors.push(frame.message);
@@ -535,15 +725,25 @@ export class RelayCallTransport {
535
725
  this.#rejectReady(error);
536
726
  this.#emit("error", error);
537
727
  }
538
- #connectionStateChanged() {
539
- const state = this.#peer?.connectionState;
540
- if (state === "connected" && !this.#reportedConnected) {
541
- this.#reportedConnected = true;
728
+ #connectionStateChanged(peer) {
729
+ const state = peer.connectionState;
730
+ if (state === "connected") {
731
+ this.#clearTimer("connect");
732
+ this.#clearTimer("disconnect");
733
+ if (this.#peerConnected)
734
+ return;
735
+ this.#peerConnected = true;
736
+ this.#failedAttempts = 0;
542
737
  try {
738
+ // Sent for every new session: the room re-pulls a restarted participant's
739
+ // tracks once it reports `connected` (PROTOCOL.md section 2).
543
740
  this.#room.connected();
544
- this.#resolveReady();
545
- this.#startStallGuard();
546
- this.#emit("connected");
741
+ if (!this.#reportedConnected) {
742
+ this.#reportedConnected = true;
743
+ this.#resolveReady();
744
+ this.#startStallGuard();
745
+ this.#emit("connected");
746
+ }
547
747
  }
548
748
  catch (error) {
549
749
  const parsed = error instanceof Error ? error : new Error(String(error));
@@ -552,10 +752,113 @@ export class RelayCallTransport {
552
752
  }
553
753
  }
554
754
  else if (state === "failed") {
555
- const error = new RelayCallTransportError("Relay WebRTC connection failed.");
556
- this.#rejectReady(error);
557
- this.#emit("error", error);
755
+ this.#requestRestart("failed", peer);
756
+ }
757
+ else if (state === "disconnected") {
758
+ if (this.#disconnectTimer)
759
+ return;
760
+ this.#disconnectTimer = setTimeout(() => {
761
+ this.#disconnectTimer = undefined;
762
+ if (peer.connectionState !== "connected")
763
+ this.#requestRestart("disconnected", peer);
764
+ }, RESTART_DISCONNECTED_MS);
765
+ this.#disconnectTimer.unref?.();
766
+ }
767
+ }
768
+ #armConnectTimer(peer) {
769
+ this.#clearTimer("connect");
770
+ this.#connectTimer = setTimeout(() => {
771
+ this.#connectTimer = undefined;
772
+ if (!this.#peerConnected)
773
+ this.#requestRestart("timeout", peer);
774
+ }, this.#sessionConnectTimeoutMs);
775
+ this.#connectTimer.unref?.();
776
+ }
777
+ #clearTimer(which) {
778
+ if (which === "connect") {
779
+ clearTimeout(this.#connectTimer);
780
+ this.#connectTimer = undefined;
558
781
  }
782
+ else {
783
+ clearTimeout(this.#disconnectTimer);
784
+ this.#disconnectTimer = undefined;
785
+ }
786
+ }
787
+ #callActive() {
788
+ if (this.#closed || this.#ended)
789
+ return false;
790
+ return this.#callStatus === undefined || ACTIVE_CALL_STATUSES.has(this.#callStatus);
791
+ }
792
+ /**
793
+ * Retire `peer` now, wait the backoff, then publish from a new peer on a new
794
+ * session. Unlimited while the Call is ringing or in progress; stops on
795
+ * `ended`, a terminal status, or `close()`.
796
+ */
797
+ #requestRestart(reason, peer) {
798
+ if (this.#restartPending || peer !== this.#peer || !this.#callActive())
799
+ return;
800
+ const summary = this.diagnostics().summary;
801
+ this.#restartPending = true;
802
+ this.#restarts += 1;
803
+ this.#failedAttempts += 1;
804
+ const restarts = this.#restarts;
805
+ const delayMs = restartDelayMs(this.#failedAttempts);
806
+ this.#retirePeer();
807
+ this.#queueNegotiation(async () => {
808
+ await this.#restartBackoff(delayMs);
809
+ this.#restartPending = false;
810
+ if (!this.#callActive())
811
+ return;
812
+ try {
813
+ await this.#startPeer();
814
+ }
815
+ catch (error) {
816
+ if (!this.#callActive())
817
+ return;
818
+ const parsed = error instanceof Error ? error : new Error(String(error));
819
+ this.#emit("error", new RelayCallTransportError(`Relay WebRTC restart ${restarts} failed: ${parsed.message}`, "restart_failed"));
820
+ this.#requestRestart("error", this.#peer);
821
+ return;
822
+ }
823
+ if (this.#peer)
824
+ this.#emit("restarted", { reason, summary, restarts, delayMs });
825
+ });
826
+ }
827
+ #restartBackoff(milliseconds) {
828
+ return new Promise((resolve) => {
829
+ const wake = () => {
830
+ clearTimeout(timer);
831
+ if (this.#wakeRestart === wake)
832
+ this.#wakeRestart = undefined;
833
+ resolve();
834
+ };
835
+ const timer = setTimeout(wake, milliseconds);
836
+ timer.unref?.();
837
+ this.#wakeRestart = wake;
838
+ });
839
+ }
840
+ /** Close the current peer and its sink; the audio source and local track stay for the next peer. */
841
+ #retirePeer() {
842
+ this.#clearTimer("connect");
843
+ this.#clearTimer("disconnect");
844
+ const peer = this.#peer;
845
+ this.#peer = undefined;
846
+ this.#peerConnected = false;
847
+ this.#publishTransceiver = undefined;
848
+ this.#initialAnswerSdp = undefined;
849
+ if (this.#remoteSink) {
850
+ this.#retiredSinkStats = this.#sinkStats();
851
+ this.#remoteSink.stop();
852
+ this.#remoteSink = undefined;
853
+ }
854
+ if (!peer)
855
+ return;
856
+ peer.onconnectionstatechange = null;
857
+ peer.ontrack = null;
858
+ peer.onicecandidate = null;
859
+ peer.onicegatheringstatechange = null;
860
+ peer.oniceconnectionstatechange = null;
861
+ closePeer(peer);
559
862
  }
560
863
  #remoteTrack(track) {
561
864
  if (track.kind !== "audio" || !this.#factory)
@@ -577,6 +880,10 @@ export class RelayCallTransport {
577
880
  sampleRate: data.sampleRate,
578
881
  channelCount,
579
882
  });
883
+ if (!this.#peerAudioArrived) {
884
+ this.#peerAudioArrived = true;
885
+ this.#checkPeerAudio();
886
+ }
580
887
  };
581
888
  }
582
889
  #writeAudio(frame, generation) {
@@ -628,23 +935,6 @@ export class RelayCallTransport {
628
935
  changed();
629
936
  });
630
937
  }
631
- async #waitForConnection() {
632
- if (this.#reportedConnected)
633
- return;
634
- await new Promise((resolve, reject) => {
635
- const timeout = setTimeout(() => {
636
- reject(new RelayCallTransportError(`Timed out connecting Relay WebRTC media (${this.diagnostics().summary})`));
637
- }, this.#connectionTimeoutMs);
638
- timeout.unref?.();
639
- this.#ready.then(() => {
640
- clearTimeout(timeout);
641
- resolve();
642
- }, (error) => {
643
- clearTimeout(timeout);
644
- reject(error);
645
- });
646
- });
647
- }
648
938
  /**
649
939
  * werift emits these as W3C-style handler calls (peerConnection.js:314-341:
650
940
  * `onicegatheringstatechange`, `oniceconnectionstatechange`,
@@ -691,27 +981,26 @@ export class RelayCallTransport {
691
981
  this.#readySettled = true;
692
982
  this.#readyReject?.(error);
693
983
  }
694
- #localDescription(type) {
695
- const local = this.#requirePeer().localDescription;
984
+ #localDescription(peer, type) {
985
+ const local = peer.localDescription;
696
986
  if (!local || local.type !== type || !local.sdp) {
697
987
  throw new RelayCallTransportError(`Relay WebRTC did not produce a complete ${type} SDP.`);
698
988
  }
699
989
  return { type, sdp: local.sdp };
700
990
  }
701
- #requirePeer() {
702
- if (!this.#peer)
703
- throw new Error("Relay Call transport is not connected.");
704
- return this.#peer;
705
- }
706
991
  #shutdownMedia() {
707
992
  this.#stopStallGuard();
708
- this.#finalSinkStats ??= this.#remoteSink?.stats?.();
993
+ this.#clearTimer("connect");
994
+ this.#clearTimer("disconnect");
995
+ this.#wakeRestart?.();
996
+ this.#finalSinkStats ??= this.#sinkStats();
709
997
  this.#finalSourceStats ??= this.#audioSource?.stats?.();
710
998
  this.#remoteSink?.stop();
711
999
  this.#remoteSink = undefined;
712
1000
  this.#localTrack?.stop();
713
1001
  this.#localTrack = undefined;
714
- this.#peer?.close();
1002
+ if (this.#peer)
1003
+ closePeer(this.#peer);
715
1004
  this.#peer = undefined;
716
1005
  this.#audioSource = undefined;
717
1006
  }