224 lines
8.0 KiB
JavaScript
224 lines
8.0 KiB
JavaScript
// 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);
|
|
}
|
|
}
|