p2p-media-loader-core
Version:
P2P Media Loader core functionality
893 lines (760 loc) • 25.5 kB
text/typescript
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();
}
}