The Principal Dev – Masterclass for Tech Leads

The Principal Dev – Masterclass for Tech Leads28-29 May

Join

✨🀝✨ Trystero

Build instant multiplayer web apps, no server required

πŸ‘‰ Try it live on trystero.dev πŸ‘ˆ

Trystero makes browsers discover each other and communicate directly. No accounts. No deploying infrastructure. Just import and connect.

Peers can connect via 🌊 BitTorrent, 🐦 Nostr, πŸ“‘ MQTT, ⚑️ Supabase, πŸ”₯Firebase, or πŸͺ IPFS – all using the same API.

Besides making peer matching automatic, Trystero offers some nice abstractions on top of WebRTC:

You can see what people are building with Trystero here.


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, Supabase, 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

Install Trystero with your preferred package manager, then import it in your code:

npm i trystero
import {joinRoom} from 'trystero'

No package manager? You can also use a CDN:

<script type="module">
  import {joinRoom} from 'https://esm.run/trystero'
</script>

The default Trystero package runs on the Nostr network, but you can swap in any other stategy by changing which package you import:

import {joinRoom} from '@trystero-p2p/mqtt'
// or
import {joinRoom} from '@trystero-p2p/torrent'
// or
import {joinRoom} from '@trystero-p2p/supabase'
// or
import {joinRoom} from '@trystero-p2p/firebase'
// or
import {joinRoom} from '@trystero-p2p/ipfs'

Next, join the user to a room with an ID:

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ΒΉ. The second argument is the room ID.

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).

ΒΉ When using Firebase, appId should be your databaseURL and when using Supabase, it should be your project URL.

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()

You can access the local user's peer ID by importing selfId like so:

import {selfId} from 'trystero'

console.log(`my peer ID is ${selfId}`)

Broadcast events

Send peers your video stream:

const stream = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: true
})
room.addStream(stream)

Send and subscribe to custom peer-to-peer 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, peerId) =>
  console.log(
    `got a ${data.drink} with${data.withIce ? '' : 'out'} ice from ${peerId}`
  )
)

If you're using TypeScript, you can add a type hint to the action:

type CursorMove = {x: number; y: number}

const [sendCursor, getCursor] = room.makeAction<CursorMove>('cursor-move')

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, 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')

// tell new peers your name when they connect
room.onPeerJoin(peerId => sendName('Oedipa', peerId))

// listen for peers naming themselves
getName((name, peerId) => (idsToNames[peerId] = name))

// tell all peers at once when your name changes
nameInput.addEventListener('change', e => sendName(e.target.value))

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:

// this object can store audio instances for later
const peerAudios = {}

// get a local audio stream from the microphone
const selfStream = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: false
})

// send stream to peers currently in the room
room.addStream(selfStream)

// send stream to peers who join later
room.onPeerJoin(peerId => room.addStream(selfStream, peerId))

