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