// RTCEngine — the main WebRTC entry point for the MWSE SDK. // // Drop-in replacement for the old WebRTC.js stub. Peer.js still imports // from WebRTC.js which re-exports this class, so no change is needed there. // // Architecture: // RTCEngine — coordinates all sub-systems, surfaces public API // ├── PeerConnection — RTCPeerConnection wrapper with full event coverage // ├── Negotiator — perfect-negotiation (offer/answer/ICE, no collisions) // ├── StreamManager — named media sources (addStream/replaceTrack/setEncodings) // ├── DataChannel — primary data channel with auto-reconnect + message queue // └── FileSender — multi-channel parallel file transfer // // Signaling relay: // Incoming → Peer.js emits 'input' on the engine → receive(msg) → Negotiator // Outgoing → Negotiator emits 'output' on the engine → Peer.js relays to server // // ICE restart: automatic exponential backoff on 'failed' (1 s → 2 s → 4 s). import MWSEEventTarget from '../EventTarget.js'; import PeerConnection from './PeerConnection.js'; import Negotiator from './Negotiator.js'; import StreamManager from './StreamManager.js'; import DataChannel from './DataChannel.js'; import FileSender from './FileSender.js'; export { MediaSources } from './MediaSources.js'; export { default as StreamManager } from './StreamManager.js'; export { default as FileSender } from './FileSender.js'; const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }]; const RESTART_DELAYS = [1000, 2000, 4000]; // ms, exponential backoff export default class RTCEngine extends MWSEEventTarget { constructor(config) { super(); this._config = config; // Public state — Peer.js reads these. this.active = false; this.peer = null; // set by Peer.js this.connectionStatus = 'new'; this.iceStatus = 'new'; this.gatheringStatus = 'new'; this.signalingStatus = ''; // Sub-system references (null until connect() is called). this._pc = null; this._neg = null; this._streams = null; this._data = null; this._files = null; this._restartN = 0; // Peer.js legacy: `this.rtc.channel` for direct DataChannel access. this.channel = null; // Bridge the 'input' event (from Peer.js) → receive(). this.on('input', msg => this.receive(msg)); } // ---- Connection lifecycle ------------------------------------------- // Connect to the remote peer. Call after the pair has been established. // // polite: true → this side yields on offer collision (accept incoming offer) // polite: false → this side wins on collision (the impolite peer) // // Typically set polite = (mySocketId < peerSocketId) so exactly one side // is polite per pair without any extra signaling. connect({ polite = false, iceServers } = {}) { if (this._pc) return this; // already connected const cfg = this._config ?? { iceServers: iceServers ?? DEFAULT_ICE }; this._pc = new PeerConnection(cfg); this._neg = new Negotiator(this._pc, { polite, onSend: msg => this.emit('output', msg) }).attach(); this._streams = new StreamManager(this._pc); this._data = new DataChannel(this._pc, 'mwse'); this.channel = this._data; this._files = new FileSender(this._pc); // The side with polite=false initiates the primary data channel // (creates the SDP line), which triggers onnegotiationneeded and // kicks off the offer/answer exchange. if (!polite) { this._data.initiate(); } // State events. this._pc.on('state-change', s => { this.connectionStatus = s; this.emit('state-change', s); }); this._pc.on('connected', () => { this.active = true; this.iceStatus = 'connected'; this._restartN = 0; this.emit('connected'); }); this._pc.on('disconnected', () => { this.active = false; this.emit('disconnected'); }); this._pc.on('failed', () => { this.active = false; this.emit('failed'); this._scheduleRestart(); }); this._pc.on('ice-state-change', s => { this.iceStatus = s; }); this._pc.on('gathering-change', s => { this.gatheringStatus = s; }); this._pc.on('signaling-change', s => { this.signalingStatus = s; }); this._pc.on('track', (track, streams) => this.emit('track', track, streams)); this._data.on('open', () => this.emit('channel-open')); this._data.on('message', msg => this.emit('message', msg)); this._data.on('close', () => this.emit('channel-close')); return this; } // ---- Signaling ------------------------------------------------------ // Handle an incoming signaling payload (relayed as ':rtcpack:' by Peer.js). async receive(msg) { if (!this._neg) return; try { await this._neg.receive(msg); } catch (err) { this.emit('error', err); } } // Compatibility shim: Peer.js may call rtc.send() for outgoing signaling. send(data) { this.emit('output', data); } // ---- Media (StreamManager proxy) ------------------------------------ get streams() { return this._streams; } // Add a named MediaStream. Triggers renegotiation. addStream(label, stream) { if (!this._streams) throw new Error('RTCEngine: call connect() first'); return this._streams.addStream(label, stream); } // Swap one track inside a named source by kind ('video'|'audio'). // No renegotiation — instant hot-swap. async replaceTrack(label, newTrack) { return this._streams?.replaceTrack(label, newTrack); } // Remove a named source (stops tracks and renegotiates). removeStream(label) { this._streams?.removeStream(label); } // Adjust bitrate / framerate / scale for an encoding on a named source. async setEncodings(label, kind, params) { return this._streams?.setEncodings(label, kind, params); } // Mute/unmute a track by label and kind. setEnabled(label, kind, enabled) { this._streams?.setEnabled(label, kind, enabled); } // ---- DataChannel ---------------------------------------------------- sendMessage(data) { this._data?.send(data); } // ---- File transfer (FileSender proxy) ------------------------------- get files() { return this._files; } // Send a File/Blob to the remote peer over parallel DataChannels. async sendFile(file) { if (!this._files) throw new Error('RTCEngine: call connect() first'); return this._files.send(file); } // Prepare to receive a file split into partCount partitions. // onFile(blob) is called when fully assembled. receiveFile(partCount, onFile) { this._files?.receive(partCount, onFile); } // ---- Lifecycle ------------------------------------------------------ destroy() { this._streams?.destroy(); this._data?.close(); this._pc?.close(); this._pc = null; this._neg = null; this._streams = null; this._data = null; this._files = null; this.channel = null; this.active = false; this.connectionStatus = 'closed'; this.emit('disconnected'); } // ---- ICE restart ---------------------------------------------------- _scheduleRestart() { const delay = RESTART_DELAYS[Math.min(this._restartN, RESTART_DELAYS.length - 1)]; this._restartN++; setTimeout(async () => { if (!this._pc || this.connectionStatus === 'closed') return; try { await this._pc.restartIce(); } catch (err) { this.emit('error', err); } }, delay); } }