@jellyfish-dev/ts-client-sdk
Version:
Typescript client library for Jellyfish.
641 lines (640 loc) • 23.9 kB
JavaScript
import { WebRTCEndpoint, } from "./webrtc";
import { EventEmitter } from "events";
import { PeerMessage } from "./protos/jellyfish/peer_notifications";
import { ReconnectManager } from "./reconnection";
import { isAuthError } from "./auth";
const isPeer = (endpoint) => endpoint.type === "webrtc";
const isComponent = (endpoint) => endpoint.type === "recording" ||
endpoint.type === "hls" ||
endpoint.type === "file" ||
endpoint.type === "rtsp" ||
endpoint.type === "sip";
/**
* JellyfishClient is the main class to interact with Jellyfish.
*
* @example
* ```typescript
* const client = new JellyfishClient<PeerMetadata, TrackMetadata>();
* const peerToken = "YOUR_PEER_TOKEN";
*
* // You can listen to events emitted by the client
* client.on("joined", (peerId, peersInRoom) => {
* console.log("join success");
* });
*
* // Start the peer connection
* client.connect({
* peerMetadata: {},
* isSimulcastOn: false,
* token: peerToken
* });
*
* // Close the peer connection
* client.disconnect();
* ```
*
* You can register callbacks to handle the events emitted by the Client.
*
* @example
* ```typescript
*
* client.on("trackReady", (ctx) => {
* console.log("On track ready");
* });
* ```
*/
export class JellyfishClient extends EventEmitter {
websocket = null;
webrtc = null;
removeEventListeners = null;
status = "new";
connectConfig = null;
reconnectManager;
peerMetadataParser;
trackMetadataParser;
constructor(config) {
super();
this.peerMetadataParser = config?.peerMetadataParser ?? ((x) => x);
this.trackMetadataParser = config?.trackMetadataParser ?? ((x) => x);
this.reconnectManager = new ReconnectManager(this, (peerMetadata) => this.initConnection(peerMetadata), config?.reconnect);
}
/**
* Uses the {@link !WebSocket} connection and {@link !WebRTCEndpoint | WebRTCEndpoint} to join to the room. Registers the callbacks to
* handle the events emitted by the {@link !WebRTCEndpoint | WebRTCEndpoint}. Make sure that peer metadata is serializable.
*
* @example
* ```typescript
* const client = new JellyfishClient<PeerMetadata, TrackMetadata>();
*
* client.connect({
* peerMetadata: {},
* token: peerToken
* });
* ```
*
* @param {ConnectConfig} config - Configuration object for the client
*/
connect(config) {
this.reconnectManager.reset(config.peerMetadata);
this.connectConfig = config;
this.initConnection(config.peerMetadata);
}
initConnection(peerMetadata) {
if (this.status === "initialized") {
this.disconnect();
}
this.webrtc = new WebRTCEndpoint({
endpointMetadataParser: this.peerMetadataParser,
trackMetadataParser: this.trackMetadataParser,
});
this.initWebsocket(peerMetadata);
this.setupCallbacks();
this.status = "initialized";
}
initWebsocket(peerMetadata) {
if (!this.connectConfig)
throw Error("ConnectConfig is null");
const { token, signaling } = this.connectConfig;
const protocol = signaling?.protocol ?? "ws";
const host = signaling?.host ?? "localhost:5002";
const path = signaling?.path ?? "/socket/peer/websocket";
const websocketUrl = protocol + "://" + host + path;
this.websocket = new WebSocket(websocketUrl);
this.websocket.binaryType = "arraybuffer";
const socketOpenHandler = (event) => {
this.emit("socketOpen", event);
const message = PeerMessage.encode({ authRequest: { token } }).finish();
this.websocket?.send(message);
};
const socketErrorHandler = (event) => {
this.emit("socketError", event);
};
const socketCloseHandler = (event) => {
if (isAuthError(event.reason)) {
this.emit("authError", event.reason);
}
this.emit("socketClose", event);
};
this.websocket.addEventListener("open", socketOpenHandler);
this.websocket.addEventListener("error", socketErrorHandler);
this.websocket.addEventListener("close", socketCloseHandler);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messageHandler = (event) => {
const uint8Array = new Uint8Array(event.data);
try {
const data = PeerMessage.decode(uint8Array);
if (data.authenticated !== undefined) {
this.emit("authSuccess");
this.webrtc?.connect(peerMetadata);
}
else if (data.authRequest !== undefined) {
console.warn("Received unexpected control message: authRequest");
}
else if (data.mediaEvent !== undefined) {
this.webrtc?.receiveMediaEvent(data.mediaEvent.data);
}
}
catch (e) {
console.warn(`Received invalid control message, error: ${e}`);
}
};
this.websocket.addEventListener("message", messageHandler);
this.removeEventListeners = () => {
this.websocket?.removeEventListener("open", socketOpenHandler);
this.websocket?.removeEventListener("error", socketErrorHandler);
this.websocket?.removeEventListener("close", socketCloseHandler);
this.websocket?.removeEventListener("message", messageHandler);
};
}
/**
* Retrieves statistics related to the RTCPeerConnection.
* These statistics provide insights into the performance and status of the connection.
*
* @return {Promise<RTCStatsReport>}
*
* @external RTCPeerConnection#getStats()
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats | MDN Web Docs: RTCPeerConnection.getStats()}
*/
async getStatistics(selector) {
return (await this.webrtc?.getStatistics(selector)) ?? new Map();
}
/**
* Returns a snapshot of currently received remote tracks.
*
* @example
* if (client.getRemoteTracks()[trackId]?.simulcastConfig?.enabled) {
* client.setTargetTrackEncoding(trackId, encoding);
* }
*/
getRemoteTracks() {
return this.webrtc?.getRemoteTracks() ?? {};
}
/**
* Returns a snapshot of currently received remote endpoints.
*/
getRemoteEndpoints() {
return this.webrtc?.getRemoteEndpoints() ?? {};
}
/**
* Returns a snapshot of currently received remote peers.
*/
getRemotePeers() {
return Object.entries(this.webrtc?.getRemoteEndpoints() ?? {}).reduce((acc, [id, peer]) => {
if (!isPeer(peer))
return acc;
acc[id] = peer;
return acc;
}, {});
}
getRemoteComponents() {
return Object.entries(this.webrtc?.getRemoteEndpoints() ?? {}).reduce((acc, [id, component]) => {
if (!isComponent(component))
return acc;
acc[id] = component;
return acc;
}, {});
}
getLocalEndpoint() {
return this.webrtc?.getLocalEndpoint() || null;
}
getBandwidthEstimation() {
if (!this.webrtc)
throw Error("Webrtc not initialized");
return this.webrtc?.getBandwidthEstimation();
}
setupCallbacks() {
this.webrtc?.on("sendMediaEvent", (mediaEvent) => {
const message = PeerMessage.encode({ mediaEvent: { data: mediaEvent } }).finish();
this.websocket?.send(message);
});
this.webrtc?.on("connected", (peerId, endpointsInRoom) => {
const peers = endpointsInRoom
.filter((endpoint) => isPeer(endpoint))
.map((peer) => peer);
const components = endpointsInRoom
.filter((endpoint) => isComponent(endpoint))
.map((component) => component);
this.emit("joined", peerId, peers, components);
});
this.webrtc?.on("disconnected", () => {
this.emit("disconnected");
});
this.webrtc?.on("endpointAdded", (endpoint) => {
if (isPeer(endpoint)) {
this.emit("peerJoined", endpoint);
}
if (isComponent(endpoint)) {
this.emit("componentAdded", endpoint);
}
});
this.webrtc?.on("endpointRemoved", (endpoint) => {
if (isPeer(endpoint)) {
this.emit("peerLeft", endpoint);
}
if (isComponent(endpoint)) {
this.emit("componentRemoved", endpoint);
}
});
this.webrtc?.on("endpointUpdated", (endpoint) => {
if (isPeer(endpoint)) {
this.emit("peerUpdated", endpoint);
}
if (isComponent(endpoint)) {
this.emit("componentUpdated", endpoint);
}
});
this.webrtc?.on("trackReady", (ctx) => {
if (!isPeer(ctx.endpoint))
return;
this.emit("trackReady", ctx);
});
this.webrtc?.on("trackAdded", (ctx) => {
if (!isPeer(ctx.endpoint))
return;
this.emit("trackAdded", ctx);
});
this.webrtc?.on("trackRemoved", (ctx) => {
if (!isPeer(ctx.endpoint))
return;
this.emit("trackRemoved", ctx);
ctx.removeAllListeners();
});
this.webrtc?.on("trackUpdated", (ctx) => {
if (!isPeer(ctx.endpoint))
return;
this.emit("trackUpdated", ctx);
});
this.webrtc?.on("tracksPriorityChanged", (enabledTracks, disabledTracks) => {
this.emit("tracksPriorityChanged", enabledTracks, disabledTracks);
});
this.webrtc?.on("connectionError", (metadata) => {
this.emit("joinError", metadata);
});
this.webrtc?.on("bandwidthEstimationChanged", (estimation) => {
this.emit("bandwidthEstimationChanged", estimation);
});
this.webrtc?.on("targetTrackEncodingRequested", (event) => {
this.emit("targetTrackEncodingRequested", event);
});
this.webrtc?.on("localTrackAdded", (event) => {
this.emit("localTrackAdded", event);
});
this.webrtc?.on("localTrackRemoved", (event) => {
this.emit("localTrackRemoved", event);
});
this.webrtc?.on("localTrackReplaced", (event) => {
this.emit("localTrackReplaced", event);
});
this.webrtc?.on("localTrackBandwidthSet", (event) => {
this.emit("localTrackBandwidthSet", event);
});
this.webrtc?.on("localTrackEncodingBandwidthSet", (event) => {
this.emit("localTrackEncodingBandwidthSet", event);
});
this.webrtc?.on("localTrackEncodingEnabled", (event) => {
this.emit("localTrackEncodingEnabled", event);
});
this.webrtc?.on("localTrackEncodingDisabled", (event) => {
this.emit("localTrackEncodingDisabled", event);
});
this.webrtc?.on("localEndpointMetadataChanged", (event) => {
this.emit("localEndpointMetadataChanged", event);
});
this.webrtc?.on("localTrackMetadataChanged", (event) => {
this.emit("localTrackMetadataChanged", event);
});
this.webrtc?.on("disconnectRequested", (event) => {
this.emit("disconnectRequested", event);
});
}
/**
* Register a callback to be called when the event is emitted.
* Full list of callbacks can be found here {@link MessageEvents}.
*
* @example
* ```ts
* const callback = ()=>{ };
*
* client.on("onJoinSuccess", callback);
* ```
*
* @param event - Event name from {@link MessageEvents}
* @param listener - Callback function to be called when the event is emitted
* @returns This
*/
on(event, listener) {
return super.on(event, listener);
}
/**
* Remove a callback from the list of callbacks to be called when the event is emitted.
*
* @example
* ```ts
* const callback = ()=>{ };
*
* client.on("onJoinSuccess", callback);
*
* client.off("onJoinSuccess", callback);
* ```
*
* @param event - Event name from {@link MessageEvents}
* @param listener - Reference to function to be removed from called callbacks
* @returns This
*/
off(event, listener) {
return super.off(event, listener);
}
handleWebRTCNotInitialized() {
return new Error("WebRTC is not initialized");
}
/**
* Adds track that will be sent to the RTC Engine.
*
* @example
* ```ts
* const localStream: MediaStream = new MediaStream();
* try {
* const localAudioStream = await navigator.mediaDevices.getUserMedia(
* { audio: true }
* );
* localAudioStream
* .getTracks()
* .forEach((track) => localStream.addTrack(track));
* } catch (error) {
* console.error("Couldn't get microphone permission:", error);
* }
*
* try {
* const localVideoStream = await navigator.mediaDevices.getUserMedia(
* { video: true }
* );
* localVideoStream
* .getTracks()
* .forEach((track) => localStream.addTrack(track));
* } catch (error) {
* console.error("Couldn't get camera permission:", error);
* }
*
* localStream
* .getTracks()
* .forEach((track) => client.addTrack(track, localStream));
* ```
*
* @param track - Audio or video track e.g. from your microphone or camera.
* @param stream - Stream that this track belongs to.
* @param trackMetadata - Any information about this track that other peers will receive in
* {@link MessageEvents.peerJoined}. E.g. this can source of the track - wheather it's screensharing, webcam or some
* other media device.
* @param simulcastConfig - Simulcast configuration. By default, simulcast is disabled. For more information refer to
* {@link !SimulcastConfig | SimulcastConfig}.
* @param maxBandwidth - Maximal bandwidth this track can use. Defaults to 0 which is unlimited. This option has no
* effect for simulcast and audio tracks. For simulcast tracks use {@link JellyfishClient.setTrackBandwidth}.
* @returns {string} Returns id of added track
*/
addTrack(track, stream, trackMetadata, simulcastConfig = { enabled: false, activeEncodings: [], disabledEncodings: [] }, maxBandwidth = 0) {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
return this.webrtc.addTrack(track, stream, trackMetadata, simulcastConfig, maxBandwidth);
}
/**
* Replaces a track that is being sent to the RTC Engine.
*
* @example
* ```ts
* // setup camera
* let localStream: MediaStream = new MediaStream();
* try {
* localVideoStream = await navigator.mediaDevices.getUserMedia(
* VIDEO_CONSTRAINTS
* );
* localVideoStream
* .getTracks()
* .forEach((track) => localStream.addTrack(track));
* } catch (error) {
* console.error("Couldn't get camera permission:", error);
* }
* let oldTrackId;
* localStream
* .getTracks()
* .forEach((track) => trackId = webrtc.addTrack(track, localStream));
*
* // change camera
* const oldTrack = localStream.getVideoTracks()[0];
* let videoDeviceId = "abcd-1234";
* navigator.mediaDevices.getUserMedia({
* video: {
* ...(VIDEO_CONSTRAINTS as {}),
* deviceId: {
* exact: videoDeviceId,
* },
* }
* })
* .then((stream) => {
* let videoTrack = stream.getVideoTracks()[0];
* webrtc.replaceTrack(oldTrackId, videoTrack);
* })
* .catch((error) => {
* console.error('Error switching camera', error);
* })
* ```
*
* @param {string} trackId - Id of audio or video track to replace.
* @param {MediaStreamTrack} newTrack - New audio or video track.
* @param {TrackMetadata} [newTrackMetadata] - Optional track metadata to apply to the new track. If no track metadata is passed, the
* old track metadata is retained.
* @returns {Promise<boolean>} Success
*/
async replaceTrack(trackId, newTrack, newTrackMetadata) {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
return this.webrtc.replaceTrack(trackId, newTrack, newTrackMetadata);
}
/**
* Updates maximum bandwidth for the track identified by trackId. This value directly translates to quality of the
* stream and, in case of video, to the amount of RTP packets being sent. In case trackId points at the simulcast
* track bandwidth is split between all of the variant streams proportionally to their resolution.
*
* @param {string} trackId
* @param {BandwidthLimit} bandwidth In kbps
* @returns {Promise<boolean>} Success
*/
setTrackBandwidth(trackId, bandwidth) {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
return this.webrtc.setTrackBandwidth(trackId, bandwidth);
}
/**
* Updates maximum bandwidth for the given simulcast encoding of the given track.
*
* @param {string} trackId - Id of the track
* @param {string} rid - Rid of the encoding
* @param {BandwidthLimit} bandwidth - Desired max bandwidth used by the encoding (in kbps)
* @returns
*/
setEncodingBandwidth(trackId, rid, bandwidth) {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
return this.webrtc.setEncodingBandwidth(trackId, rid, bandwidth);
}
/**
* Removes a track from connection that was being sent to the RTC Engine.
*
* @example
* ```ts
* // setup camera
* let localStream: MediaStream = new MediaStream();
* try {
* localVideoStream = await navigator.mediaDevices.getUserMedia(
* VIDEO_CONSTRAINTS
* );
* localVideoStream
* .getTracks()
* .forEach((track) => localStream.addTrack(track));
* } catch (error) {
* console.error("Couldn't get camera permission:", error);
* }
*
* let trackId
* localStream
* .getTracks()
* .forEach((track) => trackId = webrtc.addTrack(track, localStream));
*
* // remove track
* webrtc.removeTrack(trackId)
* ```
*
* @param {string} trackId - Id of audio or video track to remove.
*/
removeTrack(trackId) {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
return this.webrtc.removeTrack(trackId);
}
/**
* Sets track encoding that server should send to the client library.
*
* The encoding will be sent whenever it is available. If chosen encoding is temporarily unavailable, some other
* encoding will be sent until chosen encoding becomes active again.
*
* @example
* ```ts
* webrtc.setTargetTrackEncoding(incomingTrackCtx.trackId, "l")
* ```
*
* @param {string} trackId - Id of track
* @param {TrackEncoding} encoding - Encoding to receive
*/
setTargetTrackEncoding(trackId, encoding) {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
return this.webrtc.setTargetTrackEncoding(trackId, encoding);
}
/**
* Enables track encoding so that it will be sent to the server.
*
* @example
* ```ts
* const trackId = webrtc.addTrack(track, stream, {}, {enabled: true, active_encodings: ["l", "m", "h"]});
* webrtc.disableTrackEncoding(trackId, "l");
* // wait some time
* webrtc.enableTrackEncoding(trackId, "l");
* ```
*
* @param {string} trackId - Id of track
* @param {TrackEncoding} encoding - Encoding that will be enabled
*/
enableTrackEncoding(trackId, encoding) {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
return this.webrtc.enableTrackEncoding(trackId, encoding);
}
/**
* Disables track encoding so that it will be no longer sent to the server.
*
* @example
* ```ts
* const trackId = webrtc.addTrack(track, stream, {}, {enabled: true, active_encodings: ["l", "m", "h"]});
* webrtc.disableTrackEncoding(trackId, "l");
* ```
*
* @param {string} trackId - Id of track
* @param {rackEncoding} encoding - Encoding that will be disabled
*/
disableTrackEncoding(trackId, encoding) {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
return this.webrtc.disableTrackEncoding(trackId, encoding);
}
/**
* Updates the metadata for the current peer.
*
* @param peerMetadata - Data about this peer that other peers will receive upon joining.
*
* If the metadata is different from what is already tracked in the room, the event {@link MessageEvents.peerUpdated} will
* be emitted for other peers in the room.
*/
updatePeerMetadata = (peerMetadata) => {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
this.webrtc.updateEndpointMetadata(peerMetadata);
};
/**
* Updates the metadata for a specific track.
*
* @param trackId - TrackId (generated in addTrack) of audio or video track.
* @param trackMetadata - Data about this track that other peers will receive upon joining.
*
* If the metadata is different from what is already tracked in the room, the event {@link MessageEvents.trackUpdated} will
* be emitted for other peers in the room.
*/
updateTrackMetadata = (trackId, trackMetadata) => {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
this.webrtc.updateTrackMetadata(trackId, trackMetadata);
};
/**
* Leaves the room. This function should be called when user leaves the room in a clean way e.g. by clicking a
* dedicated, custom button `disconnect`. As a result there will be generated one more media event that should be sent
* to the RTC Engine. Thanks to it each other peer will be notified that peer left in {@link MessageEvents.peerLeft},
*/
leave = () => {
if (!this.webrtc)
throw this.handleWebRTCNotInitialized();
this.webrtc.disconnect();
};
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
isOpen(websocket) {
return websocket?.readyState === 1;
}
/**
* Disconnect from the room, and close the websocket connection. Tries to leave the room gracefully, but if it fails,
* it will close the websocket anyway.
*
* @example
* ```typescript
* const client = new JellyfishClient<PeerMetadata, TrackMetadata>();
*
* client.connect({ ... });
*
* client.disconnect();
* ```
*/
disconnect() {
try {
this.webrtc?.removeAllListeners();
this.webrtc?.disconnect();
this.webrtc?.cleanUp();
}
catch (e) {
console.warn(e);
}
this.removeEventListeners?.();
this.removeEventListeners = null;
if (this.isOpen(this.websocket || null)) {
this.websocket?.close();
}
this.websocket = null;
this.webrtc = null;
this.emit("disconnected");
}
}