Skip to content

substrate-system/webrtc

Repository files navigation

webrtc

tests module semantic versioning Common Changelog install size GZip size license

Simpler peer-to-peer connections. Use this module to simplify webrtc data channel connections. This combines signaling events with webrtc events, because in practice, you only need to know a few things for peer connections — did we connect to a peer? which peers exist? and did we get a new message?

Warning

WIP status.

Contents

Screenshot of the example app

Install

npm i -S @substrate-system/webrtc

Get Started

You can use the example app here.

.env file

You need to provide a Clouflare TURN server.

# .env
NODE_ENV="development"
DEBUG="*"
CF_TURN_TOKEN_ID="123abc"
CF_TURN_API_TOKEN="123bc"

Logs

We use @substrate-system/debug for logging. In the browser, you can set the key DEBUG to webrtc to enable logging in the browser.

window.localStorage.setItem('DEBUG', 'webrtc')

This package dynamically imports @substrate-system/debug; it is an optionalDependency. If you want to use the debug logs for this module, then your application should use an HTML importmap to map @substrate-system/debug to the debug module.

Servers

You need 2 things: a Partykit server and a TURN server. The good news is that both of these are easy & free to use.

For the TURN server, I recommend Cloudflare's service. It is easy to setup and free for demonstration purposes.

If you want to run the example app locally, you will need to create a .env file following the example in .env.example. Replace the variables with your own Cloudflare credentials.

Websocket Server

In your Partykit project, create a server that inherits from the /server path here. This is a signaling server.

See ./example_backend.

import type * as Party from 'partykit/server'
import Server, { defaultHeaders } from '@substrate-system/webtrc/server'

export default class PartyServer extends Server {
    async onRequest (req:Party.Request):Promise<Response> {
        const res = await super.onRequest(req)

        // example: set the headers in response
        return new Response(res.body, {
            status: res.status,
            statusText: res.statusText,
            headers: defaultHeaders()
        })
    }
}

Server satisfies Party.Worker

Client Example

This is browser-side code.

import { type Connection, connect } from '@substrate-system/webrtc'
import Debug from '@substrate-system/debug'
const debug = Debug(true)

const connection = await connect({
    host: PARTYKIT_HOST,
    room: 'example'
})

// now we are connected to the websocket server,
// because we awaited the connection

connection.on('message', ev => {
    debug('message event', ev)
})

connection.on('peerlist', list => {
    debug('peerlist event', list)
})

connection.on('peer', ([peerId, dc]) => {
    // now we are connected to a peer
    debug('peer', peerId)
    debug('data channel', dc)
})

connection.on('message', ev => {
    debug('got a message from peer', ev.peer)
    debug('the message content', ev.data)
})

connection.on('peerlist', list => {
    debug('the list of peers connected to the websocket:', list)
})

connection.on('peer', ([peerId, _dc]:[string, RTCDataChannel]) => {
    // when a connection is established to a peer
    debug('new peer connection', peerId)
})

API

Client-Side

connect

export function connect ({ host, room }:{
  host:string;
  room:string;
}):Promise<Connection>

Create a websocket connection to the given host.

Events

peerlist
peerlist:(peers:string[]) => void

Emitted when you first connect to the websocket server with a list of peer IDs currently in the room.

Parameters: peers: string[]

socket
socket:(ws:PartySocket)=>void

Emitted when the websocket connection is established.

Parameters: ws: PartySocket

message
message:(ev:{ data:string, peer:string })=>void|Promise<void>

Emitted when a message is received from a connected peer. Includes the message data and the sender's peer ID.

Parameters: { data: string, peer: string }

datachannel
datachannel:(dc:RTCDataChannel)=>void

Emitted when a WebRTC data channel connection is established.

Parameters: dc: RTCDataChannel

peer
peer:(arg:[string, RTCDataChannel])=>void

Emitted when a peer is successfully connected via WebRTC. Provides both the peer ID and the data channel.

Parameters: [peerId: string, dc: RTCDataChannel]

peer-disconnect
'peer-disconnect':(peerId:string)=>void

Emitted when a peer disconnects from the WebRTC connection.

Parameters: peerId: string

webrtc-close
'webrtc-close':(dc:RTCDataChannel)=>void

