UNPKG

p2p-media-loader-core

Version:
411 lines (364 loc) 12.1 kB
import { PeerError, TrackerError, TrackerWarning, PeerConnectError, } from "../../types.js"; import { EventTarget } from "../../utils/event-target.js"; import { getRTCErrorMessage, isTerminalConnectionState } from "../utils.js"; import { WebTorrentClient } from "../webtorrent-client/index.js"; import { WebTorrentSocketPool } from "../webtorrent-socket-pool/index.js"; export interface WebTorrentManagerConfig { infoHash: string; peerId: string; trackerUrls: string[]; rtcConfig?: () => RTCConfiguration | undefined; channelConfig?: RTCDataChannelInit; socketPool: WebTorrentSocketPool; offersCount?: () => number; offerTimeout?: () => number; iceGatheringTimeout?: () => number; connectionTimeout?: () => number; maxPeers?: () => number; maxPeersMultiplier?: () => number; } export type WebTorrentManagerEventMap = { peerConnected: (event: { peerId: string; connection: RTCPeerConnection; channel: RTCDataChannel; trackerUrl: string; close: (error?: PeerError) => void; }) => void; peerDisconnected: ( event: { peerId: string; trackerUrl: string; } & ( | { error: PeerError; disconnectReason?: never } | { error?: never; disconnectReason: string } ), ) => void; peerConnectFailed: (event: { peerId: string; trackerUrl: string; error: PeerConnectError; }) => void; warning: (event: { trackerUrl: string; warning: TrackerWarning }) => void; error: (event: { trackerUrl: string; error: TrackerError }) => void; }; type ConnectedPeer = { connection: RTCPeerConnection; channel: RTCDataChannel; trackerUrl: string; cleanup: () => void; }; const WEBTORRENT_DEFAULT_MAX_PEERS = 50; const WEBTORRENT_DEFAULT_MAX_PEERS_MULTIPLIER = 1.5; type ResolvedConfig = Required< Pick<WebTorrentManagerConfig, "maxPeers" | "maxPeersMultiplier"> > & WebTorrentManagerConfig; export class WebTorrentManager { readonly #config: ResolvedConfig; readonly #eventTarget = new EventTarget<WebTorrentManagerEventMap>(); readonly #connectingPeers = new Set<string>(); readonly #connectedPeers = new Map<string, ConnectedPeer>(); readonly #clients = new Set<{ client: WebTorrentClient; releaseSocket: () => void; cleanupListeners: () => void; }>(); #destroyed = false; #started = false; #claimPeer = (remotePeerId: string): boolean => { if (this.#destroyed) return false; if ( this.#connectingPeers.has(remotePeerId) || this.#connectedPeers.has(remotePeerId) ) { return false; } // claimPeer is used to passively ACCEPT incoming connections. // We accept peers up to the hard limit (maxPeers * multiplier) to allow new // peers to join the swarm and be evaluated by the background peer churning process. const hardLimit = Math.floor( this.#config.maxPeers() * Math.max(1.0, this.#config.maxPeersMultiplier()), ); if (this.#connectingPeers.size + this.#connectedPeers.size >= hardLimit) { return false; } this.#connectingPeers.add(remotePeerId); return true; }; constructor(config: WebTorrentManagerConfig) { this.#config = { ...config, maxPeers: config.maxPeers ?? (() => WEBTORRENT_DEFAULT_MAX_PEERS), maxPeersMultiplier: config.maxPeersMultiplier ?? (() => WEBTORRENT_DEFAULT_MAX_PEERS_MULTIPLIER), }; } public addEventListener<K extends keyof WebTorrentManagerEventMap>( eventName: K, listener: WebTorrentManagerEventMap[K], ): void { this.#eventTarget.addEventListener(eventName, listener); } public removeEventListener<K extends keyof WebTorrentManagerEventMap>( eventName: K, listener: WebTorrentManagerEventMap[K], ): void { this.#eventTarget.removeEventListener(eventName, listener); } public start(): void { if (this.#destroyed || this.#started) return; this.#started = true; try { for (const url of this.#config.trackerUrls) { const { client: wsClient, release } = this.#config.socketPool.acquire(url); let addedToClients = false; try { const client = new WebTorrentClient({ infoHash: this.#config.infoHash, peerId: this.#config.peerId, wsClient, rtcConfig: this.#config.rtcConfig, channelConfig: this.#config.channelConfig, claimPeer: this.#claimPeer, offersCount: this.#config.offersCount, offerTimeout: this.#config.offerTimeout, iceGatheringTimeout: this.#config.iceGatheringTimeout, connectionTimeout: this.#config.connectionTimeout, // shouldGenerateOffers is used to proactively INITIATE connections. // We stop actively hunting for peers once we hit the soft limit (maxPeers). // This prevents artificial hyper-churn where a stable swarm constantly // cycles connections to reach the hard limit unnecessarily. shouldGenerateOffers: () => this.#connectingPeers.size + this.#connectedPeers.size < this.#config.maxPeers(), }); const onPeerConnected = (event: { peerId: string; connection: RTCPeerConnection; channel: RTCDataChannel; }) => { this.#connectingPeers.delete(event.peerId); this.#addConnectedPeer( event.peerId, event.connection, event.channel, url, ); }; const onPeerConnectFailed = (event: { peerId: string; error: PeerConnectError; }) => { if (this.#connectingPeers.has(event.peerId)) { this.#connectingPeers.delete(event.peerId); this.#eventTarget.dispatchEvent("peerConnectFailed", { peerId: event.peerId, trackerUrl: url, error: event.error, }); } }; const onWarning = (warning: TrackerWarning) => { this.#eventTarget.dispatchEvent("warning", { trackerUrl: url, warning, }); }; const onError = (error: TrackerError) => { this.#eventTarget.dispatchEvent("error", { trackerUrl: url, error, }); }; client.addEventListener("peerConnected", onPeerConnected); client.addEventListener("peerConnectFailed", onPeerConnectFailed); client.addEventListener("warning", onWarning); client.addEventListener("error", onError); const cleanupListeners = () => { client.removeEventListener("peerConnected", onPeerConnected); client.removeEventListener( "peerConnectFailed", onPeerConnectFailed, ); client.removeEventListener("warning", onWarning); client.removeEventListener("error", onError); }; this.#clients.add({ client, releaseSocket: release, cleanupListeners, }); addedToClients = true; client.start(); } catch (error) { if (!addedToClients) { release(); } throw error; } } } catch (error) { this.destroy(); throw error; } } public destroy(): void { if (this.#destroyed) return; this.#destroyed = true; // Remove our listeners BEFORE destroying the client. This ensures that // if client.destroy() synchronously dispatches events, // they won't reach this already-destroyed manager. for (const { client, releaseSocket, cleanupListeners } of this.#clients) { cleanupListeners(); client.destroy(); releaseSocket(); } this.#clients.clear(); this.#connectingPeers.clear(); const connectedSnapshot = [...this.#connectedPeers.entries()]; this.#connectedPeers.clear(); for (const [peerId, peer] of connectedSnapshot) { peer.cleanup(); try { peer.channel.close(); } catch { // ignore } try { peer.connection.close(); } catch { // ignore } this.#eventTarget.dispatchEvent("peerDisconnected", { peerId, trackerUrl: peer.trackerUrl, disconnectReason: "Manager destroyed", }); } this.#eventTarget.clear(); } #closePeer( peerId: string, cause: | { error: PeerError; disconnectReason?: never } | { error?: never; disconnectReason: string }, ): void { if (this.#destroyed) return; const connected = this.#connectedPeers.get(peerId); if (!connected) return; // Synchronously extract from map first to prevent re-entrant double-fire // if close() synchronously triggers event listeners. this.#connectedPeers.delete(peerId); connected.cleanup(); try { connected.channel.close(); } catch { // ignore } try { connected.connection.close(); } catch { // ignore } this.#eventTarget.dispatchEvent("peerDisconnected", { peerId, trackerUrl: connected.trackerUrl, ...cause, }); } #addConnectedPeer( peerId: string, connection: RTCPeerConnection, channel: RTCDataChannel, trackerUrl: string, ): void { if (isTerminalConnectionState(connection.iceConnectionState)) { try { connection.close(); } catch { // ignore } this.#eventTarget.dispatchEvent("peerConnectFailed", { peerId, trackerUrl, error: new PeerConnectError( "connection-failed", "Connection failed during promotion", ), }); return; } const onDisconnect = ( cause: | { error: PeerError; disconnectReason?: never } | { error?: never; disconnectReason: string }, ) => this.#closePeer(peerId, cause); const onIceConnectionStateChange = () => { if (isTerminalConnectionState(connection.iceConnectionState)) { onDisconnect({ error: new PeerError( "connection-lost", `ICE connection state became ${connection.iceConnectionState}`, ), }); } }; const onChannelClose = () => onDisconnect({ disconnectReason: "Data channel closed" }); const onChannelClosing = () => onDisconnect({ disconnectReason: "Data channel closing" }); const onChannelError = (event: Event) => { const msg = getRTCErrorMessage(event, "Data channel error"); onDisconnect({ error: new PeerError("transport-error", `Data channel error: ${msg}`), }); }; // Indirection so that cleanup() can null out the reference. Without this, // the close() closure exposed in the peerConnected event would capture // `this` permanently, preventing GC of the manager after destruction. let closeRef: ((error?: PeerError) => void) | null = (error) => this.#closePeer( peerId, error ? { error } : { disconnectReason: "Closed by consumer" }, ); const cleanup = () => { closeRef = null; connection.removeEventListener( "iceconnectionstatechange", onIceConnectionStateChange, ); channel.removeEventListener("close", onChannelClose); channel.removeEventListener("closing", onChannelClosing); channel.removeEventListener("error", onChannelError); }; this.#connectedPeers.set(peerId, { connection, channel, trackerUrl, cleanup, }); connection.addEventListener( "iceconnectionstatechange", onIceConnectionStateChange, ); channel.addEventListener("close", onChannelClose); channel.addEventListener("closing", onChannelClosing); channel.addEventListener("error", onChannelError); this.#eventTarget.dispatchEvent("peerConnected", { peerId, connection, channel, trackerUrl, close: (error?: PeerError) => closeRef?.(error), }); } }