snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
import {
type IMediaCastReceiverOptions,
type IReceiverSession,
type MediaCastSignal,
type MediaCastSignalHandler,
isMediaCastSignal
} from './types.web';
/**
* The subset of {@link MediaCastSignal} that carries a new SDP offer.
*/
type OfferSignal = Extract<MediaCastSignal, { kind: 'offer'; }>;
/**
* Receives one media track over a plain RTCPeerConnection.
*
* The receiver owns the connection lifecycle while the caller owns the received track:
* the current track is surfaced through the onTrack handler and released on close. Session
* state is versioned by a generation counter so signals from superseded sessions are ignored.
*/
export default class MediaCastReceiver {
/**
* Whether the receiver has been permanently disposed and must ignore further signals.
*/
private _disposed = false;
/**
* The media track received by the current session, if any.
*/
private _currentTrack: MediaStreamTrack | null = null;
/**
* The generation of the most recently negotiated session.
*/
private _generation = 0;
/**
* Whether the sender is currently transmitting a null (muted) track.
*/
private _muted = false;
/**
* Handler invoked when an asynchronous receiving error occurs.
*/
private _onError: (error: unknown) => void;
/**
* Handler used to forward outgoing signals to the remote peer.
*/
private _onSignal: MediaCastSignalHandler;
/**
* Handler invoked whenever the received media track changes.
*/
private _onTrack: (track: MediaStreamTrack | null) => void;
/**
* Optional RTCPeerConnection configuration applied to new connections.
*/
private _pcConfig?: RTCConfiguration;
/**
* The live incoming session, if any.
*/
private _session?: IReceiverSession;
/**
* Promise chain enforcing transport ordering of incoming signals.
*/
private _signalQueue = Promise.resolve();
/**
* Creates a media-cast receiver.
*
* @param {IMediaCastReceiverOptions} options - Configuration for the receiver.
*/
constructor({
onError = () => undefined,
onSignal,
onTrack,
pcConfig
}: IMediaCastReceiverOptions) {
this._onError = onError;
this._onSignal = onSignal;
this._onTrack = onTrack;
this._pcConfig = pcConfig;
}
/**
* Processes one signal in transport order.
*
* @param {MediaCastSignal} signal - Signal received from the remote peer.
* @returns {void}
*/
handleSignal(signal: MediaCastSignal): void {
if (!isMediaCastSignal(signal)) {
this._onError(new TypeError('Malformed media-cast signal received.'));
return;
}
const generation = signal.generation;
this._signalQueue = this._signalQueue
.then(() => this._processSignal(signal))
.catch(error => {
this._onError(error);
this._requestRestart(generation);
});
}
/**
* Stops the current session and resets generation tracking so this receiver can accept
* a new sender whose first offer starts again at generation one.
*
* @returns {void}
*/
stop(): void {
if (this._disposed) {
return;
}
this._generation = 0;
this._closeSession();
}
/**
* Permanently disposes the receiver and ignores queued or future signals.
*
* @returns {void}
*/
dispose(): void {
if (this._disposed) {
return;
}
this._disposed = true;
this._generation = 0;
this._closeSession();
}
/**
* Closes the current session's RTCPeerConnection and releases the received track.
*
* @returns {void}
*/
private _closeSession(): void {
const session = this._session;
this._session = undefined;
this._currentTrack = null;
this._muted = false;
this._onTrack(null);
if (!session) {
return;
}
session.peerConnection.onconnectionstatechange = null;
session.peerConnection.onicecandidate = null;
session.peerConnection.ontrack = null;
session.peerConnection.close();
}
/**
* Applies one incoming signal to the current session, or handles a new offer.
*
* @param {MediaCastSignal} signal - Signal received from the remote peer.
* @returns {Promise<void>}
*/
private async _processSignal(signal: MediaCastSignal): Promise<void> {
if (this._disposed) {
return;
}
if (signal.kind === 'offer') {
await this._handleOffer(signal);
return;
}
const session = this._session;
if (!session || signal.generation !== session.generation) {
return;
}
switch (signal.kind) {
case 'candidate':
await session.peerConnection.addIceCandidate(signal.candidate);
break;
case 'mute':
this._setMuted(signal.muted);
break;
case 'stop':
this.stop();
break;
}
}
/**
* Negotiates an answer for an incoming offer, replacing any previous session.
*
* @param {OfferSignal} signal - The received offer.
* @returns {Promise<void>}
*/
private async _handleOffer(signal: OfferSignal): Promise<void> {
if (signal.generation <= this._generation) {
return;
}
this._closeSession();
const peerConnection = new RTCPeerConnection(this._pcConfig);
const session: IReceiverSession = {
answerSent: false,
generation: signal.generation,
peerConnection,
pendingCandidates: [],
restartSent: false
};
this._generation = signal.generation;
this._session = session;
peerConnection.ontrack = ({ track }) => {
if (this._session === session) {
this._currentTrack = track;
this._onTrack(this._muted ? null : track);
}
};
peerConnection.onicecandidate = ({ candidate }) => {
if (candidate && this._session === session) {
const candidateInit = candidate.toJSON();
if (!session.answerSent) {
session.pendingCandidates.push(candidateInit);
return;
}
this._onSignal({
candidate: candidateInit,
generation: session.generation,
kind: 'candidate'
});
}
};
peerConnection.onconnectionstatechange = () => {
if (this._session === session && peerConnection.connectionState === 'failed') {
this._requestRestart(session.generation);
}
};
await peerConnection.setRemoteDescription(signal.sdp);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
if (this._session !== session || !peerConnection.localDescription) {
return;
}
const { sdp, type } = peerConnection.localDescription;
this._onSignal({
generation: session.generation,
kind: 'answer',
sdp: {
sdp,
type
}
});
session.answerSent = true;
for (const candidate of session.pendingCandidates.splice(0)) {
this._onSignal({
candidate,
generation: session.generation,
kind: 'candidate'
});
}
}
/**
* Requests an ICE restart from the sender unless one is already pending for this session.
*
* @param {number} generation - The generation that is allowed to request a restart.
* @returns {void}
*/
private _requestRestart(generation: number): void {
const session = this._session;
if (!session || session.generation !== generation || session.restartSent) {
return;
}
session.restartSent = true;
this._onSignal({
generation,
kind: 'restart'
});
}
/**
* Applies the sender's mute state, re-surfacing the held track when unmuted
* without waiting for a new ontrack event.
*
* @param {boolean} muted - Whether the sender is currently transmitting a null track.
* @returns {void}
*/
private _setMuted(muted: boolean): void {
this._muted = muted;
this._onTrack(muted ? null : this._currentTrack);
}
}

