MWSE/sdk/webrtc/index.js

225 lines
8.1 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';
export { default as CanvasCompositor} from './CanvasCompositor.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);
}
}