Skip to content

A tiny relay server that bridges two WebSocket connections, allowing the clients to talk directly to each other. (Formerly known as 🐟 Cevitxe Signal Server.)

Notifications You must be signed in to change notification settings

local-first-web/relay

Repository files navigation

@localfirst/relay logo

@localfirst/relay is a tiny service that helps local-first applications connect with peers on other devices. It can run in the cloud or on any device with a known address.

Deploy to: Glitch | Heroku | AWS | Google | Azure | local server

Why

Getting two end-user devices to communicate with each other over the internet is hard. Most devices don't have stable public IP addresses, and they're often behind firewalls that turn away attempts to connect from the outside. This is a connection problem.

Even within a local network, or in other situations where devices can be reached directly, devices that want to communicate need a way to find each other. This is a problem of discovery.

What

This little server offers a solution to each of these two problems.

1. Discovery

Alice can provide a documentId (or several) that she's interested in. (A documentId is a unique ID for a topic or channel β€” it could be a GUID, or just a unique string like ambitious-mongoose.)

diagram

If Bob is interested in the same documentId, each will receive an Introduction message with the other's peerId. They can then use that information to connect.

2. Connection

Alice can request to connect with Bob on a given documentId. If we get matching connection requests from Alice and Bob, we pipe their sockets together.

diagram

How

Server

From this repo, you can run this server as follows:

pnpm
pnpm start

You should see something like thsi:

> @localfirst/[email protected] start local-first-web/relay
> node dist/start.js

🐟 Listening at http://localhost:8080

You can visit that URL with a web browser to confirm that it's working; you should see something like this:

Running server from another package

From another codebase, you can import the server and run it as follows:

import { Server } from "@localfirst/relay/Server.js"

const DEFAULT_PORT = 8080
const port = Number(process.env.PORT) || DEFAULT_PORT

const server = new Server({ port })

server.listen()

Client

This library includes a lightweight client designed to be used with this server.

The client keeps track of all peers that the server connects you to, and for each peer it keeps track of each documentId (aka discoveryKey, aka channel) that you're working with that peer on.

import { Client } from "@localfirst/relay/Client.js"

client = new Client({ peerId: "alice", url: "myrelay.somedomain.com" })
  .join("ambitious-mongoose")
  .on("peer-connect", ({ documentId, peerId, socket }) => {
    // `socket` is a WebSocket

    // send a message
    socket.write("Hello! πŸŽ‰")

    // listen for messages
    socket.addEventListener("data", event => {
      const message = event.data
      console.log(`message from ${peerId} about ${documentId}`, message)
    })
  })

⚠ Security

This server makes no security guarantees. Alice and Bob should probably:

  1. Authenticate each other, to ensure that "Alice" is actually Alice and "Bob" is actually Bob.
  2. Encrypt all communications with each other.

The @localfirst/auth library can be used with this relay service. It provides peer-to-peer authentication and end-to-end encryption, and allows you to treat this relay (and the rest of the network) as untrusted.

Server API

The following documentation might be of interest to anyone working on the @localfirst/relay Client, or replacing it with a new client. You don't need to know any of this to interact with this server if you're using the included client.

This server has two WebSocket endpoints: /introduction and /connection.

In the following examples, Alice is the local peer and Bob is a remote peer. We're using alice and bob as their peerIds; in practice, typically these would be GUIDs that uniquely identify their devices.

/introduction/:localPeerId

  • :localPeerId is the local peer's unique peerId.

Alice connects to this endpoint, e.g. wss://myrelay.somedomain.com/introduction/alice.

Once a WebSocket connection has been made, Alice sends an introduction request containing one or more documentIds that she has or is interested in:

{
  type: 'Join',
  documentIds: ['ambitious-mongoose', 'frivolous-platypus'], // documents Alice has or is interested in
}

If Bob is connected to the same server and interested in one or more of the same documents IDs, the server sends Alice an introduction message:

{
  type: 'Introduction',
  peerId: 'bob', // Bob's peerId
  documentIds: ['ambitious-mongoose'] // documents we're both interested in
}

Alice can now use this information to request a connection to this peer via the connection endpoint:

/connection/:localPeerId/:remotePeerId/:documentId

Once Alice has Bob's peerId, she makes a new connection to this endpoint, e.g. wss://myrelay.somedomain.com/connection/alice/bob/ambitious-mongoose.

  • :localPeerId is the local peer's unique peerId.
  • :remotePeerId is the remote peer's unique peerId.
  • :documentId is the document ID.

If and when Bob makes a reciprocal connection by connecting to wss://myrelay.somedomain.com/connection/bob/alice/ambitious-mongoose, the server pipes their sockets together and leaves them to talk.

The client and server don't communicate with each other via the connection endpoint; it's purely a relay between two peers.

Deployment

Deploying to Glitch

You can deploy this relay to Glitch by clicking this button:

Remix on Glitch

Alternatively, you can remix the local-first-relay project.

Deploying to Heroku

This server can be deployed to Heroku. By design, it should only ever run with a single dyno. You can deploy it by clicking on this button:

Deploy

Or, you can install using the Heroku CLI as follows:

heroku create
git push heroku main
heroku open

Deploying to AWS Elastic Beanstalk

Install using the AWS CLI:

eb init
eb create
eb open

Deploying to Google Cloud

Install using the Google Cloud SDK:

gcloud projects create my-local-first-relay --set-as-default
gcloud app create
gcloud app deploy
gcloud app browse

Deploying to Azure

Install using the Azure CLI:

az group create --name my-local-first-relay --location eastus
az configure --defaults group=my-local-first-relay location=eastus
az appservice plan create --name my-local-first-relay --sku F1
az webapp create --name my-local-first-relay --plan my-local-first-relay
az webapp deployment user set --user-name PEERID --password PASSWORD
az webapp deployment source config-local-git --name my-local-first-relay
git remote add azure https://[email protected]/my-local-first-relay.git
git push azure main
az webapp browse --name my-local-first-relay

AWS Lambda, Azure Functions, Vercel, Serverless, Cloudwatch Workers, etc.

Since true serverless functions are stateless and only spun up on demand, they're not a good fit for this server, which needs to remember information about connected peers and maintain a stable websocket connection with each one.

License

MIT

Prior art

Inspired by https://github.com/orionz/discovery-cloud-server

Formerly known as 🐟 Cevitxe Signal Server. (Cevitxe is now @localfirst/state)