UNPKG

p2p-media-loader-core

Version:
893 lines (760 loc) 25.5 kB
import { EventTarget } from "../../utils/event-target.js"; import { getPromiseWithResolvers } from "../../utils/utils.js"; import { isTerminalConnectionState } from "../utils.js"; import { WebSocketClient } from "../websocket-client/index.js"; import { TrackerError, TrackerWarning, PeerConnectError } from "../../types.js"; import { SafeAbortController } from "../../utils/abort-controller.js"; import { PeerConnection, SessionDescription, safeCreateOffer, safeCreateAnswer, safeSetLocalDescription, safeSetRemoteDescription, } from "./webrtc-utils.js"; export type WebTorrentClientEventMap = { peerConnected: (event: { peerId: string; connection: RTCPeerConnection; channel: RTCDataChannel; }) => void; peerConnectFailed: (event: { peerId: string; error: PeerConnectError; }) => void; warning: (warning: TrackerWarning) => void; error: (error: TrackerError) => void; }; const WEBTORRENT_DEFAULT_OFFER_TIMEOUT = 50000; const WEBTORRENT_DEFAULT_CONNECTION_TIMEOUT = 15000; const WEBTORRENT_DEFAULT_OFFERS_COUNT = 5; const WEBTORRENT_DEFAULT_ICE_GATHERING_TIMEOUT = 5000; export interface WebTorrentClientConfig { wsClient: WebSocketClient; infoHash: string; peerId: string; rtcConfig?: () => RTCConfiguration | undefined; channelConfig?: RTCDataChannelInit; offerTimeout?: () => number; offersCount?: () => number; iceGatheringTimeout?: () => number; connectionTimeout?: () => number; claimPeer?: (peerId: string) => boolean; shouldGenerateOffers?: () => boolean; } type PendingOffer = { connection: RTCPeerConnection; channel: RTCDataChannel; timeoutId: ReturnType<typeof setTimeout>; }; type IncomingOffer = { sdp: RTCSessionDescriptionInit; peerId: string; offerId: string; }; type IncomingAnswer = { sdp: RTCSessionDescriptionInit; peerId: string; offerId: string; }; function generateOfferId(): string { // Generate a safe 20-character alphanumeric string let id = ""; const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for (let i = 0; i < 20; i++) { id += chars.charAt(Math.floor(Math.random() * chars.length)); } return id; } const VALID_SDP_TYPES = new Set(["offer", "answer", "pranswer", "rollback"]); function isSessionDescriptionInit( value: unknown, ): value is RTCSessionDescriptionInit { if (typeof value !== "object" || value === null) return false; const obj = value as Record<string, unknown>; return ( typeof obj.type === "string" && VALID_SDP_TYPES.has(obj.type) && typeof obj.sdp === "string" ); } export class WebTorrentClient { static readonly #DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 120; static readonly #MIN_ANNOUNCE_INTERVAL_SECONDS = 20; readonly #config: Required< Omit<WebTorrentClientConfig, "rtcConfig" | "channelConfig" | "wsClient"> > & { rtcConfig?: () => RTCConfiguration | undefined; channelConfig?: RTCDataChannelInit; }; readonly #wsClient: WebSocketClient; readonly #eventTarget = new EventTarget<WebTorrentClientEventMap>(); readonly #pendingOffers = new Map<string, PendingOffer>(); readonly #negotiatingConnections = new Set<RTCPeerConnection>(); readonly #destroyAbortController = new SafeAbortController(); #announceTimeoutId: ReturnType<typeof setTimeout> | null = null; #announceIntervalSeconds: number | null = null; #scheduleAnnounceRunId = 0; #activeAnnouncePromise: Promise<void> | null = null; #nextAnnounceEvent: "started" | undefined = undefined; #trackerId: string | null = null; #started = false; #isDestroyed(): boolean { return this.#destroyAbortController.signal.aborted; } #throwIfDestroyed(): void { if (this.#destroyAbortController.signal.aborted) { throw new Error("Client destroyed"); } } constructor(config: WebTorrentClientConfig) { this.#config = { infoHash: config.infoHash, peerId: config.peerId, rtcConfig: config.rtcConfig, channelConfig: config.channelConfig, offerTimeout: config.offerTimeout ?? (() => WEBTORRENT_DEFAULT_OFFER_TIMEOUT), offersCount: config.offersCount ?? (() => WEBTORRENT_DEFAULT_OFFERS_COUNT), iceGatheringTimeout: config.iceGatheringTimeout ?? (() => WEBTORRENT_DEFAULT_ICE_GATHERING_TIMEOUT), connectionTimeout: config.connectionTimeout ?? (() => WEBTORRENT_DEFAULT_CONNECTION_TIMEOUT), claimPeer: config.claimPeer ?? (() => true), shouldGenerateOffers: config.shouldGenerateOffers ?? (() => true), }; this.#wsClient = config.wsClient; } public addEventListener<K extends keyof WebTorrentClientEventMap>( eventName: K, listener: WebTorrentClientEventMap[K], ): void { this.#eventTarget.addEventListener(eventName, listener); } public removeEventListener<K extends keyof WebTorrentClientEventMap>( eventName: K, listener: WebTorrentClientEventMap[K], ): void { this.#eventTarget.removeEventListener(eventName, listener); } public start(): void { if (this.#isDestroyed() || this.#started) return; this.#started = true; this.#wsClient.addEventListener("connected", this.#onWsConnected); this.#wsClient.addEventListener("disconnected", this.#onWsDisconnected); this.#wsClient.addEventListener("message", this.#onWsMessage); if (this.#wsClient.state === "connected") { this.#onWsConnected(); } } public destroy(): void { if (this.#isDestroyed()) return; this.#destroyAbortController.abort(); this.#clearAnnounceTimeout(); this.#sendStopped(); this.#cleanupPendingOffers(); this.#cleanupNegotiatingConnections(); if (this.#started) { this.#wsClient.removeEventListener("connected", this.#onWsConnected); this.#wsClient.removeEventListener( "disconnected", this.#onWsDisconnected, ); this.#wsClient.removeEventListener("message", this.#onWsMessage); } this.#eventTarget.clear(); } #onWsConnected = (): void => { // Setup a fallback interval in case the tracker doesn't provide one this.#scheduleAnnounce(WebTorrentClient.#DEFAULT_ANNOUNCE_INTERVAL_SECONDS); // Send the initial announce event this.#announce("started").catch((err: unknown) => { if (this.#isDestroyed()) return; this.#eventTarget.dispatchEvent( "error", new TrackerError( "announce-failed", `Initial announce failed: ${err instanceof Error ? err.message : String(err)}`, err, ), ); }); }; #onWsDisconnected = (): void => { this.#clearAnnounceTimeout(); this.#announceIntervalSeconds = null; }; #onWsMessage = (data: ArrayBuffer | string): void => { if (this.#isDestroyed()) return; let msg: unknown; try { const text = typeof data === "string" ? data : new TextDecoder().decode(data); msg = JSON.parse(text) as unknown; } catch (err: unknown) { this.#eventTarget.dispatchEvent( "error", new TrackerError( "parse-error", `Failed to parse tracker message: ${err instanceof Error ? err.message : String(err)}`, err, ), ); return; } if (typeof msg !== "object" || msg === null || Array.isArray(msg)) return; const dataObject = msg as Record<string, unknown>; // Ignore messages for a different torrent (possible on shared WebSocket connections) const infoHash = dataObject.info_hash; if (typeof infoHash === "string" && infoHash !== this.#config.infoHash) { return; } const warningMessage = dataObject["warning message"]; if (typeof warningMessage === "string") { this.#eventTarget.dispatchEvent( "warning", new TrackerWarning("tracker-response", warningMessage), ); } const failureReason = dataObject["failure reason"]; if (typeof failureReason === "string") { this.#eventTarget.dispatchEvent( "error", new TrackerError("tracker-response", failureReason), ); return; } const { interval } = dataObject; if (typeof interval === "number" && interval > 0) { // Defend against trackers asking for extremely frequent announces const safeInterval = Math.max( WebTorrentClient.#MIN_ANNOUNCE_INTERVAL_SECONDS, interval, ); if (this.#announceIntervalSeconds !== safeInterval) { this.#scheduleAnnounce(safeInterval); } } // The WebTorrent tracker protocol specifies "tracker id" (with a space) in the // response, but expects it to be echoed back as "trackerid" (no space) in // subsequent announce requests. We store it here and send it back later. const trackerId = dataObject["tracker id"]; if (typeof trackerId === "string") { this.#trackerId = trackerId; } // Ignore offers/answers from ourselves const peerId = dataObject.peer_id; if (typeof peerId === "string" && peerId === this.#config.peerId) { return; } // Both offer and answer messages require peer_id and offer_id const offerId = dataObject.offer_id; if (typeof peerId !== "string" || typeof offerId !== "string") return; if (isSessionDescriptionInit(dataObject.offer)) { this.#handleIncomingOffer({ sdp: dataObject.offer, peerId, offerId, }).catch((err: unknown) => { if (this.#isDestroyed()) return; this.#eventTarget.dispatchEvent( "error", new TrackerError( "signaling-failed", `Failed to handle offer: ${err instanceof Error ? err.message : String(err)}`, err, ), ); }); } else if (isSessionDescriptionInit(dataObject.answer)) { this.#handleIncomingAnswer({ sdp: dataObject.answer, peerId, offerId, }).catch((err: unknown) => { if (this.#isDestroyed()) return; this.#eventTarget.dispatchEvent( "error", new TrackerError( "signaling-failed", `Failed to handle answer: ${err instanceof Error ? err.message : String(err)}`, err, ), ); }); } }; #scheduleAnnounce(intervalSeconds: number): void { this.#clearAnnounceTimeout(); this.#announceIntervalSeconds = intervalSeconds; const runId = ++this.#scheduleAnnounceRunId; const run = async () => { try { await this.#announce(); } catch (err: unknown) { if (this.#isDestroyed()) return; this.#eventTarget.dispatchEvent( "error", new TrackerError( "announce-failed", `Announce failed: ${err instanceof Error ? err.message : String(err)}`, err, ), ); } if ( !this.#isDestroyed() && this.#announceIntervalSeconds !== null && this.#scheduleAnnounceRunId === runId ) { this.#announceTimeoutId = setTimeout( run, this.#announceIntervalSeconds * 1000, ); } }; this.#announceTimeoutId = setTimeout(run, intervalSeconds * 1000); } #clearAnnounceTimeout(): void { if (this.#announceTimeoutId !== null) { clearTimeout(this.#announceTimeoutId); this.#announceTimeoutId = null; } } async #announce(event?: "started"): Promise<void> { if (this.#isDestroyed() || this.#wsClient.state !== "connected") return; if (event) { this.#nextAnnounceEvent = event; } if (this.#activeAnnouncePromise) { return this.#activeAnnouncePromise; } const promise = (async () => { const shouldGenerateOffers = this.#config.shouldGenerateOffers(); const offersCount = shouldGenerateOffers ? this.#config.offersCount() : 0; // Generate offers in parallel to avoid sequential ICE gathering latency. // Each #createOffer() internally catches its own errors and returns // undefined on failure, so Promise.all() will never reject here. // We avoid Promise.allSettled() for older browser compatibility. const results = await Promise.all( Array.from({ length: offersCount }, () => this.#createOffer()), ); if (this.#isDestroyed()) { for (const result of results) { if (result) { this.#cleanupPendingOffer(result.offer_id); } } return; } const offers: { offer: { type: string; sdp: string }; offer_id: string; }[] = []; for (const result of results) { if (result) { offers.push(result); } } const currentEvent = this.#nextAnnounceEvent; this.#nextAnnounceEvent = undefined; const payload = this.#buildAnnouncePayload({ numwant: offers.length, offers, event: currentEvent, }); if (this.#wsClient.state !== "connected") { for (const offer of offers) { this.#cleanupPendingOffer(offer.offer_id); } return; } try { this.#wsClient.send(JSON.stringify(payload)); } catch (err: unknown) { for (const offer of offers) { this.#cleanupPendingOffer(offer.offer_id); } throw err; } })(); this.#activeAnnouncePromise = promise; try { await promise; } finally { if (this.#activeAnnouncePromise === promise) { this.#activeAnnouncePromise = null; } } } async #createOffer(): Promise< | { offer: { type: string; sdp: string }; offer_id: string; } | undefined > { if (this.#isDestroyed()) return undefined; let pc: RTCPeerConnection | undefined; try { pc = new PeerConnection(this.#config.rtcConfig?.()); this.#negotiatingConnections.add(pc); const channel = pc.createDataChannel( "webtorrent", this.#config.channelConfig, ); const offer = await safeCreateOffer(pc); this.#throwIfDestroyed(); await safeSetLocalDescription(pc, offer); this.#throwIfDestroyed(); await this.#waitForIceGathering(pc); this.#throwIfDestroyed(); const sdp = pc.localDescription; if (!sdp?.sdp) { pc.close(); return undefined; } const offerId = generateOfferId(); this.#pendingOffers.set(offerId, { connection: pc, channel, timeoutId: setTimeout(() => { this.#cleanupPendingOffer(offerId); }, this.#config.offerTimeout()), }); return { offer: { type: sdp.type, sdp: sdp.sdp }, offer_id: offerId, }; } catch (err: unknown) { pc?.close(); if (!this.#isDestroyed()) { this.#eventTarget.dispatchEvent( "warning", new TrackerWarning( "offer-failed", `Failed to create offer: ${err instanceof Error ? err.message : String(err)}`, err, ), ); } } finally { if (pc) { this.#negotiatingConnections.delete(pc); } } return undefined; } #sendStopped(): void { if (this.#wsClient.state !== "connected") return; const payload = this.#buildAnnouncePayload({ numwant: 0, offers: [], event: "stopped", }); try { this.#wsClient.send(JSON.stringify(payload)); } catch { // Best-effort "stopped" notification } } #buildAnnouncePayload({ numwant, offers, event, }: { numwant: number; offers: { offer: { type: string; sdp: string }; offer_id: string }[]; event?: string; }): Record<string, unknown> { const payload: Record<string, unknown> = { action: "announce", info_hash: this.#config.infoHash, peer_id: this.#config.peerId, numwant, uploaded: 0, downloaded: 0, offers, }; if (event) { payload.event = event; } if (this.#trackerId) { payload.trackerid = this.#trackerId; } return payload; } async #handleIncomingOffer({ sdp: offerSdp, peerId: remotePeerId, offerId: remoteOfferId, }: IncomingOffer): Promise<void> { if (this.#isDestroyed()) return; if (!this.#config.claimPeer(remotePeerId)) { return; // Reject offer silently } let pc: RTCPeerConnection | undefined; try { pc = new PeerConnection(this.#config.rtcConfig?.()); this.#negotiatingConnections.add(pc); await safeSetRemoteDescription(pc, new SessionDescription(offerSdp)); this.#throwIfDestroyed(); const answer = await safeCreateAnswer(pc); this.#throwIfDestroyed(); await safeSetLocalDescription(pc, answer); this.#throwIfDestroyed(); await this.#waitForIceGathering(pc); this.#throwIfDestroyed(); const sdp = pc.localDescription; if (!sdp) { throw new Error("Failed to get local description after ICE gathering"); } const payload = { action: "announce", info_hash: this.#config.infoHash, peer_id: this.#config.peerId, to_peer_id: remotePeerId, offer_id: remoteOfferId, answer: { type: sdp.type, sdp: sdp.sdp }, }; this.#wsClient.send(JSON.stringify(payload)); const channel = await this.#waitForConnection(pc); this.#throwIfDestroyed(); this.#eventTarget.dispatchEvent("peerConnected", { peerId: remotePeerId, connection: pc, channel, }); } catch (err: unknown) { pc?.close(); // Always dispatch peerConnectFailed so the Manager can release the peer // from #connectingPeers. Safe to call after destroy: event target is // already cleared, making the dispatch a no-op. this.#eventTarget.dispatchEvent("peerConnectFailed", { peerId: remotePeerId, error: new PeerConnectError( "connection-failed", err instanceof Error ? err.message : String(err), err, ), }); } finally { if (pc) this.#negotiatingConnections.delete(pc); } } async #handleIncomingAnswer({ sdp: answerSdp, peerId: remotePeerId, offerId: ourOfferId, }: IncomingAnswer): Promise<void> { if (this.#isDestroyed()) return; const pending = this.#pendingOffers.get(ourOfferId); if (!pending) return; // Offer expired or invalid // Stop tracking it as pending this.#pendingOffers.delete(ourOfferId); clearTimeout(pending.timeoutId); if (!this.#config.claimPeer(remotePeerId)) { pending.connection.close(); return; // Reject answer silently } this.#negotiatingConnections.add(pending.connection); try { await safeSetRemoteDescription( pending.connection, new SessionDescription(answerSdp), ); this.#throwIfDestroyed(); const channel = await this.#waitForConnection( pending.connection, pending.channel, ); this.#throwIfDestroyed(); this.#eventTarget.dispatchEvent("peerConnected", { peerId: remotePeerId, connection: pending.connection, channel, }); } catch (err: unknown) { pending.connection.close(); // Always dispatch peerConnectFailed so the Manager can release the peer // from #connectingPeers. Safe to call after destroy: event target is // already cleared, making the dispatch a no-op. this.#eventTarget.dispatchEvent("peerConnectFailed", { peerId: remotePeerId, error: new PeerConnectError( "connection-failed", err instanceof Error ? err.message : String(err), err, ), }); } finally { this.#negotiatingConnections.delete(pending.connection); } } #waitForIceGathering(pc: RTCPeerConnection): Promise<void> { return new Promise((resolve, reject) => { if (pc.iceGatheringState === "complete") { resolve(); return; } if (pc.signalingState === "closed") { reject(new Error("RTCPeerConnection closed")); return; } let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined; const cleanup = () => { clearTimeout(timeoutId); pc.removeEventListener("icegatheringstatechange", onGatheringChange); pc.removeEventListener("icecandidate", onIceCandidate); pc.removeEventListener("signalingstatechange", onSignalingChange); this.#destroyAbortController.signal.removeEventListener( "abort", onAbort, ); }; const onGatheringChange = () => { if (pc.iceGatheringState === "complete") { cleanup(); resolve(); } }; // A null candidate also signals gathering completion in some browsers // more reliably than icegatheringstatechange. const onIceCandidate = (event: RTCPeerConnectionIceEvent) => { if (event.candidate === null) { cleanup(); resolve(); } }; const onSignalingChange = () => { if (pc.signalingState === "closed") { cleanup(); reject(new Error("RTCPeerConnection closed")); } }; const onAbort = () => { cleanup(); reject(new Error("ICE gathering aborted due to teardown")); }; if (this.#destroyAbortController.signal.aborted) { onAbort(); return; } timeoutId = setTimeout(() => { cleanup(); resolve(); // Use whatever candidates we have gathered so far }, this.#config.iceGatheringTimeout()); pc.addEventListener("icegatheringstatechange", onGatheringChange); pc.addEventListener("icecandidate", onIceCandidate); pc.addEventListener("signalingstatechange", onSignalingChange); this.#destroyAbortController.signal.addEventListener("abort", onAbort); }); } #waitForConnection( pc: RTCPeerConnection, channel?: RTCDataChannel, ): Promise<RTCDataChannel> { const { promise, resolve, reject } = getPromiseWithResolvers<RTCDataChannel>(); let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined; let boundChannel: RTCDataChannel | undefined = channel; const rejectIfTerminalState = () => { if (isTerminalConnectionState(pc.iceConnectionState)) { cleanup(); reject(new Error(`ICE connection ${pc.iceConnectionState}`)); return true; } return false; }; const onChannelOpen = () => { cleanup(); if (boundChannel) { resolve(boundChannel); } else { reject(new Error("Data channel missing on open")); } }; const onChannelError = () => { cleanup(); reject(new Error("Data channel error")); }; const onChannelClose = () => { cleanup(); reject(new Error("Data channel closed prematurely")); }; const bindDataChannel = (dc: RTCDataChannel) => { boundChannel = dc; if (dc.readyState === "open") { onChannelOpen(); } else if (dc.readyState === "closed" || dc.readyState === "closing") { onChannelClose(); } else { dc.addEventListener("open", onChannelOpen); dc.addEventListener("error", onChannelError); dc.addEventListener("close", onChannelClose); dc.addEventListener("closing", onChannelClose); } }; const onDataChannel = (event: RTCDataChannelEvent) => { if (!boundChannel) { bindDataChannel(event.channel); } }; const onAbort = () => { cleanup(); reject(new Error("Connection aborted due to teardown")); }; const cleanup = () => { clearTimeout(timeoutId); pc.removeEventListener("iceconnectionstatechange", rejectIfTerminalState); pc.removeEventListener("datachannel", onDataChannel); if (boundChannel) { boundChannel.removeEventListener("open", onChannelOpen); boundChannel.removeEventListener("error", onChannelError); boundChannel.removeEventListener("close", onChannelClose); boundChannel.removeEventListener("closing", onChannelClose); } this.#destroyAbortController.signal.removeEventListener("abort", onAbort); }; if (this.#destroyAbortController.signal.aborted) { onAbort(); return promise; } if (rejectIfTerminalState()) return promise; timeoutId = setTimeout(() => { cleanup(); reject(new Error("Data channel open timeout")); }, this.#config.connectionTimeout()); pc.addEventListener("iceconnectionstatechange", rejectIfTerminalState); this.#destroyAbortController.signal.addEventListener("abort", onAbort); if (boundChannel) { bindDataChannel(boundChannel); } else { pc.addEventListener("datachannel", onDataChannel); } return promise; } #cleanupPendingOffer(offerId: string, pending?: PendingOffer): void { const entry = pending ?? this.#pendingOffers.get(offerId); if (entry) { clearTimeout(entry.timeoutId); entry.connection.close(); this.#pendingOffers.delete(offerId); } } #cleanupPendingOffers(): void { for (const [offerId, pending] of this.#pendingOffers) { this.#cleanupPendingOffer(offerId, pending); } } #cleanupNegotiatingConnections(): void { for (const pc of this.#negotiatingConnections) { pc.close(); } this.#negotiatingConnections.clear(); } }