npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@substrate-system/pp2p

v0.2.13

Published

Practical p2p, for the browser

Readme

Practical P2P

tests module semantic versioning license

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 start

You 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. type is the file's optional MIME string, written only when present. seeders is 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 the Signaling interface works.
  • Peer — one RTCPeerConnection plus a single ordered data channel carrying length-prefixed binary frames.
  • Swarm — joins the signaling channel, opens a Peer per remote, and runs the chunk-exchange loop.
  • Bao tree — a BLAKE3 Merkle tree over the chunks. Each Piece message 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:

  1. That peer has not opened within relayFallbackDelayMs (default 5s) of becoming known.
  2. That peer's connect/reconnect attempts (see reconnectMaxAttempts above) are exhausted, whether or not other peers are open.
  3. fetch() has gone relayFallbackStallMs (default 3x requestTimeoutMs) 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:

  1. Sets the bit in its own bitfield.
  2. Broadcasts a Have to every connected peer, when the swarm was given getChunk.

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 broadcasts Have(i) so the chunk can be re-served to peers (re-serve reads bytes back through getChunk). 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 plain ws:// 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 from example/server/seeder-routes.ts. It falls through to env.ASSETS for everything else.
  • Hibernation API -- ctx.acceptWebSocket(ws) plus serializeAttachment mean 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's assets binding (run_worker_first: true), so the Worker sees every request first and only delegates static files back to env.ASSETS.

Durable Objects, each addressed with idFromName:

  • SignalingDO (example/server/signaling-do.ts, binding SIGNALING_DO) -- one instance per swarmId, so all peers in a swarm meet at the same signaling room.
  • SeederDO (example/server/seeder-do.ts, binding SEEDER_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 the SEEDER_BUCKET R2 bucket and proofs plus its bitfield in Durable Object storage. See Server seeder.
  • RelayBridgeDO (example/server/relay-bridge-do.ts, binding RELAY_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's SignalingDO.
  • GET /api/relay/:swarmId?pair=<token> -- WebSocket upgrade to the relay bridge for that pair.
  • GET /api/seed/:swarmId -- WebSocket upgrade to the swarm's SeederDO. This is the URL a seeded manifest lists in seeders.
  • 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 the SeederDO and returns the manifest.
  • GET /api/manifest/:swarmId -- the manifest the SeederDO holds, 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 -- the SeederDO's stats as JSON.
  • GET /api/seed-debug/:swarmId/storage-keys -- lists the SeederDO's storage keys. It returns 404 unless the ENVIRONMENT var is test or development.
  • GET /api/ice-servers -- ICE servers for RTCPeerConnection. With the TURN_KEY_ID and TURN_KEY_API_TOKEN secrets 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 start

This 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 deploy

Run 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 test

npm test runs four stages in order, and stops at the first failure:

  1. test:unit -- browser unit tests in Chromium through tapout.
  2. test:server -- Node tests under test/server/.
  3. test:worker -- Worker and Durable Object tests in vitest.
  4. 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.