p2p-media-loader-core
Version:
P2P Media Loader core functionality
224 lines (164 loc) • 14.1 kB
Markdown
# WebTorrent Client Specification
A lightweight WebTorrent signaling client that handles the WebSocket tracker protocol and establishes WebRTC connections.
## Overview
The `WebTorrentClient` acts as a signaling layer using the `WebSocketClient` under the hood. It connects to a single standard WebTorrent tracker, exchanges SDP offers and answers, manages ICE candidate gathering, and returns fully connected `RTCPeerConnection` and `RTCDataChannel` instances to consumers.
**Key Design Decisions:**
1. **Strictly Browser Implementation**: Uses native browser `RTCPeerConnection` APIs without Node.js polyfills or dependencies. Designed to support older browsers (down to Chrome 49+, legacy Safari, and older Smart TVs) by falling back to legacy callback-based (non-Promise) WebRTC signatures on engines where native WebRTC Promises are missing or incomplete, avoiding `RTCPeerConnection.connectionState`, and using a `queueMicrotask` fallback. It also utilizes a custom `SafeAbortController` polyfill to guarantee safe and robust asynchronous request cancellation in environments lacking native `AbortController` support.
2. **Single Responsibility (SRP)**: Each instance manages exactly **one** WebSocket connection to a **single** tracker.
3. **Early Deduplication**: To prevent duplicate peer connections across multiple tracker clients, it accepts a `claimPeer` callback to attempt to lock a `peer_id` for signaling, successfully deduplicating at the earliest possible stage.
4. **Negotiate and Connect**: The client handles WebRTC SDP signaling and waits for the data channel to open. Once fully connected, it emits the connected objects to a higher layer (e.g., Swarm Manager) which takes ownership and handles the BitTorrent wire protocol.
5. **Data Channel Configuration**: Matches the default behavior of the `bittorrent-tracker` (and `simple-peer`) npm packages, which create reliable, ordered Data Channels (empty `RTCDataChannelInit` config) unless explicitly overridden.
6. **Optional WebSocket Pooling**: The `WebTorrentClient` does not create its own WebSocket connection. Instead, it accepts an injected `WebSocketClient` in its constructor. This allows higher layers to implement connection pooling (sharing a single WebSocket across multiple torrents to the same tracker) or manage the socket lifecycle independently.
---
## 1. Public API
### Constructor Configuration
```typescript
interface WebTorrentClientConfig {
wsClient: WebSocketClient; // The underlying WebSocket client
infoHash: string; // 20-character ASCII string representing the torrent (derived from Base64 hash)
peerId: string; // 20-byte string representing this client
rtcConfig?: RTCConfiguration; // Optional custom STUN/TURN server configuration
channelConfig?: RTCDataChannelInit; // Optional Data Channel overrides
offerTimeout?: number; // Time (in ms) to keep unanswered offers before destroying them. Default: 50000 (50s).
offersCount?: number; // Maximum number of offers to generate per announce interval. Default: 5.
connectionTimeout?: number; // Time (in ms) to wait for the data channel to open. Default: 15000 (15s).
// Callback invoked when a peer_id is discovered via an offer or answer.
// Attempts to claim the peer to prevent duplicate connections.
// Return true to accept and connect, false if the peer is already claimed/connected.
claimPeer?: (peerId: string) => boolean;
// Callback invoked before every announce to determine if new offers should be generated.
// Return false to stop generating offers (numwant: 0), while still answering incoming offers.
shouldGenerateOffers?: () => boolean;
}
```
### Methods
- `start(): void`: Begins the `announce` loop if the injected `WebSocketClient` is already connected, or waits for it to connect. It does not call `connect()` on the injected client.
- `destroy(): void`: Complete teardown. Sends a final `event: "stopped"` announce to the tracker (if connected), clears all pending offers and timers, removes all event listeners, and disposes the abort controllers. Does NOT dispose the `WebSocketClient` as it may be pooled.
- `addEventListener<K extends keyof WebTorrentClientEventMap>(eventName: K, listener: WebTorrentClientEventMap[K]): void`: Registers an event listener.
- `removeEventListener<K extends keyof WebTorrentClientEventMap>(eventName: K, listener: WebTorrentClientEventMap[K]): void`: Removes a registered event listener.
### Events
- `peerConnected` (payload: `{ peerId: string, connection: RTCPeerConnection, channel: RTCDataChannel }`): Fired when the peer finishes WebRTC signaling and its Data Channel is successfully opened. The Swarm Manager takes immediate ownership.
- `peerConnectFailed` (payload: `{ peerId: string, error: string }`): Fired if WebRTC SDP negotiation fails or the data channel fails to open after a peer has been claimed (e.g., ICE gathering timeout or connection timeout), allowing the manager to release the claim.
- `warning` (payload: `string`): Fired if the tracker returns a warning, or if the client encounters a local recoverable issue during signaling or offer/answer creation.
- `error` (payload: `string`): Fired if the tracker returns an error, or if the underlying WebSocket encounters a failure.
---
## 2. Architecture & Lifecycle
### Early Deduplication (The `claimPeer` Hook)
Because the higher layer (e.g., Peer Manager) may spin up multiple `WebTorrentClient` instances for different public trackers, the same remote peer will likely be discovered multiple times. The client uses the injected `claimPeer` to deduplicate aggressively:
1. **When Receiving an Offer:** The tracker JSON includes the remote `peer_id`. The client immediately calls `claimPeer(peer_id)`. If it returns `false`, the client **ignores the JSON message entirely** and does not create an `RTCPeerConnection`.
2. **When Receiving an Answer:** The tracker forwards an `answer` to a pending offer we sent. The JSON includes the remote `peer_id`. The client calls `claimPeer(peer_id)`. If it returns `false`, the client **immediately closes** the pending `RTCPeerConnection` and discards the answer.
### Message Validation
Before processing offers and answers, the client validates incoming messages:
1. **`info_hash` Check**: If the message includes an `info_hash` field that does not match the client's configured `infoHash`, the entire message is silently discarded. This prevents cross-talk when WebSocket connections are shared across multiple torrents.
2. **Self-Peer Check**: If the message's `peer_id` matches our own `peerId`, it is silently discarded. This prevents the client from attempting to connect to itself.
3. **Runtime Type Checks**: All fields from the untrusted JSON are validated with `typeof` checks before use. No unsafe `as` casts are applied to the raw parsed data.
### WebTorrent Tracker Protocol (Detailed Message Flow)
The client communicates via JSON messages over the `WebSocketClient`. Below is the exact format of the payloads exchanged between the client and the tracker.
#### 1. Announcing & Sending Offers (Client -> Tracker)
Periodically (based on the tracker's `interval`), or when first connecting, the client sends an `announce` action. If the client wants to initiate connections (and `shouldGenerateOffers` returns `true`), it gathers ICE candidates and includes an array of `offers`. If `shouldGenerateOffers` returns `false`, it sends `"numwant": 0` and an empty `"offers": []` array to maintain presence in the swarm without incurring offer-generation overhead.
```json
{
"action": "announce",
"info_hash": "<20-character-binary-string>",
"peer_id": "<20-character-binary-string>",
"numwant": 5,
"uploaded": 0,
"downloaded": 0,
"event": "started", // "started", "completed", "stopped", or omitted
"offers": [
{
"offer": { "type": "offer", "sdp": "v=0\r\no=..." },
"offer_id": "<20-char-alphanumeric>"
}
],
"trackerid": "<tracker-assigned-id>" // included if previously received from tracker
}
```
#### 2. Tracker Announce Response (Tracker -> Client)
The tracker replies to the announce with swarm statistics. Upon receiving this message, the client reads the `interval` property and schedules the next announce using an internal `setInterval`. To prevent unnecessarily resetting the announce loop, the interval timer is only cleared and restarted if the tracker explicitly changes the `interval` value.
```json
{
"action": "announce",
"interval": 120,
"info_hash": "<20-character-binary-string>",
"complete": 5,
"incomplete": 3,
"tracker id": "<optional-tracker-assigned-id>" // stored and echoed back in subsequent announces as "trackerid"
}
```
#### 3. Tracker Error & Warning Responses (Tracker -> Client)
Trackers may respond with errors (which usually mean the announce failed entirely) or warnings. They follow this specific format:
```json
{
"failure reason": "Invalid info_hash"
}
```
```json
{
"warning message": "Rate limited, please slow down"
}
```
#### 4. Receiving an Offer (Tracker -> Client)
When another peer generates an offer and the tracker routes it to us, it arrives in this format:
```json
{
"action": "announce",
"info_hash": "<20-character-binary-string>",
"peer_id": "<remote-peer-id-binary>",
"offer_id": "<remote-offer-id>",
"offer": { "type": "offer", "sdp": "v=0\r\no=..." }
}
```
#### 5. Sending an Answer (Client -> Tracker)
When the client accepts an incoming offer, it generates an answer and sends it back to the tracker. Note the inclusion of `to_peer_id` to tell the tracker exactly who should receive it.
```json
{
"action": "announce",
"info_hash": "<20-character-binary-string>",
"peer_id": "<my-peer-id-binary>",
"to_peer_id": "<remote-peer-id-binary>",
"offer_id": "<remote-offer-id>",
"answer": { "type": "answer", "sdp": "v=0\r\no=..." }
}
```
#### 6. Receiving an Answer (Tracker -> Client)
When a remote peer accepts an offer we previously sent, the tracker routes their answer back to us.
```json
{
"action": "announce",
"info_hash": "<20-character-binary-string>",
"peer_id": "<remote-peer-id-binary>",
"offer_id": "<our-original-offer-id>",
"answer": { "type": "answer", "sdp": "v=0\r\no=..." }
}
```
### WebRTC Connection Flow (Offers & Answers)
Standard WebTorrent signaling **does not use Trickle ICE**. All ICE candidates must be gathered and bundled into a single SDP before sending it to the tracker.
#### ICE Gathering Timeout
ICE gathering can stall indefinitely if STUN/TURN servers are unreachable or the network is offline. To prevent this, the client enforces a **5-second timeout** on ICE gathering (matching `simple-peer`'s `ICECOMPLETE_TIMEOUT`). If gathering does not complete within 5 seconds, the promise is resolved to use whatever candidates have been gathered so far. After any async ICE wait, the client also checks the `destroyed` flag to avoid acting on a torn-down instance.
#### Initiating Connections (Sending Offers)
When generating offers, we do **not** know which peer will receive them. All offers are generated **in parallel** (via `Promise.allSettled`) to avoid sequential ICE gathering latency — reducing worst-case announce delay from `offersCount × ICE_TIMEOUT` to a single `ICE_TIMEOUT`.
1. For each offer slot, the client concurrently creates a new `RTCPeerConnection` and `RTCDataChannel`.
2. It creates an SDP offer, waits for ICE gathering, and bundles the SDP.
3. It generates a random alphanumeric `offer_id` (e.g., 20 characters) to ensure safe string handling during JSON serialization/deserialization.
4. It stores the pending `RTCPeerConnection` in an internal Map keyed by `offer_id` (e.g., `pendingOffers.set(offer_id, pc)`).
5. It sets a timeout (`offerTimeout`, default 50s). If no answer is received before the timeout, the `RTCPeerConnection` is closed and removed from the Map to prevent memory leaks.
6. Once all parallel offer tasks settle, the successfully created offers are collected and attached to a single tracker `announce` payload. Failed offers are discarded (with an error event dispatched). (The Swarm Manager is **not** involved yet, because we don't have a `peer_id`).
#### Receiving Offers
1. The tracker sends an incoming `offer` originating from another peer. This payload **does** contain their `peer_id`.
2. **Deduplication Check**: Call `claimPeer(peer_id)`. Abort if `false`.
3. The client creates a new `RTCPeerConnection` and calls `setRemoteDescription`.
4. It creates an SDP answer, waits for ICE gathering, and sends it back to the tracker.
5. **Wait for Connection**: Signaling is complete. The client waits for the data channel to open, then emits the `peerConnected` event with `{ peerId, connection, channel }`. The client then hands off ownership of the connection.
#### Receiving Answers
1. The tracker forwards an `answer` SDP to an `offer_id` we previously sent. This payload now reveals the remote `peer_id`.
2. **Deduplication Check**: We finally know who answered! Call `claimPeer(peer_id)`.
3. If `claimPeer` returns `false` (we are already connected to them via another route), we look up the pending `RTCPeerConnection` by `offer_id`, immediately call `.close()`, clear the timeout, delete it, and abort.
4. If `true`, the client applies `setRemoteDescription(answer)` to the pending connection and clears the timeout.
5. **Wait for Connection**: Signaling is complete. The client removes the connection from `pendingOffers`, waits for the data channel to open, and emits the `peerConnected` event with `{ peerId, connection, channel }`.
### Hand-Off
The `WebTorrentClient`'s job ends the exact millisecond the data channel is successfully opened. It emits the `peerConnected` event and drops its internal reference.
The upper Peer Manager receives the connected objects and takes full responsibility for:
1. Listening to `channel.onclose` and `connection.oniceconnectionstatechange` to detect failures/drops.
2. Handling the BitTorrent wire protocol messages over the data channel.
3. Cleaning up its internal state if the connection closes.