🤝 Trystero
Build instant multiplayer webapps, no server required
👉 TRY THE DEMO 👈
Trystero manages a clandestine courier network that lets your application's
users talk directly with one another, encrypted and without a server middleman.
Peers can connect via
🌊 BitTorrent, 🐦 Nostr, 📡 MQTT, 🔥 Firebase, or 🪐 IPFS
– all using the same API.
Besides making peer matching automatic, Trystero offers some nice abstractions
on top of WebRTC:
- 👂📣 Rooms / broadcasting
- 🔢📩 Automatic serialization / deserialization of data
- 🎥🏷 Attach metadata to binary data and media streams
- ✂️⏳ Automatic chunking and throttling of large data
- ⏱🤞 Progress events and promises for data transfers
- 🔐📝 Session data encryption
- ⚛️🪝 React hooks
Contents
How it works
👉 If you just want to try out Trystero, you can skip this explainer and
jump into using it.
To establish a direct peer-to-peer connection with WebRTC, a signalling channel
is needed to exchange peer information
(SDP). Typically
this involves running your own matchmaking server but Trystero abstracts this
away for you and offers multiple "serverless" strategies for connecting peers
(currently BitTorrent, Nostr, MQTT, Firebase, and IPFS).
The important point to remember is this:
🔒
Beyond peer discovery, your app's data never touches the strategy medium and
is sent directly peer-to-peer and end-to-end encrypted between users.
👆
You can compare strategies here.
Get started
You can install with npm (npm i trystero
) and import like so:
import {joinRoom} from 'trystero'
Or maybe you prefer a simple script tag? Download a pre-built JS file from the
latest release and import
it locally:
<script type="module">
import {joinRoom} from './trystero-torrent.min.js'
</script>
By default, the Nostr strategy is used. To use a
different one just deep import like so (your bundler should handle including
only relevant code):
import {joinRoom} from 'trystero/mqtt'
import {joinRoom} from 'trystero/torrent'
import {joinRoom} from 'trystero/firebase'
import {joinRoom} from 'trystero/ipfs'
Next, join the user to a room with a namespace:
const config = {appId: 'san_narciso_3d'}
const room = joinRoom(config, 'yoyodyne')
The first argument is a configuration object that requires an appId
. This
should be a completely unique identifier for your app (or in the case of
Firebase, your databaseURL
). The second argument is the room name.
Why rooms? Browsers can only handle a limited amount of WebRTC connections at
a time so it's recommended to design your app such that users are divided into
groups (or rooms, or namespaces, or channels... whatever you'd like to call
them).
Listen for events
Listen for peers joining the room:
room.onPeerJoin(peerId => console.log(`${peerId} joined`))
Listen for peers leaving the room:
room.onPeerLeave(peerId => console.log(`${peerId} left`))
Listen for peers sending their audio/video streams:
room.onPeerStream(
(stream, peerId) => (peerElements[peerId].video.srcObject = stream)
)
To unsubscribe from events, leave the room:
room.leave()
Broadcast events
Send peers your video stream:
room.addStream(
await navigator.mediaDevices.getUserMedia({audio: true, video: true})
)
Send and subscribe to custom P2P actions:
const [sendDrink, getDrink] = room.makeAction('drink')
sendDrink({drink: 'negroni', withIce: true}, friendId)
sendDrink({drink: 'mezcal', withIce: false})
getDrink((data, peerId) =>
console.log(
`got a ${data.drink} with${data.withIce ? '' : 'out'} ice from ${peerId}`
)
)
You can also use actions to send binary data, like images:
const [sendPic, getPic] = room.makeAction('pic')
canvas.toBlob(blob => sendPic(blob))
getPic(
(data, peerId) => (imgs[peerId].src = URL.createObjectURL(new Blob([data])))
)
Let's say we want users to be able to name themselves:
const idsToNames = {}
const [sendName, getName] = room.makeAction('name')
sendName('Oedipa')
room.onPeerJoin(peerId => sendName('Oedipa', peerId))
getName((name, peerId) => (idsToNames[peerId] = name))
room.onPeerLeave(peerId =>
console.log(`${idsToNames[peerId] || 'a weird stranger'} left`)
)
Actions are smart and handle serialization and chunking for you behind the
scenes. This means you can send very large files and whatever data you send
will be received on the other side as the same type (a number as a number,
a string as a string, an object as an object, binary as binary, etc.).
Audio and video
Here's a simple example of how you could create an audio chatroom:
const peerAudios = {}
const selfStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
})
room.addStream(selfStream)
room.onPeerJoin(peerId => room.addStream(selfStream, peerId))
room.onPeerStream((stream, peerId) => {
const audio = new Audio()
audio.srcObject = stream
audio.autoplay = true
peerAudios[peerId] = audio
})
Doing the same with video is similar, just be sure to add incoming streams to
video elements in the DOM:
const peerVideos = {}
const videoContainer = document.getElementById('videos')
room.onPeerStream((stream, peerId) => {
let video = peerVideos[peerId]
if (!video) {
video = document.createElement('video')
video.autoplay = true
videoContainer.appendChild(video)
}
video.srcObject = stream
peerVideos[peerId] = video
})
Advanced
Binary metadata
Let's say your app supports sending various types of files and you want to
annotate the raw bytes being sent with metadata about how they should be
interpreted. Instead of manually adding metadata bytes to the buffer you can
simply pass a metadata argument in the sender action for your binary payload:
const [sendFile, getFile] = makeAction('file')
getFile((data, peerId, metadata) =>
console.log(
`got a file (${metadata.name}) from ${peerId} with type ${metadata.type}`,
data
)
)
sendFile(buffer, null, {name: 'The Courierʼs Tragedy', type: 'application/pdf'})
Action promises
Action sender functions return a promise that resolves when they're done
sending. You can optionally use this to indicate to the user when a large
transfer is done.
await sendFile(amplePayload)
console.log('done sending to all peers')
Progress updates
Action sender functions also take an optional callback function that will be
continuously called as the transmission progresses. This can be used for showing
a progress bar to the sender for large tranfers. The callback is called with a
percentage value between 0 and 1 and the receiving peer's ID:
sendFile(
payload,
[peerIdA, peerIdB, peerIdC],
{filename: 'paranoids.flac'},
(percent, peerId) => (loadingBars[peerId].value = percent)
)
Similarly you can listen for progress events as a receiver like this:
const [sendFile, getFile, onFileProgress] = room.makeAction('file')
onFileProgress((percent, peerId, metadata) =>
console.log(
`${percent * 100}% done receiving ${metadata.filename} from ${peerId}`
)
)
Notice that any metadata is sent with progress events so you can show the
receiving user that there is a transfer in progress with perhaps the name of the
incoming file.
Since a peer can send multiple transmissions in parallel, you can also use
metadata to differentiate between them, e.g. by sending a unique ID.
Encryption
Once peers are connected to each other all of their communications are
end-to-end encrypted. During the initial connection / discovery process, peers'
SDPs are sent via
the chosen peering strategy medium. The SDP is encrypted over the wire, but is
visible in plaintext as it passes through the medium (a public torrent tracker
for example). This is fine for most use cases but you can choose to hide SDPs
from the peering medium with Trystero's encryption option. This can protect
against a MITM peering attack if both intended peers have a shared secret. To
opt in, just pass a password
parameter in the app configuration object:
joinRoom({appId: 'kinneret', password: 'MuchoMaa$'}, 'w_a_s_t_e__v_i_p')
Keep in mind the password has to match for all peers in the room for them to be
able to connect. An example use case might be a private chat room where users
learn the password via external means.
React hooks
Trystero functions are idempotent so they already work out of the box as React
hooks.
Here's a simple example component where each peer syncs their favorite
color to everyone else:
import {joinRoom} from 'trystero'
import {useState} from 'react'
const trysteroConfig = {appId: 'thurn-und-taxis'}
export default function App({roomId}) {
const room = joinRoom(trysteroConfig, roomId)
const [sendColor, getColor] = room.makeAction('color')
const [myColor, setMyColor] = useState('#c0ffee')
const [peerColors, setPeerColors] = useState({})
room.onPeerJoin(peer => sendColor(myColor, peer))
getColor((color, peer) =>
setPeerColors(peerColors => ({...peerColors, [peer]: color}))
)
const updateColor = e => {
const {value} = e.target
setMyColor(value)
sendColor(value)
}
return (
<>
<h1>Trystero + React</h1>
<h2>My color:</h2>
<input type="color" value={myColor} onChange={updateColor} />
<h2>Peer colors:</h2>
<ul>
{Object.entries(peerColors).map(([peerId, color]) => (
<li key={peerId} style={{backgroundColor: color}}>
{peerId}: {color}
</li>
))}
</ul>
</>
)
}
Astute readers may notice the above example is simple and doesn't consider if we
want to change the component's room ID or unmount it. For those scenarios you
can use this simple useRoom()
hook that unsubscribes from room events
accordingly:
import {joinRoom} from 'trystero'
import {useEffect, useRef} from 'react'
export const useRoom = (roomConfig, roomId) => {
const roomRef = useRef(joinRoom(roomConfig, roomId))
useEffect(() => {
roomRef.current = joinRoom(roomConfig, roomId)
return () => roomRef.current.leave()
}, [roomConfig, roomId])
return roomRef.current
}
Firebase setup
If you want to use the Firebase strategy and don't have an existing project:
- Create a Firebase project
- Create a new Realtime Database
- Copy the
databaseURL
and use it as the appId
in your Trystero config - [Optional] Configure the database with security rules to limit activity:
{
"rules": {
".read": false,
".write": false,
"__trystero__": {
".read": false,
".write": false,
"$room_id": {
".read": true,
".write": true
}
}
}
}
These rules ensure room peer presence is only readable if the room namespace is
known ahead of time.
API
joinRoom(config, namespace)
Adds local user to room whereby other peers in the same namespace will open
communication channels and send events. Calling joinRoom()
multiple times with
the same namespace will return the same room instance.
Returns an object with the following methods:
-
leave()
Remove local user from room and unsubscribe from room events.
-
getPeers()
Returns a map of
RTCPeerConnection
s
for the peers present in room (not including the local user). The keys of
this object are the respective peers' IDs.
-
addStream(stream, [targetPeers], [metadata])
Broadcasts media stream to other peers.
-
stream
- A MediaStream
with audio and/or video to send to peers in the
room.
-
targetPeers
- (optional) If specified, the stream is sent only to the
target peer ID (string) or list of peer IDs (array).
-
metadata
- (optional) Additional metadata (any serializable type) to
be sent with the stream. This is useful when sending multiple streams so
recipients know which is which (e.g. a webcam versus a screen capture). If
you want to broadcast a stream to all peers in the room with a metadata
argument, pass null
as the second argument.
-
removeStream(stream, [targetPeers])
Stops sending previously sent media stream to other peers.
-
stream
- A previously sent MediaStream
to stop sending.
-
targetPeers
- (optional) If specified, the stream is removed only from
the target peer ID (string) or list of peer IDs (array).
-
addTrack(track, stream, [targetPeers], [metadata])
Adds a new media track to a stream.
-
track
- A MediaStreamTrack
to add to an existing stream.
-
stream
- The target MediaStream
to attach the new track to.
-
targetPeers
- (optional) If specified, the track is sent only to the
target peer ID (string) or list of peer IDs (array).
-
metadata
- (optional) Additional metadata (any serializable type) to
be sent with the track. See metadata
notes for addStream()
above for
more details.
-
removeTrack(track, stream, [targetPeers])
Removes a media track from a stream.
-
track
- The MediaStreamTrack
to remove.
-
stream
- The MediaStream
the track is attached to.
-
targetPeers
- (optional) If specified, the track is removed only from
the target peer ID (string) or list of peer IDs (array).
-
replaceTrack(oldTrack, newTrack, stream, [targetPeers])
Replaces a media track with a new one.
-
oldTrack
- The MediaStreamTrack
to remove.
-
newTrack
- A MediaStreamTrack
to attach.
-
stream
- The MediaStream
the oldTrack
is attached to.
-
targetPeers
- (optional) If specified, the track is replaced only for
the target peer ID (string) or list of peer IDs (array).
-
onPeerJoin(callback)
Registers a callback function that will be called when a peer joins the room.
If called more than once, only the latest callback registered is ever called.
callback(peerId)
- Function to run whenever a peer joins, called with the
peer's ID.
Example:
onPeerJoin(peerId => console.log(`${peerId} joined`))
-
onPeerLeave(callback)
Registers a callback function that will be called when a peer leaves the room.
If called more than once, only the latest callback registered is ever called.
callback(peerId)
- Function to run whenever a peer leaves, called with the
peer's ID.
Example:
onPeerLeave(peerId => console.log(`${peerId} left`))
-
onPeerStream(callback)
Registers a callback function that will be called when a peer sends a media
stream. If called more than once, only the latest callback registered is ever
called.
callback(stream, peerId, metadata)
- Function to run whenever a peer sends
a media stream, called with the the peer's stream, ID, and optional metadata
(see addStream()
above for details).
Example:
onPeerStream((stream, peerId) =>
console.log(`got stream from ${peerId}`, stream)
)
-
onPeerTrack(callback)
Registers a callback function that will be called when a peer sends a media
track. If called more than once, only the latest callback registered is ever
called.
callback(track, stream, peerId, metadata)
- Function to run whenever a
peer sends a media track, called with the the peer's track, attached stream,
ID, and optional metadata (see addTrack()
above for details).
Example:
onPeerTrack((track, stream, peerId) =>
console.log(`got track from ${peerId}`, track)
)
-
makeAction(namespace)
Listen for and send custom data actions.
namespace
- A string to register this action consistently among all peers.
Returns an array of three functions:
-
Sender
-
Sends data to peers and returns a promise that resolves when all
target peers are finished receiving data.
-
(data, [targetPeers], [metadata], [onProgress])
-
data
- Any value to send (primitive, object, binary). Serialization
and chunking is handled automatically. Binary data (e.g. Blob
,
TypedArray
) is received by other peer as an agnostic ArrayBuffer
.
-
targetPeers
- (optional) Either a peer ID (string), an array of
peer IDs, or null
(indicating to send to all peers in the room).
-
metadata
- (optional) If the data is binary, you can send an
optional metadata object describing it (see
Binary metadata).
-
onProgress
- (optional) A callback function that will be called
as every chunk for every peer is transmitted. The function will be
called with a value between 0 and 1 and a peer ID. See
Progress updates for an example.
-
Receiver
-
Progress handler
-
Registers a callback function that runs when partial data is received
from peers. You can use this for tracking large binary transfers. See
Progress updates for an example.
-
(percent, peerId, metadata)
-
percent
- A number between 0 and 1 indicating the percentage complete
of the transfer.
-
peerId
- The ID string of the sending peer.
-
metadata
- (optional) Optional metadata object supplied by the
sender.
Example:
const [sendCursor, getCursor] = room.makeAction('cursormove')
window.addEventListener('mousemove', e => sendCursor([e.clientX, e.clientY]))
getCursor(([x, y], peerId) => {
const peerCursor = cursorMap[peerId]
peerCursor.style.left = x + 'px'
peerCursor.style.top = y + 'px'
})
-
ping(peerId)
Takes a peer ID and returns a promise that resolves to the milliseconds the
round-trip to that peer took. Use this for measuring latency.
peerId
- Peer ID string of the target peer.
Example:
room.onPeerJoin(peerId =>
setInterval(
async () => console.log(`took ${await room.ping(peerId)}ms`),
2000
)
)
selfId
A unique ID string other peers will know the local user as globally across
rooms.
getRelaySockets()
(🌊 BitTorrent, 🐦 Nostr, 📡 MQTT only) Returns an object of relay URL keys
mapped to their WebSocket connections. This can be useful for determining the
state of the user's connection to the relays and handling any connection
failures.
Example:
console.log(trystero.getRelaySockets())
getOccupants(config, namespace)
(🔥 Firebase only) Returns a promise that resolves to a list of user IDs
present in the given namespace. This is useful for checking how many users are
in a room without joining it.
config
- A configuration objectnamespace
- A namespace string that you'd pass to joinRoom()
.
Example:
console.log((await trystero.getOccupants(config, 'the_scope')).length)
Strategy comparison
| one-time setup¹ | bundle size² | time to connect³ |
---|
🐦 Nostr | none 🏆 | 54K | ⏱️⏱️ |
📡 MQTT | none 🏆 | 332K | ⏱️⏱️ |
🌊 BitTorrent | none 🏆 | 25K 🏆 | ⏱️⏱️ |
🔥 Firebase | ~5 mins | 177K | ⏱️ 🏆 |
🪐 IPFS | none 🏆 | 1MB | ⏱️⏱️⏱️ |
¹ All strategies except Firebase require zero setup. Firebase is a managed
strategy which requires setting up an account.
² Calculated via Rollup bundling + Terser compression.
³ Relative speed of peers connecting to each other when joining a room.
Firebase is near-instantaneous while the other strategies are a bit slower to
exchange peering info.
How to choose
Trysteroʼs unique advantage is that it requires zero backend setup and uses
decentralized infrastructure in most cases. This allows for frictionless
experimentation and no single point of failure. One potential drawback is that
itʼs difficult to guarantee that the public infrastructure it uses will always
be highly available, even with the redundancy techniques Trystero uses. While
the other strategies are decentralized, the Firebase strategy is a more managed
approach with greater control and an SLA, which might be more appropriate for
“production” apps.
Luckily, Trystero makes it trivial to switch between strategies — just change a
single import line and quickly experiment:
import {joinRoom} from 'trystero/[torrent|nostr|mqtt|firebase|ipfs]'
Trystero by Dan Motzenbecker