@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
162 lines (146 loc) • 5.35 kB
JavaScript
/**
* Client-side WebRTC stream manager backed by PeerJS.
* Handles peer creation, media capture, and room-based A/V streaming.
*
* @module client/core/Stream
* @see https://peerjs.com/docs/
*/
import { loggerFactory } from './Logger.js';
import { getProxyPath } from './Router.js';
const logger = loggerFactory(import.meta);
/**
* @class Stream
* @classdesc Static manager for PeerJS connections and MediaStream lifecycle.
* Supports multiple concurrent peer sessions keyed by an arbitrary `id`.
*/
class Stream {
/** @type {Object.<string, { peer: Peer, options: Object }>} Active peer sessions keyed by session id. */
static #sessions = {};
/**
* Builds PeerJS connection options from current page location.
* @returns {{ host: string, port: number, path: string, secure: boolean }}
*/
static #buildPeerOptions() {
return {
host: location.hostname,
port: location.protocol === 'https:' ? 443 : location.port ? parseInt(location.port) + 1 : 80,
path: `${getProxyPath()}peer`,
secure: location.protocol === 'https:',
};
}
/**
* Creates a PeerJS client for the given session id.
* @param {string} id - Unique session identifier.
* @returns {{ peer: Peer, options: Object }} The Peer instance and its options.
*/
static createPeer(id) {
this.destroyPeer(id);
const options = this.#buildPeerOptions();
logger.info('peerOptions', options);
const peer = new Peer(undefined, options);
this.#sessions[id] = { peer, options };
return this.#sessions[id];
}
/**
* Returns an existing session or `undefined`.
* @param {string} id
* @returns {{ peer: Peer, options: Object }|undefined}
*/
static getSession(id) {
return this.#sessions[id];
}
/**
* Destroys a peer session — closes all connections and the peer itself.
* @param {string} id - Session identifier.
*/
static destroyPeer(id) {
const session = this.#sessions[id];
if (!session) return;
const { peer } = session;
if (peer) {
for (const key in peer.connections) {
for (const conn of peer.connections[key]) {
if (conn.peerConnection?.close) conn.peerConnection.close();
if (conn.close) conn.close();
}
}
if (!peer.destroyed) peer.destroy();
}
delete this.#sessions[id];
}
/**
* Calls a remote peer and streams local media to them.
* @param {string} id - Session identifier.
* @param {string} remotePeerId - The remote peer's ID.
* @param {MediaStream} localStream - Local media stream to send.
* @param {Object} [callbacks]
* @param {function(HTMLMediaElement): void} [callbacks.onStream] - Called when remote stream arrives.
* @param {function(HTMLMediaElement): void} [callbacks.onClose] - Called when the call closes.
* @returns {{ call: import('peerjs').MediaConnection, element: HTMLVideoElement }}
*/
static callPeer(id, remotePeerId, localStream, { onStream, onClose } = {}) {
const session = this.#sessions[id];
if (!session) throw new Error(`No peer session "${id}"`);
const call = session.peer.call(remotePeerId, localStream);
const element = this.createVideoElement();
call.on('stream', (remoteStream) => {
this.attachStream(element, remoteStream);
onStream?.(element);
});
call.on('close', () => onClose?.(element));
return { call, element };
}
// ── Media helpers ────────────────────────────────────────────────
/**
* Requests user media (camera / microphone) with graceful fallback.
* Falls back to video-only if audio is unavailable (NotReadableError).
* @param {{ video?: boolean|MediaTrackConstraints, audio?: boolean|MediaTrackConstraints }} [constraints]
* @returns {Promise<MediaStream|undefined>}
*/
static async getMediaStream(constraints = { video: true, audio: true }) {
const fallbacks = [
constraints,
constraints.audio ? { video: constraints.video, audio: false } : null,
constraints.video ? { video: false, audio: constraints.audio } : null,
].filter(Boolean);
for (const c of fallbacks) {
try {
return await navigator.mediaDevices.getUserMedia(c);
} catch (err) {
logger.warn(`getUserMedia failed (${err.name}): ${err.message}`, c);
}
}
logger.error('All media capture attempts failed');
return undefined;
}
/**
* Stops all tracks of a MediaStream.
* @param {MediaStream} stream
*/
static stopStream(stream) {
if (!stream) return;
stream.getTracks().forEach((track) => track.stop());
}
/**
* Creates a `<video>` element pre-configured for inline autoplay.
* @returns {HTMLVideoElement}
*/
static createVideoElement() {
const video = document.createElement('video');
video.playsInline = true;
video.autoplay = true;
return video;
}
/**
* Attaches a MediaStream to a media element and starts playback.
* @param {HTMLMediaElement} element
* @param {MediaStream} stream
* @returns {HTMLMediaElement}
*/
static attachStream(element, stream) {
element.srcObject = stream;
element.addEventListener('loadedmetadata', () => element.play());
return element;
}
}
export { Stream };