UNPKG

@jellyfish-dev/ts-client-sdk

Version:
641 lines (640 loc) 23.9 kB
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"); } }