View File

@@ -0,0 +1,506 @@
import {
type IMediaCastSenderOptions,
type ISenderSession,
type MediaCastSignal,
type MediaCastSignalHandler,
isMediaCastSignal
} from './types.web';
/**
* Delay in milliseconds before re-establishing the connection after a failure.
*/
const DEFAULT_RECONNECT_DELAY = 1000;
/**
* Maximum time in milliseconds to wait for a newly offered connection to become connected.
*/
const DEFAULT_SETUP_TIMEOUT = 10000;
/**
* Maximum consecutive reconnection attempts before giving up on a broken connection.
*/
const MAX_RECONNECT_ATTEMPTS = 3;
/**
* Multiplier applied to the reconnect delay after each consecutive failed attempt.
*/
const RECONNECT_DELAY_MULTIPLIER = 2;
/**
* Sends one replaceable video track over a plain RTCPeerConnection.
*
* The sender owns the connection lifecycle while the caller owns the track: starting or
* updating the track never stops or disposes the conference-owned source track. Session
* state is versioned by a generation counter so signals from superseded sessions are ignored.
*/
export default class MediaCastSender {
/**
* Whether the sender has been started and may maintain a connection.
*/
private _active = false;
/**
* The generation of the most recently started session.
*/
private _generation = 0;
/**
* Handler invoked when an asynchronous sending error occurs.
*/
private _onError: (error: unknown) => void;
/**
* Handler used to forward outgoing signals to the remote peer.
*/
private _onSignal: MediaCastSignalHandler;
/**
* Optional RTCPeerConnection configuration applied to new connections.
*/
private _pcConfig?: RTCConfiguration;
/**
* Number of consecutive reconnection attempts made for the current connection.
*/
private _reconnectAttempts = 0;
/**
* Delay in milliseconds before re-establishing the connection after a failure.
*/
private _reconnectDelay: number;
/**
* Handle of the pending reconnection timer, if any.
*/
private _reconnectTimer?: number;
/**
* The live outgoing session, if any.
*/
private _session?: ISenderSession;
/**
* Handle of the timer that fails a session whose offer was never answered, if any.
*/
private _setupTimer?: number;
/**
* Maximum time in milliseconds to wait for a newly offered connection to become connected.
*/
private _setupTimeout: number;
/**
* Promise chain enforcing transport ordering of incoming signals.
*/
private _signalQueue = Promise.resolve();
/**
* The track currently being transmitted, or null to send silence.
*/
private _track: MediaStreamTrack | null = null;
/**
* Creates a media-cast sender.
*
* @param {IMediaCastSenderOptions} options - Configuration for the sender.
*/
constructor({
onError = () => undefined,
onSignal,
pcConfig,
reconnectDelay = DEFAULT_RECONNECT_DELAY,
setupTimeout = DEFAULT_SETUP_TIMEOUT
}: IMediaCastSenderOptions) {
this._onError = onError;
this._onSignal = onSignal;
this._pcConfig = pcConfig;
this._reconnectDelay = reconnectDelay;
this._setupTimeout = setupTimeout;
}
/**
* Starts sending the supplied track. A null track keeps the negotiated video
* transceiver alive without sending media.
*
* @param {MediaStreamTrack|null} track - Conference-owned input track.
* @returns {void}
*/
start(track: MediaStreamTrack | null): void {
if (this._active) {
void this.setTrack(track);
return;
}
this._active = true;
this._reconnectAttempts = 0;
this._track = track;
this._startSession();
}
/**
* Replaces the currently transmitted track without renegotiating.
*
* @param {MediaStreamTrack|null} track - Conference-owned input track.
* @returns {Promise<void>}
*/
async setTrack(track: MediaStreamTrack | null): Promise<void> {
this._track = track;
const session = this._session;
if (!this._active || !session) {
return;
}
if (session.lastRequestedTrack === track) {
return;
}
session.lastRequestedTrack = track;
try {
await session.sender.replaceTrack(track);
// Only report the mute state once the offer has been sent: the receiver drops any
// signal for a session it does not know yet, and the final flush in _negotiate
// covers the state that changed while the offer was still being prepared.
if (this._session === session && session.offerSent) {
this._notifyMute(session);
}
} catch (error) {
if (this._session === session) {
this._reportError(error);
this._scheduleReconnect(session.generation);
}
}
}
/**
* Processes an answer, candidate, or restart request in transport order.
*
* @param {MediaCastSignal} signal - Signal received from the remote peer.
* @returns {void}
*/
handleSignal(signal: MediaCastSignal): void {
if (!isMediaCastSignal(signal)) {
this._reportError(new TypeError('Malformed media-cast signal received.'));
return;
}
const generation = signal.generation;
this._signalQueue = this._signalQueue
.then(() => this._processSignal(signal))
.catch(error => {
this._reportError(error);
this._scheduleReconnect(generation);
});
}
/**
* Stops the sender without stopping or disposing the source track.
*
* @returns {void}
*/
stop(): void {
this._stop(true);
}
/**
* Stops the sender, optionally notifying the remote peer with a stop signal.
*
* @param {boolean} notify - Whether to send a stop signal to the remote peer.
* @returns {void}
*/
private _stop(notify: boolean): void {
if (!this._active && !this._session) {
return;
}
const generation = this._session?.generation ?? this._generation;
this._active = false;
this._track = null;
this._clearReconnectTimer();
this._closeSession();
if (notify && generation > 0) {
this._onSignal({
generation,
kind: 'stop'
});
}
}
/**
* Cancels any pending reconnection attempt.
*
* @returns {void}
*/
private _clearReconnectTimer(): void {
if (this._reconnectTimer !== undefined) {
window.clearTimeout(this._reconnectTimer);
this._reconnectTimer = undefined;
}
}
/**
* Closes the current session's RTCPeerConnection and detaches its event handlers.
*
* @returns {void}
*/
private _closeSession(): void {
const session = this._session;
this._session = undefined;
this._clearSetupTimer();
if (!session) {
return;
}
session.peerConnection.onconnectionstatechange = null;
session.peerConnection.onicecandidate = null;
session.peerConnection.close();
}
/**
* Applies one incoming signal to the current session if it is still current.
*
* @param {MediaCastSignal} signal - Signal received from the remote peer.
* @returns {Promise<void>}
*/
private async _processSignal(signal: MediaCastSignal): Promise<void> {
const session = this._session;
if (!this._active || !session || signal.generation !== session.generation) {
return;
}
switch (signal.kind) {
case 'answer':
await session.peerConnection.setRemoteDescription(signal.sdp);
break;
case 'candidate':
await session.peerConnection.addIceCandidate(signal.candidate);
break;
case 'restart':
this._scheduleReconnect(signal.generation);
break;
case 'stop':
this._stop(false);
break;
}
}
/**
* Reports the current mute state to the remote peer when it changed.
*
* @param {ISenderSession} session - The session the mute state applies to.
* @returns {void}
*/
private _notifyMute(session: ISenderSession): void {
const muted = this._track === null;
if (session.muteNotified === muted) {
return;
}
session.muteNotified = muted;
this._onSignal({
generation: session.generation,
kind: 'mute',
muted
});
}
/**
* Invokes the configured error handler.
*
* @param {unknown} error - The error that occurred.
* @returns {void}
*/
private _reportError(error: unknown): void {
this._onError(error);
}
/**
* Schedules a reconnection for the given generation unless one is already pending, backing
* off after each failed attempt and giving up once MAX_RECONNECT_ATTEMPTS is reached.
*
* @param {number} expectedGeneration - The generation that is allowed to reconnect.
* @returns {void}
*/
private _scheduleReconnect(expectedGeneration: number): void {
if (!this._active
|| this._session?.generation !== expectedGeneration
|| this._reconnectTimer !== undefined) {
return;
}
if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
if (this._reconnectAttempts === MAX_RECONNECT_ATTEMPTS) {
this._reconnectAttempts += 1;
this._reportError(new Error('Media-cast connection could not be re-established.'));
}
return;
}
const delay = this._reconnectDelay * (RECONNECT_DELAY_MULTIPLIER ** this._reconnectAttempts);
this._reconnectAttempts += 1;
this._reconnectTimer = window.setTimeout(() => {
this._reconnectTimer = undefined;
if (this._active && this._session?.generation === expectedGeneration) {
this._startSession();
}
}, delay);
}
/**
* Starts the timer that fails a session whose offer was never answered, retrying through
* _scheduleReconnect once the delay elapses with the connection still not connected.
*
* @param {ISenderSession} session - The session being set up.
* @returns {void}
*/
private _armSetupTimeout(session: ISenderSession): void {
this._clearSetupTimer();
this._setupTimer = window.setTimeout(() => {
this._setupTimer = undefined;
if (this._session === session && session.peerConnection.connectionState !== 'connected') {
this._scheduleReconnect(session.generation);
}
}, this._setupTimeout);
}
/**
* Cancels the setup timeout for the current session, if any.
*
* @returns {void}
*/
private _clearSetupTimer(): void {
if (this._setupTimer !== undefined) {
window.clearTimeout(this._setupTimer);
this._setupTimer = undefined;
}
}
/**
* Establishes a new send-only RTCPeerConnection and negotiates an offer with the remote peer.
*
* @returns {void}
*/
private _startSession(): void {
this._clearReconnectTimer();
this._closeSession();
const peerConnection = new RTCPeerConnection(this._pcConfig);
const sender = peerConnection.addTransceiver('video', { direction: 'sendonly' }).sender;
const session: ISenderSession = {
generation: ++this._generation,
offerSent: false,
peerConnection,
pendingCandidates: [],
sender
};
this._session = session;
peerConnection.onicecandidate = ({ candidate }) => {
if (!candidate || this._session !== session) {
return;
}
const candidateInit = candidate.toJSON();
if (!session.offerSent) {
session.pendingCandidates.push(candidateInit);
return;
}
this._onSignal({
candidate: candidateInit,
generation: session.generation,
kind: 'candidate'
});
};
peerConnection.onconnectionstatechange = () => {
if (this._session !== session) {
return;
}
if (peerConnection.connectionState === 'connected') {
this._clearSetupTimer();
this._clearReconnectTimer();
this._reconnectAttempts = 0;
} else if (peerConnection.connectionState === 'failed') {
this._clearSetupTimer();
this._scheduleReconnect(session.generation);
}
};
this._armSetupTimeout(session);
void this._negotiate(session);
}
/**
* Creates and sends the offer for a session, flushing buffered ICE candidates afterwards.
*
* @param {ISenderSession} session - The session being negotiated.
* @returns {Promise<void>}
*/
private async _negotiate(session: ISenderSession): Promise<void> {
try {
session.lastRequestedTrack = this._track;
await session.sender.replaceTrack(this._track);
if (this._session !== session) {
return;
}
const offer = await session.peerConnection.createOffer();
await session.peerConnection.setLocalDescription(offer);
if (this._session !== session || !session.peerConnection.localDescription) {
return;
}
const { sdp, type } = session.peerConnection.localDescription;
this._onSignal({
generation: session.generation,
kind: 'offer',
sdp: {
sdp,
type
}
});
session.offerSent = true;
for (const candidate of session.pendingCandidates.splice(0)) {
this._onSignal({
candidate,
generation: session.generation,
kind: 'candidate'
});
}
this._notifyMute(session);
} catch (error) {
if (this._session === session) {
this._reportError(error);
this._scheduleReconnect(session.generation);
}
}
}
}