Emitted when a WebRTC data channel is closed.

Parameters: dc: RTCDataChannel


Security

End-to-End Encryption

Your messages between clients are encrypted end-to-end. WebRTC data channels use DTLS (Datagram Transport Layer Security) and SRTP (Secure Real-time Transport Protocol) by default. This means:

  • All peer-to-peer communication is encrypted before it leaves your browser
  • Only the sending and receiving peers can decrypt the messages
  • Encryption keys are negotiated directly between peers using DTLS-SRTP key exchange
  • Encryption is mandatory in WebRTC — there is no "unencrypted mode"

What the Signaling Server Can See

The Partykit (signaling) server facilitates the initial connection setup, but cannot read your peer-to-peer messages. The signaling server can see:

  • Connection metadata (who is connecting to whom)
  • Peer IDs and room names
  • WebRTC session descriptions (SDP) — these contain network information like IP addresses and supported codecs
  • ICE candidates — potential network paths for establishing connections
  • Presence information (who joined/left the room)

What it CANNOT see:

  • The actual content of messages sent through WebRTC data channels
  • Any data transmitted peer-to-peer after the connection is established
  • Encryption keys (these are negotiated directly between peers)

The signaling server's role ends once the peer-to-peer connection is established. After that, data flows directly between peers, bypassing the signaling server entirely.

What the TURN Server Can See

The Cloudflare TURN server is used only when a direct peer-to-peer connection cannot be established (typically due to restrictive NATs or firewalls).

When TURN is used:

What it CAN see:

  • That encrypted packets are being relayed between two peers
  • The size and timing of packets
  • Source and destination IP addresses
  • The volume of traffic

What it CANNOT see:

  • The content of your messages (packets are encrypted with DTLS)
  • Encryption keys
  • Decrypted data

The TURN server is a blind relay — it forwards encrypted packets without being able to decrypt them.

Summary

Component Can See Metadata Can Read Messages
Signaling Server (WebSocket) ✅ Yes ❌ No
TURN Server (Cloudflare) ✅ Yes ❌ No
Peers (You & Other Clients) ✅ Yes ✅ Yes

The peer-to-peer messages are protected by end-to-end encryption. Neither the signaling server nor the TURN server can decrypt the messages.


Modules

ESM

import { webrtc } from '@substrate-system/webrtc'

pre-built JS

This package exposes minified JS files too. Copy them to a location that is accessible to your web server, then link to them in HTML.

copy

cp ./node_modules/@substrate-system/webrtc/dist/index.min.js ./public/webrtc.min.js

HTML

<script type="module" src="/webrtc.min.js"></script>

Develop

Start a local websocket server and a local vite server for the front-end.

npm start

To run the example, you will need to create a cloudflare account and generate credentials. Paste the credentials into .dev.vars.

Deploy

Deploying the backend means deploying partykit.

To deploy with environment variables in a .env file, run with the flag --with-vars.

npx partykit deploy --with-vars

Perfect Negotiation

This uses the Perfect Negotiation pattern to establish a connection.

Because WebRTC doesn't mandate a specific transport mechanism for signaling during the negotiation of a new peer connection, it's highly flexible.

Negotiation is an inherently asymmetric operation: one side needs to serve as the "caller" while the other peer is the "callee."

[...] your application doesn't need to care which end of the connection it is.

the same code is used for both the caller and the callee

Assign each of the two peers a role to play in the negotiation process:

  • A polite peer, which uses ICE rollback to prevent collisions with incoming offers. May send out offers, but then responds if an offer arrives from the other peer with "Okay, never mind, drop my offer and I'll consider yours instead."
  • An impolite peer, which always ignores incoming offers that collide with its own offers. Always ignores incoming offers that collide with its own offers.

Both peers know exactly what should happen if there are collisions between offers.

We assign the polite role to the first peer to connect to the signaling server.

The two peers can then work together to manage signaling in a way that doesn't deadlock.

See Also

WebRTC For The Curious is an open-source book created by WebRTC implementers to share their hard-earned knowledge with the world.

About

Simpler webrtc

Topics

Resources

License

Stars

Watchers

Forks

Contributors 2

  •  
  •  

Languages