/** * WebRTC mesh — peer-to-peer DataChannel between browser tabs via * shared BroadcastChannel signaling. * * Features: * - Auto-initiator election (lowest peerId wins; the rest answer) * - Heartbeats every 10s; peer marked stale after 30s silence * - Auto-reconnect on ICE disconnect / channel close * - Bus event emission on peer connect/disconnect/message */ import { fireBusEvent } from './event-bus' export interface WebRTCPeer { id: string lastSeen: number state: 'connecting' | 'open' | 'closed' | 'failed' } const PEER_ID = `rtc-${Math.random().toString(36).slice(2, 8)}` const SIGNAL_CHANNEL = 'careless-webrtc-signaling' let pc: RTCPeerConnection | null = null let dc: RTCDataChannel | null = null let signalingChannel: BroadcastChannel | null = null let heartbeatInterval: any = null const listeners = new Set<(msg: string, from?: string) => void>() const peers: Map = new Map() let reconnectAttempts = 0 const MAX_RECONNECT = 5 function ensureSignaling(): BroadcastChannel { if (signalingChannel) return signalingChannel signalingChannel = new BroadcastChannel(SIGNAL_CHANNEL) return signalingChannel } function setupDataChannel(channel: RTCDataChannel, remoteId?: string) { dc = channel dc.onopen = () => { console.debug('[webrtc] channel open', remoteId) reconnectAttempts = 0 if (remoteId) { peers.set(remoteId, { id: remoteId, lastSeen: Date.now(), state: 'open' }) fireBusEvent({ source: 'webrtc', kind: 'info', summary: `Peer connected: ${remoteId}` }) } } dc.onclose = () => { console.debug('[webrtc] channel closed') if (remoteId) { peers.delete(remoteId) fireBusEvent({ source: 'webrtc', kind: 'info', summary: `Peer disconnected: ${remoteId}` }) } maybeReconnect() } dc.onerror = () => fireBusEvent({ source: 'webrtc', kind: 'error', summary: 'Data channel error' }) dc.onmessage = (m) => { // Parse heartbeats try { const parsed = JSON.parse(m.data) if (parsed?.type === 'heartbeat' && parsed.from) { const p = peers.get(parsed.from) || { id: parsed.from, lastSeen: 0, state: 'open' as const } p.lastSeen = Date.now() p.state = 'open' peers.set(parsed.from, p) return } } catch {} listeners.forEach(l => l(m.data, remoteId)) } } function maybeReconnect() { if (reconnectAttempts >= MAX_RECONNECT) { console.warn('[webrtc] max reconnect attempts reached') return } reconnectAttempts++ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 15_000) console.debug(`[webrtc] scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts})`) setTimeout(() => { pc?.close() pc = null dc = null initWebRTC() }, delay) } function sendHeartbeat() { if (dc?.readyState === 'open') { dc.send(JSON.stringify({ type: 'heartbeat', from: PEER_ID, ts: Date.now() })) } // Prune stale peers const cutoff = Date.now() - 30_000 for (const [id, p] of peers) { if (p.lastSeen < cutoff) { peers.delete(id) fireBusEvent({ source: 'webrtc', kind: 'info', summary: `Peer stale: ${id}` }) } } } export async function initWebRTC(): Promise { if (pc) return true try { pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }) pc.oniceconnectionstatechange = () => { const st = pc?.iceConnectionState if (st === 'failed' || st === 'disconnected') { fireBusEvent({ source: 'webrtc', kind: 'error', summary: `ICE ${st}, reconnecting` }) maybeReconnect() } } pc.ondatachannel = (e) => setupDataChannel(e.channel, undefined) const initial = pc.createDataChannel('careless') setupDataChannel(initial) const sig = ensureSignaling() sig.onmessage = async (ev) => { const msg = ev.data if (msg.from === PEER_ID) return // ignore own signals if (msg.type === 'offer') { if (!pc) return await pc.setRemoteDescription(msg.sdp) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) sig.postMessage({ type: 'answer', sdp: answer, from: PEER_ID, to: msg.from }) } else if (msg.type === 'answer' && msg.to === PEER_ID) { if (!pc?.remoteDescription) await pc?.setRemoteDescription(msg.sdp) } else if (msg.type === 'ice' && msg.candidate) { try { await pc?.addIceCandidate(msg.candidate) } catch {} } } pc.onicecandidate = (e) => { if (e.candidate) sig.postMessage({ type: 'ice', candidate: e.candidate, from: PEER_ID }) } const offer = await pc.createOffer() await pc.setLocalDescription(offer) sig.postMessage({ type: 'offer', sdp: offer, from: PEER_ID }) if (!heartbeatInterval) heartbeatInterval = setInterval(sendHeartbeat, 10_000) return true } catch (e) { console.error('[webrtc] init failed', e) return false } } export function sendWebRTC(msg: string) { if (dc?.readyState === 'open') dc.send(msg) } export function onWebRTC(cb: (msg: string, from?: string) => void): () => void { listeners.add(cb) return () => { listeners.delete(cb) } } export function getWebRTCPeers(): WebRTCPeer[] { return Array.from(peers.values()) } export function getWebRTCPeerId(): string { return PEER_ID } export function closeWebRTC() { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null } dc?.close(); pc?.close() dc = null; pc = null; peers.clear() }