@substrate-system/pp2p
v0.2.13
Published
Practical p2p, for the browser
Readme
Practical P2P
P2P for the browser.
Contents
Example
import { baoEncode, DEFAULT_CHUNK_SIZE } from '@substrate-system/pp2p'
// ... get a file somehow ...
const encoder = baoEncode(file.stream(), DEFAULT_CHUNK_SIZE)
encoder.on('progress', ({ processed, total }) => {
console.log('progress percent', Math.floor(100 * processed / total))
})Start a localhost server:
npm startYou can open a second browser window in "incognito" mode to test p2p transfer.
Overview
Transfer content from browser to browser over WebRTC. A swarm of peers exchange fixed-size chunks. Each chunk is incrementally verified via a bao hash, so peers don't have to trust each other.
- Manifest --
{ version: 1, root, length, chunkSize, name?, type?, seeders? }. The 32-byte root commits to every byte of the content.typeis the file's optional MIME string, written only when present.seedersis an optional list of WebSocket URLs to dial for the content. Whoever has the manifest can fetch the bytes and verify them. - Signaling — peers find each other through a WebSocket
relay (
src/server). The library is signaling-agnostic; anything implementing theSignalinginterface works. - Peer — one
RTCPeerConnectionplus a single ordered data channel carrying length-prefixed binary frames. - Swarm — joins the signaling channel, opens a
Peerper remote, and runs the chunk-exchange loop. - Bao tree — a BLAKE3 Merkle tree over the chunks. Each
Piecemessage ships an O(log n) proof; the receiver re-derives the root and rejects on mismatch.
Custom signaling relays
The library is signaling-agnostic, but if you write your own relay
(instead of using the provided src/server / Cloudflare Worker), do
not hand-roll the relay wire protocol. Wrap RelayCore + RelayHost
from src/signaling/relay.ts instead -- that is exactly what
example/server/signaling-do.ts
does. A hand-rolled relay that silently speaks a different message
shape causes join() to hang forever waiting for a welcome frame
that never arrives in the shape it expects.
RelayCore implements the room logic (join/welcome/peer-joined,
targeted signal relay, leave/close -> peer-left) against a runtime-
supplied RelayHost (sockets(), getAttachment(),
setAttachment(), newPeerId()). Your adapter only needs to plug in
the runtime-specific socket glue; RelayCore owns the wire format.
The wire format is JSON over the WebSocket:
client -> server:
| Frame | Shape |
|-------|-------|
| join | { t:'join', swarm:string } |
| signal | { t:'signal', to:string, data:SignalMsg } |
| leave | { t:'leave' } |
server -> client:
| Frame | Shape |
|-------|-------|
| welcome | { t:'welcome', peerId:string, peers:string[] } |
| peer-joined | { t:'peer-joined', peerId:string } |
| peer-left | { t:'peer-left', peerId:string } |
| signal | { t:'signal', from:string, data:SignalMsg } |
NAT traversal: STUN, TURN, and connect timeout
createSwarm({ iceServers }) is forwarded verbatim to every
RTCPeerConnection the swarm opens (defaultRtcFactory in
src/swarm.ts passes it through to Peer, src/peer.ts, unchanged --
entries, including TURN username/credential, are never filtered or
rewritten).
STUN alone is not enough for a consistent connection rate. STUN only
resolves a peer's public server-reflexive address; it does nothing
when the network path itself blocks a direct route. In practice that
covers roughly 10-20% of real-world peer pairs: symmetric NAT (each
outbound port gets a different public mapping, so the reflexive
address the other peer learns isn't dialable), VPNs that block mDNS
(Chrome's .local host candidates never resolve), and VPN NATs that
can't hairpin a connection back out through the same tunnel. Without a
TURN relay configured, those pairs fail ICE outright and the swarm
never opens a data channel for them. A TURN server forwards the
already-DTLS-encrypted data channel traffic between the two peers, so
it sees ciphertext, not content (more in
How the WebRTC connection is encrypted).
const swarm = createSwarm({
swarmId,
signaling,
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'alice',
credential: 'secret',
},
],
})The example app's Worker provisions this automatically: GET
/api/ice-servers returns Cloudflare TURN credentials when configured,
falling back to a public STUN server otherwise (example/server/app.ts).
peerConnectTimeoutMs (default 30s, Peer's
DEFAULT_PEER_CONNECT_TIMEOUT_MS) bounds how long a single
RTCPeerConnection is given to open its data channel before that
attempt is treated as failed. It composes with createSwarm's bounded
reconnect (reconnectMaxAttempts, default 3; reconnectBaseDelayMs,
default 1000ms with doubling backoff): a peer that times out here gets
a fresh RTCPeerConnection and another peerConnectTimeoutMs window
on the next attempt, rather than pinning the swarm on one wedged
connection indefinitely.
Connection diagnostics
Register onPeerConnectionInfo to observe the selected transport path for a
remote peer:
swarm.onPeerConnectionInfo((peerId, info) => {
console.log(peerId, info.localCandidateType)
console.log(info.remoteCandidateType, info.protocol)
console.log(info.relayProtocol)
})The info value is a PeerConnectionInfo with four nullable string fields:
localCandidateType, remoteCandidateType, protocol, and
relayProtocol. Candidate types have the WebRTC meanings below:
host— a local interface candidate.srflx— a server-reflexive candidate learned through STUN.prflx— a peer-reflexive candidate discovered during connectivity checks.relay— a candidate supplied by a TURN relay.
relay means that a relay candidate was selected for the connection. It
does not mean that the signaling server handled the data transfer.
This event is best-effort and may be absent when browser connection stats are unavailable. Reports contain no IP address or port. Candidate types describe the selected network path, but do not prove VPN causality; do not infer that a VPN caused a path from this event alone.
WebSocket relay fallback
Even with TURN configured, some pairs never open a direct connection
(misconfigured TURN, a network that blocks WebRTC entirely). relayUrls
gives createSwarm an explicit last resort: a plain WebSocket URL,
dialed with the same wire protocol as any other peer, so a server-side
seeder or a dumb relay that just bridges two WsPeers both work. This
is the wormhole.app model -- a transfer never
strictly depends on browser-to-browser connectivity succeeding.
Unlike a TURN relay, a WebSocket relay is not covered by the data
channel's DTLS. It receives every wire frame in the clear: the
Handshake (full root, length, chunk size, name, MIME type), bitfields,
requests and chunk bytes. It cannot alter content, since every chunk is
still verified against the Bao root, but it can read whatever the
application seeded. See
The relay bridge exception.
const swarm = createSwarm({
swarmId,
signaling,
relayUrls: ['wss://relay.example.com/api/relay/my-swarm'],
})The swarm opens these sockets with the global WebSocket, looked up
each time it dials, including the redial after a relay socket drops.
To build them some other way, pass WebSocketCtor, the same option
(new (url:string) => WebSocket) that WebSocketSignaling takes. Use
it where the global is missing or is not the one you want, such as a
test harness that opens sockets its own way:
const swarm = createSwarm({
swarmId,
signaling,
relayUrls: ['wss://relay.example.com/api/relay/my-swarm'],
WebSocketCtor: MyWebSocket,
})WebSocketCtor also applies to the swarm's manifest.seeders dials.
Signaling is separate: WebSocketSignaling.close() still reads the
readyState constants on the global WebSocket, so in a runtime with
no global it fails even when it was given a WebSocketCtor.
The fallback is decided per peer. It triggers for a given peer on whichever comes first:
- That peer has not opened within
relayFallbackDelayMs(default 5s) of becoming known. - That peer's connect/reconnect attempts (see
reconnectMaxAttemptsabove) are exhausted, whether or not other peers are open. fetch()has gonerelayFallbackStallMs(default 3xrequestTimeoutMs) with no verified chunk arriving from any source. This one fires for every known peer at once. It covers a peer that opens and never closes but stops delivering anything, which the other two triggers don't see.
Once triggered for a peer, the swarm dials every URL in relayUrls
with a per-pair ?pair= parameter (see below) and stays with whatever
the wire protocol negotiates from there. There's nothing else to
configure.
A relayed peer is reported under its signaling peer id, the same id an
RTC connection to it would use. The relay socket's remote id is unknown
until the remote Handshake arrives, so for a relay dial onPeerOpen
fires once, after that Handshake, with the bare peer id. onPeerClose,
onBytesSent, onBytesReceived, bytesByPeer() and
bytesSentByPeer() all use that id too. A relay socket that closes
before any Handshake fires neither onPeerOpen nor onPeerClose.
manifest.seeders dials work the same way: onPeerOpen fires after the
seeder's Handshake, with the id the seeder names there (the example
seeder Durable Object names itself seeder:<url>).
Fallback is a two-party decision. Whichever side triggers it (the
relayFallbackDelayMs timer, connect-attempt exhaustion or the stall
watchdog) also sends the remote a { t: 'relay-fallback' } signal over
signaling. On receiving it, the remote dials the same pair path, so the
bridge pairs the two sockets even when the remote saw nothing wrong,
such as a pure seeder whose RTC channel opened and then went silent.
Each side dials at most once per peer, and the signal is never echoed
back. Older peers ignore the signal. The remote dials only if it was
built with relayUrls itself; a remote without them receives the signal
and dials nothing, so the pair never forms. Give relayUrls to both
sides, including a pure seeder.
Reference relay bridge server
src/server/relay-bridge.ts is a
small Node + ws reference implementation of a relayUrls target:
startRelayBridgeServer({ port }) pairs the first two clients that
dial the same URL path and forwards binary frames verbatim in both
directions until either side closes. It never parses the wire
protocol, but it does receive every frame in the clear (see above).
Two-party limitation, worked around automatically per pair. The
bridge itself still pairs exactly two sockets per path -- the wire
session between them is point-to-point (one Handshake per
connection), so it cannot multiplex many recipients onto a single path
by itself. Swarm works around this automatically: each (seeder,
recipient) pair derives its own ?pair=<token> query parameter from
their two peer ids (both sides already know each other's id from the
signaling room, so both independently compute the same parameter with
no extra signaling round-trip), so one seeder can relay-serve multiple
concurrent recipients through the same relayUrls entry, each pair
landing on its own parameter. You do not need to give each recipient
its own path yourself -- Swarm does this for you.
Every concurrent recipient still costs the seeder one extra WebSocket connection to the bridge -- fine for the wormhole.app-style few-peers case this fallback targets, but it does not scale to a seeder with many simultaneous relay recipients. See docs/follow-ups/2026-07-13-relay-bridge-multiplexing.md for a sketch of a bridge-side multiplexing design (Option B, not implemented) that would remove this per-recipient connection cost -- the automatic per-pair paths described above are that document's Option A.
Durable Object relay bridge
example/server/relay-bridge-do.ts
is a second bridge implementation, for apps on Cloudflare Workers that
have no separate Node server: RelayBridgeDO, a Durable Object behind
the example Worker's GET /api/relay/:swarmId route. Point
relayUrls at the route; the swarm appends ?pair= itself.
const swarm = createSwarm({
swarmId,
signaling,
relayUrls: [`wss://${location.host}/api/relay/${swarmId}`],
})The Worker names one DO instance from the swarmId and the pair token
together, so only the two peers that derived the same token share an
instance, and a third socket on that pair is closed rather than paired
fresh. Both bridges share their pairing logic, RelayBridgeCore
(src/signaling/bridge-core.ts): frames the first peer sends before
its partner arrives (its Handshake and Bitfield) are queued, up to
2 * MAX_FRAME_BYTES, and flushed in order on pairing. Closing either
side closes the other.
The DO uses the WebSocket hibernation API, so an idle paired bridge
costs nothing. The pre-pair queue lives only in memory, never in DO
storage, which would write Handshake metadata to disk. If Cloudflare
evicts the DO while a peer is waiting with queued frames, the bridge
closes that peer when its partner arrives, and the swarm's relay
reconnect redials and greets again.
Deploying it takes a RELAY_BRIDGE_DO Durable Object binding and a
migration in wrangler.jsonc, declared in the top-level block and in
every named env, since wrangler does not inherit Durable Object
bindings into named envs.
A Worker-side WebSocket has no flow control, so the only bound on what
a slow recipient makes a bridge hold is the recipient's request window:
maxInFlightPerPeer single-chunk requests per peer (default 4). A
larger window can raise throughput over a relayed path, at the cost of
more chunks buffered on the bridge.
Discovering a manifest
A recipient that has only the 32-byte root can join the swarm and learn
the rest of the manifest (length, chunk size, name, type) from the
first peer whose Handshake names that root:
const swarm = createSwarm({ swarmId, signaling })
const manifest = await swarm.discoverManifest(root)
for await (const chunk of swarm.fetch(manifest)) {
// ...
}Called with no root, discoverManifest() adopts the first Handshake
that passes the length and chunk size checks, and closes any later peer
whose Handshake names a different root. This lets an app show a share
link before hashing finishes: use a random room id as swarmId, put it
in the link, and let the recipient discover the content from whoever
seeds that room.
This changes what the recipient trusts. Without a root, it trusts whichever peer greets it first to name the content, so a hostile signaling or relay bridge operator can steer it to other content. Bao still guarantees the bytes match the adopted root, so the risk is substitution, not corruption. Use rootless discovery only when the app authenticates the content another way, for example by encrypting it with an AEAD key the operator never sees.
Multiple peers
See test/unit/concurrency.test.ts and test/e2e/multi-peer.spec.ts.
A downloader gets bytes from from every connected peer concurrently. Each peer
gets its own request queue capped at SwarmOpts.maxInFlightPerPeer
in-flight chunks (default 4), and the scheduler assigns work via a
slot-aware round-robin: with N peers,
peer i is preferred for chunks where index % N === i. Once a peer
runs out of slot-aligned chunks it falls back to anything else it has
that the local side is missing, so the tail of a download drains
instead of stalling on one slow remote.
const swarm = createSwarm({ swarmId, signaling })
for await (const chunk of swarm.fetch(manifest)) {
// chunks arrive in order, verified, possibly from different peers
}There's nothing extra to call. Adding a third peer to a swarm of two just means the next request goes to the new peer when its slot comes up. The iterator yields chunks in order regardless of which remote each one came from.
Partial Seeding
A peer serves chunks as soon as it has verified them, before
the full download is complete. After a chunk passes its Bao proof and
the consumer accepts it via onChunk, the peer immediately:
- Sets the bit in its own bitfield.
- Broadcasts a
Haveto every connected peer, when the swarm was givengetChunk.
From then on, when the swarm was given getChunk, any peer that sends
a Request for that chunk gets a Piece back, regardless of how much of the file the local side has
finished downloading. The same code path that handles full seeders
handles half-finished peers, so the swarm self-heals as it grows: the
second peer to join starts uploading to the third before it has
finished downloading from the first.
Relation to BitTorrent
Same shape, different parts. This splits content into pieces, gossips a bitfield of what you have, requests what you don't, and hash-checks on arrival. This library reuses the pattern but swaps out the parts that don't work in a browser.
| BitTorrent | Practical P2P |
|----------------------------------|--------------------------------|
| .torrent / magnet link | Manifest (root + length) |
| Tracker (HTTP/UDP) or DHT | WebSocket signaling server |
| TCP / uTP between peers | WebRTC data channel |
| Pieces (16 KiB and up) | Chunks (default 64 KiB) |
| SHA-1 piece-hash list | BLAKE3 Bao Merkle tree |
| bitfield / have / request / piece | same names, same role |
| choke / unchoke / interested | not implemented |
| Rare-first chunk selection | round-robin slot assignment |
hash format
The biggest practical difference is the hash tree. A BitTorrent
client commits to a flat list of SHA-1 hashes up front; a
peer has to receive a whole piece before it can verify any of it.
Bao gives per-chunk verified streaming: a peer hands over a single
64 KiB chunk plus a 32 * ceil(log2(n))-byte proof, and the
receiver verifies it standalone against the same root that
identifies the swarm. Proofs are O(log n) instead of O(n).
The other practical difference is the transport. BitTorrent peers need IP reachability; browsers don't have that. WebRTC trades the tracker for a signaling server, but the signaling server is only used during connection setup — once the data channel is open, bytes flow peer-to-peer.
Wire protocol
After a peer's data channel opens, both sides exchange:
| Type | Message | Meaning |
|------|-----------|-------------------------------------------------------|
| 1 | Handshake | protoVer, root, length, chunkSize, name, mime, peerId |
| 2 | Have | sender just got a new chunk |
| 3 | Bitfield | which chunk indices the sender already has |
| 4 | Request | please send chunks [first, last] |
| 5 | Piece | chunkIndex, Merkle proof, raw bytes |
| 6 | Reject | sender cannot serve chunkIndex |
| 7 | Done | sender's fetch has every chunk (no payload) |
The Handshake mime is a u16-length-prefixed utf-8 string carried between
name and peerId; it may be empty (the "unknown MIME" sentinel). The
swarm advertises manifest.type as mime and adopts a non-empty mime
into manifest.type on discovery.
A fetching swarm sends Done to its open peers as soon as it has every
chunk. Register onPeerDone to hear it, for example so a seeder can
tell a recipient that finished from one that gave up (onPeerClose
fires for both):
swarm.onPeerDone(peerId => {
console.log(`${peerId} has every chunk`)
})Done is informational. It carries no data, nothing verifies it, and it
does not change what the swarm requests, so a peer that falsely claims
to be done only misleads a progress display. It is sent once, to the
peers open at that moment.
Done arrived with Handshake protoVer 3. Older peers treat an unknown
message type as a broken connection, so the swarm records each peer's
protoVer and sends Done only to peers that advertised 3 or higher.
Chunks are scheduled per peer with a small in-flight window (default 4) and a slot-aware round-robin so concurrent peers don't all race for the same chunk first.
A Piece frame (default 64 KiB chunk plus proof) is well past the
~16 KiB message size that was historically unsafe across browsers on a
single WebRTC DataChannel send() call, and a caller-chosen chunkSize
can push it further still. Peer.send() fragments every frame into
physical sends of at most MAX_SEND_CHUNK_BYTES (16 KiB); the
receiver's FrameDecoder already buffers across arbitrary message
boundaries, so fragmentation is transparent and needs no wire-format
change. Backpressure (bufferedAmount / bufferedAmountLowThreshold,
see BACKPRESSURE_THRESHOLD) is honored before each physical send, so
a fast seeder serving many chunks cannot overflow the channel's send
buffer.
Swarm inject API
| Export | Signature |
|--------|-----------|
| has(i:number) | :boolean |
| missing() | :number[] |
| provideChunk(i:number, bytes:Uint8Array, opts?:{ proof?:Uint8Array }) | :Promise<void> |
Feed chunks obtained out-of-band (e.g. from an HTTP web seed) into an
in-progress fetch() so a download draws concurrently from a WebRTC
peer and an HTTP source. The library stays WebRTC + signaling + Bao;
provideChunk takes bytes the application already has.
Ordering model: fetch() is an async generator, so calling it runs
no setup until first pulled. provideChunk is safe to call before the
first pull — a trusted (no-proof) inject made early is queued and
applied when fetch() starts. has() and missing() read live state
and return false and [] until the manifest is known, so drive a
web-seed loop off the manifest's chunk count
(manifestChunkCount(manifest)), not off missing().
Two paths:
- With
proof: the library verifies against the Bao root before marking the chunk held (throws on mismatch), caches the proof, and broadcastsHave(i)so the chunk can be re-served to peers (re-serve reads bytes back throughgetChunk). Use this for untrusted sources, at the cost of distributing the outboard. - Without
proof: the chunk is TRUSTED, neither advertised nor re-served. See the trusted-injection note.
Backpressure: provideChunk applies none. An injected chunk sits
in memory until the in-order iterator reaches it; a web-seed loop that
outruns the consumer can buffer the whole file. The application must
bound how far ahead it fetches.
Example: inject chunks from an HTTP web seed, queued before fetch()
starts.
const manifest = decodeManifest(manifestString)
const swarm = createSwarm({ swarmId, signaling })
const count = manifestChunkCount(manifest)
for (let i = 0; i < count; i++) {
const bytes = await httpFetch(i)
swarm.provideChunk(i, bytes) // queued, no proof
}
for await (const chunk of swarm.fetch(manifest)) {
// chunks arrive in-order, from web seed or peers
}Resume API
| Export | Signature |
|--------|-----------|
| exportBitfield() | :Uint8Array |
| exportProofs() | :Map<number, Uint8Array> |
| initialChunks / initialBitfield (SwarmOpts) | resume hint: chunks already held |
| initialProofs (SwarmOpts) | Map<number, Uint8Array>, resume hint: proofs for held chunks |
initialChunks/initialBitfield already let a fresh Swarm skip
re-fetching chunks the consumer has from a previous session: fetch()
never requests a pre-marked index, and its iterator skips yielding it
(the caller is the source of truth for those bytes). exportBitfield()
returns the current bitfield in the same encoding initialBitfield
expects, so persisting progress and resuming it later is a direct
round trip:
// Before the tab closes (or on an interval):
const bitfield = swarm.exportBitfield()
await db.put('bitfield', bitfield)
// On the next visit:
const bitfield = await db.get('bitfield')
const swarm = createSwarm({ swarmId, signaling, initialBitfield: bitfield })That alone resumes downloading: the fresh swarm won't re-request
what it already has. It does not resume seeding those chunks back to
other peers, because handleRequest needs a Bao proof for each chunk
it serves, and a fresh swarm's proof cache starts empty.
exportProofs() returns the proofs cached for every locally-verified
chunk (a subset keyed the same way as the bitfield); pass it back as
initialProofs so a resumed swarm can re-serve immediately instead of
sending Reject for chunks it can otherwise supply:
const proofs = swarm.exportProofs()
await db.put('proofs', proofs)
// On the next visit:
const proofs = await db.get('proofs')
const swarm = createSwarm({
swarmId,
signaling,
getChunk: i => localStore.get(i),
initialBitfield: bitfield,
initialProofs: proofs,
})initialProofs is trusted as-is (proofs are not re-verified against
the manifest root): only pass through proofs this library produced
itself, such as a prior exportProofs(). An index present in
initialProofs but not marked held by initialChunks/
initialBitfield is cached but has no effect. This is the same
bitfield-of-verified-ranges model iroh-blobs uses for resume.
Security
Trustlessness
Every chunk is verified against the manifest's 32-byte Bao root (see Bao), so the trust here depends on how much you trust the hash you were given. It's impossible to transfer bytes that don't match up with the hash string.
Confidentiality
There is no content-level encryption here. Anyone with the link (hash) can reconstruct the file. Keeping content private means keeping the manifest private.
How the WebRTC connection is encrypted
A Peer is a plain RTCPeerConnection with one data channel (src/peer.ts).
WebRTC data channels run
SCTP
over DTLS --
encryption is mandatory and there is no unencrypted mode.
The DTLS keys are negotiated directly between the two browsers using
ephemeral keys; the signaling server never sees them. Once the channel
is open, chunk bytes are encrypted end-to-end by the two peers.
The DTLS certificates are self-signed, so the remote peer is
authenticated only by the certificate fingerprint carried in the SDP
(a=fingerprint). That SDP passes through the signaling relay.
A passive relay learns nothing about the content. An active relay
that rewrites the fingerprints on both offer and answer could insert
itself as a DTLS man-in-the-middle and observe the plaintext chunks.
This library does no out-of-band fingerprint verification. Even then,
the Bao root still prevents that relay from altering any byte: a MITM
can at worst read content, not forge it.
If a direct path can't be established, peers fall back to a
TURN
relay (/api/ice-servers returns Cloudflare TURN credentials when
configured, otherwise a public Google STUN server, see
example/server/app.ts). A TURN relay forwards the DTLS-encrypted
packets without the keys, so it sees ciphertext and the peers' IP
addresses and traffic timing.
How the signaling WebSocket is encrypted
Signaling carries JSON only: join / signal / leave going up, and
welcome / peer-joined / peer-left / signal coming down
(src/signaling/index.ts). The library adds no application-layer
encryption to these messages; their confidentiality in transit is
whatever the WebSocket URL provides.
- Production (Cloudflare Workers): the client picks
wss:whenever the page is served over HTTPS (example/state.ts), so signaling is TLS-encrypted from the browser to Cloudflare's edge. - Local dev (
npm start): a plainws://localhost socket with no TLS. Fine for loopback, not for anything exposed to a network.
In both cases the TLS terminates at the relay. A relay is a relay because it reads these messages — the operator necessarily sees the signaling traffic in cleartext at the application layer.
What the server operator can learn
For a pure browser-to-browser transfer the operator runs only the
signaling relay (the Cloudflare Worker plus the per-swarm Durable Object,
example/server/signaling-do.ts). From the messages it can observe:
| Observable | Detail |
|------------|--------|
| swarmId | The first 8 bytes of the content's BLAKE3 root, hex-encoded (swarmIdFromManifest, src/index.ts). A stable content fingerprint: the operator can group peers in the same swarm and link sessions moving the same file. An app that uses a random room id as swarmId (with rootless discovery) does not expose this fingerprint. |
| Peer membership and timing | Server-assigned peer ids, swarm size, and the exact moments peers join and leave. This reveals who is swarming with whom and for how long. |
| IP addresses | SDP offers/answers and ICE candidates carry peer IP addresses (public server-reflexive addresses from STUN, and relay addresses). Browsers mask local LAN IPs behind mDNS .local candidates by default, but the public reflexive IP is still exposed. |
| DTLS fingerprints | The certificate fingerprints from each SDP, plus negotiated DTLS/codec parameters. |
What the relay does not see in this mode: the file contents; the
file name, MIME type, or length (those ride in the Handshake message
over the encrypted data channel, not over signaling); the full root
hash (only the 8-byte prefix); and the transferred byte volume (that
flows peer-to-peer, never through the relay).
The server-seeder exception
The optional server seeder flow is different in kind.
When a browser POSTs bytes to /api/seed/:swarmId
(example/server/seeder-routes.ts), the Worker bao-encodes the file
and writes every chunk to R2. In that mode the operator is a peer:
it holds the full plaintext, the name, the MIME type, and the length,
and stores the chunks at rest. Confidentiality against the operator is
gone by design here; the server is another trusted peer.
The relay bridge exception
When relayUrls is set and a pair falls back to it, whoever runs the
bridge sits on the wire session itself. The bridge terminates the
WebSocket and forwards WsPeer frames, which pp2p does not encrypt
beyond the transport's TLS. Unlike a TURN relay, it sees:
| Observable | Detail |
|------------|--------|
| Manifest metadata | The full root, length, chunk size, manifest.name and manifest.type, all carried in each side's Handshake. |
| Content | Every chunk's bytes, plus the bitfields and requests that show which chunks each side has and wants. |
| Volume and timing | The byte volume of the transfer and when it happens, along with both peers' IP addresses. |
It cannot alter content: the recipient still verifies every chunk
against the Bao root. For confidentiality against the bridge, encrypt
content before seeding and keep plaintext out of manifest.name and
manifest.type. Encrypted metadata can go in name, a u16-length
string that holds up to 65535 bytes, with type left empty. With both
in place the bridge learns only IP addresses, timing and volume.
pp2p has two bridge implementations, and both sit in this position:
the Node
reference relay bridge server and
the Cloudflare
Durable Object relay bridge in the
example Worker. A Durable Object bridge has limits of its own. It
accepts received WebSocket messages up to 32 MiB, well above the 1 MiB
wire frame cap, and its CPU budget resets on each message, so a long
transfer does not run it out. Message count is what costs: a much
smaller chunkSize multiplies the number of messages, and the
per-message overhead, on a DO bridge.
The trusted chunk-injection exception
The provideChunk API without a proof offers a fast path for
applications that supply bytes from a trusted source. This applies the
same trusted-source model as the server seeder: the caller stakes the
security of the transfer.
A no-proof provideChunk marks the bit, yields the bytes, and stops
the scheduler requesting that index — with no library-side
verification. The caller must have authenticated those exact bytes
for that index, per-chunk, before the call. Deferred or whole-file
authentication does not qualify; if authentication fails after
injection, the bit is already set and the scheduler has already moved on,
so the corruption is terminal.
Verifying before injection makes a failure a no-op at the library
boundary: if the bytes don't authenticate, the call throws, the index
stays in missing(), and the scheduler recovers it from a peer. This
is why pre-injection authentication is a non-negotiable precondition.
The blast radius is strictly bounded. The no-proof path never broadcasts
Have and never re-serves, so a bad injected chunk can only corrupt the
local transfer, never poison peers. Compare this to a full seeder that
can broadcast bad chunks to the swarm.
Per-chunk AEAD authentication requires the transfer chunkSize to be an
integer multiple of the application's AEAD record size (see the
record-aligned guidance in Bao API).
See Also
Bao
Bao is a Merkle tree over BLAKE3 that gives per-chunk verified streaming.
A single 32-byte root hash commits to every byte of the file,
so anyone holding the root can fetch one chunk plus a
32 * ceil(log2(n))-byte proof and verify it independently,
without downloading the full file.
This is how the swarm can be safe between untrusted peers. A tampered or mis-indexed chunk fails verification and is rejected before it is handed to the consumer or gets re-served.
This implementation is Bao-inspired, but not Bao-spec compatible. It
sits on top of plain BLAKE3 from @noble/hashes and appends a
one-byte domain-separation tag to each hash input, so the same security
properties hold without pulling in an external Bao library:
leaf = BLAKE3(chunk || 0x00)
leaf_root = BLAKE3(chunk || 0x01) // 1-chunk file
node = BLAKE3(left || right || 0x02)
node_root = BLAKE3(left || right || u64_be(length) || 0x03)The tag separates leaves from interior nodes, and root hashes from
non-root hashes. A single-chunk file's root is its leaf_root; any
larger file's root is the node_root over the two top subtrees.
The total length, a big-endian u64, is bound only at the root, so a peer can't lie about how long the file is. No interior node is hashed with the root tag, so an interior hash can't be re-finalized as a root under a different length.
The leaf hash does not include a chunk index. A chunk's position is
bound by the tree path: the verifier walks from the leaf to the root,
taking the left or right branch that chunkIndex dictates at each
level. At every interior node, the left subtree holds the largest power
of two strictly less than the node's chunk count.
Bao standalone
The hash module is exposed as a separate entry point. You can use
it without any of the swarm / signaling / WebRTC parts, e.g. to verify a
chunk you fetched over plain HTTP.
import {
encode,
proofFor,
verifyChunk,
} from '@substrate-system/pp2p/hash'
// Sender: build the tree once over the whole file.
const data = new TextEncoder().encode('hello world, '.repeat(500))
const { root, outboard, chunkCount, length } = encode(data, 1024)
// `root` is the 32-byte commitment to publish / share.
// `outboard` is the pre-order list of every subtree hash; keep it
// alongside the file so you can produce proofs on demand.
// For any one chunk, slice the bytes and build an O(log n) proof.
const chunkIndex = 3
const start = chunkIndex * 1024
const chunk = data.subarray(start, start + 1024)
const proof = proofFor(outboard, chunkIndex, chunkCount)
// Receiver: verify the chunk standalone against just the root.
const ok = verifyChunk({
root,
chunkIndex,
chunkCount,
length,
chunk,
proof,
})
// ok === true; flip a bit in `chunk` and it becomes false.API
Exports of the hash entry point (@substrate-system/pp2p/hash):
| Export | Signature |
|--------|-----------|
| encode(data, chunkSize?) | Uint8Array, number => { root, outboard, chunkCount, length, chunkSize } |
| proofFor(outboard, chunkIndex, chunkCount) | => Uint8Array (sibling hashes leaf-first, packed 32 B each) |
| verifyChunk({ root, chunkIndex, chunkCount, length, chunk, proof }) | => boolean |
| DEFAULT_CHUNK_SIZE | 65536 |
| HASH_LEN | 32 |
outboard has 2 * chunkCount - 1 entries of 32 bytes each. The
sender computes it once and slices proofs out of it for each chunk
request; the receiver doesn't need it — just the root.
For file transfer, choose a record-aligned chunkSize (the default
65536 = 64 KiB works); consumers using AEAD-at-injection must choose
a chunkSize that is an integer multiple of their AEAD record size
so each chunk authenticates independently.
WASM hasher
Hashing runs on @noble/hashes BLAKE3 by default — no setup
needed. Consumers that hash a lot of data (large files, many peers)
can opt in to a WASM BLAKE3 backend for roughly a 5-10x hashing
throughput improvement:
import { enableWasmHasher } from '@substrate-system/pp2p/hash/wasm'
const ok = await enableWasmHasher()
// ok === true once the WASM module is instantiated and installed
// as the active backend.Enabling is process-global: it swaps the module-level hash backend
used by every subsequent hashLeaf/hashNode call, so it also
speeds up the receive/verify path, not just seeding.
enableWasmHasher() never throws. If the WASM module fails to
instantiate (e.g. a strict Content-Security-Policy without
wasm-unsafe-eval, which WebAssembly instantiation needs), it
resolves false and leaves the default @noble/hashes backend
active — hashing still works, just without the speedup. Call
disableWasmHasher() to restore the @noble/hashes backend
explicitly.
Backend
The example backend in this repo is a single Cloudflare Worker. It serves signaling, the WebSocket relay bridge, a server seeder and a few helper endpoints, with three Durable Objects holding per-swarm state and an R2 bucket holding seeded chunks. The SPA is served from the Worker's static asset binding.
- Worker entry (
example/server/index.ts) -- exports the Durable Object classes. It sends WebSocket upgrades on/api/signaling/and/api/seed/straight to their Durable Object and hands every other request to the Hono app. - Hono app (
example/server/app.ts) -- the HTTP routes below, plus the seeder routes fromexample/server/seeder-routes.ts. It falls through toenv.ASSETSfor everything else. - Hibernation API --
ctx.acceptWebSocket(ws)plusserializeAttachmentmean the Durable Objects can sleep between messages without dropping connections, so idle swarms cost nothing. - Static assets -- the SPA built into
./public/is served by Cloudflare'sassetsbinding (run_worker_first: true), so the Worker sees every request first and only delegates static files back toenv.ASSETS.
Durable Objects, each addressed with idFromName:
SignalingDO(example/server/signaling-do.ts, bindingSIGNALING_DO) -- one instance per swarmId, so all peers in a swarm meet at the same signaling room.SeederDO(example/server/seeder-do.ts, bindingSEEDER_DO) -- one instance per swarmId. It holds the manifest and acts as a peer for every browser that dials/api/seed/:swarmId, keeping chunk bytes in theSEEDER_BUCKETR2 bucket and proofs plus its bitfield in Durable Object storage. See Server seeder.RelayBridgeDO(example/server/relay-bridge-do.ts, bindingRELAY_BRIDGE_DO) -- one instance per swarmId plus?pair=token, pairing the two sockets of a relayed connection. See Durable Object relay bridge.
Routes:
GET /api/signaling/:swarmId-- WebSocket upgrade to the swarm'sSignalingDO.GET /api/relay/:swarmId?pair=<token>-- WebSocket upgrade to the relay bridge for that pair.GET /api/seed/:swarmId-- WebSocket upgrade to the swarm'sSeederDO. This is the URL a seeded manifest lists inseeders.POST /api/seed/:swarmId-- upload raw bytes (up to 50 MB). The Worker bao-encodes them, stores the chunks in R2, installs the manifest on theSeederDOand returns the manifest.GET /api/manifest/:swarmId-- the manifest theSeederDOholds, or 404.POST /api/seed-init/:swarmId-- install a manifest JSON body (up to 64 KB) without bytes, so the server leeches it from a browser.POST /api/encode-only-- bao-encode the body and return its manifest fields (version,root,length,chunkSize) without storing anything.GET /api/seed-stats/:swarmId-- theSeederDO's stats as JSON.GET /api/seed-debug/:swarmId/storage-keys-- lists theSeederDO's storage keys. It returns 404 unless theENVIRONMENTvar istestordevelopment.GET /api/ice-servers-- ICE servers forRTCPeerConnection. With theTURN_KEY_IDandTURN_KEY_API_TOKENsecrets set it adds Cloudflare TURN credentials; without them it returns public STUN only.GET /api/health--{ ok: true, status: 'ok' }.
Configuration
This is a minimal excerpt of the Worker config. The full file is
wrangler.jsonc.
{
"name": "practical-p2p",
"main": "example/server/index.ts",
"compatibility_date": "2025-12-17",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [
{ "name": "SIGNALING_DO", "class_name": "SignalingDO" },
{ "name": "SEEDER_DO", "class_name": "SeederDO" },
{ "name": "RELAY_BRIDGE_DO", "class_name": "RelayBridgeDO" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["SignalingDO"] },
{ "tag": "v2", "new_sqlite_classes": ["SeederDO"] },
{ "tag": "v3", "new_sqlite_classes": ["RelayBridgeDO"] }
],
"r2_buckets": [
{ "binding": "SEEDER_BUCKET", "bucket_name": "practical-p2p-seed" }
],
"assets": {
"directory": "./public/",
"not_found_handling": "single-page-application",
"binding": "ASSETS",
"run_worker_first": true
}
}The Worker needs all three Durable Object bindings (SIGNALING_DO,
SEEDER_DO, RELAY_BRIDGE_DO) and the SEEDER_BUCKET R2 bucket. A
migrations entry is required the first time you deploy a new
Durable Object class, so each class has its own tag.
wrangler.jsonc also defines development and staging envs.
Wrangler does not inherit durable_objects, migrations or
r2_buckets into a named env, so each env redeclares every binding
and migration to match the top level. If you add a binding or a
migration, add it to every env too.
TURN_KEY_ID and TURN_KEY_API_TOKEN are optional secrets, set with
wrangler secret put. See /api/ice-servers above.
Server seeder
A Cloudflare-Workers-hosted seeder can participate in a swarm as a full
peer over a single WebSocket per browser. It downloads via the same
module, uploads chunks before its own download is complete, and
publishes its WebSocket URL via the manifest's seeders[] field.
Two flows are supported:
Server holds bytes first — POST raw bytes; the worker bao-encodes,
writes chunks to R2, and returns a manifest with seeders pointing at
its own /api/seed/:swarmId endpoint.
curl -X POST --data-binary @file.bin \
https://your-worker.example.com/api/seed/my-swarm// Browser side: decode the manifest and call fetch().
import { decodeManifest, createSwarm } from '@substrate-system/pp2p'
const r = await fetch('/api/manifest/my-swarm')
const manifest = decodeManifest(await r.text())
const chunks = new Map<number, Uint8Array>()
const swarm = createSwarm({
swarmId: 'my-swarm',
signaling,
onChunk: (i, b) => { chunks.set(i, b) },
})
for await (const chunk of swarm.fetch(manifest)) {
// chunks arrive in order, verified
}Browser holds bytes first — the server gets only the manifest, a
browser opens a swarm with the bytes via seedFromManifest, and the
server downloads as a leecher just like any other peer.
import { createSwarm, type Manifest } from '@substrate-system/pp2p'
const manifest:Manifest = await fetchManifestSomehow()
const sw = createSwarm({ swarmId: manifest.name!, signaling })
await sw.seedFromManifest(manifest, bytes)The seeder URL listed in manifest.seeders[] is dialed automatically
by fetch() and seedFromManifest(). From the swarm scheduler's
point of view, the server is just another peer — same Have / Request
/ Piece exchange as browser-to-browser WebRTC.
Develop
npm startThis runs Vite with the Cloudflare plugin (@cloudflare/vite-plugin,
see vite.config.js), so the Worker and its Durable Objects run
locally alongside the example app, at http://localhost:3333.
Deploy
npm run build-example
npm run deployRun npm run build-example first. The deploy script runs
wrangler deploy -c public/practical_p2p/wrangler.json, and that
config file exists only after the example build has produced it.
The deploy uploads the Worker, registers its Durable Object classes,
and publishes ./public/ to Cloudflare's edge. You'll need a
Cloudflare account and wrangler login first.
Using a different backend
The signaling protocol is defined in src/signaling/index.ts
and is transport-agnostic. Anything that speaks the same JSON
messages (join / welcome / signal / peer-joined /
peer-left / leave) over a WebSocket works -- the Durable Object
implementation is one option, not a requirement.
This is not BitTorrent on the wire and won't interoperate with a BitTorrent client.
Test
npm run build-example # once per checkout, for the e2e stage
npm testnpm test runs four stages in order, and stops at the first failure:
test:unit-- browser unit tests in Chromium through tapout.test:server-- Node tests undertest/server/.test:worker-- Worker and Durable Object tests in vitest.test:e2e-- Playwright end-to-end tests.
The e2e stage starts wrangler dev, which serves the public/
directory that npm run build-example creates, so run that build
first on a fresh checkout.
The suite needs these ports free: 8123 for tapout, and 8125, 8126
and 9999 for e2e (a static fixture server, a Node signaling relay
and wrangler dev). Set WRANGLER_PORT to move wrangler dev off
9999.
