Serverless WebRTC matchmaking for painless P2P: make any site multiplayer in afew lines
Trystero manages a clandestine courier network that lets your application'susers talk directly with one another, encrypted and without a server middleman.
Peers can connect via BitTorrent, Firebase, or IPFS –all using the same API.
To establish a direct peer-to-peer connection with WebRTC, a signalling channelis needed to exchange peer information(SDP). Typicallythis involves running your own matchmaking server but Trystero abstracts thisaway for you and offers multiple "serverless" strategies for connecting peers(currently BitTorrent, Firebase, and IPFS).
The important point to remember is this:
�� Beyond peer discovery, your app's data never touches the strategy medium andis sent directly peer-to-peer and end-to-end encrypted between users.
��
You can compare strategies here.
You can install with npm (npm i trystero
) and import like so:
import {joinRoom} from 'trystero'
Or maybe you prefer a simple script tag?
<script type="module">
import {joinRoom} from 'https://cdn.skypack.dev/trystero'
</script>
By default, the BitTorrent strategy is used. To use adifferent one just deep import like so (your bundler should handle includingonly relevant code):
import {joinRoom} from 'trystero/firebase'
// or
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
. Thisshould be a completely unique identifier for your app (for the BitTorrent andIPFS strategies) or your Firebase database ID if you're using Firebase. Thesecond argument is the room name.
Why rooms? Browsers can only handle a limited amount of WebRTC connections ata time so it's recommended to design your app such that users are divided intogroups (or rooms, or namespaces, or channels... whatever you'd like to callthem).
Listen for peers joining the room:
room.onPeerJoin(id => console.log(`${id} joined`))
Listen for peers leaving the room:
room.onPeerLeave(id => console.log(`${id} left`))
Listen for peers sending their audio/video streams:
room.onPeerStream((stream, id) => (peerElements[id].video.srcObject = stream))
To unsubscribe from events, leave the room:
room.leave()
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')
// buy drink for a friend
sendDrink({drink: 'negroni', withIce: true}, friendId)
// buy round for the house (second argument omitted)
sendDrink({drink: 'mezcal', withIce: false})
// listen for drinks sent to you
getDrink((data, id) =>
console.log(
`got a ${data.drink} with${data.withIce ? '' : 'out'} ice from ${id}`
)
)
You can also use actions to send binary data, like images:
const [sendPic, getPic] = room.makeAction('pic')
// blobs are automatically handled, as are any form of TypedArray
canvas.toBlob(blob => sendPic(blob))
// binary data is received as raw ArrayBuffers so your handling code should
// interpret it in a way that makes sense
getPic((data, id) => (imgs[id].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')
// tell other peers currently in the room our name
sendName('Oedipa')
// tell newcomers
room.onPeerJoin(id => sendName('Oedipa', id))
// listen for peers naming themselves
getName((name, id) => (idsToNames[id] = name))
room.onPeerLeave(id =>
console.log(`${idsToNames[id] || 'a weird stranger'} left`)
)
Actions are smart and handle serialization and chunking for you behind thescenes. This means you can send very large files and whatever data you sendwill 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.).
Let's say your app supports sending various types of files and you want toannotate the raw bytes being sent with metadata about how they should beinterpreted. Instead of manually adding metadata bytes to the buffer you cansimply pass a metadata argument in the sender action for your binary payload:
const [sendFile, getFile] = makeAction('file')
getFile((data, id, meta) =>
console.log(
`got a file (${meta.name}) from ${id} with type ${meta.type}`,
data
)
)
// to send metadata, pass a third argument
// to broadcast to the whole room, set the second peer ID argument to null
sendFile(buffer, null, {name: 'The Courierʼs Tragedy', type: 'application/pdf'})
Action sender functions return a promise that resolves when they're donesending. You can optionally use this to indicate to the user when a largetransfer is done.
await sendFile(amplePayload)
console.log('done sending')
joinRoom(config, namespace)
Adds local user to room whereby other peers in the same namespace will opencommunication channels and send events.
config
- Configuration object containing the following keys:
appId
- (required) A unique string identifying your app. If usingFirebase this should be the database ID (also see firebaseApp
below foran alternative way of configuring the Firebase strategy).
rtcConfig
- (optional) Specifies a customRTCConfiguration
for all peer connections.
trackerUrls
- (optional,
trackerRedundancy
- (optional,
trackerUrls
option will cause thisoption to be ignored as the entire list will be used.
firebaseApp
- (optional,
appId
. Normally Trysterowill initialize a Firebase app based on the appId
but this will fail ifyouʼve already initialized it for use elsewhere.
rootPath
- (optional,
'__trystero__'
bydefault). Changing this is useful if you want to run multiple apps using thesame database and don't want to worry about namespace collisions.
swarmAddresses
- (optional,
config.Addresses.Swarm
.
namespace
- A string to namespace peers and events within a room.
Returns an object with the following methods:
leave()
Remove local user from room and unsubscribe from room events.
getPeers()
Returns a list of peer IDs present in room (not including the local user).
addStream(stream, [peerId], [metadata])
Broadcasts media stream to other peers.
stream
- A MediaStream
with audio and/or video to send to peers in theroom.
peerId
- (optional) If specified, the stream is sent only to thetarget peer ID (string) or list of peer IDs (array).
metadata
- (optional) Additional metadata (any serializable type) tobe sent with the stream. This is useful when sending multiple streams sorecipients know which is which (e.g. a webcam versus a screen capture). Ifyou want to broadcast a stream to all peers in the room with a metadataargument, pass null
as the second argument.
removeStream(stream, [peerId])
Stops sending previously sent media stream to other peers.
stream
- A previously sent MediaStream
to stop sending.
peerId
- (optional) If specified, the stream is removed only from thetarget peer ID (string) or list of peer IDs (array).
addTrack(track, stream, [peerId], [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.
peerId
- (optional) If specified, the track is sent only to thetarget peer ID (string) or list of peer IDs (array).
metadata
- (optional) Additional metadata (any serializable type) tobe sent with the track. See metadata
notes for addStream()
above formore details.
removeTrack(track, stream, [peerId])
Removes a media track from a stream.
track
- The MediaStreamTrack
to remove.
stream
- The MediaStream
the track is attached to.
peerId
- (optional) If specified, the track is removed only from thetarget peer ID (string) or list of peer IDs (array).
replaceTrack(oldTrack, newTrack, stream, [peerId])
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.
peerId
- (optional) If specified, the track is replaced only for thetarget 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 thepeer's ID.Example:
onPeerJoin(id => console.log(`${id} 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 thepeer's ID.Example:
onPeerLeave(id => console.log(`${id} left`))
onPeerStream(callback)
Registers a callback function that will be called when a peer sends a mediastream. If called more than once, only the latest callback registered is evercalled.
callback(stream, peerId, metadata)
- Function to run whenever a peer sendsa media stream, called with the the peer's stream, ID, and optional metadata(see addStream()
above for details).Example:
onPeerStream((stream, id) => console.log(`got stream from ${id}`, stream))
onPeerTrack(callback)
Registers a callback function that will be called when a peer sends a mediatrack. If called more than once, only the latest callback registered is evercalled.
callback(track, stream, peerId, metadata)
- Function to run whenever apeer 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, id) => console.log(`got track from ${id}`, track))
makeAction(namespace)
Listen for and send custom data actions.
namespace
- A string to register this action consistently among all peers.Returns a pair containing a function to send the action to peers and afunction to register a listener. The sender function takes anyJSON-serializable value (primitive or object) or binary data as its firstargument and takes an optional second argument of a peer ID or a list of peerIDs to send to. By default it will broadcast the value to all peers in theroom. If the sender function is called with binary data (Blob
,TypedArray
), it will be received on the other end as an ArrayBuffer
ofagnostic bytes. The sender function returns a promise that resolves when alltarget peers are finished receiving data.
Example:
const [sendCursor, getCursor] = room.makeAction('cursormove')
window.addEventListener('mousemove', e => sendCursor([e.clientX, e.clientY]))
getCursor(([x, y], id) => {
const peerCursor = cursorMap[id]
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 theround-trip to that peer took. Use this for measuring latency.
peerId
- Peer ID string of the target peer.Example:
// log round-trip time every 2 seconds
room.onPeerJoin(id =>
setInterval(async () => console.log(`took ${await room.ping(id)}ms`), 2000)
)
selfId
A unique ID string other peers will know the local user as globally acrossrooms.
getOccupants(config, namespace)
(
config
- A configuration objectnamespace
- A namespace string that you'd pass to joinRoom()
.Example:
console.log((await trystero.getOccupants(config, 'the_scope')).length)
// => 3
Loose, (overly) simple advice for choosing a strategy: Use the BitTorrent orIPFS strategy for experiments or when your heart yearns for fullerdecentralization, use Firebase for "production" apps where you need full controland reliability. IPFS is itself in alpha so the Trystero IPFS strategy should beconsidered experimental.
Trystero makes it trivial to switch between strategies – just change a singleimport line:
import {joinRoom} from 'trystero/[torrent|firebase|ipfs]'
setup¹ | reliability² | time to connect³ | bundle size⁴ | occupancy polling⁵ | |
---|---|---|---|---|---|
|
none
|
variable | better | ~24K
|
none |
|
~5 mins | reliable
|
best
|
~173K | yes
|
|
none
|
variable | good | ~1.63M
|
none |
¹ Firebase requires an account and project which take a few minutes to setup.
² Firebase has a 99.95% SLA. The BitTorrent strategy uses public trackerswhich may go down/misbehave at their own whim. Trystero has a built-inredundancy approach that connects to multiple trackers simultaneously to avoidissues. IPFS relies on public gateways which are also prone to downtime.
³ Relative speed of peers connecting to each other when joining a room.Firebase is near-instantaneous while the other strategies are a bit slower.
⁴ Calculated via Rollup bundling + Terser compression.
⁵ The Firebase strategy supports calling getOccupants()
on a room to seewhich/how many users are currently present without joining the room.
If you want to use the Firebase strategy and don't have an existing project:
appId
in your Trysteroconfig{
"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 isknown ahead of time.
Trystero by Dan Motzenbecker