// handle streams from other peers
room.onPeerStream((stream, peerId) => {
  // create an audio instance and set the incoming stream
  const audio = new Audio()
  audio.srcObject = stream
  audio.autoplay = true

  // add the audio to peerAudios object if you want to address it for something
  // later (volume, etc.)
  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 this peer hasn't sent a stream before, create a video element
  if (!video) {
    video = document.createElement('video')
    video.autoplay = true

    // add video element to the DOM
    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] = room.makeAction('file')

getFile((data, peerId, metadata) =>
  console.log(
    `got a file (${metadata.name}) from ${peerId} with type ${metadata.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 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 transfers. The callback is called with a percentage value between 0 and 1 and the receiving peer's ID:

sendFile(
  payload,
  // notice the peer target argument for any action sender can be a single peer
  // ID, an array of IDs, or null (meaning send to all peers in the room)
  [peerIdA, peerIdB, peerIdC],
  // metadata, which can also be null if you're only interested in the
  // progress handler
  {filename: 'paranoids.flac'},
  // assuming each peer has a loading bar added to the DOM, its value is
  // updated here
  (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. By default the SDP is encrypted using a key derived from your app ID and room ID to prevent plaintext session data from appearing in logs. This is fine for most use cases, however a relay strategy operator can reverse engineer the key using the room and app IDs. A more secure option is to pass a password parameter in the app configuration object which will be used to derive the encryption key:

joinRoom({appId: 'kinneret', password: 'MuchoMaa$'}, 'w_a_s_t_e__v_i_p')

This is a shared secret that must be known ahead of time and the password must 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({})

  // whenever new peers join the room, send my color to them:
  room.onPeerJoin(peer => sendColor(myColor, peer))

  // listen for peers sending their colors and update the state accordingly:
  getColor((color, peer) =>
    setPeerColors(peerColors => ({...peerColors, [peer]: color}))
  )

  const updateColor = e => {
    const {value} = e.target

    // when updating my own color, broadcast it to all peers:
    sendColor(value)
    setMyColor(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))
  const lastRoomIdRef = useRef(roomId)

  useEffect(() => {
    if (roomId !== lastRoomIdRef.current) {
      roomRef.current.leave()
      roomRef.current = joinRoom(roomConfig, roomId)
      lastRoomIdRef.current = roomId
    }

    return () => roomRef.current.leave()
  }, [roomConfig, roomId])

  return roomRef.current
}

Troubleshooting connection issues

WebRTC is powerful but some networks simply don't allow direct P2P connections using it. If you find that certain user pairings aren't working in Trystero, you're likely encountering an issue at the network provider level. To solve this you can configure a TURN server which will act as a proxy layer for peers that aren't able to connect directly to one another.

  1. If you can, confirm that the issue is specific to particular network conditions (e.g. user with ISP X cannot connect to a user with ISP Y). If other user pairings are working (like those between two browsers on the same machine), this likely confirms that Trystero is working correctly.
  2. Sign up for a TURN service or host your own. There are various hosted TURN services you can find online like Cloudflare (which offers a free tier with 1,000 GB traffic per month) or Open Relay. You can also host an open source TURN server like coturn, Pion TURN, Violet, or eturnal. Keep in mind data will only go through the TURN server for peers that can't directly connect and will still be end-to-end encrypted.
  3. Once you have a TURN server, configure Trystero with it like this:
    const room = joinRoom(
      {
        // ...your app config
        turnConfig: [
          {
            // single string or list of strings of URLs to access TURN server
            urls: ['turn:your-turn-server.ok:1979'],
            username: 'username',
            credential: 'password'
          }
        ]
      },
      'roomId'
    )
    

Running server-side (Node, Bun)

Trystero works outside browsers too, like in Node or Bun. Why would you want to run something that helps you avoid servers on a server? One reason is if you want an always-on peer which can be useful for remembering the last state of data, broadcasting it to new users. Another reason might be to run peers that are lighter weight and don't need a full browser running, like an embedded device or Raspberry Pi.

Running server-side uses the same syntax as in the browser, but you need to import a polyfill for WebRTC support:

import {joinRoom} from 'trystero'
import {RTCPeerConnection} from 'werift'

const room = joinRoom(
  {appId: 'your-app-id', rtcPolyfill: RTCPeerConnection},
  'your-room-name'
)

Write your own strategy

If you want to provide your own signaling backend, you can build a custom strategy with createStrategy.

The example below assumes a WebSocket relay that does simple pub/sub routing:

import {createStrategy, selfId, toJson} from '@trystero-p2p/core'

export const joinRoom = createStrategy({
  // Define init as a function that returns a promise of your signaling client.
  // Resolve the promise when your client is ready to send messages.
  // You can also return an array of client promises for redundancy.
  // In this case, the client is a single WebSocket.
  init: config =>
    new Promise((resolve, reject) => {
      const ws = new WebSocket(config.relayUrl)
      ws.addEventListener('open', () => resolve(ws), {once: true})
      ws.addEventListener('error', reject, {once: true})
    }),

  // Subscribe takes 5 arguments:
  // 1. One of the relay clients you defined in init.
  // 2. rootTopic is the channel where peers in a room announce their presence.
  // 3. selfTopic is the current peer's own channel where messages specific to
  //    it should flow (i.e. offers and answers from external peers).
  // 4. onMessage is a function that should be called when your relay client
  // gets a message. Call it with the topic, data payload, and a callback
  // function that takes another topic and payload.
  // 5. getOffers is an optional helper for precomputed offers (only needed if
  // the strategy needs to send offers in bulk, like torrents).
  // It should return a cleanup function that unsubscribes
  subscribe: (client, rootTopic, selfTopic, onMessage, _getOffers) => {
    const topics = [rootTopic, selfTopic]
    const onWsMessage = event => {
      const {topic, payload} = JSON.parse(String(event.data))

      if (!topics.includes(topic)) {
        return
      }

      onMessage(topic, payload, (peerTopic, signal) =>
        client.send(
          toJson({type: 'publish', topic: peerTopic, payload: signal})
        )
      )
    }

    client.addEventListener('message', onWsMessage)
    topics.forEach(topic => client.send(toJson({type: 'subscribe', topic})))

    return () => {
      topics.forEach(topic => client.send(toJson({type: 'unsubscribe', topic})))
      client.removeEventListener('message', onWsMessage)
    }
  },

  // announce takes a relay client and a rootTopic
  announce: (client, rootTopic) =>
    client.send(
      toJson({
        type: 'publish',
        topic: rootTopic,
        payload: toJson({peerId: selfId})
      })
    )
})

const room = joinRoom(
  {appId: 'my-app-id', relayUrl: 'wss://my-relay.example'},
  'my-room-id'
)

Supabase setup

To use the Supabase strategy:

  1. Create a Supabase project or use an existing one
  2. On the dashboard, go to Project Settings -> API
  3. Copy the Project URL and set that as the appId in the Trystero config, copy the anon public API key and set it as supabaseKey in the Trystero config

Firebase setup

If you want to use the Firebase strategy and don't have an existing project:

  1. Create a Firebase project
  2. Create a new Realtime Database
  3. 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, roomId, [callbacks])

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:

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:

import {getRelaySockets} from '@trystero-p2p/torrent'

console.log(getRelaySockets())
// => Object {
//  "wss://tracker.webtorrent.dev": WebSocket,
//  "wss://tracker.openwebtorrent.com": WebSocket
//  }

pauseRelayReconnection()

(🐦 Nostr, 🌊 BitTorrent only) Normally Trystero will try to automatically reconnect to relay sockets unless manualRelayReconnection: true is set in the room config. Calling this function stops relay reconnection attempts until resumeRelayReconnection() is called.

resumeRelayReconnection()

(🐦 Nostr, 🌊 BitTorrent only) Allows relay reconnection attempts to resume. (See pauseRelayReconnection() above).

Which strategy should I choose?

By default Trystero uses the Nostr network which is highly decentralized with hundreds of active relays running. This is a good choice if you're interested in decentralization and high redundancy. The other decentralized strategies are recommended in the order of MQTT, BitTorrent, and IPFS, based on robustness. These networks have far less relay redundancy than Nostr, but you might prefer them for other reasons. You can of course host your own relay server for any of these strategies.

For a middleground between using public relays and self-hosting, the built-in Supabase and Firebase strategies are a good option.


Trystero by Dan Motzenbecker

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.