@relaymessenger/livekit 0.1.0-staging.10 → 0.1.0-staging.12

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
@@ -1,3 +1,5 @@
1
+ import { RemoteVideoTrack, } from "./video.js";
2
+ export { LocalVideoTrack, RemoteVideoTrack, VideoBufferType, VideoCodec, VideoFrame, VideoRotation, VideoSource, VideoStream, } from "./video.js";
1
3
  /** Opus's native rate and Relay's wire channel count. */
2
4
  export const DEFAULT_INBOUND_AUDIO = Object.freeze({
3
5
  sampleRate: 48_000,
@@ -41,6 +43,11 @@ export class RelayCallTransportError extends Error {
41
43
  }
42
44
  }
43
45
  const DEFAULT_PEER_CONFIG = { iceServers: [], iceTransportPolicy: "all" };
46
+ /**
47
+ * Cloudflare Realtime's echo example builds its peer with exactly this list
48
+ * (cloudflare/realtime-examples echo/index.html `createPeerConnection`).
49
+ */
50
+ const DEFAULT_ICE_SERVERS = [{ urls: "stun:stun.cloudflare.com:3478" }];
44
51
  const loadWebRTCFactory = async (engine) => {
45
52
  if (engine === "werift") {
46
53
  const { createWeriftWebRTCFactory } = await import("./engine-werift.js");
@@ -68,7 +75,8 @@ const seconds = (milliseconds) => `${(milliseconds / 1000).toFixed(1)}s`;
68
75
  const summarizeIce = (diagnostics) => {
69
76
  const { local, remote, transitions, connected } = diagnostics;
70
77
  const localPart = `local: host ${local.host}, srflx ${local.srflx}, relay ${local.relay}`
71
- + (local.other ? `, other ${local.other}` : "");
78
+ + (local.other ? `, other ${local.other}` : "")
79
+ + (diagnostics.selectedPair ? `, pair ${diagnostics.selectedPair}` : "");
72
80
  const remotePart = remote.length
73
81
  ? `remote: ${remote.map((candidate) => `${candidate.transport} ${candidate.port}`).join(", ")}`
74
82
  : "remote: none";
@@ -215,6 +223,7 @@ export class RelayCallTransport {
215
223
  #stallWarned = false;
216
224
  #iceLocal = { host: 0, srflx: 0, relay: 0, other: 0 };
217
225
  #iceRemote = [];
226
+ #selectedPair;
218
227
  #iceTransitions = [];
219
228
  #listeners = new Map();
220
229
  #factory;
@@ -225,7 +234,11 @@ export class RelayCallTransport {
225
234
  #peerConnected = false;
226
235
  #audioSource;
227
236
  #localTrack;
237
+ /** Set while an offer waits for its first local candidate. */
238
+ #firstLocalCandidate;
228
239
  #remoteSink;
240
+ /** The track `#remoteSink` decodes; a repeated `ontrack` for it keeps the sink. */
241
+ #remoteSinkTrack;
229
242
  #publishTransceiver;
230
243
  #publishFrame;
231
244
  #initialAnswerSdp;
@@ -254,6 +267,17 @@ export class RelayCallTransport {
254
267
  #ready;
255
268
  #handlersAttached = false;
256
269
  #closed = false;
270
+ #muted = false;
271
+ /** The published camera: one per call, kept across restarts (PROTOCOL.md section 1). */
272
+ #video;
273
+ #videoTransceiver;
274
+ #remoteVideoTrack;
275
+ /** The engine track the current video receiver reads. */
276
+ #remoteVideoEngineTrack;
277
+ /** An add-track offer is out; a pull offer that crosses it waits for its answer. */
278
+ #addTrackPending = false;
279
+ #deferredOffer;
280
+ #lastAnswerSdp;
257
281
  constructor(options) {
258
282
  if (!options.callId.trim())
259
283
  throw new Error("callId is required.");
@@ -289,7 +313,7 @@ export class RelayCallTransport {
289
313
  throw new Error('iceTransportPolicy must be "all" or "relay".');
290
314
  }
291
315
  this.#iceTransportPolicy = policy;
292
- const iceServers = options.iceServers ?? [];
316
+ const iceServers = options.iceServers ?? DEFAULT_ICE_SERVERS;
293
317
  this.#iceServers = typeof iceServers === "function" ? iceServers : copyIceServers(iceServers);
294
318
  if (!Number.isFinite(this.#iceGatheringTimeoutMs) || this.#iceGatheringTimeoutMs <= 0) {
295
319
  throw new Error("iceGatheringTimeoutMs must be greater than zero.");
@@ -383,6 +407,7 @@ export class RelayCallTransport {
383
407
  local: { ...this.#iceLocal },
384
408
  remote: this.#iceRemote.map((candidate) => ({ ...candidate })),
385
409
  transitions: this.#iceTransitions.map((transition) => ({ ...transition })),
410
+ selectedPair: this.#selectedPair,
386
411
  connected: this.#peerConnected,
387
412
  inbound: this.#inboundDiagnostics(),
388
413
  outbound: this.#outboundDiagnostics(),
@@ -566,8 +591,83 @@ export class RelayCallTransport {
566
591
  waiter.reject(error);
567
592
  }
568
593
  setMuted(muted) {
594
+ this.#muted = muted;
569
595
  this.#room.userUpdate({ muted });
570
596
  }
597
+ /**
598
+ * Publish the camera (LiveKit `LocalParticipant.publishTrack`). The first
599
+ * call adds a `video` track to the SFU session with an add-track offer, then
600
+ * announces `userUpdate { video: true }`; later calls, after
601
+ * `unpublishTrack`, only resume sending and announce it again
602
+ * (PROTOCOL.md sections 1-2). Requires `connect()` and the werift engine.
603
+ */
604
+ async publishTrack(track, options = {}) {
605
+ if (this.#closed || this.#ended)
606
+ throw new Error("Relay Call transport is closed.");
607
+ if (!this.#factory || !this.#reportedConnected)
608
+ throw new Error("Relay Call transport is not connected.");
609
+ if (this.#video) {
610
+ if (this.#video.track !== track)
611
+ throw new RelayCallTransportError("A Relay call publishes one video track.");
612
+ this.#video.sender.setEnabled(true);
613
+ this.#room.userUpdate({ muted: this.#muted, video: true });
614
+ return;
615
+ }
616
+ const factory = this.#factory;
617
+ if (!factory.createVideoSender) {
618
+ throw new RelayCallTransportError('This engine cannot send video; use the "werift" engine.');
619
+ }
620
+ const sender = await factory.createVideoSender(options);
621
+ if (this.#closed || this.#ended) {
622
+ sender.close();
623
+ throw new Error("Relay Call transport is closed.");
624
+ }
625
+ const detach = track.source._attach((frame, timestampUs, rotation) => sender.capture(frame, timestampUs, rotation));
626
+ this.#video = { track, sender, detach };
627
+ await this.#negotiate(async () => {
628
+ // No peer: a restart is pending, and the next peer publishes video with audio.
629
+ const peer = this.#peer;
630
+ if (!peer)
631
+ return;
632
+ this.#addVideoTransceiver(peer);
633
+ await this.#publishLocalAudio(peer, false);
634
+ this.#addTrackPending = this.#peer === peer;
635
+ });
636
+ this.#room.userUpdate({ muted: this.#muted, video: true });
637
+ }
638
+ /**
639
+ * Stop sending the camera and announce `userUpdate { video: false }`. The
640
+ * track stays negotiated on the SFU (PROTOCOL.md section 1), so a later
641
+ * `publishTrack` with the same track resumes it without renegotiating.
642
+ */
643
+ async unpublishTrack(track) {
644
+ if (!this.#video || this.#video.track !== track)
645
+ return;
646
+ this.#video.sender.setEnabled(false);
647
+ if (!this.#closed && !this.#ended)
648
+ this.#room.userUpdate({ muted: this.#muted, video: false });
649
+ }
650
+ /** The person's video track, once it has reached this peer. */
651
+ get remoteVideoTrack() {
652
+ return this.#remoteVideoTrack;
653
+ }
654
+ /** Frame, packet and keyframe counters for both video directions. */
655
+ videoStats() {
656
+ return { outbound: this.#video?.sender.stats(), inbound: this.#remoteVideoTrack?.stats() };
657
+ }
658
+ /** Runs `work` in the negotiation queue and hands its result or error to the caller. */
659
+ #negotiate(work) {
660
+ const run = this.#negotiationTail.then(work);
661
+ this.#negotiationTail = run.then(() => undefined, () => undefined);
662
+ return run;
663
+ }
664
+ #addVideoTransceiver(peer) {
665
+ const video = this.#video;
666
+ if (!video)
667
+ return;
668
+ this.#videoTransceiver = peer.addTransceiver(video.sender.track, { direction: "sendonly" });
669
+ video.sender.bind(this.#videoTransceiver, peer);
670
+ }
571
671
  end() {
572
672
  this.#room.end();
573
673
  }
@@ -604,6 +704,7 @@ export class RelayCallTransport {
604
704
  this.#peerConnected = false;
605
705
  this.#initialAnswerSdp = undefined;
606
706
  this.#publishTransceiver = peer.addTransceiver(track, { direction: "sendonly" });
707
+ this.#addVideoTransceiver(peer);
607
708
  this.#observeIce(peer);
608
709
  peer.onconnectionstatechange = () => {
609
710
  if (this.#peer !== peer)
@@ -613,24 +714,36 @@ export class RelayCallTransport {
613
714
  };
614
715
  peer.ontrack = (event) => {
615
716
  if (this.#peer === peer)
616
- this.#remoteTrack(event.track);
717
+ this.#remoteTrack(event.track, event.transceiver);
617
718
  };
618
719
  await this.#publishLocalAudio(peer, restarts > 0);
619
720
  }
721
+ /**
722
+ * Sends the offer without waiting for ICE gathering, as Cloudflare's echo
723
+ * example does (`setLocalDescription(offer)`, then `tracks/new` at once): the
724
+ * SFU is ICE-lite and learns this peer's address from its connectivity
725
+ * checks. werift's `setLocalDescription` resolves only after every host,
726
+ * STUN and TURN candidate has settled (peerConnection.js `await
727
+ * this.gatherCandidates()`; ice.js `await Promise.allSettled(candidatePromises)`),
728
+ * which took 6.6 s on a staging call on 2026-09-22, so the offer leaves as
729
+ * soon as the description is applied and the first local candidate exists.
730
+ */
620
731
  async #publishLocalAudio(peer, restart) {
621
732
  const offer = await peer.createOffer();
622
- await peer.setLocalDescription(offer);
623
- await this.#waitForIceGathering(peer);
733
+ await this.#applyLocalOffer(peer, offer);
624
734
  if (this.#peer !== peer)
625
735
  return;
626
736
  const description = this.#localDescription(peer, "offer");
627
737
  const mid = this.#publishTransceiver?.mid;
628
738
  if (!mid)
629
739
  throw new RelayCallTransportError("Relay audio publication has no WebRTC MID.");
740
+ const videoMid = this.#video ? this.#videoTransceiver?.mid : undefined;
741
+ if (this.#video && !videoMid)
742
+ throw new RelayCallTransportError("Relay video publication has no WebRTC MID.");
630
743
  this.#publishFrame = {
631
744
  type: "offer",
632
745
  session_description: description,
633
- tracks: [{ mid, name: "audio" }],
746
+ tracks: videoMid ? [{ mid, name: "audio" }, { mid: videoMid, name: "video" }] : [{ mid, name: "audio" }],
634
747
  ...(restart ? { restart: true } : {}),
635
748
  };
636
749
  this.#room.send(this.#publishFrame);
@@ -702,7 +815,7 @@ export class RelayCallTransport {
702
815
  // Reconnecting the signaling socket replays the exact initial offer. Relay
703
816
  // returns its cached answer; applying that answer again in stable state is
704
817
  // invalid WebRTC signaling, so recognize and ignore the replay.
705
- if (peer.signalingState === "stable" && this.#initialAnswerSdp === sdp)
818
+ if (peer.signalingState === "stable" && (this.#initialAnswerSdp === sdp || this.#lastAnswerSdp === sdp))
706
819
  return;
707
820
  const initial = this.#initialAnswerSdp === undefined;
708
821
  if (initial)
@@ -711,22 +824,35 @@ export class RelayCallTransport {
711
824
  if (this.#peer !== peer)
712
825
  return;
713
826
  this.#initialAnswerSdp ??= sdp;
827
+ this.#lastAnswerSdp = sdp;
714
828
  if (initial && !this.#peerConnected)
715
829
  this.#armConnectTimer(peer);
830
+ if (this.#addTrackPending) {
831
+ this.#addTrackPending = false;
832
+ const deferred = this.#deferredOffer;
833
+ this.#deferredOffer = undefined;
834
+ if (deferred)
835
+ await this.#serverOffer(deferred);
836
+ }
716
837
  }
717
838
  async #serverOffer(frame) {
718
839
  const peer = this.#peer;
840
+ // A pull offer that crosses this participant's add-track offer is for the
841
+ // live session: it is answered once the add-track answer is applied.
842
+ if (peer && this.#addTrackPending) {
843
+ this.#deferredOffer = frame;
844
+ return;
845
+ }
719
846
  // A pull offer that arrives while this participant's restart offer is
720
847
  // unanswered was sent for the replaced session: the room clears those
721
848
  // pulls on restart and pulls again after the new session connects.
722
849
  if (!peer || peer.signalingState === "have-local-offer")
723
850
  return;
724
- // `video` m-lines are answered receive-only by the engine and never decoded
725
- // (`#remoteTrack` takes audio only).
851
+ // `video` m-lines are answered receive-only; `#remoteTrack` decodes them
852
+ // when the engine has video.
726
853
  await peer.setRemoteDescription(frame.session_description);
727
854
  const answer = await peer.createAnswer();
728
855
  await peer.setLocalDescription(answer);
729
- await this.#waitForIceGathering(peer);
730
856
  if (this.#peer !== peer)
731
857
  return;
732
858
  this.#room.send({ type: "answer", session_description: this.#localDescription(peer, "answer") });
@@ -746,6 +872,7 @@ export class RelayCallTransport {
746
872
  return;
747
873
  this.#peerConnected = true;
748
874
  this.#failedAttempts = 0;
875
+ void this.#readSelectedPair(peer);
749
876
  // The SFU will not pull a track that has carried no RTP, so the source
750
877
  // sends silence from here on until it is closed.
751
878
  this.#audioSource?.start?.();
@@ -860,11 +987,18 @@ export class RelayCallTransport {
860
987
  this.#peer = undefined;
861
988
  this.#peerConnected = false;
862
989
  this.#publishTransceiver = undefined;
990
+ this.#videoTransceiver = undefined;
863
991
  this.#initialAnswerSdp = undefined;
992
+ this.#lastAnswerSdp = undefined;
993
+ this.#addTrackPending = false;
994
+ this.#deferredOffer = undefined;
995
+ this.#remoteVideoTrack?._detach();
996
+ this.#remoteVideoEngineTrack = undefined;
864
997
  if (this.#remoteSink) {
865
998
  this.#retiredSinkStats = this.#sinkStats();
866
999
  this.#remoteSink.stop();
867
1000
  this.#remoteSink = undefined;
1001
+ this.#remoteSinkTrack = undefined;
868
1002
  }
869
1003
  if (!peer)
870
1004
  return;
@@ -875,12 +1009,29 @@ export class RelayCallTransport {
875
1009
  peer.oniceconnectionstatechange = null;
876
1010
  closePeer(peer);
877
1011
  }
878
- #remoteTrack(track) {
1012
+ /**
1013
+ * werift fires `ontrack` again for every sending m-line on each
1014
+ * `setRemoteDescription` (transceiverManager.js `setRemoteRTP`), so a pull
1015
+ * offer that adds the person's video re-announces the audio track already
1016
+ * decoded. That track keeps its sink, decoder and packet counts; a different
1017
+ * audio track retires the old sink's counts into the call totals, as a restart does.
1018
+ */
1019
+ #remoteTrack(track, transceiver) {
1020
+ if (track.kind === "video") {
1021
+ this.#subscribeRemoteVideo(track, transceiver);
1022
+ return;
1023
+ }
879
1024
  if (track.kind !== "audio" || !this.#factory)
880
1025
  return;
881
- this.#remoteSink?.stop();
1026
+ if (this.#remoteSink && this.#remoteSinkTrack === track)
1027
+ return;
1028
+ if (this.#remoteSink) {
1029
+ this.#retiredSinkStats = this.#sinkStats();
1030
+ this.#remoteSink.stop();
1031
+ }
882
1032
  const sink = this.#factory.createAudioSink(track, this.#inboundAudio);
883
1033
  this.#remoteSink = sink;
1034
+ this.#remoteSinkTrack = track;
884
1035
  sink.ondata = (data) => {
885
1036
  if (this.#closed || this.#remoteSink !== sink)
886
1037
  return;
@@ -901,6 +1052,27 @@ export class RelayCallTransport {
901
1052
  }
902
1053
  };
903
1054
  }
1055
+ /**
1056
+ * One `RemoteVideoTrack` per call; each new session's receiver is attached
1057
+ * to it. werift fires `ontrack` again for every sending m-line on each
1058
+ * `setRemoteDescription` (transceiverManager.js `setRemoteRTP`), so the
1059
+ * track already being decoded keeps its receiver.
1060
+ */
1061
+ #subscribeRemoteVideo(track, transceiver) {
1062
+ const factory = this.#factory;
1063
+ if (!factory?.createVideoReceiver || this.#closed)
1064
+ return;
1065
+ if (this.#remoteVideoEngineTrack === track)
1066
+ return;
1067
+ this.#remoteVideoEngineTrack = track;
1068
+ const receiver = factory.createVideoReceiver(track, transceiver);
1069
+ const existing = this.#remoteVideoTrack;
1070
+ const remote = existing ?? new RemoteVideoTrack();
1071
+ this.#remoteVideoTrack = remote;
1072
+ remote._attach(receiver);
1073
+ if (!existing)
1074
+ this.#emit("trackSubscribed", remote);
1075
+ }
904
1076
  #writeAudio(frame, generation) {
905
1077
  const source = this.#audioSource;
906
1078
  if (!source || generation !== this.#audioGeneration)
@@ -927,28 +1099,47 @@ export class RelayCallTransport {
927
1099
  for (const release of [...this.#playoutWaiters])
928
1100
  release();
929
1101
  }
930
- async #waitForIceGathering(peer) {
931
- if (peer.iceGatheringState === "complete")
932
- return;
933
- await new Promise((resolve, reject) => {
934
- const timeout = setTimeout(() => {
935
- cleanup();
936
- reject(new RelayCallTransportError("Timed out gathering Relay WebRTC ICE candidates."));
937
- }, this.#iceGatheringTimeoutMs);
938
- timeout.unref?.();
939
- const changed = () => {
940
- if (peer.iceGatheringState !== "complete")
941
- return;
942
- cleanup();
943
- resolve();
944
- };
945
- const cleanup = () => {
946
- clearTimeout(timeout);
947
- peer.removeEventListener("icegatheringstatechange", changed);
948
- };
949
- peer.addEventListener("icegatheringstatechange", changed);
950
- changed();
1102
+ /**
1103
+ * Resolves once the offer is applied and has a first local candidate, or
1104
+ * once `setLocalDescription` resolves (W3C engines resolve before gathering),
1105
+ * whichever comes first.
1106
+ */
1107
+ async #applyLocalOffer(peer, offer) {
1108
+ const applied = peer.setLocalDescription(offer);
1109
+ let settled = false;
1110
+ applied.catch((error) => {
1111
+ if (!settled || this.#peer !== peer)
1112
+ return;
1113
+ const parsed = error instanceof Error ? error : new Error(String(error));
1114
+ this.#rejectReady(parsed);
1115
+ this.#emit("error", parsed);
951
1116
  });
1117
+ try {
1118
+ await new Promise((resolve, reject) => {
1119
+ const timeout = setTimeout(() => {
1120
+ cleanup();
1121
+ reject(new RelayCallTransportError("Timed out gathering Relay WebRTC ICE candidates."));
1122
+ }, this.#iceGatheringTimeoutMs);
1123
+ timeout.unref?.();
1124
+ const cleanup = () => {
1125
+ clearTimeout(timeout);
1126
+ if (this.#firstLocalCandidate === done)
1127
+ this.#firstLocalCandidate = undefined;
1128
+ };
1129
+ const done = () => {
1130
+ cleanup();
1131
+ resolve();
1132
+ };
1133
+ this.#firstLocalCandidate = done;
1134
+ applied.then(done, (error) => {
1135
+ cleanup();
1136
+ reject(error);
1137
+ });
1138
+ });
1139
+ }
1140
+ finally {
1141
+ settled = true;
1142
+ }
952
1143
  }
953
1144
  /**
954
1145
  * werift emits these as W3C-style handler calls (peerConnection.js:314-341:
@@ -965,6 +1156,8 @@ export class RelayCallTransport {
965
1156
  this.#iceLocal[type] += 1;
966
1157
  else
967
1158
  this.#iceLocal.other += 1;
1159
+ if (this.#peer === peer && peer.localDescription)
1160
+ this.#firstLocalCandidate?.();
968
1161
  };
969
1162
  peer.onicegatheringstatechange = () => this.#recordTransition("gathering", peer.iceGatheringState);
970
1163
  peer.oniceconnectionstatechange = () => {
@@ -972,6 +1165,35 @@ export class RelayCallTransport {
972
1165
  this.#recordTransition("ice", peer.iceConnectionState);
973
1166
  };
974
1167
  }
1168
+ /**
1169
+ * W3C stats path: `transport.selectedCandidatePairId` -> `candidate-pair`
1170
+ * -> `local-candidate.candidateType` (werift transport/dtls.js and
1171
+ * transport/ice.js `getStats`); a nominated, succeeded pair when the
1172
+ * transport names none.
1173
+ */
1174
+ async #readSelectedPair(peer) {
1175
+ let report;
1176
+ try {
1177
+ report = await peer.getStats?.();
1178
+ }
1179
+ catch {
1180
+ return;
1181
+ }
1182
+ if (!report || this.#peer !== peer)
1183
+ return;
1184
+ const stats = new Map();
1185
+ report.forEach((stat) => stats.set(String(stat.id), stat));
1186
+ const all = [...stats.values()];
1187
+ const selectedId = all.find((stat) => stat.type === "transport" && stat.selectedCandidatePairId)
1188
+ ?.selectedCandidatePairId;
1189
+ const pair = selectedId !== undefined
1190
+ ? stats.get(String(selectedId))
1191
+ : all.find((stat) => stat.type === "candidate-pair" && stat.nominated === true && stat.state === "succeeded");
1192
+ const local = pair ? stats.get(String(pair.localCandidateId)) : undefined;
1193
+ if (typeof local?.candidateType !== "string")
1194
+ return;
1195
+ this.#selectedPair = `${local.candidateType} ${typeof local.protocol === "string" ? local.protocol : "?"}`;
1196
+ }
975
1197
  #recordTransition(kind, state) {
976
1198
  this.#iceTransitions.push({ kind, state, atMs: Date.now() - this.#connectStartedAt });
977
1199
  }
@@ -1012,8 +1234,18 @@ export class RelayCallTransport {
1012
1234
  this.#finalSourceStats ??= this.#audioSource?.stats?.();
1013
1235
  this.#remoteSink?.stop();
1014
1236
  this.#remoteSink = undefined;
1237
+ this.#remoteSinkTrack = undefined;
1015
1238
  this.#localTrack?.stop();
1016
1239
  this.#localTrack = undefined;
1240
+ if (this.#video) {
1241
+ this.#video.detach();
1242
+ this.#video.sender.close();
1243
+ }
1244
+ const remoteVideo = this.#remoteVideoTrack;
1245
+ if (remoteVideo) {
1246
+ remoteVideo._end();
1247
+ this.#emit("trackUnsubscribed", remoteVideo);
1248
+ }
1017
1249
  if (this.#peer)
1018
1250
  closePeer(this.#peer);
1019
1251
  this.#peer = undefined;