View File

@@ -0,0 +1,197 @@
/**
* Structured-clone-safe signalling exchanged by a media-cast sender and receiver.
*/
export type MediaCastSignal =
| { generation: number; kind: 'offer'; sdp: RTCSessionDescriptionInit; }
| { generation: number; kind: 'answer'; sdp: RTCSessionDescriptionInit; }
| { candidate: RTCIceCandidateInit; generation: number; kind: 'candidate'; }
| { generation: number; kind: 'mute'; muted: boolean; }
| { generation: number; kind: 'restart'; }
| { generation: number; kind: 'stop'; };
/**
* Callback used to forward one media-cast signal over an application-owned transport.
*/
export type MediaCastSignalHandler = (signal: MediaCastSignal) => void;
/**
* The media-cast signal kinds accepted over the transport.
*/
const MEDIA_CAST_SIGNAL_KINDS = new Set([
'answer',
'candidate',
'mute',
'offer',
'restart',
'stop'
]);
/**
* Checks whether an unknown value is a well-formed media-cast signal, so malformed messages
* received from the host are rejected before they reach the WebRTC layer.
*
* @param {unknown} value - Value received over the application transport.
* @returns {boolean} Whether the value is a well-formed media-cast signal.
*/
export function isMediaCastSignal(value: unknown): value is MediaCastSignal {
if (typeof value !== 'object' || value === null) {
return false;
}
const signal = value as Record<string, unknown>;
const { kind } = signal;
if (typeof kind !== 'string' || !MEDIA_CAST_SIGNAL_KINDS.has(kind)) {
return false;
}
if (typeof signal.generation !== 'number') {
return false;
}
switch (kind) {
case 'answer':
case 'offer': {
const sdp = signal.sdp;
return typeof sdp === 'object' && sdp !== null
&& typeof (sdp as { type?: unknown; }).type === 'string'
&& typeof (sdp as { sdp?: unknown; }).sdp === 'string';
}
case 'candidate':
return typeof signal.candidate === 'object' && signal.candidate !== null;
case 'mute':
return typeof signal.muted === 'boolean';
case 'restart':
case 'stop':
return true;
default:
return false;
}
}
/**
* Options for constructing a {@link MediaCastSender}.
*/
export interface IMediaCastSenderOptions {
/**
* Optional handler invoked with any asynchronous error encountered while sending media.
*/
onError?: (error: unknown) => void;
/**
* Handler used to forward outgoing signals (offer, candidates, mute, stop) to the remote peer.
*/
onSignal: MediaCastSignalHandler;
/**
* Optional RTCPeerConnection configuration (e.g. custom ICE servers) applied to new connections.
*/
pcConfig?: RTCConfiguration;
/**
* Delay in milliseconds before re-establishing the connection after a failure.
*/
reconnectDelay?: number;
/**
* Maximum time in milliseconds to wait for a newly offered connection to become connected.
*/
setupTimeout?: number;
}
/**
* State associated with one live outgoing RTCPeerConnection session.
*/
export interface ISenderSession {
/**
* Monotonically increasing session identifier used to discard stale signals.
*/
generation: number;
/**
* The last track requested to be sent, so track updates are compared against what was
* requested instead of the possibly-stale sender.track value.
*/
lastRequestedTrack?: MediaStreamTrack | null;
/**
* Whether the last transmitted mute state has already been reported to the remote peer.
*/
muteNotified?: boolean;
/**
* Whether the initial offer has been sent; ICE candidates are buffered until then.
*/
offerSent: boolean;
/**
* The active RTCPeerConnection sending the video track.
*/
peerConnection: RTCPeerConnection;
/**
* ICE candidates gathered before the offer was sent, flushed once it is.
*/
pendingCandidates: RTCIceCandidateInit[];
/**
* The RTCRtpSender transmitting the current track.
*/
sender: RTCRtpSender;
}
/**
* Options for constructing a {@link MediaCastReceiver}.
*/
export interface IMediaCastReceiverOptions {
/**
* Optional handler invoked with any asynchronous error encountered while receiving media.
*/
onError?: (error: unknown) => void;
/**
* Handler used to forward outgoing signals (answer, candidates, restart) to the remote peer.
*/
onSignal: MediaCastSignalHandler;
/**
* Handler invoked whenever the received track changes; receives null when it is muted or closed.
*/
onTrack: (track: MediaStreamTrack | null) => void;
/**
* Optional RTCPeerConnection configuration (e.g. custom ICE servers) applied to new connections.
*/
pcConfig?: RTCConfiguration;
}
/**
* State associated with one live incoming RTCPeerConnection session.
*/
export interface IReceiverSession {
/**
* Whether the answer to the received offer has been sent; ICE candidates are buffered until then.
*/
answerSent: boolean;
/**
* Monotonically increasing session identifier used to discard stale signals.
*/
generation: number;
/**
* The active RTCPeerConnection receiving the negotiated media track.
*/
peerConnection: RTCPeerConnection;
/**
* ICE candidates gathered before the answer was sent, flushed once it is.
*/
pendingCandidates: RTCIceCandidateInit[];
/**
* Whether an ICE restart request has already been sent for this session.
*/
restartSent: boolean;
}