UNPKG

tiny-server-essentials

Version:

A good utility toolkit to unify Express v5 and Socket.IO v4 into a seamless development experience with modular helpers, server wrappers, and WebSocket tools.

553 lines (496 loc) 18.5 kB
'use strict'; var events = require('events'); /** @typedef {'video'|'audio'|HTMLVideoElement|HTMLAudioElement} ReceiverTags */ /** * TinyMediaReceiver is a lightweight media stream handler designed to manage * continuous streaming of audio or video chunks into an HTMLMediaElement using MediaSource. * * It handles buffering, memory cleanup, and playback synchronization, allowing for dynamic * appending of media data and auto-cleaning old buffer segments to preserve memory and prevent playback issues. * * This class supports custom configuration such as buffer size, cleanup interval, and buffer tolerance. * It emits events throughout the lifecycle, such as when the media source is open, buffer is cleaned, * time is synced, or an error occurs. * * Example usage: * ```js * const receiver = new TinyMediaReceiver({ * element: 'audio', * mimeType: 'audio/webm;codecs=opus' * }); * receiver.push(someAudioChunk); * ``` * * @class * @beta */ class TinyMediaReceiver { /** * Important instance used to make event emitter. * @type {EventEmitter} */ #events = new events.EventEmitter(); /** * Important instance used to make system event emitter. * @type {EventEmitter} */ #sysEvents = new events.EventEmitter(); #sysEventsUsed = false; /** * Event labels used internally and externally for stream control and monitoring. * These events are emitted or listened to over socket or internal dispatch. * @readonly */ Events = { /** * Emitted when the buffer has been successfully cleaned. * Useful for freeing up memory or managing playback state. * @type {'BufferCleaned'} */ BufferCleaned: 'BufferCleaned', /** * Emitted to synchronize the playback time, typically used * when aligning multiple streams or correcting drift. * @type {'SyncTime'} */ SyncTime: 'SyncTime', /** * Event name emitted when the instance is destroyed. * This constant can be used to subscribe to the destruction event of the instance. * @type {'Destroyed'} */ Destroyed: 'Destroyed', /** * Emitted when an error occurs in the stream process. * Can be used to log or handle critical failures. * @type {'Error'} */ Error: 'Error', /** * Emitted when the MediaSource becomes open and is ready for SourceBuffer initialization. * @type {'SourceOpen'} */ SourceOpen: 'SourceOpen', /** * Emitted when a new chunk of data is being fed into the SourceBuffer queue. * Useful for tracking data flow or debugging buffering behavior. * @type {'FeedQueue'} */ FeedQueue: 'FeedQueue', }; /** * Checks whether a given event name is defined in the Events map. * * This method verifies if the provided string matches one of the predefined * event labels (e.g., "Mic", "Cam", "Screen", "MicMeter", "ScreenMeter"). * * @param {string} name - The name of the event to check. * @returns {boolean} Returns `true` if the event exists in the Events map, otherwise `false`. */ existsEvent(name) { // @ts-ignore if (typeof this.Events[name] === 'string') return true; return false; } /** * Emits an event with optional arguments to all system emit. * @param {string | symbol} event - The name of the event to emit. * @param {...any} args - Arguments passed to event listeners. */ #emit(event, ...args) { this.#events.emit(event, ...args); if (this.#sysEventsUsed) this.#sysEvents.emit(event, ...args); } /** * Provides access to a secure internal EventEmitter for subclass use only. * * This method exposes a dedicated EventEmitter instance intended specifically for subclasses * that extend the main class. It prevents subclasses from accidentally or intentionally using * the primary class's public event system (`emit`), which could lead to unpredictable behavior * or interference in the base class's event flow. * * For security and consistency, this method is designed to be accessed only once. * Multiple accesses are blocked to avoid leaks or misuse of the internal event bus. * * @returns {EventEmitter} A special internal EventEmitter instance for subclass use. * @throws {Error} If the method is called more than once. */ getSysEvents() { if (this.#sysEventsUsed) throw new Error( 'Access denied: getSysEvents() can only be called once. ' + 'This restriction ensures subclass event isolation and prevents accidental interference ' + 'with the main class event emitter.', ); this.#sysEventsUsed = true; return this.#sysEvents; } /** * @typedef {(...args: any[]) => void} ListenerCallback * A generic callback function used for event listeners. */ /** * Sets the maximum number of listeners for the internal event emitter. * * @param {number} max - The maximum number of listeners allowed. */ setMaxListeners(max) { this.#events.setMaxListeners(max); } /** * Emits an event with optional arguments. * @param {string | symbol} event - The name of the event to emit. * @param {...any} args - Arguments passed to event listeners. * @returns {boolean} `true` if the event had listeners, `false` otherwise. */ emit(event, ...args) { return this.#events.emit(event, ...args); } /** * Registers a listener for the specified event. * @param {string | symbol} event - The name of the event to listen for. * @param {ListenerCallback} listener - The callback function to invoke. * @returns {this} The current class instance (for chaining). */ on(event, listener) { this.#events.on(event, listener); return this; } /** * Registers a one-time listener for the specified event. * @param {string | symbol} event - The name of the event to listen for once. * @param {ListenerCallback} listener - The callback function to invoke. * @returns {this} The current class instance (for chaining). */ once(event, listener) { this.#events.once(event, listener); return this; } /** * Removes a listener from the specified event. * @param {string | symbol} event - The name of the event. * @param {ListenerCallback} listener - The listener to remove. * @returns {this} The current class instance (for chaining). */ off(event, listener) { this.#events.off(event, listener); return this; } /** * Alias for `on`. * @param {string | symbol} event - The name of the event. * @param {ListenerCallback} listener - The callback to register. * @returns {this} The current class instance (for chaining). */ addListener(event, listener) { this.#events.addListener(event, listener); return this; } /** * Alias for `off`. * @param {string | symbol} event - The name of the event. * @param {ListenerCallback} listener - The listener to remove. * @returns {this} The current class instance (for chaining). */ removeListener(event, listener) { this.#events.removeListener(event, listener); return this; } /** * Removes all listeners for a specific event, or all events if no event is specified. * @param {string | symbol} [event] - The name of the event. If omitted, all listeners from all events will be removed. * @returns {this} The current class instance (for chaining). */ removeAllListeners(event) { this.#events.removeAllListeners(event); return this; } /** * Returns the number of times the given `listener` is registered for the specified `event`. * If no `listener` is passed, returns how many listeners are registered for the `event`. * @param {string | symbol} eventName - The name of the event. * @param {Function} [listener] - Optional listener function to count. * @returns {number} Number of matching listeners. */ listenerCount(eventName, listener) { return this.#events.listenerCount(eventName, listener); } /** * Adds a listener function to the **beginning** of the listeners array for the specified event. * The listener is called every time the event is emitted. * @param {string | symbol} eventName - The event name. * @param {ListenerCallback} listener - The callback function. * @returns {this} The current class instance (for chaining). */ prependListener(eventName, listener) { this.#events.prependListener(eventName, listener); return this; } /** * Adds a **one-time** listener function to the **beginning** of the listeners array. * The next time the event is triggered, this listener is removed and then invoked. * @param {string | symbol} eventName - The event name. * @param {ListenerCallback} listener - The callback function. * @returns {this} The current class instance (for chaining). */ prependOnceListener(eventName, listener) { this.#events.prependOnceListener(eventName, listener); return this; } /** * Returns an array of event names for which listeners are currently registered. * @returns {(string | symbol)[]} Array of event names. */ eventNames() { return this.#events.eventNames(); } /** * Gets the current maximum number of listeners allowed for any single event. * @returns {number} The max listener count. */ getMaxListeners() { return this.#events.getMaxListeners(); } /** * Returns a copy of the listeners array for the specified event. * @param {string | symbol} eventName - The event name. * @returns {Function[]} An array of listener functions. */ listeners(eventName) { return this.#events.listeners(eventName); } /** * Returns a copy of the internal listeners array for the specified event, * including wrapper functions like those used by `.once()`. * @param {string | symbol} eventName - The event name. * @returns {Function[]} An array of raw listener functions. */ rawListeners(eventName) { return this.#events.rawListeners(eventName); } /** @type {HTMLMediaElement|null} */ #element = null; #url = ''; /** @type {null|NodeJS.Timeout} */ #cleanupBuffer = null; /** * Handles the `sourceopen` event from the MediaSource. * Initializes the SourceBuffer and starts feeding data into it. * Emits an error and destroys the instance if the MIME type is not supported. * * @private * @returns {void} */ _onSourceOpen() { if (!MediaSource.isTypeSupported(this.mimeType)) { const err = new Error(`MIME type ${this.mimeType} not supported.`); console.error(err); this.#emit(this.Events.Error, err); this.destroy(); return; } this.sourceBuffer = this.mediaSource.addSourceBuffer(this.mimeType); this.sourceBuffer.mode = 'sequence'; // garante ordem this.sourceBuffer.addEventListener('updateend', () => this._feedQueue()); this.#emit(this.Events.SourceOpen); this._feedQueue(); } /** * Feeds queued media chunks into the SourceBuffer if it's ready. * Emits FeedQueue event for each chunk and handles errors by emitting an Error event and destroying the instance. * * @private * @returns {void} */ _feedQueue() { if (!this.sourceBuffer || this.queue.length === 0 || this.sourceBuffer.updating) return; const chunk = this.queue.shift(); try { this.#emit(this.Events.FeedQueue, chunk); // @ts-ignore this.sourceBuffer.appendBuffer(chunk); if (this.queue.length > 0) this._feedQueue(); } catch (e) { console.error('Failed to append buffer:', e); this.#emit(this.Events.Error, e); this.destroy(); } } /** * Cleans the SourceBuffer to maintain a limited buffer size. * Removes old buffered data that exceeds the configured max buffer back. * Also adjusts the currentTime if it leaves the valid buffered range. * Emits BufferCleaned and SyncTime events when appropriate. * * @private * @returns {void} */ _cleanupBuffer() { if (!this.sourceBuffer || !this.#element) return; if (!this.sourceBuffer.buffered || this.sourceBuffer.updating) return; const media = this.#element; const buffered = this.sourceBuffer.buffered; const maxBack = this.getMaxBufferBack(); const tolerance = this.getTolerance(); if (buffered.length === 0) return; const bufferStart = buffered.start(0); const bufferEnd = buffered.end(buffered.length - 1); const bufferedDuration = bufferEnd - bufferStart; // Cleans only if the buffer total exceeds the limit if (bufferedDuration > maxBack + tolerance) { const extra = bufferedDuration - maxBack; const removeEnd = bufferStart + extra; try { this.sourceBuffer.remove(bufferStart, removeEnd); this.#emit?.(this.Events.BufferCleaned, { from: bufferStart, to: removeEnd }); } catch (e) { console.warn('Failed to clean buffer range:', e); } } // Fixes currentTime if it leaves the allowed range const idealTime = bufferEnd - 0.1; const minTime = bufferEnd - maxBack; if (media.currentTime < minTime + tolerance || media.currentTime > bufferEnd - tolerance) { media.currentTime = idealTime; this.#emit?.(this.Events.SyncTime, idealTime); } } /** * Initializes a media player for continuous streaming from received chunks. * * @param {Object} [options={}] * @param {ReceiverTags} [options.element] - The tag to attach the stream. * @param {string} [options.mimeType] - The mime type, e.g., 'audio/webm;codecs=opus'. * @param {number} [options.maxBufferBack=10] - Maximum buffer (in seconds) back to keep in the buffer behind the current time. * @param {number} [options.cleanupTime] - Interval time in milliseconds to perform buffer cleanup. Must be a positive number. * @param {number} [options.bufferTolerance] - Tolerance value (in seconds) used when comparing buffer ranges. Must be a positive number. */ constructor({ element, mimeType, maxBufferBack = 10, cleanupTime = 100, bufferTolerance = 0.1, } = {}) { if (typeof mimeType !== 'string' || !MediaSource.isTypeSupported(mimeType)) throw new Error(`MIME type not supported: ${mimeType}`); if (!Number.isInteger(maxBufferBack) || maxBufferBack <= 0) throw new Error('Expected a positive number in maxBufferBack.'); if (typeof cleanupTime !== 'number' || cleanupTime <= 0) throw new Error('Expected a positive number for cleanupTime.'); if (typeof bufferTolerance !== 'number' || bufferTolerance <= 0) throw new Error('Expected a positive number for bufferTolerance.'); this.mimeType = mimeType; /** @type {BufferSource[]} */ this.queue = []; this.sourceBuffer = null; this.mediaSource = new MediaSource(); this.isBufferUpdating = false; this.isEnded = false; this.maxBufferBack = maxBufferBack; this.tolerance = bufferTolerance; if (typeof element === 'string') { if (element !== 'audio' && element !== 'video') throw new Error("element must be either 'audio' or 'video'."); this.#element = document.createElement(element); } else if (element instanceof HTMLMediaElement) this.#element = element; else throw new Error('Expected a string (tag name) or an HTMLMediaElement instance.'); this.#element.src = this.#url; this.#element.src = URL.createObjectURL(this.mediaSource); this.mediaSource.addEventListener('sourceopen', () => { this._onSourceOpen(); }); this.#cleanupBuffer = setInterval(() => this._cleanupBuffer(), cleanupTime); } /** * Returns the current buffer tolerance in seconds. * @returns {number} The current tolerance value. */ getTolerance() { if (typeof this.tolerance !== 'number' || this.tolerance <= 0) throw new Error('Tolerance must be a positive number.'); return this.tolerance; } /** * Sets a new buffer tolerance value. * * @param {number} value - The new tolerance value in seconds. Must be a positive number. * @throws {Error} If the value is not a positive number. */ setTolerance(value) { if (typeof value !== 'number' || value <= 0) throw new Error('Tolerance must be a positive number.'); this.tolerance = value; } /** * Gets the maximum buffer back. * Only returns a positive integer. * @returns {number} */ getMaxBufferBack() { if (!Number.isInteger(this.maxBufferBack) || this.maxBufferBack <= 0) throw new Error( `[MediaStream] Invalid internal maxBufferBack detected (${this.maxBufferBack}), resetting to 30.`, ); return this.maxBufferBack; } /** * Sets the maximum buffer back. * Value must be a positive integer. * @param {number} value */ setMaxBufferBack(value) { if (!Number.isInteger(value) || value <= 0) throw new Error(`[MediaStream] maxBufferBack must be a positive integer. Received: ${value}`); this.maxBufferBack = value; } /** @returns {HTMLMediaElement} */ getElement() { if (!(this.#element instanceof HTMLMediaElement)) throw new Error('Element is not a valid HTMLMediaElement'); return this.#element; } /** * Pushes a new chunk of media data to the playback buffer. * @param {ArrayBuffer} buffer */ pushChunk(buffer) { if (this.isEnded) return new Promise((resolve) => resolve(undefined)); this.queue.push(buffer); this._feedQueue(); } /** * Alias for `pushChunk`. * @param {ArrayBuffer} buffer */ push(buffer) { return this.pushChunk(buffer); } /** * Finalizes the media stream and releases resources. */ destroy() { if (!this.isEnded && this.mediaSource.readyState === 'open') { this.isEnded = true; const tryEnd = () => { if (!this.sourceBuffer) throw new Error('No sourcerBuffer'); if (!this.sourceBuffer.updating && this.mediaSource.readyState === 'open') { this.mediaSource.endOfStream(); URL.revokeObjectURL(this.#url); if (this.#element) { this.#element.remove(); this.#element = null; } if (this.#cleanupBuffer) clearInterval(this.#cleanupBuffer); this.#emit(this.Events.Destroyed); this.#events.removeAllListeners(); this.#sysEvents.removeAllListeners(); } else { setTimeout(tryEnd, 100); } }; tryEnd(); } } } module.exports = TinyMediaReceiver;