snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# @rocket.chat/media-signaling
## 1.2.0
### Minor Changes
- ([#41416](https://github.com/RocketChat/Rocket.Chat/pull/41416)) Removes voice calling microphone requirement for starting a successful call, allowing users to join as "listen-only".
## 1.2.0-rc.0
### Minor Changes
- ([#41416](https://github.com/RocketChat/Rocket.Chat/pull/41416)) Removes voice calling microphone requirement for starting a successful call, allowing users to join as "listen-only".
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/emitter@0.33.0
</details>
## 1.1.0
### Minor Changes
- ([#40422](https://github.com/RocketChat/Rocket.Chat/pull/40422)) Fixes a soft lock issue where the call negotiation doesn't proceed if the call is accepted while still being initialized
### Patch Changes
- ([#41044](https://github.com/RocketChat/Rocket.Chat/pull/41044)) Fixes an issue where voice calls could fail if the user navigated between rooms during the initial connection
## 1.1.0-rc.1
### Patch Changes
- ([#41044](https://github.com/RocketChat/Rocket.Chat/pull/41044)) Fixes an issue where voice calls could fail if the user navigated between rooms during the initial connection
## 1.1.0-rc.0
### Minor Changes
- ([#40422](https://github.com/RocketChat/Rocket.Chat/pull/40422)) Fixes a soft lock issue where the call negotiation doesn't proceed if the call is accepted while still being initialized
## 1.0.0
### Major Changes
- ([#37161](https://github.com/RocketChat/Rocket.Chat/pull/37161)) Publishes the media signaling package to npm
### Minor Changes
- ([#40141](https://github.com/RocketChat/Rocket.Chat/pull/40141)) Adds a new REST endpoint to accept or reject media calls without an active media session
- ([#40093](https://github.com/RocketChat/Rocket.Chat/pull/40093)) Adds new API endpoints to load the user's current voice call state from the server
## 1.0.0-rc.1
### Major Changes
- ([#37161](https://github.com/RocketChat/Rocket.Chat/pull/37161)) Publishes the media signaling package to npm
## 0.3.0-rc.0
### Minor Changes
- ([#40141](https://github.com/RocketChat/Rocket.Chat/pull/40141)) Adds a new REST endpoint to accept or reject media calls without an active media session
- ([#40093](https://github.com/RocketChat/Rocket.Chat/pull/40093)) Adds new API endpoints to load the user's current voice call state from the server
## 0.2.0
### Minor Changes
- ([#37922](https://github.com/RocketChat/Rocket.Chat/pull/37922)) Introduces native screen sharing for internal voice calls. This feature is currently in beta and can be disabled through admin settings.
### Patch Changes
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
## 0.2.0-rc.0
### Minor Changes
- ([#37922](https://github.com/RocketChat/Rocket.Chat/pull/37922)) Introduces native screen sharing for internal voice calls. This feature is currently in beta and can be disabled through admin settings.
### Patch Changes
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
## 0.1.1
### Patch Changes
- ([#37816](https://github.com/RocketChat/Rocket.Chat/pull/37816)) Fixes an issue where the client would play the Call Ended sound effect when the user started calling someone on a different session
- ([#38025](https://github.com/RocketChat/Rocket.Chat/pull/38025)) Adds a timeout to ensure transferred calls stop ringing if there's no response from the final user
## 0.1.1-rc.1
### Patch Changes
- ([#38025](https://github.com/RocketChat/Rocket.Chat/pull/38025)) Adds a timeout to ensure transferred calls stop ringing if there's no response from the final user
## 0.1.1-rc.0
### Patch Changes
- ([#37816](https://github.com/RocketChat/Rocket.Chat/pull/37816)) Fixes an issue where the client would play the Call Ended sound effect when the user started calling someone on a different session
## 0.1.0
### Minor Changes
- ([#37386](https://github.com/RocketChat/Rocket.Chat/pull/37386)) Fixes an error where Voice Calls could fail to negotiate webrtc params if both clients requested a renegotiation at the same time
## 0.1.0-rc.0
### Minor Changes
- ([#37386](https://github.com/RocketChat/Rocket.Chat/pull/37386)) Fixes an error where Voice Calls could fail to negotiate webrtc params if both clients requested a renegotiation at the same time

View File

@@ -0,0 +1,6 @@
import client from '@rocket.chat/jest-presets/client';
import type { Config } from 'jest';
export default {
preset: client.preset,
} satisfies Config;

View File

@@ -0,0 +1,42 @@
{
"name": "@rocket.chat/media-signaling",
"description": "Rocket.Chat signaling client for media calls",
"version": "1.2.0",
"license": "MIT",
"homepage": "https://github.com/RocketChat/Rocket.Chat/#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/RocketChat/Rocket.Chat.git",
"directory": "packages/media-signaling"
},
"bugs": {
"url": "https://github.com/RocketChat/Rocket.Chat/issues"
},
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.json",
"build-preview": "mkdir -p ../../.preview && cp -r ./dist ../../.preview/media-signaling",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rocket.chat/emitter": "workspace:~",
"ajv": "^8.20.0"
},
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@types/jest": "~30.0.0",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"typescript": "~5.9.3"
}
}

View File

@@ -0,0 +1,37 @@
import type { ClientState } from '../client';
import type { CallState } from './IClientMediaCall';
export type CallEvents = {
/* Triggered when the call's server state is changed on this client, with the old state as param */
stateChange: CallState;
/* Triggered when the call's client state is changed (based on user interaction or internal service state) */
clientStateChange: ClientState;
/* Triggered when an audio track is changed, muted or put on hold */
trackStateChange: void;
/* Triggered when the contact information for the remote user is updated */
contactUpdate: void;
/* Triggered as soon as the call is registered on this client */
initialized: void;
/* Triggered when the call is confirmed to exist in the server */
confirmed: void;
/* Triggered when this client is telling the server that we want to accept the call */
accepting: void;
/* Triggered when the call's state on the server changes to 'accepted' */
accepted: void;
/* Triggered when we detect a stable webrtc connection on our side or the other side reports one to the server */
active: void;
/* Triggered when this client puts the call on the ignore list, either because the call was accepted on a different client or because you called `call.ignore()` */
hidden: void;
/* Triggered when the call's state on the server changes to 'hangup' */
ended: void;
/* Triggered when screen share is toggled */
screenShareRequestChange: boolean;
/* Triggered when any of the streams or tracks have changed */
streamChange: void;
};

View File

@@ -0,0 +1,128 @@
import type { Emitter } from '@rocket.chat/emitter';
import type { CallEvents } from './CallEvents';
import type {
AnyClientMediaCallParticipant,
IClientMediaCallLocalParticipant,
IClientMediaCallRemoteParticipant,
} from './IClientMediaCallParticipant';
import type { CallActorType } from './common';
export type CallService = 'webrtc';
export const callFeatureList = ['audio', 'screen-share', 'transfer', 'hold'] as const;
export type CallFeature = (typeof callFeatureList)[number];
export const callStateList = [
'none', // trying to call with no idea if it'll reach anyone
'ringing', // call has been acknoledged by the callee's agent, but no response about them accepting it or not
'accepted', // call has been accepted and the webrtc offer is being exchanged
'active', // webrtc connection has been established
'renegotiating', // a webrtc connection had been established before, but a new one is being negotiated
'hangup', // call is over
] as const;
export type CallState = (typeof callStateList)[number];
export const callHangupReasonList = [
'normal', // User explicitly hanged up
'remote', // The client was told the call is over
'rejected', // The callee rejected the call
'unavailable', // The actor is not available
'transfer', // one of the users requested the other be transferred to someone else
'not-answered', // max ringing duration was reached with no answer from the other user
'timeout-local-track', // Timeout waiting for the local audio track
'timeout-remote-sdp', // Timeout waiting for the remote SDP
'timeout-local-sdp', // Timeout while generating the local SDP + waiting for ICE Gathering
'timeout-activation', // Timeout connecting to the negotiated session
'timeout-accepting', // Timeout waiting for server to acknowledge our acceptance
'timeout', // The call state hasn't progressed for too long
'signaling-error', // Hanging up because of an error during the signal processing
'service-error', // Hanging up because of an error setting up the service connection
'media-error', // Hanging up because of an error setting up the media connection
'input-error', // Something wrong with the audio input track on the client
'error', // Hanging up because of an unidentified error
'unknown', // One of the call's signed users reported they don't know this call
'another-client', // One of the call's users requested a hangup from a different client session than the one where the call is happening
] as const;
export type CallHangupReason = (typeof callHangupReasonList)[number];
export const isCallHangupReason = (reason: string): reason is CallHangupReason =>
(callHangupReasonList as readonly string[]).includes(reason);
export const callAnswerList = [
'accept', // actor accepts the call
'reject', // actor rejects the call
'ack', // agent confirms the actor is reachable
'unavailable', // agent reports the actor is unavailable
] as const;
export type CallAnswer = (typeof callAnswerList)[number];
export const callNotificationList = [
'accepted', // notify that the call has been accepted by both actors
'active', // notify that call activity was confirmed
'hangup', // notify that the call is over;
'trying', // notify that the other client is connecting but still need more time
] as const;
export type CallNotification = (typeof callNotificationList)[number];
export const callRejectedReasonList = [
'invalid-call-id', // the call id can't be used for a new call
'invalid-contract-id', // this specific contract can't request this call
'existing-call-id', // the call already exists with a different callee or contract
'already-requested', // the request is valid, but a call matching its params is already underway
'unsupported', // no matching supported services between actors
'unavailable', // the callee is unavailable
'busy', // the actor who requested the call is supposedly busy
'invalid-call-params', // something is wrong with the params (eg. no valid route between caller and callee)
'forbidden', // one of the actors on the call doesn't have permission for it
] as const;
export type CallRejectedReason = (typeof callRejectedReasonList)[number];
export const callFlagList = ['internal', 'create-data-channel'];
export type CallFlag = (typeof callFlagList)[number];
export interface IClientMediaCall {
callId: string;
state: CallState;
ignored: boolean;
signed: boolean;
hidden: boolean;
/* busy = state >= 'accepted' && state < 'hangup' */
busy: boolean;
/** The timestamp of the moment the call was marked as active for the first time */
activeTimestamp?: Date;
/** if the call was requested by this session, then this will have the ID used to request the call, otherwise it will be the same as callId */
readonly tempCallId: string;
/** confirmed indicates if the call exists on the server */
readonly confirmed: boolean;
emitter: Emitter<CallEvents>;
accept(): void;
reject(): void;
hangup(): void;
requestScreenShare(requested: boolean): void;
setScreenVideoTrack(videoTrack: MediaStreamTrack | null): Promise<void>;
hasScreenVideoTrack(): boolean;
canHaveScreenVideoTrack(): boolean;
transfer(callee: { type: CallActorType; id: string }): void;
sendDTMF(dtmf: string, duration?: number): void;
getStats(selector?: MediaStreamTrack | null): Promise<RTCStatsReport | null>;
isFeatureAvailable(feature: CallFeature): boolean;
hasFlag(flag: CallFlag): boolean;
readonly localParticipant: IClientMediaCallLocalParticipant;
readonly remoteParticipants: IClientMediaCallRemoteParticipant[];
readonly participants: AnyClientMediaCallParticipant[];
}

View File

@@ -0,0 +1,35 @@
import type { IMediaStreamWrapper } from '../media';
import type { CallActorType, CallContact, CallRole } from './common';
export interface IClientMediaCallParticipant {
readonly local: boolean;
readonly participantId: string;
readonly actorType: CallActorType;
readonly actorId: string;
readonly role: CallRole;
readonly muted: boolean;
readonly held: boolean;
readonly contact: CallContact;
getMediaStream(tag?: string): IMediaStreamWrapper | null;
}
export interface IClientMediaCallLocalParticipant extends IClientMediaCallParticipant {
readonly local: true;
setMuted(muted: boolean): void;
setHeld(onHold: boolean): void;
}
export interface IClientMediaCallRemoteParticipant extends IClientMediaCallParticipant {
readonly local: false;
}
export type AnyClientMediaCallParticipant = IClientMediaCallLocalParticipant | IClientMediaCallRemoteParticipant;

View File

@@ -0,0 +1,4 @@
import type { IDirectMediaCallData } from './IDirectMediaCallData';
import type { ITempMediaCallData } from './ITempMediaCallData';
export type AnyMediaCallData = ITempMediaCallData | IDirectMediaCallData;

View File

@@ -0,0 +1,23 @@
import type { CallFeature, CallFlag, CallService, CallState } from '../IClientMediaCall';
import type { IClientMediaCallLocalParticipant, IClientMediaCallRemoteParticipant } from '../IClientMediaCallParticipant';
import type { CallContact } from '../common';
export interface IDirectMediaCallData {
readonly confirmed: true;
readonly callId: string;
readonly service: CallService | null;
readonly flags: readonly CallFlag[];
readonly features: readonly CallFeature[];
readonly state: CallState;
readonly hidden: boolean;
readonly transferredBy: CallContact | null;
readonly activeTimestamp?: Date;
readonly tempCallId: string;
readonly localParticipant: IClientMediaCallLocalParticipant;
readonly remoteParticipant: IClientMediaCallRemoteParticipant;
}

View File

@@ -0,0 +1,13 @@
import type { CallState } from '../IClientMediaCall';
import type { IClientMediaCallLocalParticipant } from '../IClientMediaCallParticipant';
export interface ITempMediaCallData {
readonly confirmed: false;
readonly tempCallId: string;
readonly state: CallState;
readonly title: string;
readonly localParticipant: IClientMediaCallLocalParticipant;
}

View File

@@ -0,0 +1,3 @@
export type * from './AnyMediaCallData';
export type * from './IDirectMediaCallData';
export type * from './ITempMediaCallData';

View File

@@ -0,0 +1,13 @@
export type CallActorType = 'user' | 'sip';
export type CallContact = {
type?: CallActorType;
id?: string;
contractId?: string;
displayName?: string;
username?: string;
sipExtension?: string;
};
export type CallRole = 'caller' | 'callee';

View File

@@ -0,0 +1,5 @@
export type * from './CallEvents';
export type * from './callStates';
export type * from './common';
export * from './IClientMediaCall';
export type * from './IClientMediaCallParticipant';

View File

@@ -0,0 +1,28 @@
export const clientStateList = [
'none', // The client doesn't recognize a specific call id at all
'pending', // The call is ringing
'accepting', // The client tried to accept the call and is wating for confirmation from the server
'waiting-for-track', // The call was accepted, but the client doesn't have a local audio stream yet
'waiting-for-offer', // The call was accepted, but the client doesn't have a webrtc offer yet
'waiting-for-answer', // The call was accepted and an offer was already sent, but the client doesn't have an answer yet
'generating-local-sdp', // The client is generating its first local sdp (offer/answer)
'activating', // The WebRTC signaling has reached the stable state, but the connection is not yet active
'busy-elsewhere', // The call is happening in a different session/client
'active', // The webrtc call was established
'renegotiating', // the webrtc call was established but the client is starting a new negotiation
'hangup', // The call is over, or happening in some other client
] as const;
export type ClientState = (typeof clientStateList)[number];
export const clientContractStateList = [
'proposed', // we don't know if the contract will be signed
'signed', // the server signed this session's contract
'pre-signed', // the session that requested a call is assuming it will be signed into it
'self-signed', // the call has progressed beyond the signing stage without any signature confirmation
'ignored', // the server signed a contract from a different session
] as const;
export type ClientContractState = (typeof clientContractStateList)[number];
export type RandomStringFactory = () => string;

View File

@@ -0,0 +1,6 @@
export * from './call';
export type * from './services';
export type * from './media';
export * from './signals';
export * from './client';
export type * from './logger';

View File

@@ -0,0 +1,6 @@
export interface IMediaSignalLogger {
log(...what: any[]): void;
debug(...what: any[]): void;
error(...what: any[]): void;
warn(...what: any[]): void;
}

View File

@@ -0,0 +1,28 @@
import type { Emitter } from '@rocket.chat/emitter';
import type { IMediaStreamWrapper } from './IMediaStreamWrapper';
export type MediaStreamManagerEvents = {
streamChanged: void;
};
export interface IMediaStreamManager {
readonly emitter: Emitter<MediaStreamManagerEvents>;
readonly mainLocal: IMediaStreamWrapper;
readonly screenShareLocal: IMediaStreamWrapper;
readonly mainRemote: IMediaStreamWrapper;
readonly screenShareRemote: IMediaStreamWrapper;
getStreams(): IMediaStreamWrapper[];
getLocalStreams(): IMediaStreamWrapper[];
getRemoteStreams(): IMediaStreamWrapper[];
getLocalStreamByTag(tag: string): IMediaStreamWrapper | null;
getRemoteStreamByTag(tag: string): IMediaStreamWrapper | null;
hasAllRequiredTracks(): boolean;
}

View File

@@ -0,0 +1,70 @@
import type { Emitter } from '@rocket.chat/emitter';
export type MediaStreamEvents = {
trackChanged: {
track: MediaStreamTrack | null;
kind: MediaStreamTrack['kind'];
};
stateChanged: void;
};
/**
* An object that holds a reference to a single MediaStream, plus additional data related to that specific stream
*
* We make a few assumptions about the stream based on our intendend use cases; Use multiple streams if you need, but make sure each individual stream follow the rules:
*
* 1. A stream MAY be empty (have no tracks)
*
* 2. A stream MAY have both an audio track and a video track at the same time, but only if they are related
*
* 3. A stream MAY NOT have multiple tracks of the same kind (audio/video).
*
* Audio and video are related if they come from the same source. For example: if the user is sharing their screen, the stream with the video may only include audio if that audio is from the user's screen - it may not include the user's mic.
* The audio from the user's mic and the video from the user's camera also count as being related.
*
* We have no control over what remote peers may send us if they are not also a rocket.chat client. If they send us multiple tracks in the same stream, we'll use the first and ignore the rest. It's likely that all tracks would have the same data anyway - just with different encodings.
* */
export interface IMediaStreamWrapper {
readonly emitter: Emitter<MediaStreamEvents>;
readonly remote: boolean;
readonly stream: MediaStream;
readonly localId: string;
readonly active: boolean;
/**
* The audio level of the current audio track: A number between 0 and 1 (linear), where 0 represents silence, and 1.0 represents maximum level
*
* Will be zero if the browser doesn't provide this information:
*
* Chrome: Provides audio level for both tracks
* Firefox: Provides audio level for remote track, but not for local track
* Safari: Doesn't provide any audio level
**/
readonly audioLevel: number;
/**
* Indicates if there's any track on this stream that is receiving or may receive audio
**/
hasAudio(): boolean;
/**
* Indicates if there's any track on this stream that is receiving or may receive video
**/
hasVideo(): boolean;
/**
* indicates if the audio track is not receiving audio from the system or network; beyond what the rocket.chat client can control
* */
isAudioMutedBySystem(): boolean;
/**
* indicates if the audio track is enabled by the rocket.chat client (aka not muted by the user nor placed on hold)
* */
isAudioEnabled(): boolean;
isStopped(): boolean;
}

View File

@@ -0,0 +1,4 @@
export type MediaStreamIdentification = {
tag: string;
id: string;
};

View File

@@ -0,0 +1,3 @@
export type * from './IMediaStreamManager';
export type * from './IMediaStreamWrapper';
export type * from './MediaStreamIdentification';

View File

@@ -0,0 +1,26 @@
import type { Emitter } from '@rocket.chat/emitter';
export type DefaultServiceStateMap = Record<string, unknown>;
export type ServiceStateEvent<ServiceStateMap extends DefaultServiceStateMap, K extends keyof ServiceStateMap = keyof ServiceStateMap> = {
[P in keyof ServiceStateMap]: ServiceStateMap[P] extends infer U ? { stateName: P; stateValue: U } : { stateName: P; stateValue: string };
}[K];
export type ServiceStateValue<ServiceStateMap extends DefaultServiceStateMap, K extends keyof ServiceStateMap> = ServiceStateEvent<
ServiceStateMap,
K
>['stateValue'];
export type ServiceProcessorEvents<ServiceStateMap extends DefaultServiceStateMap> = {
internalStateChange: keyof ServiceStateMap;
internalError: { critical: boolean; error: string | Error; errorDetails?: string };
};
export interface IServiceProcessor<
ServiceStateMap extends DefaultServiceStateMap = DefaultServiceStateMap,
ServiceUniqueEvents = Record<never, never>,
> {
emitter: Emitter<ServiceProcessorEvents<ServiceStateMap> & ServiceUniqueEvents>;
getInternalState<K extends keyof ServiceStateMap>(stateName: K): ServiceStateValue<ServiceStateMap, K>;
}

View File

@@ -0,0 +1,5 @@
import type { WebRTCProcessorFactory } from './webrtc/IWebRTCProcessor';
export interface IServiceProcessorFactoryList {
webrtc?: WebRTCProcessorFactory;
}

View File

@@ -0,0 +1 @@
export type MediaStreamFactory = (constraints: MediaStreamConstraints) => Promise<MediaStream>;

View File

@@ -0,0 +1,4 @@
export type * from './webrtc/IWebRTCProcessor';
export type * from './IServiceProcessorFactoryList';
export type * from './MediaStreamFactory';
export type * from './negotiation';

View File

@@ -0,0 +1,26 @@
import type { IMediaSignalLogger } from '../logger';
export type NegotiationManagerEvents = {
'error': { errorCode: string; negotiationId: string };
'local-sdp': { negotiationId: string; sdp: RTCSessionDescriptionInit };
'negotiation-needed': { oldNegotiationId: string };
'negotiation-started': void;
};
export type NegotiationManagerConfig = {
logger?: IMediaSignalLogger | null;
};
export type NegotiationEvents = {
'error': { errorCode: string };
'ended': void;
'local-sdp': { sdp: RTCSessionDescriptionInit };
};
export type NegotiationData = {
negotiationId: string;
sequence: number;
isPolite: boolean;
remoteOffer: RTCSessionDescriptionInit | null;
};

View File

@@ -0,0 +1,65 @@
import type { Emitter } from '@rocket.chat/emitter';
import type { IClientMediaCall } from '../../call';
import type { IMediaSignalLogger } from '../../logger';
import type { IMediaStreamManager } from '../../media/IMediaStreamManager';
import type { MediaStreamIdentification } from '../../media/MediaStreamIdentification';
import type { ServerMediaSignalRemoteSDP } from '../../signals';
import type { IServiceProcessor, ServiceProcessorEvents } from '../IServiceProcessor';
export type WebRTCInternalStateMap = {
signaling: RTCSignalingState;
connection: RTCPeerConnectionState;
iceConnection: RTCIceConnectionState;
iceGathering: RTCIceGatheringState;
iceUntrickler: 'waiting' | 'not-waiting' | 'timeout';
remoteMute: boolean;
remoteHeld: boolean;
};
export type WebRTCUniqueEvents = {
negotiationNeeded: void;
streamChanged: void;
};
export type WebRTCProcessorEvents = ServiceProcessorEvents<WebRTCInternalStateMap> & WebRTCUniqueEvents;
export interface IWebRTCProcessor extends IServiceProcessor<WebRTCInternalStateMap, WebRTCUniqueEvents> {
emitter: Emitter<WebRTCProcessorEvents>;
muted: boolean;
held: boolean;
setMuted(muted: boolean): void;
setHeld(held: boolean): void;
stop(): void;
readonly streams: IMediaStreamManager;
setInputTrack(newInputTrack: MediaStreamTrack | null): Promise<void>;
setScreenVideoTrack(newVideoTrack: MediaStreamTrack | null): Promise<void>;
createOffer(params: { iceRestart?: boolean }): Promise<RTCSessionDescriptionInit>;
createAnswer(): Promise<RTCSessionDescriptionInit>;
setLocalDescription(sdp: RTCSessionDescriptionInit): Promise<void>;
setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise<void>;
waitForIceGathering(): Promise<void>;
getLocalDescription(): RTCSessionDescriptionInit | null;
getStats(selector?: MediaStreamTrack | null): Promise<RTCStatsReport | null>;
isRemoteHeld(): boolean;
isRemoteMute(): boolean;
setRemoteIds(signal: ServerMediaSignalRemoteSDP): void;
getLocalStreamIds(): MediaStreamIdentification[];
}
export type WebRTCProcessorConfig = {
call: IClientMediaCall;
inputTrack: MediaStreamTrack | null;
screenVideoTrack?: MediaStreamTrack | null;
iceGatheringTimeout: number;
logger?: IMediaSignalLogger;
rtc?: RTCConfiguration;
};
export type WebRTCProcessorFactory = (config: WebRTCProcessorConfig) => IWebRTCProcessor;

View File

@@ -0,0 +1 @@
export type MediaSignalTransport<T> = (signal: T) => void;

View File

@@ -0,0 +1,58 @@
import { Ajv, type JSONSchemaType } from 'ajv';
import { clientMediaSignalAnswerSchema, type ClientMediaSignalAnswer } from './answer';
import { clientMediaSignalDTMFSchema, type ClientMediaSignalDTMF } from './dtmf';
import { clientMediaSignalErrorSchema, type ClientMediaSignalError } from './error';
import { clientMediaSignalHangupSchema, type ClientMediaSignalHangup } from './hangup';
import { clientMediaSignalLocalSDPSchema, type ClientMediaSignalLocalSDP } from './local-sdp';
import { clientMediaSignalLocalStateSchema, type ClientMediaSignalLocalState } from './local-state';
import { clientMediaSignalNegotiationNeededSchema, type ClientMediaSignalNegotiationNeeded } from './negotiation-needed';
import { clientMediaSignalRegisterSchema, type ClientMediaSignalRegister } from './register';
import { clientMediaSignalRequestCallSchema, type ClientMediaSignalRequestCall } from './request-call';
import { clientMediaSignalTransferSchema, type ClientMediaSignalTransfer } from './transfer';
const ajv = new Ajv({ discriminator: true });
export type ClientMediaSignal =
| ClientMediaSignalLocalSDP
| ClientMediaSignalError
| ClientMediaSignalAnswer
| ClientMediaSignalHangup
| ClientMediaSignalDTMF
| ClientMediaSignalRequestCall
| ClientMediaSignalLocalState
| ClientMediaSignalRegister
| ClientMediaSignalNegotiationNeeded
| ClientMediaSignalTransfer;
export const clientMediaSignalSchema: JSONSchemaType<ClientMediaSignal> = {
type: 'object',
additionalProperties: true,
discriminator: { propertyName: 'type' },
required: ['type'],
oneOf: [
clientMediaSignalLocalSDPSchema,
clientMediaSignalErrorSchema,
clientMediaSignalAnswerSchema,
clientMediaSignalHangupSchema,
clientMediaSignalDTMFSchema,
clientMediaSignalRequestCallSchema,
clientMediaSignalLocalStateSchema,
clientMediaSignalRegisterSchema,
clientMediaSignalNegotiationNeededSchema,
clientMediaSignalTransferSchema,
],
};
export const isClientMediaSignal = ajv.compile(clientMediaSignalSchema);
export type ClientMediaSignalType = ClientMediaSignal['type'];
type ExtractMediaSignal<T, K extends ClientMediaSignalType> = T extends { type: K } ? T : never;
export type GenericClientMediaSignal<K extends ClientMediaSignalType> = ExtractMediaSignal<ClientMediaSignal, K>;
export type ClientMediaSignalBody<K extends ClientMediaSignalType = ClientMediaSignalType> = Omit<
GenericClientMediaSignal<K>,
'callId' | 'contractId' | 'type'
>;

View File

@@ -0,0 +1,51 @@
import type { JSONSchemaType } from 'ajv';
import type { CallAnswer, CallFeature } from '../../call';
import { callAnswerList, callFeatureList } from '../../call/IClientMediaCall';
/** Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached */
export type ClientMediaSignalAnswer = {
callId: string;
type: 'answer';
contractId: string;
answer: CallAnswer;
supportedFeatures?: CallFeature[];
};
export const clientMediaSignalAnswerSchema: JSONSchemaType<ClientMediaSignalAnswer> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
minLength: 1,
},
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'answer',
},
answer: {
type: 'string',
enum: callAnswerList,
nullable: false,
},
supportedFeatures: {
type: 'array',
items: {
type: 'string',
enum: callFeatureList,
nullable: false,
},
nullable: true,
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type', 'answer'],
};

View File

@@ -0,0 +1,42 @@
import type { JSONSchemaType } from 'ajv';
/** Client is sending DTMF tones to the other side of the call */
export type ClientMediaSignalDTMF = {
callId: string;
type: 'dtmf';
contractId: string;
dtmf: string;
duration?: number;
};
export const clientMediaSignalDTMFSchema: JSONSchemaType<ClientMediaSignalDTMF> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
minLength: 1,
},
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'dtmf',
},
dtmf: {
type: 'string',
nullable: false,
minLength: 1,
},
duration: {
type: 'number',
nullable: true,
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type', 'dtmf'],
};

View File

@@ -0,0 +1,57 @@
import type { JSONSchemaType } from 'ajv';
/** Client is reporting an error */
export type ClientMediaSignalError = {
callId: string;
contractId: string;
type: 'error';
errorType?: 'signaling' | 'service' | 'other';
errorCode?: string;
negotiationId?: string;
critical?: boolean;
errorDetails?: string;
};
export const clientMediaSignalErrorSchema: JSONSchemaType<ClientMediaSignalError> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
minLength: 1,
},
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'error',
},
errorType: {
type: 'string',
enum: ['signaling', 'service', 'other'],
nullable: true,
},
errorCode: {
type: 'string',
nullable: true,
},
negotiationId: {
type: 'string',
nullable: true,
},
critical: {
type: 'boolean',
nullable: true,
},
errorDetails: {
type: 'string',
nullable: true,
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type'],
};

View File

@@ -0,0 +1,39 @@
import type { JSONSchemaType } from 'ajv';
import { callHangupReasonList, type CallHangupReason } from '../../call';
/** Client is saying they hanged up from a call. The reason specifies if its a clean hangup or an error */
export type ClientMediaSignalHangup = {
callId: string;
contractId: string;
type: 'hangup';
reason: CallHangupReason;
};
export const clientMediaSignalHangupSchema: JSONSchemaType<ClientMediaSignalHangup> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
minLength: 1,
},
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'hangup',
},
reason: {
type: 'string',
enum: callHangupReasonList,
nullable: false,
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type', 'reason'],
};

View File

@@ -0,0 +1,12 @@
export * from './answer';
export * from './dtmf';
export * from './error';
export * from './hangup';
export * from './local-sdp';
export * from './local-state';
export * from './negotiation-needed';
export * from './register';
export * from './request-call';
export * from './transfer';
export * from './MediaSignal';

View File

@@ -0,0 +1,76 @@
import type { JSONSchemaType } from 'ajv';
import type { MediaStreamIdentification } from '../../media/MediaStreamIdentification';
/** Client is sending the local session description to the server */
export type ClientMediaSignalLocalSDP = {
callId: string;
contractId: string;
type: 'local-sdp';
sdp: RTCSessionDescriptionInit;
negotiationId: string;
streams?: MediaStreamIdentification[];
};
export const clientMediaSignalLocalSDPSchema: JSONSchemaType<ClientMediaSignalLocalSDP> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
minLength: 1,
},
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'local-sdp',
},
sdp: {
type: 'object',
properties: {
sdp: {
type: 'string',
nullable: true,
},
type: {
type: 'string',
enum: ['offer', 'answer', 'pranswer', 'rollback'],
nullable: false,
},
},
additionalProperties: false,
nullable: false,
required: ['type'],
},
negotiationId: {
type: 'string',
nullable: false,
},
streams: {
type: 'array',
nullable: true,
items: {
type: 'object',
properties: {
tag: {
type: 'string',
minLength: 1,
},
id: {
type: 'string',
minLength: 1,
},
},
additionalProperties: false,
required: ['tag', 'id'],
},
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type', 'sdp', 'negotiationId'],
};

View File

@@ -0,0 +1,74 @@
import type { JSONSchemaType } from 'ajv';
import { callStateList, type CallState } from '../../call';
import type { ClientContractState, ClientState } from '../../client';
import { clientContractStateList, clientStateList } from '../../client';
/** Client is sending their local call state */
export type ClientMediaSignalLocalState = {
callId: string;
contractId: string;
type: 'local-state';
callState: CallState;
clientState: ClientState;
serviceStates?: Record<string, string>;
ignored?: boolean;
contractState: ClientContractState;
negotiationId?: string;
};
export const clientMediaSignalLocalStateSchema: JSONSchemaType<ClientMediaSignalLocalState> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
minLength: 1,
},
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'local-state',
},
callState: {
type: 'string',
enum: callStateList,
nullable: false,
},
clientState: {
type: 'string',
enum: clientStateList,
nullable: false,
},
serviceStates: {
type: 'object',
patternProperties: {
'.*': {
type: 'string',
},
},
nullable: true,
required: [],
},
ignored: {
type: 'boolean',
nullable: true,
},
contractState: {
type: 'string',
enum: clientContractStateList,
nullable: false,
},
negotiationId: {
type: 'string',
nullable: true,
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type', 'callState', 'clientState', 'contractState'],
};

View File

@@ -0,0 +1,34 @@
import type { JSONSchemaType } from 'ajv';
/** Client is reporting they need a new service negotiation */
export type ClientMediaSignalNegotiationNeeded = {
callId: string;
type: 'negotiation-needed';
contractId: string;
oldNegotiationId: string;
};
export const clientMediaSignalNegotiationNeededSchema: JSONSchemaType<ClientMediaSignalNegotiationNeeded> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
},
contractId: {
type: 'string',
nullable: false,
},
type: {
type: 'string',
const: 'negotiation-needed',
},
oldNegotiationId: {
type: 'string',
nullable: false,
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type', 'oldNegotiationId'],
};

View File

@@ -0,0 +1,36 @@
import type { JSONSchemaType } from 'ajv';
/** Client is reporting a clean session, possibly brand new */
export type ClientMediaSignalRegister = {
type: 'register';
contractId: string;
oldContractId?: string;
// If true, signals for existing calls will be re-sent to the client
requestSignals?: boolean;
};
export const clientMediaSignalRegisterSchema: JSONSchemaType<ClientMediaSignalRegister> = {
type: 'object',
properties: {
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'register',
},
oldContractId: {
type: 'string',
nullable: true,
},
requestSignals: {
type: 'boolean',
nullable: true,
},
},
additionalProperties: false,
required: ['contractId', 'type'],
};

View File

@@ -0,0 +1,74 @@
import type { JSONSchemaType } from 'ajv';
import type { CallFeature, CallService } from '../../call';
import { callFeatureList } from '../../call/IClientMediaCall';
export type ClientMediaSignalRequestCall = {
/** the callId on this signal is temporary and is never propagated to other agents */
callId: string;
contractId: string;
type: 'request-call';
callee: {
type: 'user' | 'sip';
id: string;
};
supportedServices: CallService[];
supportedFeatures?: CallFeature[];
};
export const clientMediaSignalRequestCallSchema: JSONSchemaType<ClientMediaSignalRequestCall> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
minLength: 1,
},
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'request-call',
},
callee: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['user', 'sip'],
nullable: false,
},
id: {
type: 'string',
minLength: 1,
nullable: false,
},
},
required: ['type', 'id'],
additionalProperties: false,
},
supportedServices: {
type: 'array',
items: {
type: 'string',
enum: ['webrtc'],
nullable: false,
},
nullable: false,
},
supportedFeatures: {
type: 'array',
items: {
type: 'string',
enum: callFeatureList,
nullable: false,
},
nullable: true,
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type', 'callee', 'supportedServices'],
};

View File

@@ -0,0 +1,52 @@
import type { JSONSchemaType } from 'ajv';
/** Client is transfering the other actor to a new actor */
export type ClientMediaSignalTransfer = {
callId: string;
type: 'transfer';
contractId: string;
to: {
type: 'user' | 'sip';
id: string;
};
};
export const clientMediaSignalTransferSchema: JSONSchemaType<ClientMediaSignalTransfer> = {
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
minLength: 1,
},
contractId: {
type: 'string',
nullable: false,
minLength: 1,
},
type: {
type: 'string',
const: 'transfer',
},
to: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['user', 'sip'],
nullable: false,
},
id: {
type: 'string',
nullable: false,
minLength: 1,
},
},
required: ['type', 'id'],
additionalProperties: false,
},
},
additionalProperties: false,
required: ['callId', 'contractId', 'type', 'to'],
};

View File

@@ -0,0 +1,3 @@
export * from './client';
export type * from './server';
export type * from './Transport';

View File

@@ -0,0 +1,19 @@
import type { ServerMediaSignalNewCall } from './new';
import type { ServerMediaSignalNotification } from './notification';
import type { ServerMediaSignalRegistered } from './registered';
import type { ServerMediaSignalRejectedCallRequest } from './rejected-call-request';
import type { ServerMediaSignalRemoteSDP } from './remote-sdp';
import type { ServerMediaSignalRequestOffer } from './request-offer';
export type ServerMediaCallSignal =
| ServerMediaSignalNewCall
| ServerMediaSignalRemoteSDP
| ServerMediaSignalRequestOffer
| ServerMediaSignalNotification
| ServerMediaSignalRejectedCallRequest;
export type ServerMediaSessionSignal = ServerMediaSignalRegistered;
export type ServerMediaSignal = ServerMediaCallSignal | ServerMediaSessionSignal;
export type ServerMediaSignalType = ServerMediaSignal['type'];

View File

@@ -0,0 +1,8 @@
export type * from './new';
export type * from './notification';
export type * from './registered';
export type * from './rejected-call-request';
export type * from './remote-sdp';
export type * from './request-offer';
export type * from './MediaSignal';

View File

@@ -0,0 +1,23 @@
import type { CallContact, CallRole, CallService, CallFlag } from '../../call';
/** Sent by the server to notify an agent that there's a new call for their actor */
export type ServerMediaSignalNewCall = {
callId: string;
type: 'new';
service: CallService;
kind: 'direct';
role: CallRole;
self: CallContact;
contact: CallContact;
/** This will only be sent to the caller, with the id it used to request this call */
requestedCallId?: string;
/** If this new call initiated from a transfer, this will hold the id of the call that was transferred */
replacingCallId?: string;
/** If this new call initiated from a transfer, this will hold the information of the user who requested the transfer */
transferredBy?: CallContact;
// A list of flags that may be sent to the client to toggle custom behaviors
flags?: CallFlag[];
};

View File

@@ -0,0 +1,18 @@
import type { CallFeature, CallHangupReason, CallNotification } from '../../call';
/** Server is sending a notification about the call state */
export type ServerMediaSignalNotification = {
callId: string;
type: 'notification';
notification: CallNotification;
/**
* Used to inform which contract was signed by the server, without targeting the signal to that contract alone
* Optional in general, but at least one notification must be sent with it before the callee can join the call
*/
signedContractId?: string;
features?: CallFeature[];
hangupReason?: CallHangupReason;
};

View File

@@ -0,0 +1,10 @@
/** Server is notifying the client that its registration was processed */
export type ServerMediaSignalRegistered = {
type: 'registered';
toContractId: string;
calls: string[];
activeCalls: string[];
};

View File

@@ -0,0 +1,8 @@
import type { CallRejectedReason } from '../../call';
export type ServerMediaSignalRejectedCallRequest = {
callId: string;
type: 'rejected-call-request';
toContractId: string;
reason: CallRejectedReason;
};

View File

@@ -0,0 +1,12 @@
import type { MediaStreamIdentification } from '../../media/MediaStreamIdentification';
/** Server is sending the other actor's sdp */
export type ServerMediaSignalRemoteSDP = {
callId: string;
toContractId: string;
type: 'remote-sdp';
sdp: RTCSessionDescriptionInit;
negotiationId: string;
streams?: MediaStreamIdentification[];
};

View File

@@ -0,0 +1,8 @@
/** Server is requesting a webrtc offer */
export type ServerMediaSignalRequestOffer = {
callId: string;
toContractId: string;
type: 'request-offer';
negotiationId: string;
};

View File

@@ -0,0 +1,2 @@
export * from './definition';
export * from './lib';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,304 @@
import { Emitter } from '@rocket.chat/emitter';
import type { IClientMediaCall, IWebRTCProcessor, NegotiationManagerEvents, NegotiationManagerConfig } from '../definition';
import { Negotiation } from './services/webrtc/Negotiation';
export class NegotiationManager {
public readonly emitter: Emitter<NegotiationManagerEvents>;
public get currentNegotiationId(): string | null {
return this.currentNegotiation?.negotiationId || this.highestNegotiationId;
}
public get hasFinishedAnyNegotiation(): boolean {
return Boolean(this.highestFinishedNegotiationId);
}
protected negotiations: Map<string, Negotiation>;
/** negotiation actively being processed, null once completed */
protected currentNegotiation: Negotiation | null;
protected highestProcessedSequence: number;
protected highestImpoliteSequence: number;
protected highestSequence: number;
protected webrtcProcessor: IWebRTCProcessor | null;
/** id of the newest negotiation that has reached the processing state */
protected highestNegotiationId: string | null;
/** id of the newest negotiation, regardless of state */
protected highestKnownNegotiationId: string | null;
/** id of the newest negotiation that has finished processing */
protected highestFinishedNegotiationId: string | null;
constructor(
protected readonly call: IClientMediaCall,
protected readonly config: NegotiationManagerConfig,
) {
this.negotiations = new Map();
this.currentNegotiation = null;
this.highestProcessedSequence = 0;
this.highestImpoliteSequence = 0;
this.highestSequence = 0;
this.webrtcProcessor = null;
this.highestNegotiationId = null;
this.highestKnownNegotiationId = null;
this.highestFinishedNegotiationId = null;
this.emitter = new Emitter();
}
public async addNegotiation(
negotiationId: string,
remoteOffer: RTCSessionDescriptionInit | null = null,
negotiationSequence: number | null = null,
): Promise<void> {
if (this.negotiations.has(negotiationId)) {
return;
}
if (remoteOffer && remoteOffer.type !== 'offer') {
return;
}
// If we are not receiving the negotiation sequence, trust that they are arriving in order.
const sequence = negotiationSequence || this.highestSequence + 1;
const isRemoteOffer = Boolean(remoteOffer);
this.config.logger?.debug('NegotiationManager.addNegotiation', negotiationId, sequence, isRemoteOffer);
const isPoliteNegotiation = isRemoteOffer !== this.isPoliteClient();
const maxSkipSequence = isPoliteNegotiation ? this.highestSequence : this.highestImpoliteSequence;
const negotiation = new Negotiation(
{
negotiationId,
sequence,
isPolite: isPoliteNegotiation,
remoteOffer,
},
this.config.logger,
);
if (sequence <= maxSkipSequence) {
negotiation.end();
}
this.addToQueue(negotiation);
return this.processNegotiations();
}
public async setRemoteDescription(
negotiationId: string,
remoteDescription: RTCSessionDescriptionInit,
negotiationSequence: number | null = null,
): Promise<void> {
if (remoteDescription.type === 'offer') {
return this.addNegotiation(negotiationId, remoteDescription, negotiationSequence);
}
this.config.logger?.debug('NegotiationManager.setRemoteDescription', negotiationId);
if (this.currentNegotiation?.negotiationId !== negotiationId) {
this.config.logger?.warn('Received remote description for an unexpected negotiation');
this.emitter.emit('error', { errorCode: 'not-current-negotiation', negotiationId });
return;
}
void this.currentNegotiation
.setRemoteAnswer(remoteDescription)
// No need to handle errors here as they are already handled by the 'error' event
.catch(() => null);
}
public setWebRTCProcessor(webrtcProcessor: IWebRTCProcessor) {
this.config.logger?.debug('NegotiationManager.setWebRTCProcessor');
this.webrtcProcessor = webrtcProcessor;
this.webrtcProcessor.emitter.on('internalError', (event) => this.onWebRTCInternalError(event));
this.webrtcProcessor.emitter.on('negotiationNeeded', () => this.onWebRTCNegotiationNeeded());
}
public async processNegotiations(): Promise<void> {
this.config.logger?.debug('NegotiationManager.processNegotiations');
if (!this.isConfigured()) {
return;
}
if (this.currentNegotiation) {
return;
}
const nextNegotiation = this.getNextInQueue();
if (!nextNegotiation) {
return;
}
await this.processNegotiation(nextNegotiation);
}
protected isPoliteClient(): boolean {
return this.call.localParticipant.role === 'callee';
}
protected addToQueue(negotiation: Negotiation): void {
this.config.logger?.debug('NegotiationManager.addToQueue', negotiation.negotiationId);
if (negotiation.sequence > this.highestSequence) {
this.highestSequence = negotiation.sequence;
this.highestKnownNegotiationId = negotiation.negotiationId;
}
this.negotiations.set(negotiation.negotiationId, negotiation);
if (!negotiation.ended) {
if (!negotiation.isPolite) {
if (negotiation.sequence > this.highestImpoliteSequence) {
this.highestImpoliteSequence = negotiation.sequence;
}
if (this.currentNegotiation?.isPolite) {
this.currentNegotiation.end();
this.currentNegotiation = null;
}
}
}
}
protected getNextInQueue(): Negotiation | null {
for (const negotiation of this.negotiations.values()) {
// Skip negotiations that have already started processing or been skipped
if (negotiation.ended || negotiation.started || negotiation.sequence <= this.highestProcessedSequence) {
continue;
}
// Skip negotiations that can be fulfilled by some newer negotiation
if (negotiation.sequence < this.highestImpoliteSequence) {
negotiation.end();
continue;
}
// Polite negotiations are only processed if there's nothing else queued
if (negotiation.isPolite && negotiation.sequence < this.highestSequence) {
negotiation.end();
continue;
}
return negotiation;
}
return null;
}
protected async processNegotiation(this: WebRTCNegotiationManager, negotiation: Negotiation): Promise<void> {
this.config.logger?.debug('NegotiationManager.processNegotiation', negotiation.negotiationId);
this.currentNegotiation = negotiation;
this.highestProcessedSequence = negotiation.sequence;
this.highestNegotiationId = negotiation.negotiationId;
negotiation.emitter.on('ended', () => {
if (this.currentNegotiation !== negotiation) {
return;
}
if (negotiation.finished) {
this.highestFinishedNegotiationId = negotiation.negotiationId;
}
this.config.logger?.debug('NegotiationManager.processNegotiation.ended');
this.currentNegotiation = null;
void this.processNegotiations();
});
negotiation.emitter.on('error', ({ errorCode }) => {
this.config.logger?.error('Negotiation error', errorCode);
this.emitter.emit('error', { errorCode, negotiationId: negotiation.negotiationId });
if (this.currentNegotiation === negotiation) {
this.currentNegotiation.end();
}
});
negotiation.emitter.on('local-sdp', ({ sdp }) => {
this.config.logger?.debug('NegotiationManager.processNegotiation.local-sdp');
this.emitter.emit('local-sdp', { sdp, negotiationId: negotiation.negotiationId });
});
void negotiation
.process(this.webrtcProcessor)
// No need to handle errors here as they are already handled by the 'error' event
.catch(() => null);
this.emitter.emit('negotiation-started');
}
public isConfigured(): this is WebRTCNegotiationManager {
if (this.call.state === 'hangup' || this.call.hidden) {
this.config.logger?.debug('Ignoring WebRTC negotiations due to call state.');
return false;
}
if (!this.webrtcProcessor) {
this.config.logger?.debug('Delaying WebRTC negotiations due to missing processor.');
return false;
}
// Wait for the input track before negotiating, to avoid potentially having to renegotiate immediately
if (!this.webrtcProcessor.streams.hasAllRequiredTracks()) {
this.config.logger?.debug('Delaying WebRTC negotiations due to missing required track.');
return false;
}
return true;
}
protected isFulfillingNegotiationQueued(): boolean {
// If we're a polite client, then any queued negotiation is enough to fulfill our negotiation needs
if (this.isPoliteClient()) {
return this.highestSequence > this.highestProcessedSequence;
}
// If there's an impolite negotiation queued, that's good enough for any client
return this.highestImpoliteSequence > this.highestProcessedSequence;
}
protected onWebRTCNegotiationNeeded(): void {
this.config.logger?.debug('NegotiationManager.onWebRTCNegotiationNeeded');
if (!this.isConfigured()) {
return;
}
// If we haven't processed any negotiation yet, then we can ignore any negotiation request
if (!this.highestNegotiationId || !this.highestKnownNegotiationId) {
return;
}
// If we already have a queued negotiation that would fulfill this need, then don't do anything
if (this.isFulfillingNegotiationQueued()) {
return;
}
// When requesting a renegotiation, always use the newest negotiation id we know that doesn't fulfill our need
this.emitter.emit('negotiation-needed', { oldNegotiationId: this.highestKnownNegotiationId });
}
protected onWebRTCInternalError({ critical, error }: { critical: boolean; error: string | Error; errorDetails?: string }): void {
this.config.logger?.debug('NegotiationManager.onWebRTCInternalError', critical, error);
const errorCode = typeof error === 'object' ? error.message : error;
const negotiationId = this.currentNegotiationId;
if (negotiationId) {
this.emitter.emit('error', { errorCode, negotiationId });
}
}
}
abstract class WebRTCNegotiationManager extends NegotiationManager {
protected abstract override webrtcProcessor: IWebRTCProcessor;
}

View File

@@ -0,0 +1,808 @@
import { Emitter } from '@rocket.chat/emitter';
import { ClientMediaCall } from './Call';
import { MediaSignalTransportWrapper } from './TransportWrapper';
import type {
ClientMediaSignal,
IServiceProcessorFactoryList,
MediaSignalTransport,
MediaStreamFactory,
RandomStringFactory,
ServerMediaCallSignal,
ServerMediaSessionSignal,
ServerMediaSignal,
ServerMediaSignalRegistered,
} from '../definition';
import type { IClientMediaCall, CallActorType, CallContact, CallFeature, AnyMediaCallData } from '../definition/call';
import type { IMediaSignalLogger } from '../definition/logger';
import { SessionRegistration } from './components/SessionRegistration';
import { isSameDeviceId } from './utils/isSameDeviceId';
export type MediaSignalingEvents = {
sessionStateChange: void;
newCall: { call: IClientMediaCall };
acceptedCall: { call: IClientMediaCall };
endedCall: void;
droppedCall: void;
hiddenCall: void;
registered: { activeCalls: IClientMediaCall['callId'][] };
outOfSync: { missingCalls: IClientMediaCall['callId'][] };
};
export type MediaSignalingSessionConfig = {
userId: string;
mobileDeviceId?: string;
oldSessionId?: string;
logger?: IMediaSignalLogger;
processorFactories: IServiceProcessorFactoryList;
mediaStreamFactory: MediaStreamFactory;
displayMediaFactory: MediaStreamFactory;
randomStringFactory: RandomStringFactory;
transport: MediaSignalTransport<ClientMediaSignal>;
iceGatheringTimeout?: number;
iceServers?: RTCIceServer[];
features: CallFeature[];
autoSync?: boolean;
};
const STATE_REPORT_INTERVAL = 60000;
export class MediaSignalingSession extends Emitter<MediaSignalingEvents> {
private _userId: string;
private readonly _sessionId: string;
private knownCalls: Map<string, ClientMediaCall>;
private ignoredCalls: Set<string>;
private transporter: MediaSignalTransportWrapper;
private recurringStateReportHandler: ReturnType<typeof setInterval> | null;
private inputTrack: MediaStreamTrack | null;
private switchingInputTrack: boolean;
private deviceId: ConstrainDOMString | null;
private currentDeviceId: ConstrainDOMString | null;
private callsToGetUserMedia: number;
private lastRegisterTimestamp: Date | null = null;
private lastState: { mainCall: ClientMediaCall | null; hidden: boolean; busy: boolean; localCall: ClientMediaCall | null };
private sessionEnded = false;
private registration: SessionRegistration;
private _micless: boolean = false;
private shouldMuteMiclessCall = false;
public get sessionId(): string {
return this._sessionId;
}
public get userId(): string {
return this._userId;
}
public get registered(): boolean {
return this.registration.registered;
}
// FIXME: This state is controlled outside of this class. MediaSignalingSession should handle this fallback in another way so this information doesn't depend on the consumer
// FIXME: Consumers can still unmute the call even when this is set to true. That behaviour should be guarded at the call level to avoid representing incorrect states.
/* micless: used by the consumer to identify when a "fake stream" was used due to inability to retrieve a proper device. When set to true will mute the call once when it starts */
public set micless(micless: boolean) {
if (micless) {
this.shouldMuteMiclessCall = true;
} else {
this.shouldMuteMiclessCall = false;
}
this._micless = micless;
}
public get micless() {
return this._micless;
}
constructor(private config: MediaSignalingSessionConfig) {
super();
this._userId = config.userId;
this._sessionId = config.mobileDeviceId || config.randomStringFactory();
this.recurringStateReportHandler = null;
this.knownCalls = new Map<string, ClientMediaCall>();
this.ignoredCalls = new Set<string>();
this.inputTrack = null;
this.switchingInputTrack = false;
this.deviceId = null;
this.currentDeviceId = null;
this.callsToGetUserMedia = 0;
this.lastState = { mainCall: null, hidden: false, busy: false, localCall: null };
this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger);
this.registration = new SessionRegistration({
logger: config.logger,
registerFn: () => this.sendRegisterSignal(),
});
this.registration.register();
this.enableStateReport(STATE_REPORT_INTERVAL);
}
public isBusy(): boolean {
return this.getMainCall(false)?.busy ?? false;
}
public enableStateReport(interval: number): void {
this.disableStateReport();
this.recurringStateReportHandler = setInterval(() => {
this.reportState();
}, interval);
}
public disableStateReport(): void {
if (this.recurringStateReportHandler) {
clearInterval(this.recurringStateReportHandler);
this.recurringStateReportHandler = null;
}
}
public endSession(): void {
this.sessionEnded = true;
this.registration.endSession();
this.disableStateReport();
// best‑effort: stop capturing audio
void this.setInputTrack(null).catch(() => undefined);
for (const call of this.knownCalls.values()) {
this.ignoredCalls.add(call.callId);
void this.setScreenVideoTrack(null, call).catch(() => undefined);
call.ignore();
}
this.knownCalls.clear();
this.emit('sessionStateChange');
}
public getCallData(callId: string): IClientMediaCall | null {
return this.knownCalls.get(callId) || null;
}
public getState(skipLocal = false): (AnyMediaCallData & { call: IClientMediaCall }) | null {
const call = this.getMainCall(skipLocal);
if (!call) {
return null;
}
const state = call.callStateData;
return {
...state,
call,
};
}
private getMainCall(skipLocal = false): ClientMediaCall | null {
let ringingCall: ClientMediaCall | null = null;
let pendingCall: ClientMediaCall | null = null;
for (const call of this.knownCalls.values()) {
if (call.state === 'hangup' || call.ignored || !call.initialized) {
continue;
}
if (skipLocal && !call.confirmed) {
continue;
}
if (call.busy) {
return call;
}
if (call.state === 'ringing' && !ringingCall) {
ringingCall = call;
continue;
}
if (call.state === 'none' && !pendingCall) {
pendingCall = call;
continue;
}
}
return ringingCall || pendingCall;
}
public async processSignal(signal: ServerMediaSignal): Promise<void> {
if (this.sessionEnded) {
return;
}
this.config.logger?.debug('MediaSignalingSession.processSignal', signal);
if ('callId' in signal) {
return this.processCallSignal(signal);
}
return this.processSessionSignal(signal);
}
private processSessionSignal(signal: ServerMediaSessionSignal): void {
switch (signal.type) {
case 'registered':
return this.confirmSessionRegistered(signal);
}
}
private async processCallSignal(signal: ServerMediaCallSignal): Promise<void> {
if (this.isCallIgnored(signal.callId)) {
return;
}
const call = this.getOrCreateCallBySignal(signal);
if (signal.type === 'notification' && signal.signedContractId) {
if (signal.signedContractId === this._sessionId) {
call.setContractState('signed');
} else if (signal.notification === 'accepted') {
// The server accepted a contract, but it wasn't ours - ignore the call in this session
call.setContractState('ignored');
}
} else if ('toContractId' in signal) {
call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored');
} else if (signal.type === 'new' && signal.self.contractId) {
call.setContractState(signal.self.contractId === this._sessionId ? 'signed' : 'ignored');
}
const oldCall = this.getReplacedCallBySignal(signal);
await call.processSignal(signal, oldCall);
}
public async setDeviceId(deviceId: ConstrainDOMString | null, force?: boolean): Promise<void> {
this.deviceId = deviceId;
if (this.switchingInputTrack) {
this.config.logger?.warn('Audio Device was changed while the input track was being actively switched.');
}
// do nothing if:
// 1. doesn't have any input track yet
// 2. it's the same device id (force flag bypasses this)
// 3. has no restriction on which device to use
if (!this.inputTrack || !deviceId || (isSameDeviceId(deviceId, this.currentDeviceId) && !force)) {
return;
}
this.config.logger?.debug('MediaSignalingSession.setDeviceId');
await this.setInputTrack(null);
await this.startInputTrack();
}
public async startCall(calleeType: CallActorType, calleeId: string, params: { contactInfo?: CallContact } = {}): Promise<void> {
this.config.logger?.debug('MediaSignalingSession.startCall', calleeId);
if (this.getMainCall(false)) {
throw new Error(`Already on a call.`);
}
const { contactInfo } = params;
const callId = this.createTemporaryCallId();
const call = this.createCall(callId);
await call.requestCall({ type: calleeType, id: calleeId }, this.config.features, contactInfo);
}
public setIceGatheringTimeout(newTimeout: number): void {
this.config.iceGatheringTimeout = newTimeout;
}
public setIceServers(iceServers: RTCIceServer[]): void {
this.config.iceServers = iceServers;
}
private createTemporaryCallId(): string {
return `${this._sessionId}-${this.config.randomStringFactory()}`;
}
private isCallIgnored(callId: string): boolean {
return this.ignoredCalls.has(callId);
}
private ignoreCall(callId: string) {
this.config.logger?.debug('MediaSignalingSession.ignoreCall', callId);
this.ignoredCalls.add(callId);
if (this.knownCalls.has(callId)) {
const call = this.knownCalls.get(callId);
this.knownCalls.delete(callId);
call?.ignore();
}
}
private sendRegisterSignal(): void {
this.lastRegisterTimestamp = new Date();
this.transporter.sendSignal({
type: 'register',
contractId: this._sessionId,
requestSignals: Boolean(this.config.autoSync),
...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }),
});
}
private confirmSessionRegistered(signal: ServerMediaSignalRegistered): void {
this.config.logger?.debug('MediaSignalingSession.sessionRegistered', signal.calls);
const wasRegistered = this.registered;
this.registration.confirmRegistration();
this.emit('registered', { activeCalls: signal.activeCalls });
if (!wasRegistered) {
this.onSessionStateChange();
}
if (!this.config.autoSync) {
const missingCalls = signal.calls.filter((callId) => !this.knownCalls.has(callId) && !this.ignoredCalls.has(callId));
if (missingCalls.length) {
this.emit('outOfSync', { missingCalls });
}
}
}
private getExistingCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null {
const existingCall = this.knownCalls.get(signal.callId);
if (existingCall) {
return existingCall;
}
if (signal.type === 'new' && signal.requestedCallId) {
const localCall = this.knownCalls.get(signal.requestedCallId);
if (localCall) {
this.knownCalls.set(signal.callId, localCall);
this.knownCalls.delete(signal.requestedCallId);
return localCall;
}
}
return null;
}
private getReplacedCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null {
if ('replacingCallId' in signal && signal.replacingCallId) {
return this.knownCalls.get(signal.replacingCallId) || null;
}
return null;
}
private getOrCreateCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall {
this.config.logger?.debug('MediaSignalingSession.getOrCreateCallBySignal', signal);
const existingCall = this.getExistingCallBySignal(signal);
if (existingCall) {
return existingCall;
}
return this.createCall(signal.callId);
}
private reportState(): void {
let reportedAny = false;
let anyNotOver = false;
for (const call of this.knownCalls.values()) {
if (call.state !== 'hangup') {
anyNotOver = true;
}
if (!call.isAbleToReportStates()) {
continue;
}
reportedAny = true;
call.reportStates();
}
if (reportedAny) {
// If we're reporting a call's state, then ensure we'll register again once all calls over
this.lastRegisterTimestamp = null;
return;
}
// Even if we're not reporting any calls, if we know about one that isn't over, don't register
if (anyNotOver) {
return;
}
// By registering we're telling the server we have a clean session; if it's not supposed to be clean, it'll tell us
this.autoRegister();
}
private autoRegister(): void {
if (this.lastRegisterTimestamp) {
const diff = Date.now() - this.lastRegisterTimestamp.valueOf();
if (diff < STATE_REPORT_INTERVAL * 10) {
return;
}
}
this.registration.reRegister();
}
private async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise<void> {
this.config.logger?.debug('MediaSignalingSession.setInputTrack', Boolean(newInputTrack));
const { inputTrack: oldInputTrack } = this;
if (newInputTrack === oldInputTrack) {
return;
}
this.inputTrack = newInputTrack;
for (const call of this.knownCalls.values()) {
await call.setInputTrack(newInputTrack).catch((error) => {
if (newInputTrack) {
throw error;
}
});
}
if (oldInputTrack) {
this.config.logger?.debug('MediaSignalingSession.setInputTrack.stopOldTrack');
try {
oldInputTrack.stop();
} catch {
//
}
}
}
public requestInputTrackUpdate(): void {
// Don't do anything if we don't need to switch the track now
// This extra check here ensures that requestInputTrackUpdate can be called multiple times even though switchInputTrack can't
if (!this.shouldSwitchInputTrack()) {
return;
}
this.switchInputTrack().catch(() => null);
}
/**
* Switch ON/OFF the use of an audio input track
* If there's one already in use, remove it; Otherwise, request and use a new one.
* This function assumes the current state needs to change and doesn't check anything before starting the switch process
* Switching OFF is straightforward: the current track is removed and stopped
* Switching ON is a multi-step process:
* 1. We request a new track from the media stream factory
* 2. Once the media stream factory returns a valid track, we double check that we still need it
* 2.1. If the track is still needed, we set it to all active calls
* 2.2. If the track is no longer needed by then, we stop it and keep no reference to it
*
* The track state only changes by the end of the whole process, so there's no point in calling this function twice and we guard against it --
* but we don't guard against external changes to the track (for example, calling setDeviceId will also change the track state)
* */
private async switchInputTrack(): Promise<void> {
this.config.logger?.debug('MediaSignalingSession.switchInputTrack', this.callsToGetUserMedia);
if (this.switchingInputTrack) {
this.config.logger?.warn('MediaSignalingSession.switchInputTrack', 'Input Track Switcher was called twice');
return;
}
if (!this.shouldSwitchInputTrack()) {
this.config.logger?.warn('MediaSignalingSession.switchInputTrack', 'Input Track Switcher was called but is no longer needed');
return;
}
this.switchingInputTrack = true;
try {
if (this.inputTrack) {
await this.setInputTrack(null);
return;
}
await this.startInputTrack();
} finally {
this.switchingInputTrack = false;
this.config.logger?.debug('MediaSignalingSession.switchInputTrack.finally', this.callsToGetUserMedia);
}
}
private shouldStartInputTrack(): boolean {
if (this.inputTrack) {
return false;
}
if (this.callsToGetUserMedia > 0) {
return false;
}
for (const call of this.knownCalls.values()) {
if (call.needsInputTrack()) {
return true;
}
}
return false;
}
private shouldSwitchInputTrack(): boolean {
if (this.inputTrack) {
return !this.mayNeedInputTrack();
}
return this.shouldStartInputTrack();
}
private getAudioConstraints(): boolean | MediaTrackConstraints {
if (this.deviceId) {
return { deviceId: this.deviceId };
}
return true;
}
private async startInputTrack(): Promise<void> {
this.config.logger?.debug('MediaSignalingSession.startInputTrack', this.callsToGetUserMedia);
this.currentDeviceId = this.deviceId;
let userMedia: MediaStream | null;
this.callsToGetUserMedia++;
try {
userMedia = await this.config.mediaStreamFactory({ audio: this.getAudioConstraints() }).catch(() => null);
} finally {
this.callsToGetUserMedia--;
}
// If there's multiple simultaneous attempts to get the track, only process the output of the last one
if (this.callsToGetUserMedia > 0) {
this.config.logger?.debug('MediaSignalingSession.startInputTrack.skipped', this.callsToGetUserMedia);
return;
}
this.config.logger?.debug('MediaSignalingSession.startInputTrack.done', this.callsToGetUserMedia);
if (!userMedia) {
return this.hangupCallsThatNeedInput();
}
const tracks = userMedia.getAudioTracks();
if (!tracks.length) {
return this.hangupCallsThatNeedInput();
}
const inputTrack = tracks[0];
// If we no longer have a call that can use this track, just release it
if (inputTrack && !this.mayNeedInputTrack()) {
try {
// Stop the track so the browser doesn't have to wait for GC to detect that the stream is not in use
inputTrack.stop();
} catch {
// we don't care if this failed
}
return;
}
return this.setInputTrack(inputTrack);
}
private hangupCallsThatNeedInput(): void {
this.config.logger?.debug('MediaSignalingSession.hangupCallsThatNeedInput');
for (const call of this.knownCalls.values()) {
if (!call.needsInputTrack()) {
continue;
}
try {
call.hangup('input-error');
} catch {
//
}
}
}
private mayNeedInputTrack(): boolean {
for (const call of this.knownCalls.values()) {
if (call.mayNeedInputTrack()) {
return true;
}
}
return false;
}
private async setScreenVideoTrack(newVideoTrack: MediaStreamTrack | null, call: ClientMediaCall): Promise<void> {
this.config.logger?.debug('MediaSignalingSession.setScreenVideoTrack', Boolean(newVideoTrack));
await call.setScreenVideoTrack(newVideoTrack);
}
private async startScreenVideoTrack(): Promise<MediaStreamTrack | void> {
this.config.logger?.debug('MediaSignalingSession.startScreenVideoTrack');
const displayMedia = await this.config.displayMediaFactory({}).catch(() => null);
this.config.logger?.debug('MediaSignalingSession.startScreenVideoTrack.done');
if (!displayMedia) {
this.config.logger?.error('MediaSignalingSession.startScreenVideoTrack.failed.noDisplayMedia');
throw new Error('Failed to get display media');
}
const tracks = displayMedia.getVideoTracks();
if (!tracks.length) {
this.config.logger?.error('MediaSignalingSession.startScreenVideoTrack.failed.noTracks');
throw new Error('Failed to get video tracks');
}
return tracks[0];
}
private async endScreenSharing(call: ClientMediaCall): Promise<void> {
this.config.logger?.debug('MediaSignalingSession.endScreenSharing');
await this.setScreenVideoTrack(null, call);
}
private async startScreenSharing(call: ClientMediaCall): Promise<void> {
this.config.logger?.debug('MediaSignalingSession.startScreenSharing');
const track = await this.startScreenVideoTrack();
if (!track) {
return;
}
await this.setScreenVideoTrack(track, call);
}
private createCall(callId: string): ClientMediaCall {
this.config.logger?.debug('MediaSignalingSession.createCall');
const config = {
userId: this.config.userId,
logger: this.config.logger,
transporter: this.transporter,
processorFactories: this.config.processorFactories,
iceGatheringTimeout: this.config.iceGatheringTimeout || 5000,
iceServers: this.config.iceServers || [],
sessionId: this._sessionId,
supportedFeatures: this.config.features,
};
const call = new ClientMediaCall(config, callId, { inputTrack: this.inputTrack });
this.knownCalls.set(callId, call);
call.emitter.on('contactUpdate', () => this.onCallContactUpdate(call));
call.emitter.on('stateChange', () => this.onCallStateChange(call));
call.emitter.on('clientStateChange', () => this.onCallClientStateChange(call));
call.emitter.on('trackStateChange', () => this.onTrackStateChange(call));
call.emitter.on('initialized', () => this.onNewCall(call));
call.emitter.on('confirmed', () => this.onConfirmedCall(call));
call.emitter.on('accepted', () => this.onAcceptedCall(call));
call.emitter.on('accepting', () => this.onAcceptingCall(call));
call.emitter.on('hidden', () => this.onHiddenCall(call));
call.emitter.on('active', () => this.onActiveCall(call));
call.emitter.on('ended', () => this.onEndedCall(call));
call.emitter.on('screenShareRequestChange', (requested: boolean) => this.onScreenShareRequestChange(call, requested));
call.emitter.on('streamChange', () => this.onSessionStateChange());
return call;
}
private onCallContactUpdate(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onCallContactUpdate');
this.onSessionStateChange();
}
private onCallStateChange(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onCallStateChange');
this.onSessionStateChange();
}
private onCallClientStateChange(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onCallClientStateChange');
this.onSessionStateChange();
}
private onNewCall(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onNewCall');
this.onSessionStateChange();
}
private onConfirmedCall(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onConfirmedCall');
this.onSessionStateChange();
}
private onAcceptedCall(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onAcceptedCall');
this.onSessionStateChange();
}
private onAcceptingCall(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onAcceptingCall');
this.onSessionStateChange();
}
private onTrackStateChange(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onTrackStateChange');
this.onSessionStateChange();
}
private onEndedCall(call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onEndedCall');
this.ignoreCall(call.callId);
this.onSessionStateChange();
}
private onHiddenCall(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onHiddenCall');
this.onSessionStateChange();
}
private onActiveCall(_call: ClientMediaCall): void {
this.config.logger?.debug('MediaSignalingSession.onActiveCall');
this.onSessionStateChange();
}
private async onScreenShareRequestChange(call: ClientMediaCall, requested: boolean): Promise<void> {
this.config.logger?.debug('MediaSignalingSession.onScreenShareRequestChange');
if (!requested) {
await this.endScreenSharing(call);
} else {
await this.startScreenSharing(call);
}
this.onSessionStateChange();
}
private onSessionStateChange(): void {
const { mainCall: oldCall, hidden: wasHidden, busy: wasBusy, localCall: oldLocalCall } = this.lastState;
if (!this.registration.active) {
if (oldCall) {
this.emit('endedCall');
}
this.config.logger?.debug('skipping session events on inactive session');
return;
}
// Do not skip local calls if we transitioned from a different active call to it
const mainCall = this.getMainCall(!oldCall);
const localCall = mainCall || this.getMainCall(false);
const hidden = mainCall?.hidden ?? false;
const busy = mainCall?.busy ?? false;
this.lastState = { mainCall, hidden, busy, localCall };
if (mainCall && !oldCall) {
this.emit('newCall', { call: mainCall });
}
if (mainCall && busy && !wasBusy) {
this.emit('acceptedCall', { call: mainCall });
}
this.emit('sessionStateChange');
this.requestInputTrackUpdate();
if (mainCall && this.shouldMuteMiclessCall) {
this.shouldMuteMiclessCall = false;
if (!mainCall.muted) {
mainCall.setMuted(true);
}
}
if (oldCall) {
if (!mainCall) {
this.emit('endedCall');
if (!wasHidden && !oldCall.shouldSkipDroppedEvent()) {
this.config.logger?.debug('droppedCall');
this.emit('droppedCall');
}
} else if (!wasHidden && hidden) {
this.emit('hiddenCall');
}
} else if (oldLocalCall && !localCall && !oldLocalCall.shouldSkipDroppedEvent()) {
this.config.logger?.debug('droppedCall');
this.emit('droppedCall');
}
}
}

View File

@@ -0,0 +1,55 @@
import type { CallAnswer, CallFeature, CallHangupReason } from '../definition';
import type { IMediaSignalLogger } from '../definition/logger';
import type {
MediaSignalTransport,
ClientMediaSignalType,
ClientMediaSignalBody,
GenericClientMediaSignal,
ClientMediaSignal,
ClientMediaSignalError,
} from '../definition/signals';
export class MediaSignalTransportWrapper {
constructor(
public readonly contractId: string,
private sendSignalFn: MediaSignalTransport<ClientMediaSignal>,
private logger?: IMediaSignalLogger,
) {}
public sendToServer<T extends ClientMediaSignalType>(callId: string, type: T, signal: ClientMediaSignalBody<T>) {
this.logger?.debug('MediaSignalTransportWrapper.sendToServer', type);
return this.sendSignal({
...(type !== 'register' && { callId }),
contractId: this.contractId,
type,
...signal,
} as GenericClientMediaSignal<T>);
}
public sendError(callId: string, { errorType, errorCode, negotiationId, critical, errorDetails }: Partial<ClientMediaSignalError>) {
this.sendToServer(callId, 'error', {
errorType: errorType || 'other',
...(errorCode && { errorCode }),
...(negotiationId && { negotiationId }),
...(critical ? { critical } : { critical: false }),
...(errorDetails && { errorDetails }),
});
}
public answer(callId: string, answer: CallAnswer, extraData: { supportedFeatures?: CallFeature[] } = {}) {
return this.sendToServer(callId, 'answer', { answer, ...extraData });
}
public hangup(callId: string, reason: CallHangupReason) {
return this.sendToServer(callId, 'hangup', { reason });
}
public requestRenegotiation(callId: string, oldNegotiationId: string) {
return this.sendToServer(callId, 'negotiation-needed', { oldNegotiationId });
}
public sendSignal(signal: ClientMediaSignal): void {
this.logger?.debug('MediaSignalTransportWrapper.sendSignal', signal);
this.sendSignalFn(signal);
}
}

View File

@@ -0,0 +1,81 @@
import type { IMediaSignalLogger } from '../../definition';
const REGISTER_CONFIRMATION_TIMEOUT = 1000;
const MAX_REGISTER_ATTEMPTS = 10;
type SessionRegistrationConfig = {
logger?: IMediaSignalLogger;
registerFn: () => void;
};
export class SessionRegistration {
public get registered(): boolean {
return this.registrationConfirmed;
}
public get active(): boolean {
return this.registered && !this.sessionEnded;
}
private sessionEnded = false;
private registrationConfirmed = false;
private registerConfirmationHandler: ReturnType<typeof setTimeout> | null = null;
constructor(private config: SessionRegistrationConfig) {
//
}
public register(): void {
if (this.registerConfirmationHandler) {
return;
}
this.registerAttempt(1);
}
public reRegister(): void {
if (this.sessionEnded) {
return;
}
this.config.logger?.debug('SessionRegistration.reRegister');
this.clearRegisterConfirmationHandler();
this.register();
}
public confirmRegistration(): void {
this.registrationConfirmed = true;
this.clearRegisterConfirmationHandler();
}
public endSession(): void {
this.sessionEnded = true;
}
private clearRegisterConfirmationHandler(): void {
if (this.registerConfirmationHandler) {
clearTimeout(this.registerConfirmationHandler);
this.registerConfirmationHandler = null;
}
}
private registerAttempt(attempt: number): void {
if (this.sessionEnded) {
return;
}
this.config.logger?.debug('SessionRegistration.registerAttempt', attempt);
const timeout = attempt * REGISTER_CONFIRMATION_TIMEOUT;
this.registerConfirmationHandler = setTimeout(() => {
this.registerConfirmationHandler = null;
if (attempt < MAX_REGISTER_ATTEMPTS) {
this.registerAttempt(attempt + 1);
}
}, timeout);
this.config.registerFn();
}
}

View File

@@ -0,0 +1,6 @@
export * from './Session';
export * from './Call';
export * from './TransportWrapper';
export * from './services/states';
export * from './services/webrtc';
export type { MediaStreamWrapper } from './media/MediaStreamWrapper';

View File

@@ -0,0 +1,127 @@
import { Emitter } from '@rocket.chat/emitter';
import { MediaStreamWrapper } from './MediaStreamWrapper';
import type { IMediaSignalLogger } from '../../definition';
import type { IMediaStreamManager, MediaStreamManagerEvents } from '../../definition/media/IMediaStreamManager';
import type { MediaStreamIdentification } from '../../definition/media/MediaStreamIdentification';
export class MediaStreamManager implements IMediaStreamManager {
public readonly emitter: Emitter<MediaStreamManagerEvents>;
public readonly mainLocal: MediaStreamWrapper;
public readonly screenShareLocal: MediaStreamWrapper;
public readonly mainRemote: MediaStreamWrapper;
public readonly screenShareRemote: MediaStreamWrapper;
constructor(
protected readonly peer: RTCPeerConnection,
protected readonly logger?: IMediaSignalLogger,
) {
this.emitter = new Emitter();
this.mainLocal = this.createStream(false, 'main');
this.screenShareLocal = this.createStream(false, 'screen-share');
this.mainRemote = this.createStream(true, 'main');
this.screenShareRemote = this.createStream(true, 'screen-share');
}
public stopRemoteStreams(): void {
this.mainRemote.stop();
this.screenShareRemote.stop();
}
public setRemoteIds(streams: MediaStreamIdentification[]): void {
for (const stream of streams) {
this.logger?.debug('setting remote id', stream.tag, stream.id);
const localStream = this.getRemoteStreamByTag(stream.tag);
if (!localStream) {
continue;
}
localStream.addRemoteId(stream.id);
}
}
public getLocalStreamIds(): MediaStreamIdentification[] {
return this.getLocalStreams().map((stream) => ({
tag: stream.tag,
id: stream.stream.id,
}));
}
public addRemoteTrack(track: MediaStreamTrack, streams: readonly MediaStream[]): void {
this.logger?.debug('addRemoteTrack', track.kind);
const streamWrappers = this.findStreamWrappersForRemoteTrack(track, streams);
for (const stream of streamWrappers) {
this.logger?.debug('setRemoteTrack', stream.tag, track.kind);
void stream.setTrack(track.kind, track);
}
}
public getStreams(): MediaStreamWrapper[] {
return [...this.getLocalStreams(), ...this.getRemoteStreams()];
}
public getLocalStreams(): MediaStreamWrapper[] {
return [this.mainLocal, this.screenShareLocal];
}
public getRemoteStreams(): MediaStreamWrapper[] {
return [this.mainRemote, this.screenShareRemote];
}
public getLocalStreamByTag(tag: string): MediaStreamWrapper | null {
return this.getLocalStreams().find((stream) => stream.tag === tag) || null;
}
public getRemoteStreamByTag(tag: string): MediaStreamWrapper | null {
return this.getRemoteStreams().find((stream) => stream.tag === tag) || null;
}
public hasAllRequiredTracks(): boolean {
return this.mainLocal.hasAudio();
}
private findStreamWrappersForRemoteTrack(track: MediaStreamTrack, streams: readonly MediaStream[]): MediaStreamWrapper[] {
const streamWrappers = streams
.map((stream) => this.getRemoteStreamById(stream.id))
.filter((wrapper): wrapper is MediaStreamWrapper => Boolean(wrapper));
if (streamWrappers.length) {
this.logger?.debug('found stream wrappers for track');
return streamWrappers;
}
// If no streams have been found by id and it's an audio track, this is probably an external call so assume the main stream
if (track.kind === 'audio') {
this.logger?.debug('default audio to main track');
return [this.mainRemote];
}
// A video track for an unidentified stream - since the only video we support now is screen share, assume that's what this is
this.logger?.debug('unidentified stream, assuming screen-share');
return [this.screenShareRemote];
}
private createStream(remote: boolean, tag: string): MediaStreamWrapper {
const wrapper = new MediaStreamWrapper(remote, tag, this.peer, this.logger);
wrapper.emitter.on('trackChanged', () => {
this.logger?.debug('Wrapper.trackChanged', tag, remote);
this.emitter.emit('streamChanged');
});
wrapper.emitter.on('stateChanged', () => {
this.logger?.debug('Wrapper.stateChanged', tag, remote);
this.emitter.emit('streamChanged');
});
return wrapper;
}
private getRemoteStreamById(id: string): MediaStreamWrapper | null {
return this.getRemoteStreams().find((stream) => stream.hasRemoteId(id)) || null;
}
}

View File

@@ -0,0 +1,148 @@
import { Emitter } from '@rocket.chat/emitter';
/**
* As a workaround for a chrome bug, we use a delay to ignore any 'mute' events that are immediately followed by an 'unmute' event.
* */
const MUTE_DELAY = 500;
const ENDED_INTERVAL = 100;
export type MediaStreamTrackEvents = {
mute: void;
unmute: void;
ended: void;
};
export class MediaStreamTrackWrapper {
/**
* muted is a flag that determines if the track has media coming in
*/
public get muted(): boolean {
return this.muteTriggered;
}
public get ended(): boolean {
return this.endedTriggered || this.track.readyState === 'ended';
}
/**
* enabled is a flag that determines if the rocket.chat client wants this track to be enabled
* */
public get enabled(): boolean {
return this.track.enabled;
}
public set enabled(value: boolean) {
this.track.enabled = value;
}
public readonly emitter: Emitter<MediaStreamTrackEvents>;
private muteTriggered = false;
private endedTriggered = false;
private muteTimeoutHandler: ReturnType<typeof setTimeout> | null = null;
private endedIntervalHandler: ReturnType<typeof setInterval> | null = null;
private cleared = false;
private _onTrackMute: () => void;
private _onTrackUnmute: () => void;
private _onTrackEnded: () => void;
constructor(public readonly track: MediaStreamTrack) {
this.emitter = new Emitter();
this.muteTriggered = track.muted ?? false;
this.endedIntervalHandler = setInterval(() => {
if (this.endedTriggered || this.track.readyState !== 'ended') {
return;
}
this.setEnded();
}, ENDED_INTERVAL);
this._onTrackMute = () => this.onTrackMute();
this._onTrackUnmute = () => this.onTrackUnmute();
this._onTrackEnded = () => this.setEnded();
this.track.addEventListener('mute', this._onTrackMute);
this.track.addEventListener('unmute', this._onTrackUnmute);
this.track.addEventListener('ended', this._onTrackEnded);
}
public clear() {
this.cleared = true;
this.clearMuteTimeout();
this.clearEndedInterval();
this.track.removeEventListener('mute', this._onTrackMute);
this.track.removeEventListener('unmute', this._onTrackUnmute);
this.track.removeEventListener('ended', this._onTrackEnded);
}
private setMuted(muted: boolean) {
if (this.endedTriggered || this.muteTriggered === muted) {
return;
}
this.muteTriggered = muted;
if (muted) {
this.emitter.emit('mute');
} else {
this.emitter.emit('unmute');
}
}
private setEnded() {
this.clearEndedInterval();
if (this.endedTriggered) {
return;
}
this.clearMuteTimeout();
this.endedTriggered = true;
this.emitter.emit('ended');
}
private onTrackMute() {
if (this.cleared) {
return;
}
this.clearMuteTimeout();
this.muteTimeoutHandler = setTimeout(() => {
this.setMuted(true);
}, MUTE_DELAY);
}
private onTrackUnmute() {
this.clearMuteTimeout();
if (this.muteTriggered) {
this.setMuted(false);
}
}
private clearMuteTimeout() {
if (!this.muteTimeoutHandler) {
return;
}
clearTimeout(this.muteTimeoutHandler);
this.muteTimeoutHandler = null;
}
private clearEndedInterval() {
if (!this.endedIntervalHandler) {
return;
}
clearInterval(this.endedIntervalHandler);
this.endedIntervalHandler = null;
}
}

View File

@@ -0,0 +1,364 @@
import { Emitter } from '@rocket.chat/emitter';
import { MediaStreamTrackWrapper } from './MediaStreamTrackWrapper';
import type { IMediaSignalLogger } from '../../definition/logger';
import type { IMediaStreamWrapper, MediaStreamEvents } from '../../definition/media/IMediaStreamWrapper';
const AUDIO_STATS_INTERVAL = 50;
export class MediaStreamWrapper implements IMediaStreamWrapper {
public readonly emitter: Emitter<MediaStreamEvents>;
public readonly remote: boolean;
public get local(): boolean {
return !this.remote;
}
public readonly stream: MediaStream;
public get localId(): string {
return this.stream.id;
}
private _active: boolean;
public get active(): boolean {
return this._active;
}
private audioEnabled = true;
private audioTrack: MediaStreamTrackWrapper | null = null;
private videoTrack: MediaStreamTrackWrapper | null = null;
private audioTransceiver: RTCRtpTransceiver | null = null;
private videoTransceiver: RTCRtpTransceiver | null = null;
private stopped = false;
private remoteIds: string[];
private _trackingAudioStats: boolean;
private _audioLevel: number;
public get audioLevel(): number {
return this._audioLevel;
}
constructor(
remote: boolean,
public readonly tag: string,
private readonly peer: RTCPeerConnection,
private readonly logger?: IMediaSignalLogger,
) {
this.remote = remote;
this.stream = new MediaStream();
this.emitter = new Emitter();
this.remoteIds = [];
this._audioLevel = 0;
this._trackingAudioStats = false;
// Main stream initiates as active, any other initiates as inactive
this._active = tag === 'main';
}
public hasAudio(): boolean {
if (!this.audioTrack || this.audioTrack.ended) {
return false;
}
const tracks = this.stream.getAudioTracks() || [];
if (!tracks?.length) {
return false;
}
return true;
}
public hasVideo(): boolean {
if (!this.videoTrack || this.videoTrack.ended) {
return false;
}
const tracks = this.stream.getVideoTracks() || [];
if (!tracks?.length) {
return false;
}
return true;
}
public isAudioMutedBySystem(): boolean {
if (!this.audioTrack) {
return false;
}
return this.audioTrack.muted || !this.audioEnabled;
}
public isAudioEnabled(): boolean {
if (this.audioTrack) {
return this.audioTrack.enabled;
}
return this.audioEnabled;
}
public isStopped(): boolean {
return this.stopped;
}
public setAudioEnabled(enabled: boolean) {
const wasMuted = this.isAudioMutedBySystem();
this.audioEnabled = enabled;
if (this.audioTrack) {
this.audioTrack.enabled = enabled;
}
if (this.isAudioMutedBySystem() !== wasMuted) {
this.emitter.emit('stateChanged');
}
}
public setActive(active: boolean) {
if (this._active === active) {
return;
}
this._active = active;
this.emitter.emit('stateChanged');
}
public stop(): void {
this.stopped = true;
this.removeTracks();
this.audioTrack?.clear();
this.videoTrack?.clear();
}
public async setTrack(kind: MediaStreamTrack['kind'], track: MediaStreamTrack | null): Promise<void> {
this.logger?.debug('BaseMediaStream.setTrack', kind, this.remote, this.tag);
if (track && this.isSameTrack(track.id)) {
return;
}
await this.replaceTrack(kind, track);
}
public addRemoteId(id: string): void {
if (this.hasRemoteId(id)) {
return;
}
this.remoteIds.push(id);
}
public hasRemoteId(id: string): boolean {
return this.remoteIds.includes(id);
}
private getTracks(kind?: MediaStreamTrack['kind'] | null): MediaStreamTrack[] {
switch (kind) {
case 'audio':
return this.stream.getAudioTracks();
case 'video':
return this.stream.getVideoTracks();
default:
return this.stream.getTracks();
}
}
private removeTracks(kind?: MediaStreamTrack['kind']): void {
const tracks = this.getTracks(kind);
tracks.forEach((track) => {
if (track) {
this.stream.removeTrack(track);
}
});
}
private async replaceTrack(kind: MediaStreamTrack['kind'], newTrack: MediaStreamTrack | null): Promise<void> {
this.removeTracks(kind);
if (newTrack) {
this.stream.addTrack(newTrack);
}
this.wrapTrack(kind, newTrack);
await this.syncTrackChange(kind, newTrack);
this.emitter.emit('trackChanged', { track: newTrack, kind });
}
private getCurrentTransceiver(kind: MediaStreamTrack['kind']): RTCRtpTransceiver | null {
const transceiver = kind === 'audio' ? this.audioTransceiver : this.videoTransceiver;
if (!transceiver) {
return null;
}
// transceiver.stopped is not available on safari
if ('stopped' in transceiver && transceiver.stopped) {
return null;
}
if (transceiver.currentDirection === 'stopped' || transceiver.direction === 'stopped') {
return null;
}
return transceiver;
}
private setCurrentTransceiver(kind: MediaStreamTrack['kind'], transceiver: RTCRtpTransceiver | null) {
const oldTransceiver = kind === 'audio' ? this.audioTransceiver : this.videoTransceiver;
if ((!oldTransceiver && !transceiver) || oldTransceiver === transceiver) {
return;
}
const action = transceiver ? 'Changed' : 'Removed';
this.logger?.debug(`${action} current ${kind} transceiver for ${this.tag} stream`);
if (kind === 'audio') {
this.audioTransceiver = transceiver;
} else {
this.videoTransceiver = transceiver;
}
}
private async syncTrackChange(kind: MediaStreamTrack['kind'], track: MediaStreamTrack | null): Promise<void> {
if (this.remote) {
return;
}
if (this.stopped || ['closed', 'failed'].includes(this.peer.connectionState)) {
return;
}
const currentTransceiver = this.getCurrentTransceiver(kind);
if (currentTransceiver?.sender) {
// If we already have a sender of the same kind for this stream, we can just replace the track with no issues
// TODO: safe guard against edge cases where this would fail (eg: changing number of audio channels or increasing video quality)
this.logger?.debug('MediaStreamWrapper.setPeerTrack.replaceTrack', kind);
try {
await currentTransceiver.sender.replaceTrack(track);
// Only return early if the track was successfully replaced on a transceiver - otherwise we add it to a new one
return;
} catch (err) {
this.logger?.error('MediaStreamWrapper.setPeerTrack.replaceTrack failed', kind, err);
this.setCurrentTransceiver(kind, null);
}
}
if (!track) {
return;
}
this.logger?.debug('MediaStreamWrapper.setPeerTrack.addTrack', kind);
// This will require a re-negotiation
this.peer.addTrack(track, this.stream);
const transceiver = this.peer.getTransceivers().find((t) => t.sender.track === track);
this.setCurrentTransceiver(kind, transceiver ?? null);
}
private wrapTrack(kind: MediaStreamTrack['kind'], track: MediaStreamTrack | null) {
const wrapper = track ? new MediaStreamTrackWrapper(track) : null;
const oldWrapper = this.getWrappedTrack(kind);
if (oldWrapper) {
oldWrapper.clear();
}
if (kind === 'audio') {
this.audioTrack = wrapper;
if (this.local && wrapper) {
wrapper.enabled = this.audioEnabled;
}
if (wrapper && !this._trackingAudioStats) {
this.registerAudioLevelTracker();
}
} else {
this.videoTrack = wrapper;
}
if (!wrapper) {
return;
}
wrapper.emitter.on('mute', () => {
this.emitter.emit('stateChanged');
});
wrapper.emitter.on('unmute', () => {
this.emitter.emit('stateChanged');
});
wrapper.emitter.on('ended', () => {
this.emitter.emit('stateChanged');
});
}
private isSameTrack(trackId: string): boolean {
return Boolean(this.stream.getTrackById(trackId));
}
private getWrappedTrack(kind: MediaStreamTrack['kind']): MediaStreamTrackWrapper | null {
if (kind !== 'audio') {
return this.videoTrack;
}
return this.audioTrack;
}
private registerAudioLevelTracker() {
if (this.stopped) {
return;
}
this._trackingAudioStats = true;
setTimeout(() => this.collectAudioStats(), AUDIO_STATS_INTERVAL);
}
private async collectAudioStats() {
if (this.stopped || !this.audioTrack) {
this._audioLevel = 0;
this._trackingAudioStats = false;
return;
}
try {
const stats = await this.peer.getStats(this.audioTrack.track);
if (!stats) {
return;
}
const relevantReportType = this.local ? 'media-source' : 'inbound-rtp';
// stats is an object that has a forEach function
stats.forEach((report) => {
if (report.kind !== 'audio') {
return;
}
if (report.type !== relevantReportType) {
return;
}
this._audioLevel = report.audioLevel ?? 0;
});
} catch {
this._audioLevel = 0;
} finally {
// Ensure that the countdown for the next iteration only starts after fully processing the current one
this.registerAudioLevelTracker();
}
}
}

View File

@@ -0,0 +1,11 @@
import type { CallState } from '../../definition';
/* returns true if the value represents a state in which the underlying service has not been involved yet */
export function isPendingState(state: CallState): boolean {
return ['none', 'ringing'].includes(state);
}
/* returns true if the value represents a state in which the underlying service is already involved in the call */
export function isBusyState(state: CallState): boolean {
return ['accepted', 'active', 'renegotiating'].includes(state);
}

View File

@@ -0,0 +1,247 @@
import { Emitter } from '@rocket.chat/emitter';
import { SDP } from './sdp';
import type { IMediaSignalLogger, IWebRTCProcessor, NegotiationData, NegotiationEvents } from '../../../definition';
export class Negotiation {
public readonly emitter: Emitter<NegotiationEvents>;
public get started() {
return this._startedProcessing;
}
/** Returns true when the negotiation will no longer process anything, no matter the reason */
public get ended() {
return this._ended;
}
public get isLocal(): boolean {
return !this.remoteOffer;
}
public get finished(): boolean {
return this._finished;
}
public readonly negotiationId: string;
public readonly sequence: number;
public readonly isPolite: boolean;
protected webrtcProcessor: IWebRTCProcessor | null;
protected remoteOffer: RTCSessionDescriptionInit | null;
protected _ended: boolean;
protected _startedProcessing: boolean;
protected _failed: boolean;
protected _finished: boolean;
constructor(
negotiation: NegotiationData,
protected readonly logger?: IMediaSignalLogger | null,
) {
this.webrtcProcessor = null;
this._startedProcessing = false;
this._ended = false;
this._failed = false;
this._finished = false;
this.negotiationId = negotiation.negotiationId;
this.sequence = negotiation.sequence;
this.isPolite = negotiation.isPolite;
this.remoteOffer = negotiation.remoteOffer;
this.emitter = new Emitter();
}
public end(finished = false): void {
if (this._ended) {
return;
}
this.logger?.debug('Negotiation.end', this.negotiationId);
this._ended = true;
if (finished && this._startedProcessing && !this._failed) {
this._finished = true;
}
this.emitter.emit('ended');
}
public async process(webrtcProcessor: IWebRTCProcessor): Promise<void> {
if (this._startedProcessing) {
return;
}
this.logger?.debug('Negotiation.process', this.negotiationId);
this.setWebRTCProcessor(webrtcProcessor);
this._startedProcessing = true;
if (this.remoteOffer) {
await this.createLocalAnswer(this.remoteOffer);
return;
}
// after creating the local offer, this negotiation will remain active until it receives an answer
await this.createLocalOffer();
}
public async setRemoteAnswer(sdp: RTCSessionDescriptionInit): Promise<void> {
if (!this.isWebRTCNegotiation()) {
return;
}
this.logger?.debug('Negotiation.setRemoteAnswer', this.negotiationId);
if (!this.isLocal || !this._startedProcessing || sdp.type !== 'answer') {
this.logger?.warn('Invalid negotiation workflow');
return;
}
await this.setPeerRemoteDescription(sdp);
// Local negotiations end when the remote description is available
this.end(true);
}
protected async setLocalDescription(this: WebRTCNegotiation, sdp: RTCSessionDescriptionInit): Promise<void> {
this.logger?.debug('Negotiation.setLocalDescription', this.negotiationId);
this.assertNegotiationIsActive();
await this.setPeerLocalDescription(sdp);
this.assertNegotiationIsActive();
await this.webrtcProcessor.waitForIceGathering();
this.assertNegotiationIsActive();
const localDescription = this.getPeerLocalDescription();
this.emitter.emit('local-sdp', { sdp: localDescription });
// Remote negotiations end when the local description is available
if (!this.isLocal) {
this.end(true);
}
}
protected setWebRTCProcessor(webrtcProcessor: IWebRTCProcessor): asserts this is WebRTCNegotiation {
this.webrtcProcessor = webrtcProcessor;
}
protected isWebRTCNegotiation(): this is WebRTCNegotiation {
return !!this.webrtcProcessor;
}
protected assertNegotiationIsActive(): void {
if (this._ended) {
this.fail('skipped-negotiation');
throw new Error('Skipped Negotiation');
}
}
protected async createLocalOffer(this: WebRTCNegotiation): Promise<void> {
this.logger?.debug('Negotiation.createLocalOffer', this.negotiationId);
this.assertNegotiationIsActive();
const earlyOffer = await this.webrtcProcessor.createOffer({});
await this.setLocalDescription(earlyOffer);
}
protected async createLocalAnswer(this: WebRTCNegotiation, remoteOffer: RTCSessionDescriptionInit): Promise<void> {
this.logger?.debug('Negotiation.createLocalAnswer', this.negotiationId);
this.assertNegotiationIsActive();
await this.setPeerRemoteDescription(remoteOffer);
this.assertNegotiationIsActive();
const earlyAnswer = await this.createEarlyAnswer();
this.assertNegotiationIsActive();
await this.setLocalDescription(earlyAnswer);
}
protected fail(errorCode: string): void {
if (this._failed || this._ended) {
return;
}
this.emitter.emit('error', { errorCode });
this._failed = true;
}
protected async setPeerRemoteDescription(this: WebRTCNegotiation, remoteDescription: RTCSessionDescriptionInit): Promise<void> {
try {
await this.webrtcProcessor.setRemoteDescription(remoteDescription);
} catch (err) {
this.logger?.error(err);
this.fail('failed-to-set-remote-description');
}
}
protected async createEarlyAnswer(this: WebRTCNegotiation): Promise<RTCSessionDescriptionInit> {
try {
const earlyAnswer = await this.webrtcProcessor.createAnswer();
return earlyAnswer;
} catch (err) {
this.logger?.error(err);
this.fail('failed-to-create-local-answer');
throw err;
}
}
protected async setPeerLocalDescription(this: WebRTCNegotiation, localDescription: RTCSessionDescriptionInit): Promise<void> {
try {
await this.webrtcProcessor.setLocalDescription(localDescription);
} catch (err) {
this.logger?.error(err);
this.fail('failed-to-set-local-description');
}
}
protected getPeerLocalDescription(this: WebRTCNegotiation): RTCSessionDescriptionInit {
try {
const sdp = this.webrtcProcessor.getLocalDescription();
if (!sdp) {
throw new Error('No local description');
}
return this.mutateLocalDescription(sdp);
} catch (err) {
this.logger?.error(err);
this.fail('failed-to-get-local-description');
throw err;
}
}
protected mutateLocalDescription(this: WebRTCNegotiation, description: RTCSessionDescriptionInit): RTCSessionDescriptionInit {
const { sdp, type } = description;
if (!sdp) {
return description;
}
this.logger?.debug('MediaCallWebRTCProcessor.mutateLocalDescription', type);
const mainStreamId = this.webrtcProcessor.streams.mainLocal.stream.id;
const screenShareStreamId = this.webrtcProcessor.streams.screenShareLocal.stream.id;
const mutated = SDP.mutateSDPWithStreamContents(sdp, [
{ id: mainStreamId, content: 'main' },
{ id: screenShareStreamId, content: 'slides' },
]);
if (sdp !== mutated) {
this.logger?.debug('SDP was mutated');
}
return {
type,
sdp: mutated,
};
}
}
export abstract class WebRTCNegotiation extends Negotiation {
protected abstract override webrtcProcessor: IWebRTCProcessor;
}

View File

@@ -0,0 +1,914 @@
import { Emitter } from '@rocket.chat/emitter';
import { SDP } from './sdp';
import type { IWebRTCProcessor, WebRTCInternalStateMap, WebRTCProcessorConfig, WebRTCProcessorEvents } from '../../../definition';
import type { MediaStreamIdentification } from '../../../definition/media/MediaStreamIdentification';
import type { ServiceStateValue } from '../../../definition/services/IServiceProcessor';
import type { ServerMediaSignalRemoteSDP } from '../../../definition/signals';
import { MediaStreamManager } from '../../media/MediaStreamManager';
import { getExternalWaiter, type PromiseWaiterData } from '../../utils/getExternalWaiter';
const DATA_CHANNEL_LABEL = 'rocket.chat';
type P2PCommand = 'mute' | 'unmute' | 'end';
export class MediaCallWebRTCProcessor implements IWebRTCProcessor {
public readonly emitter: Emitter<WebRTCProcessorEvents>;
public readonly streams: MediaStreamManager;
private peer: RTCPeerConnection;
private iceGatheringTimedOut = false;
private iceGatheringWaiters: Set<PromiseWaiterData>;
private inputTrack: MediaStreamTrack | null;
private screenVideoTrack: MediaStreamTrack | null;
private _muted = false;
public get muted(): boolean {
return this._muted;
}
private _held = false;
public get held(): boolean {
return this._held;
}
private stopped = false;
private iceCandidateCount = 0;
private initialization: Promise<void>;
private _dataChannel: RTCDataChannel | null;
private _remoteMute = false;
private _remoteHeld = false;
private _dataChannelEnded = false;
constructor(private readonly config: WebRTCProcessorConfig) {
this.iceGatheringWaiters = new Set();
this.inputTrack = config.inputTrack;
this.screenVideoTrack = config.screenVideoTrack || null;
this._dataChannel = null;
this.emitter = new Emitter();
this.peer = new RTCPeerConnection(config.rtc);
this.registerPeerEvents();
this.streams = new MediaStreamManager(this.peer, this.config.logger);
this.streams.emitter.on('streamChanged', () => {
config.logger?.debug('MediaCallWebRTCProcessor.streamChanged');
this.emitter.emit('streamChanged');
});
this.initialization = this.initialize().catch((e) => {
config.logger?.error('MediaCallWebRTCProcessor.initialization error', e);
this.stop();
});
}
public async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise<void> {
this.config.logger?.debug('MediaCallWebRTCProcessor.setInputTrack');
if (newInputTrack && newInputTrack.kind !== 'audio') {
throw new Error('Unsupported track kind');
}
await this.initialization;
this.inputTrack = newInputTrack;
await this.loadInputTrack();
}
public async setScreenVideoTrack(newVideoTrack: MediaStreamTrack | null): Promise<void> {
this.config.logger?.debug('MediaCallWebRTCProcessor.setScreenVideoTrack');
if (newVideoTrack && newVideoTrack.kind !== 'video') {
throw new Error('Unsupported track kind');
}
await this.initialization;
this.screenVideoTrack = newVideoTrack;
await this.loadScreenVideoTrack();
}
public async createOffer({ iceRestart }: { iceRestart?: boolean }): Promise<RTCSessionDescriptionInit> {
this.config.logger?.debug('MediaCallWebRTCProcessor.createOffer');
if (this.stopped) {
throw new Error('WebRTC Processor has already been stopped.');
}
await this.initialization;
this.createDataChannel();
this.processPreNegotiation();
if (iceRestart) {
this.restartIce();
}
return this.peer.createOffer({});
}
public setMuted(muted: boolean): void {
if (this.stopped) {
return;
}
this._muted = muted;
this.streams.mainLocal.setAudioEnabled(!muted && !this._held);
this.updateMuteForRemote();
}
public setHeld(held: boolean): void {
if (this.stopped) {
return;
}
this._held = held;
this.streams.mainLocal.setAudioEnabled(!held && !this._muted);
this.streams.mainRemote.setAudioEnabled(!held);
this.updateAudioDirectionWithoutNegotiation();
}
public stop(): void {
this.config.logger?.debug('MediaCallWebRTCProcessor.stop');
this.endDataChannel();
this.stopped = true;
// Stop only the remote stream; the track of the local stream may still be in use by another call so it's up to the session to stop it.
this.streams.stopRemoteStreams();
// The screen share local stream is safe to stop here, as currently it shouldn't be used by any other call
this.screenVideoTrack?.stop();
this.unregisterPeerEvents();
this.peer.close();
}
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
this.config.logger?.debug('MediaCallWebRTCProcessor.createAnswer');
if (this.stopped) {
throw new Error('WebRTC Processor has already been stopped.');
}
if (!this.inputTrack) {
throw new Error('no-input-track');
}
await this.initialization;
const transceivers = this.getTransceivers('audio');
if (!transceivers.length) {
throw new Error('no-audio-transceiver');
}
return this.peer.createAnswer();
}
public async setLocalDescription(sdp: RTCSessionDescriptionInit): Promise<void> {
this.config.logger?.debug('MediaCallWebRTCProcessor.setLocalDescription');
if (this.stopped) {
return;
}
await this.initialization;
if (!['offer', 'answer'].includes(sdp.type)) {
throw new Error('unsupported-description-type');
}
await this.peer.setLocalDescription(sdp);
if (sdp.type === 'answer') {
this.processPostNegotiation();
}
}
public async setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise<void> {
this.config.logger?.debug('MediaCallWebRTCProcessor.setRemoteDescription');
if (this.stopped) {
return;
}
await this.initialization;
if (!['offer', 'answer'].includes(sdp.type)) {
throw new Error('unsupported-description-type');
}
if (sdp.type === 'offer') {
this.processPreNegotiation();
}
await this.peer.setRemoteDescription(sdp);
if (sdp.type === 'answer') {
this.processPostNegotiation();
}
}
public getInternalState<K extends keyof WebRTCInternalStateMap>(stateName: K): ServiceStateValue<WebRTCInternalStateMap, K> {
switch (stateName) {
case 'signaling':
return this.peer.signalingState;
case 'connection':
return this.peer.connectionState;
case 'iceConnection':
return this.peer.iceConnectionState;
case 'iceGathering':
return this.peer.iceGatheringState;
case 'iceUntrickler':
if (this.iceGatheringTimedOut) {
return 'timeout';
}
return this.iceGatheringWaiters.size > 0 ? 'waiting' : 'not-waiting';
case 'remoteMute':
return this._remoteMute;
case 'remoteHeld':
return this._remoteHeld;
}
}
public async getStats(selector?: MediaStreamTrack | null): Promise<RTCStatsReport | null> {
if (this.stopped) {
return null;
}
await this.initialization;
return this.peer.getStats(selector);
}
public isRemoteHeld(): boolean {
return this._remoteHeld;
}
public isRemoteMute(): boolean {
return this._remoteMute;
}
public isStable(): boolean {
if (this.stopped) {
return false;
}
return this.peer.signalingState === 'stable';
}
public getLocalDescription(): RTCSessionDescriptionInit | null {
this.config.logger?.debug('MediaCallWebRTCProcessor.getLocalDescription');
if (this.stopped) {
throw new Error('WebRTC Processor has already been stopped.');
}
return this.peer.localDescription;
}
public async waitForIceGathering(): Promise<void> {
if (this.stopped) {
return;
}
if (this.peer.iceGatheringState === 'complete') {
// If the peer state is 'complete', wait long enough for a macrotask to complete to ensure this state is not outdated
await new Promise((resolve) => {
setTimeout(resolve, 1);
});
if (this.stopped || this.peer.iceGatheringState === 'complete') {
return;
}
}
this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering');
await this.initialization;
this.iceGatheringTimedOut = false;
const iceGatheringData = getExternalWaiter({
timeout: this.config.iceGatheringTimeout,
timeoutFn: () => {
if (!this.iceGatheringWaiters.has(iceGatheringData)) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering.timeout', this.iceCandidateCount);
this.clearIceGatheringData(iceGatheringData);
this.iceGatheringTimedOut = true;
this.changeInternalState('iceUntrickler');
},
});
this.iceGatheringWaiters.add(iceGatheringData);
this.changeInternalState('iceUntrickler');
await iceGatheringData.promise;
}
public setRemoteIds(signal: ServerMediaSignalRemoteSDP): void {
const {
streams,
sdp: { sdp },
} = signal;
const streamsFromSDP = sdp ? this.getRemoteIdsFromSDP(sdp) : [];
const allStreams = this.combineRemoteIds(streams || [], streamsFromSDP);
if (allStreams.length) {
this.streams.setRemoteIds(allStreams);
}
}
protected combineRemoteIds(streams1: MediaStreamIdentification[], streams2: MediaStreamIdentification[]): MediaStreamIdentification[] {
if (!streams2.length) {
return streams1;
}
if (!streams1.length) {
return streams2;
}
const result = [...streams1];
for (const stream of streams2) {
if (result.find(({ id }) => id === stream.id)) {
continue;
}
result.push(stream);
}
return result;
}
protected getRemoteIdsFromSDP(sdp: string): MediaStreamIdentification[] {
const contentMap = SDP.getStreamContentMapFromSDP(sdp);
return Object.entries(contentMap)
.map(([id, content]) => {
const tag = SDP.getStreamTagByMediaContent(content);
if (!tag) {
return null;
}
return { id, tag };
})
.filter((stream): stream is MediaStreamIdentification => Boolean(stream));
}
public getLocalStreamIds(): MediaStreamIdentification[] {
return this.streams.getLocalStreamIds();
}
private async initialize(): Promise<void> {
if (this.inputTrack) {
await this.loadInputTrack();
}
if (this.screenVideoTrack) {
await this.loadScreenVideoTrack();
}
}
private startNewGathering(): void {
this.clearIceGatheringWaiters(new Error('gathering-restarted'));
this.iceCandidateCount = 0;
}
private changeInternalState(stateName: keyof WebRTCInternalStateMap): void {
this.config.logger?.debug('MediaCallWebRTCProcessor.changeInternalState', stateName);
this.emitter.emit('internalStateChange', stateName);
}
private updateAudioDirectionBeforeNegotiation(): void {
// Before the negotiation, we set the direction based on our own state only
// We'll tell the SDK that we want to send audio and, depending on the "on hold" state, also receive it
const desiredDirection = this.held ? 'sendonly' : 'sendrecv';
this.updateDirectionBeforeNegotiation('audio', desiredDirection);
}
private updateAudioDirectionAfterNegotiation(): void {
// Before the negotiation started, we told the browser we wanted to send audio - but we don't care if actually send or not, it's up to the other side to determine if they want to receive.
// If the other side doesn't want to receive audio, the negotiation will result in a state where "direction" and "currentDirection" don't match
// But if the only difference is that we said we want to send audio and are not sending it, then we can change what we say we want to reflect the current state
// If we didn't do this, everything would still work, but the browser would trigger redundant renegotiations whenever the directions mismatch
const desiredDirection = this.held ? 'sendonly' : 'sendrecv';
const acceptableDirection = this.held ? 'inactive' : 'recvonly';
this.updateDirectionAfterNegotiation('audio', desiredDirection, acceptableDirection);
}
private updateVideoDirectionBeforeNegotiation(): void {
const desiredDirection = this.screenVideoTrack ? 'sendrecv' : 'recvonly';
this.updateDirectionBeforeNegotiation('video', desiredDirection);
}
private updateVideoDirectionAfterNegotiation(): void {
const desiredDirection = this.screenVideoTrack ? 'sendrecv' : 'recvonly';
const acceptableDirection = this.screenVideoTrack ? 'sendonly' : 'inactive';
this.updateDirectionAfterNegotiation('video', desiredDirection, acceptableDirection);
}
private updateDirectionBeforeNegotiation(kind: 'audio' | 'video', desiredDirection: RTCRtpTransceiverDirection): void {
const transceivers = this.getTransceivers(kind);
for (const transceiver of transceivers) {
if (transceiver.direction === 'stopped') {
continue;
}
if (transceiver.direction !== desiredDirection) {
this.config.logger?.debug(`Changing ${kind} direction from ${transceiver.direction} to ${desiredDirection}`);
}
transceiver.direction = desiredDirection;
}
}
private updateDirectionAfterNegotiation(
kind: 'audio' | 'video',
desiredDirection: RTCRtpTransceiverDirection,
acceptableDirection: RTCRtpTransceiverDirection,
): void {
const transceivers = this.getTransceivers(kind);
let hasAnyValidTransceiver = false;
let hasAnyStoppedTransceiver = false;
for (const transceiver of transceivers) {
if (transceiver.currentDirection === 'stopped') {
hasAnyStoppedTransceiver = true;
continue;
}
hasAnyValidTransceiver = true;
if (transceiver.direction !== desiredDirection) {
continue;
}
if (!transceiver.currentDirection || ['stopped', desiredDirection].includes(transceiver.currentDirection)) {
continue;
}
if (transceiver.currentDirection === acceptableDirection) {
this.config.logger?.debug(`Changing ${kind} direction from ${transceiver.direction} to match ${transceiver.currentDirection}.`);
transceiver.direction = transceiver.currentDirection;
}
}
if (desiredDirection.includes('send') && !hasAnyValidTransceiver && hasAnyStoppedTransceiver) {
this.reactToStoppedTransceiver(kind);
}
}
private reactToStoppedTransceiver(kind: 'audio' | 'video') {
this.config.logger?.error(`The ${kind} transceiver has stopped`);
if (kind === 'video' && this.screenVideoTrack) {
void this.streams.screenShareLocal.setTrack(kind, null).catch((err) => {
this.config.logger?.error('Failed to remove track from screen share media stream', err);
});
}
}
private requestDirection(
kind: 'audio' | 'video',
desiredDirection: RTCRtpTransceiverDirection,
acceptableDirection: RTCRtpTransceiverDirection,
): void {
if (!this.canRenegotiate()) {
return;
}
const transceivers = this.getTransceivers(kind);
for (const transceiver of transceivers) {
if ([desiredDirection, acceptableDirection, 'stopped'].includes(transceiver.direction)) {
continue;
}
this.config.logger?.debug(`Requesting new ${kind} direction: ${desiredDirection}.`);
transceiver.direction = desiredDirection;
}
}
private updateDirectionForVideoTrackChanged(): void {
const desiredDirection = this.screenVideoTrack ? 'sendrecv' : 'recvonly';
const acceptableDirection = this.screenVideoTrack ? 'sendonly' : 'inactive';
this.requestDirection('video', desiredDirection, acceptableDirection);
}
private getTransceivers(kind: 'audio' | 'video'): RTCRtpTransceiver[] {
return this.peer
.getTransceivers()
.filter((transceiver) => transceiver.sender.track?.kind === kind || transceiver.receiver.track?.kind === kind);
}
private updateAudioDirectionWithoutNegotiation(): void {
// If the signaling state is not stable, then a negotiation is already happening and the audio direction will be updated by them
if (this.peer.signalingState !== 'stable') {
return;
}
const desiredDirection = this.held ? 'sendonly' : 'sendrecv';
const acceptableDirection = this.held ? 'inactive' : 'recvonly';
const transceivers = this.getTransceivers('audio');
for (const transceiver of transceivers) {
// If the last direction we requested still matches our current requirements, then we don't need to change our request
if ([desiredDirection, acceptableDirection, 'stopped'].includes(transceiver.direction)) {
continue;
}
// If the current state of the call doesn't match what we are requesting here, the browser will trigger the negotiation-needed event for us
this.config.logger?.debug(`Changing desired audio direction from ${transceiver.direction} to ${desiredDirection}.`);
transceiver.direction = desiredDirection;
}
}
private createDataChannel(): void {
if (this._dataChannel || this._dataChannelEnded || !this.config.call.hasFlag('create-data-channel')) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.createDataChannel');
const channel = this.peer.createDataChannel(DATA_CHANNEL_LABEL);
this.initializeDataChannel(channel);
}
private endDataChannel(): void {
this._dataChannelEnded = true;
this.sendP2PCommand('end');
}
private initializeDataChannel(channel: RTCDataChannel): void {
if (channel.label !== DATA_CHANNEL_LABEL) {
this.config.logger?.warn('Unexpected Data Channel', channel.label);
return;
}
channel.onopen = (_event) => {
this.config.logger?.debug('Data Channel Open', channel.label);
if (this._dataChannel?.readyState !== 'open') {
this._dataChannel = channel;
}
this.updateMuteForRemote();
};
channel.onclose = (_event) => {
this.config.logger?.debug('Data Channel Closed', channel.label);
if (this._dataChannel === channel) {
this._dataChannel = null;
if (this.config.call.state !== 'hangup') {
this.createDataChannel();
}
}
};
channel.onmessage = (event) => {
if (typeof event.data !== 'string') {
this.config.logger?.debug('Invalid Data Channel Message');
return;
}
this.config.logger?.debug('Data Channel Message', event.data);
const command = this.getCommandFromDataChannelMessage(event.data);
if (command) {
this.onP2PCommand(command);
}
};
if (!this._dataChannel) {
this._dataChannel = channel;
}
}
private sendP2PCommand(command: P2PCommand): boolean {
this.config.logger?.debug('MediaCallWebRTCProcessor.sendP2PCommand', command);
if (!this._dataChannel) {
return false;
}
if (this._dataChannel.readyState !== 'open') {
return false;
}
const jsonCommand = JSON.stringify({ command });
this._dataChannel.send(jsonCommand);
return true;
}
private isValidCommand(command: string): command is P2PCommand {
return ['mute', 'unmute', 'end'].includes(command);
}
private getCommandFromDataChannelMessage(message: string): P2PCommand | null {
try {
const obj = JSON.parse(message);
if (obj.command && this.isValidCommand(obj.command)) {
return obj.command;
}
} catch {
this.config.logger?.debug('Failed to parse Data Channel Command');
}
return null;
}
private onP2PCommand(command: P2PCommand): void {
this.config.logger?.debug('MediaCallWebRTCProcessor.onP2PCommand', command);
switch (command) {
case 'mute':
this.setRemoteMute(true);
break;
case 'unmute':
this.setRemoteMute(false);
break;
case 'end':
this._dataChannelEnded = true;
break;
}
}
private setRemoteMute(muted: boolean): void {
if (muted === this._remoteMute) {
return;
}
this._remoteMute = muted;
this.emitter.emit('internalStateChange', 'remoteMute');
}
private setRemoteHeld(held: boolean): void {
if (held === this._remoteHeld) {
return;
}
this._remoteHeld = held;
this.config.logger?.debug('MediaCallWebRTCProcessor.setRemoteHeld', held);
this.emitter.emit('internalStateChange', 'remoteHeld');
}
private updateMuteForRemote(): void {
const command: P2PCommand = this._muted ? 'mute' : 'unmute';
this.sendP2PCommand(command);
}
private processPreNegotiation(): void {
this.updateAudioDirectionBeforeNegotiation();
this.updateVideoDirectionBeforeNegotiation();
}
private processPostNegotiation(): void {
this.updateAudioDirectionAfterNegotiation();
this.updateVideoDirectionAfterNegotiation();
this.updateRemoteHeld();
this.updateRemoteScreenShare();
}
private updateRemoteHeld(): void {
if (!this.isActiveConnection()) {
return;
}
let anyTransceiverNotSending = false;
const transceivers = this.getTransceivers('audio');
for (const transceiver of transceivers) {
if (!transceiver.currentDirection || transceiver.currentDirection === 'stopped') {
continue;
}
if (transceiver.currentDirection.includes('send')) {
this.setRemoteHeld(false);
return;
}
anyTransceiverNotSending = true;
}
this.setRemoteHeld(anyTransceiverNotSending);
}
private updateRemoteScreenShare(): void {
if (!this.isActiveConnection()) {
return;
}
const transceivers = this.getTransceivers('video');
for (const transceiver of transceivers) {
if (!transceiver.currentDirection || transceiver.currentDirection === 'stopped') {
continue;
}
if (transceiver.currentDirection.includes('recv')) {
this.config.logger?.debug(`Video Transceiver is receiving; enabling screen-share`);
this.streams.screenShareRemote.setActive(true);
return;
}
}
if (this.streams.screenShareRemote.active) {
this.config.logger?.debug(`No video Transceiver is receiving, disabling screen-share`);
this.streams.screenShareRemote.setActive(false);
}
}
private registerPeerEvents() {
const { peer } = this;
peer.ontrack = (event) => this.onTrack(event);
peer.onicecandidate = (event) => this.onIceCandidate(event);
peer.onicecandidateerror = (event) => this.onIceCandidateError(event);
peer.onconnectionstatechange = () => this.onConnectionStateChange();
peer.oniceconnectionstatechange = () => this.onIceConnectionStateChange();
peer.onnegotiationneeded = () => this.onNegotiationNeeded();
peer.onicegatheringstatechange = () => this.onIceGatheringStateChange();
peer.onsignalingstatechange = () => this.onSignalingStateChange();
peer.ondatachannel = (event) => this.onDataChannel(event);
}
private unregisterPeerEvents() {
try {
const { peer } = this;
peer.ontrack = null as any;
peer.onicecandidate = null as any;
peer.onicecandidateerror = null as any;
peer.onconnectionstatechange = null as any;
peer.oniceconnectionstatechange = null as any;
peer.onnegotiationneeded = null as any;
peer.onicegatheringstatechange = null as any;
peer.onsignalingstatechange = null as any;
peer.ondatachannel = null as any;
} catch {
// suppress exceptions here
}
}
private restartIce() {
this.config.logger?.debug('MediaCallWebRTCProcessor.restartIce');
this.startNewGathering();
this.peer.restartIce();
}
private canRenegotiate(): boolean {
return !this.stopped && this.peer.signalingState === 'stable';
}
private isActiveConnection(): boolean {
return !this.stopped && !['new', 'closed', 'failed'].includes(this.peer.connectionState);
}
private onIceCandidate(event: RTCPeerConnectionIceEvent) {
if (this.stopped) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidate', event.candidate);
this.iceCandidateCount++;
}
private onIceCandidateError(event: RTCPeerConnectionIceErrorEvent) {
if (this.stopped) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidateError');
this.config.logger?.error(event);
this.emitter.emit('internalError', { critical: false, error: 'ice-candidate-error', errorDetails: JSON.stringify(event) });
}
private onNegotiationNeeded() {
if (!this.canRenegotiate()) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.onNegotiationNeeded');
this.emitter.emit('negotiationNeeded');
}
private onTrack(event: RTCTrackEvent): void {
if (this.stopped) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.onTrack', event.track.kind);
this.streams.addRemoteTrack(event.track, event.streams);
}
private onConnectionStateChange() {
if (this.stopped) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.onConnectionStateChange');
this.changeInternalState('connection');
}
private onIceConnectionStateChange() {
if (this.stopped) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.onIceConnectionStateChange');
this.changeInternalState('iceConnection');
}
private onSignalingStateChange() {
if (this.stopped) {
return;
}
this.config.logger?.debug('MediaCallWebRTCProcessor.onSignalingStateChange');
this.changeInternalState('signaling');
}
private onIceGatheringStateChange() {
if (this.stopped) {
return;
}
const state = this.peer.iceGatheringState;
this.config.logger?.debug('MediaCallWebRTCProcessor.onIceGatheringStateChange', state);
if (state === 'gathering') {
this.iceCandidateCount = 0;
}
if (state === 'complete') {
this.onIceGatheringComplete();
}
this.changeInternalState('iceGathering');
}
private async loadInputTrack(): Promise<void> {
this.config.logger?.debug('MediaCallWebRTCProcessor.loadInputTrack');
await this.streams.mainLocal.setTrack('audio', this.inputTrack);
}
private async loadScreenVideoTrack(): Promise<void> {
this.config.logger?.debug('MediaCallWebRTCProcessor.loadScreenVideoTrack');
await this.streams.screenShareLocal.setTrack('video', this.screenVideoTrack);
this.streams.screenShareLocal.setActive(Boolean(this.screenVideoTrack));
this.updateDirectionForVideoTrackChanged();
}
private onIceGatheringComplete() {
this.config.logger?.debug('MediaCallWebRTCProcessor.onIceGatheringComplete');
this.clearIceGatheringWaiters();
}
private onDataChannel(event: RTCDataChannelEvent) {
this.config.logger?.debug('MediaCallWebRTCProcessor.onDataChannel');
this.initializeDataChannel(event.channel);
}
private clearIceGatheringData(iceGatheringData: PromiseWaiterData, error?: Error) {
this.config.logger?.debug('MediaCallWebRTCProcessor.clearIceGatheringData');
if (this.iceGatheringWaiters.has(iceGatheringData)) {
this.iceGatheringWaiters.delete(iceGatheringData);
}
if (iceGatheringData.timeout) {
clearTimeout(iceGatheringData.timeout);
}
if (error) {
if (iceGatheringData.promiseReject) {
iceGatheringData.promiseReject(error);
}
return;
}
if (iceGatheringData.promiseResolve) {
iceGatheringData.promiseResolve();
}
}
private clearIceGatheringWaiters(error?: Error) {
this.config.logger?.debug('MediaCallWebRTCProcessor.clearIceGatheringWaiters');
this.iceGatheringTimedOut = false;
if (!this.iceGatheringWaiters.size) {
return;
}
const waiters = Array.from(this.iceGatheringWaiters.values());
this.iceGatheringWaiters.clear();
for (const iceGatheringData of waiters) {
this.clearIceGatheringData(iceGatheringData, error);
}
this.changeInternalState('iceUntrickler');
}
}

View File

@@ -0,0 +1,2 @@
export * from './Processor';
export * from './sdp';

View File

@@ -0,0 +1,496 @@
import { MediaDescription, SDP } from './sdp';
const CRLF = '\r\n';
const audioMediaLines = ['m=audio 9 UDP/TLS/RTP/SAVPF 111', 'c=IN IP4 0.0.0.0', 'a=mid:0', 'a=msid:audio-stream audio-track', 'a=sendrecv'];
const videoMediaLines = [
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'c=IN IP4 0.0.0.0',
'a=mid:1',
'a=msid:video-stream video-track',
'a=content:slides',
'a=sendrecv',
];
const headerLines = ['v=0', 'o=- 4611731400430051336 2 IN IP4 127.0.0.1', 's=-', 't=0 0', 'a=group:BUNDLE 0 1'];
const buildSDP = (lines: string[], delimiter = CRLF, trailing = true): string => {
const body = lines.join(delimiter);
return trailing ? `${body}${delimiter}` : body;
};
const sampleSDP = buildSDP([...headerLines, ...audioMediaLines, ...videoMediaLines]);
// A realistic screen-share negotiation with what is currently supported: an
// audio-only `main` stream and a `slides` screen-share video stream, both
// content tags already present.
const screenShareMediaLines = [
...headerLines,
'm=audio 9 UDP/TLS/RTP/SAVPF 111',
'a=mid:0',
'a=msid:main-stream audio-track',
'a=content:main',
'a=sendrecv',
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'a=mid:1',
'a=msid:screen-stream screen-track',
'a=content:slides',
'a=sendrecv',
];
// Same negotiation but with a camera video track on the `main` stream.
const screenShareWithCameraMediaLines = [
...headerLines,
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'a=mid:0',
'a=msid:main-stream camera-track',
'a=content:main',
'a=sendrecv',
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'a=mid:1',
'a=msid:screen-stream screen-track',
'a=content:slides',
'a=sendrecv',
];
// A full browser-generated offer as it arrives before any tagging: an audio
// `main` stream and a video screen-share stream, neither carrying an
// `a=content:` line yet. This is the common input to the mutation function.
const untaggedOfferLines = [
'v=0',
'o=- 4611731400430051336 2 IN IP4 127.0.0.1',
's=-',
't=0 0',
'a=group:BUNDLE 0 1',
'a=extmap-allow-mixed',
'a=msid-semantic: WMS main-stream screen-stream',
'm=audio 9 UDP/TLS/RTP/SAVPF 111 63',
'c=IN IP4 0.0.0.0',
'a=rtcp:9 IN IP4 0.0.0.0',
'a=ice-ufrag:4ZcD',
'a=ice-pwd:by2Xr5jL6i2S3NqZ0P0m0Xa8',
'a=fingerprint:sha-256 8F:32:1A:0B:44:9C:2E:11:7D:6F:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45',
'a=setup:actpass',
'a=mid:0',
'a=sendrecv',
'a=msid:main-stream audio-track',
'a=rtcp-mux',
'a=rtpmap:111 opus/48000/2',
'm=video 9 UDP/TLS/RTP/SAVPF 96 97',
'c=IN IP4 0.0.0.0',
'a=rtcp:9 IN IP4 0.0.0.0',
'a=ice-ufrag:4ZcD',
'a=ice-pwd:by2Xr5jL6i2S3NqZ0P0m0Xa8',
'a=fingerprint:sha-256 8F:32:1A:0B:44:9C:2E:11:7D:6F:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45',
'a=setup:actpass',
'a=mid:1',
'a=sendrecv',
'a=msid:screen-stream screen-track',
'a=rtcp-mux',
'a=rtpmap:96 VP8/90000',
];
describe('MediaDescription', () => {
describe('media type parsing', () => {
it('should parse the media type from the m= line', () => {
expect(new MediaDescription(['m=audio 9 UDP/TLS/RTP/SAVPF 111']).type).toBe('audio');
expect(new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96']).type).toBe('video');
expect(new MediaDescription(['m=application 9 UDP/DTLS/SCTP webrtc-datachannel']).type).toBe('application');
});
it('should return null when there is no m= line', () => {
expect(new MediaDescription(['a=mid:0', 'a=sendrecv']).type).toBeNull();
});
it('should only use the first m= line to determine the type', () => {
const media = new MediaDescription(['m=audio 9 UDP/TLS/RTP/SAVPF 111', 'm=video 9 UDP/TLS/RTP/SAVPF 96']);
expect(media.type).toBe('audio');
});
});
describe('stream id parsing', () => {
it('should parse a stream id from the a=msid line', () => {
const media = new MediaDescription(['m=audio 9 UDP/TLS/RTP/SAVPF 111', 'a=msid:audio-stream audio-track']);
expect(media.streamIds).toEqual(['audio-stream']);
});
it('should parse a stream id even without a track id', () => {
const media = new MediaDescription(['a=msid:audio-stream']);
expect(media.streamIds).toEqual(['audio-stream']);
});
it('should ignore the "-" placeholder stream id', () => {
const media = new MediaDescription(['a=msid:- audio-track']);
expect(media.streamIds).toEqual([]);
});
it('should ignore an empty stream id', () => {
const media = new MediaDescription(['a=msid: audio-track']);
expect(media.streamIds).toEqual([]);
});
it('should deduplicate repeated stream ids', () => {
const media = new MediaDescription(['a=msid:stream track-a', 'a=msid:stream track-b']);
expect(media.streamIds).toEqual(['stream']);
});
it('should collect multiple distinct stream ids', () => {
const media = new MediaDescription(['a=msid:stream-a track-a', 'a=msid:stream-b track-b']);
expect(media.streamIds).toEqual(['stream-a', 'stream-b']);
});
it('should return an empty array when there is no msid line', () => {
const media = new MediaDescription(['m=audio 9 UDP/TLS/RTP/SAVPF 111']);
expect(media.streamIds).toEqual([]);
});
});
describe('content parsing', () => {
it('should parse the content tag from the a=content line', () => {
const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=content:slides']);
expect(media.content).toBe('slides');
});
it('should return null when there is no content line', () => {
const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96']);
expect(media.content).toBeNull();
});
it('should return null for an empty content value', () => {
const media = new MediaDescription(['a=content:']);
expect(media.content).toBeNull();
});
});
describe('lines', () => {
it('should expose a copy of the given lines', () => {
const input = ['m=audio 9 UDP/TLS/RTP/SAVPF 111', 'a=sendrecv'];
const media = new MediaDescription(input);
expect(media.lines).toEqual(input);
expect(media.lines).not.toBe(input);
});
it('should not be affected by mutations to the original array', () => {
const input = ['m=audio 9 UDP/TLS/RTP/SAVPF 111'];
const media = new MediaDescription(input);
input.push('a=sendrecv');
expect(media.lines).toEqual(['m=audio 9 UDP/TLS/RTP/SAVPF 111']);
});
});
describe('setContent', () => {
it('should add a content line when none exists', () => {
const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=sendrecv']);
media.setContent('slides');
expect(media.content).toBe('slides');
expect(media.lines).toEqual(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=sendrecv', 'a=content:slides']);
});
it('should replace an existing content line', () => {
const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=content:slides', 'a=sendrecv']);
media.setContent('main');
expect(media.content).toBe('main');
expect(media.lines).toEqual(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=sendrecv', 'a=content:main']);
expect(media.lines.filter((line) => line.startsWith('a=content:'))).toHaveLength(1);
});
it('should remove the content line when set to null', () => {
const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=content:slides', 'a=sendrecv']);
media.setContent(null);
expect(media.content).toBeNull();
expect(media.lines).toEqual(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=sendrecv']);
});
it('should be a no-op when the value does not change', () => {
const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=content:slides', 'a=sendrecv']);
const linesBefore = media.lines;
media.setContent('slides');
expect(media.lines).toBe(linesBefore);
expect(media.content).toBe('slides');
});
});
});
describe('SDP', () => {
describe('parsing', () => {
it('should split header lines from media descriptions', () => {
const sdp = new SDP(sampleSDP);
expect(sdp.medias).toHaveLength(2);
expect(sdp.medias[0].type).toBe('audio');
expect(sdp.medias[1].type).toBe('video');
});
it('should assign each media its own set of lines', () => {
const sdp = new SDP(sampleSDP);
expect(sdp.medias[0].lines[0]).toBe('m=audio 9 UDP/TLS/RTP/SAVPF 111');
expect(sdp.medias[1].lines[0]).toBe('m=video 9 UDP/TLS/RTP/SAVPF 96');
});
it('should parse stream ids and content per media', () => {
const sdp = new SDP(sampleSDP);
expect(sdp.medias[0].streamIds).toEqual(['audio-stream']);
expect(sdp.medias[0].content).toBeNull();
expect(sdp.medias[1].streamIds).toEqual(['video-stream']);
expect(sdp.medias[1].content).toBe('slides');
});
it('should handle SDPs delimited with \\n only', () => {
const sdp = new SDP(buildSDP([...headerLines, ...audioMediaLines, ...videoMediaLines], '\n'));
expect(sdp.medias).toHaveLength(2);
expect(sdp.medias[0].type).toBe('audio');
});
it('should produce no media descriptions when there are no m= lines', () => {
const sdp = new SDP(buildSDP(headerLines));
expect(sdp.medias).toHaveLength(0);
});
it('should handle an empty string', () => {
const sdp = new SDP('');
expect(sdp.medias).toHaveLength(0);
});
});
describe('joinLines', () => {
it('should round-trip an SDP delimited with CRLF', () => {
const sdp = new SDP(sampleSDP);
expect(sdp.joinLines()).toBe(sampleSDP);
});
it('should serialize using CRLF delimiters', () => {
const sdp = new SDP(buildSDP([...headerLines, ...audioMediaLines], '\n'));
const output = sdp.joinLines();
expect(output).toContain(CRLF);
expect(output).toBe(buildSDP([...headerLines, ...audioMediaLines], CRLF));
});
it('should not append a delimiter to empty lines', () => {
const sdp = new SDP(`${headerLines.join(CRLF)}${CRLF}`);
expect(sdp.joinLines()).not.toContain(`${CRLF}${CRLF}`);
});
});
describe('setContentMediaByStreamId', () => {
it('should set the content on the media owning the stream id', () => {
const sdp = new SDP(sampleSDP);
sdp.setContentMediaByStreamId('audio-stream', 'main');
expect(sdp.medias[0].content).toBe('main');
expect(sdp.medias[1].content).toBe('slides');
});
it('should do nothing when no media owns the stream id', () => {
const sdp = new SDP(sampleSDP);
sdp.setContentMediaByStreamId('unknown-stream', 'main');
expect(sdp.medias[0].content).toBeNull();
expect(sdp.medias[1].content).toBe('slides');
});
});
describe('mutateSDPWithStreamContents', () => {
it('should return the SDP unchanged when no streams are given', () => {
expect(SDP.mutateSDPWithStreamContents(sampleSDP, [])).toBe(sampleSDP);
});
it('should add content tags to the matching media', () => {
const output = SDP.mutateSDPWithStreamContents(sampleSDP, [{ id: 'audio-stream', content: 'main' }]);
expect(output).toContain(`a=content:main${CRLF}`);
expect(SDP.getStreamContentMapFromSDP(output)).toEqual({
'audio-stream': 'main',
'video-stream': 'slides',
});
});
it('should apply multiple stream contents at once', () => {
const output = SDP.mutateSDPWithStreamContents(sampleSDP, [
{ id: 'audio-stream', content: 'main' },
{ id: 'video-stream', content: 'speaker' },
]);
expect(SDP.getStreamContentMapFromSDP(output)).toEqual({
'audio-stream': 'main',
'video-stream': 'speaker',
});
});
it('should ignore stream ids that are not present', () => {
const output = SDP.mutateSDPWithStreamContents(sampleSDP, [{ id: 'ghost-stream', content: 'main' }]);
expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ 'video-stream': 'slides' });
});
describe('on a full offer with no content tags (common case)', () => {
it('should confirm the sample offer starts with no content tags', () => {
expect(buildSDP(untaggedOfferLines)).not.toContain('a=content:');
expect(SDP.getStreamContentMapFromSDP(buildSDP(untaggedOfferLines))).toEqual({});
});
it('should tag both streams and leave everything else intact', () => {
const input = buildSDP(untaggedOfferLines);
const output = SDP.mutateSDPWithStreamContents(input, [
{ id: 'main-stream', content: 'main' },
{ id: 'screen-stream', content: 'slides' },
]);
expect(SDP.getStreamContentMapFromSDP(output)).toEqual({
'main-stream': 'main',
'screen-stream': 'slides',
});
// Exactly one tag added per media, none duplicated.
expect(output.match(/a=content:/g)).toHaveLength(2);
// Every original line survives the mutation, only content lines are new.
const addedLines = output.split(CRLF).filter((line) => line && !untaggedOfferLines.includes(line));
expect(addedLines).toEqual(['a=content:main', 'a=content:slides']);
});
it('should append the content tag inside the media section that owns the stream', () => {
const output = SDP.mutateSDPWithStreamContents(buildSDP(untaggedOfferLines), [{ id: 'screen-stream', content: 'slides' }]);
const parsed = new SDP(output);
expect(parsed.medias[0].type).toBe('audio');
expect(parsed.medias[0].content).toBeNull();
expect(parsed.medias[1].type).toBe('video');
expect(parsed.medias[1].content).toBe('slides');
// The tag lands in the video block, not the audio block or the header.
expect(parsed.medias[1].lines).toContain('a=content:slides');
expect(parsed.medias[0].lines).not.toContain('a=content:slides');
});
it('should only tag the requested stream and leave the other untagged', () => {
const output = SDP.mutateSDPWithStreamContents(buildSDP(untaggedOfferLines), [{ id: 'main-stream', content: 'main' }]);
expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ 'main-stream': 'main' });
expect(output.match(/a=content:/g)).toHaveLength(1);
});
it.each([
['CRLF', CRLF],
['LF', '\n'],
])('should tag a %s-delimited offer and serialize with CRLF', (_label, delimiter) => {
const output = SDP.mutateSDPWithStreamContents(buildSDP(untaggedOfferLines, delimiter), [
{ id: 'main-stream', content: 'main' },
{ id: 'screen-stream', content: 'slides' },
]);
expect(output).toContain(`a=content:main${CRLF}`);
expect(output).toContain(`a=content:slides${CRLF}`);
expect(SDP.getStreamContentMapFromSDP(output)).toEqual({
'main-stream': 'main',
'screen-stream': 'slides',
});
});
});
});
describe('getStreamContentMapFromSDP', () => {
it('should map stream ids to their content tag', () => {
expect(SDP.getStreamContentMapFromSDP(sampleSDP)).toEqual({ 'video-stream': 'slides' });
});
it('should skip media without a content tag', () => {
const sdp = buildSDP([...headerLines, ...audioMediaLines]);
expect(SDP.getStreamContentMapFromSDP(sdp)).toEqual({});
});
it('should map every stream id of a media sharing the same content', () => {
const sdp = buildSDP([
...headerLines,
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'a=msid:stream-a track-a',
'a=msid:stream-b track-b',
'a=content:slides',
]);
expect(SDP.getStreamContentMapFromSDP(sdp)).toEqual({
'stream-a': 'slides',
'stream-b': 'slides',
});
});
});
describe('getStreamTagByMediaContent', () => {
it('should map "slides" to "screen-share"', () => {
expect(SDP.getStreamTagByMediaContent('slides')).toBe('screen-share');
});
it('should map "main" to "main"', () => {
expect(SDP.getStreamTagByMediaContent('main')).toBe('main');
});
it.each(['speaker', 'sl', 'alt', 'unknown', ''])('should return null for %p', (content) => {
expect(SDP.getStreamTagByMediaContent(content)).toBeNull();
});
});
describe('main/slides screen-share SDP', () => {
it('should parse the audio-only main stream and the slides video stream', () => {
const sdp = new SDP(buildSDP(screenShareMediaLines));
expect(sdp.medias[0].type).toBe('audio');
expect(sdp.medias[0].content).toBe('main');
expect(sdp.medias[0].streamIds).toEqual(['main-stream']);
expect(sdp.medias[1].type).toBe('video');
expect(sdp.medias[1].content).toBe('slides');
expect(sdp.medias[1].streamIds).toEqual(['screen-stream']);
});
it.each([
['CRLF', CRLF],
['LF', '\n'],
])('should read existing main and slides content tags from %s input', (_label, delimiter) => {
const sdp = buildSDP(screenShareMediaLines, delimiter);
expect(SDP.getStreamContentMapFromSDP(sdp)).toEqual({
'main-stream': 'main',
'screen-stream': 'slides',
});
});
it.each([
['CRLF', CRLF],
['LF', '\n'],
])('should preserve the exact content tags when round-tripping %s input', (_label, delimiter) => {
const sdp = new SDP(buildSDP(screenShareMediaLines, delimiter));
const output = sdp.joinLines();
expect(output).toContain(`a=content:main${CRLF}`);
expect(output).toContain(`a=content:slides${CRLF}`);
expect(output.match(/a=content:/g)).toHaveLength(2);
});
it('should replace an existing tag in place without leaving a stale one', () => {
const output = SDP.mutateSDPWithStreamContents(buildSDP(screenShareMediaLines), [{ id: 'main-stream', content: 'slides' }]);
// The main section flips main -> slides; the screen-share section is untouched.
expect(SDP.getStreamContentMapFromSDP(output)).toEqual({
'main-stream': 'slides',
'screen-stream': 'slides',
});
// No stale `main` tag left behind after replacement.
expect(output).not.toContain('a=content:main');
expect(output.match(/a=content:/g)).toHaveLength(2);
});
it('should place tags only on the media owning each stream id', () => {
const sdp = new SDP(buildSDP(screenShareMediaLines));
sdp.setContentMediaByStreamId('screen-stream', 'speaker');
expect(sdp.medias[0].content).toBe('main');
expect(sdp.medias[1].content).toBe('speaker');
});
it('should handle a main stream carrying a camera video track', () => {
const sdp = new SDP(buildSDP(screenShareWithCameraMediaLines));
expect(sdp.medias[0].type).toBe('video');
expect(sdp.medias[0].content).toBe('main');
expect(SDP.getStreamContentMapFromSDP(buildSDP(screenShareWithCameraMediaLines))).toEqual({
'main-stream': 'main',
'screen-stream': 'slides',
});
});
});
});

View File

@@ -0,0 +1,197 @@
/* Content tags defined by RFC 4796 - external SDPs may still have other values */
type MediaContent = 'slides' | 'speaker' | 'sl' | 'main' | 'alt';
type StreamContent = { id: string; content: MediaContent };
const lineDelimiter = '\r\n';
export class MediaDescription {
private _lines: string[];
public readonly streamIds: string[];
private _type: string | null = null;
private _content: string | null = null;
public get type(): string | null {
return this._type;
}
public get content(): string | null {
return this._content;
}
public get lines(): string[] {
return this._lines;
}
constructor(lines: string[]) {
this._lines = [...lines];
this.streamIds = [];
this.parseLines();
}
private parseLines() {
for (const line of this.lines) {
this.parseMediaType(line);
this.parseStreamId(line);
this.parseContent(line);
}
}
private parseMediaType(line: string) {
if (this._type || !line.startsWith('m=')) {
return;
}
this._type = line.match(/^m=(\w+)/)?.[1] || null;
}
private parseStreamId(line: string) {
if (!line.startsWith('a=msid:')) {
return;
}
const streamId = line.slice('a=msid:'.length).split(' ')[0];
if (!streamId || streamId === '-') {
return;
}
if (this.streamIds.includes(streamId)) {
return;
}
this.streamIds.push(streamId);
}
private parseContent(line: string) {
if (!line.startsWith('a=content:')) {
return;
}
this._content = line.replace('a=content:', '') || null;
}
public setContent(value: MediaContent | null) {
if (this._content === value) {
return;
}
this._content = value || null;
const lines = this.lines.filter((line) => !line.startsWith('a=content:'));
this._lines = [...lines, ...(value ? [`a=content:${value}`] : [])];
}
}
export class SDP {
private headerLines: string[];
public readonly medias: MediaDescription[];
constructor(sdp: string) {
this.headerLines = [];
this.medias = [];
this.parseSDP(sdp);
}
private addMediaDescription(lines?: string[]) {
if (!lines?.length) {
return;
}
this.medias.push(new MediaDescription(lines));
}
private parseSDP(sdp: string) {
const allLines = sdp.split(/\r?\n/);
let currentMediaLines: string[] | undefined;
for (const line of allLines) {
if (line.startsWith('m=')) {
this.addMediaDescription(currentMediaLines);
currentMediaLines = [line];
continue;
}
if (!currentMediaLines) {
this.headerLines.push(line);
continue;
}
currentMediaLines.push(line);
}
this.addMediaDescription(currentMediaLines);
}
public joinLines(): string {
const lines = [...this.headerLines, ...this.medias.flatMap(({ lines }) => lines)];
const delimitedLines = lines.map((line) => {
if (!line) {
return line;
}
return `${line}${lineDelimiter}`;
});
return delimitedLines.join('');
}
public setContentMediaByStreamId(streamId: string, content: MediaContent) {
for (const media of this.medias) {
if (media.streamIds.includes(streamId)) {
media.setContent(content);
}
}
}
public static mutateSDPWithStreamContents(sdp: string, streams: StreamContent[]): string {
if (!streams.length) {
return sdp;
}
const parsed = new SDP(sdp);
for (const { id, content } of streams) {
parsed.setContentMediaByStreamId(id, content);
}
return parsed.joinLines();
}
/*
* Returns an object where the key is a stream id and the object is a stream content tag
*/
public static getStreamContentMapFromSDP(sdp: string): Record<string, string> {
const streams: Record<string, string> = {};
const parsed = new SDP(sdp);
for (const media of parsed.medias) {
const { streamIds, content } = media;
if (!streamIds.length || !content) {
continue;
}
for (const id of streamIds) {
streams[id] = content;
}
}
return streams;
}
public static getStreamTagByMediaContent(content: string): string | null {
switch (content) {
case 'slides':
return 'screen-share';
case 'main':
return 'main';
default:
return null;
}
}
}

View File

@@ -0,0 +1,70 @@
export type PromiseWaiterData = {
done: boolean;
promise: Promise<void>;
promiseReject: (error: Error) => void;
promiseResolve: () => void;
timeout: ReturnType<typeof setTimeout> | null;
};
export type PromiseWaiterParams = {
timeout?: number;
timeoutFn?: () => void;
cleanupFn?: () => void;
};
export function getExternalWaiter({ timeout, timeoutFn, cleanupFn }: PromiseWaiterParams = {}): PromiseWaiterData {
const data: Partial<PromiseWaiterData> = {
timeout: null,
done: false,
};
const flagAsDone = () => {
if (data.done) {
return;
}
if (data.timeout) {
clearTimeout(data.timeout);
data.timeout = null;
}
data.done = true;
cleanupFn?.();
};
data.promise = new Promise((resolve, reject) => {
data.promiseResolve = () => {
if (data.done) {
return;
}
flagAsDone();
resolve();
};
data.promiseReject = (error: Error) => {
if (data.done) {
return;
}
flagAsDone();
reject(error);
};
});
if (timeout) {
data.timeout = setTimeout(() => {
data.timeout = null;
if (data.done) {
return;
}
if (timeoutFn) {
timeoutFn();
}
if (data.promiseReject && !data.done) {
data.promiseReject(new Error('timeout'));
}
}, timeout);
}
return data as PromiseWaiterData;
}

View File

@@ -0,0 +1,71 @@
function ensureStringArray(value: string | string[] | undefined): string[] {
if (!value) {
return [];
}
if (typeof value === 'string') {
return [value];
}
if (Array.isArray(value)) {
return value;
}
return [];
}
function normalizeDeviceId(deviceId: ConstrainDOMString | null): { exact: string[]; ideal: string[] } {
if (!deviceId) {
return {
exact: [],
ideal: [],
};
}
if (typeof deviceId === 'object' && !Array.isArray(deviceId)) {
return {
exact: ensureStringArray(deviceId.exact),
ideal: ensureStringArray(deviceId.ideal),
};
}
return {
exact: [],
ideal: ensureStringArray(deviceId),
};
}
function isSameStringArray(array1: string[], array2: string[]): boolean {
const uniqueArray1 = [...new Set(array1)];
const uniqueArray2 = [...new Set(array2)];
if (uniqueArray1.length !== uniqueArray2.length) {
return false;
}
for (const value of uniqueArray1) {
if (!uniqueArray2.includes(value)) {
return false;
}
}
return true;
}
export function isSameDeviceId(deviceId1: ConstrainDOMString | null, deviceId2: ConstrainDOMString | null): boolean {
if (deviceId1 === deviceId2 || (!deviceId1 && !deviceId2)) {
return true;
}
const normalizedDeviceId1 = normalizeDeviceId(deviceId1);
const normalizedDeviceId2 = normalizeDeviceId(deviceId2);
if (!isSameStringArray(normalizedDeviceId1.exact, normalizedDeviceId2.exact)) {
return false;
}
if (!isSameStringArray(normalizedDeviceId1.ideal, normalizedDeviceId2.ideal)) {
return false;
}
return true;
}

View File

@@ -0,0 +1,37 @@
export function serializeError(error: unknown): string | undefined {
try {
if (!error) {
return undefined;
}
if (typeof error === 'string') {
return error;
}
if (typeof error === 'object') {
if (error instanceof Error) {
return JSON.stringify({
...error,
name: error.name,
message: error.message,
});
}
const errorData: Record<string, any> = { ...error };
if ('name' in error) {
errorData.name = error.name;
}
if ('message' in error) {
errorData.message = error.message;
}
if (Object.keys(errorData).length > 0) {
return JSON.stringify(errorData);
}
}
} catch {
//
}
return undefined;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@rocket.chat/tsconfig/client.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src/**/*"],
}