Presence channels

A presence channel knows who is in it. Members carry data your server vouched for, everyone is told when someone joins or leaves, and members can send messages to each other directly.

How joining works

Any channel whose name starts with presence- is a presence channel. Joining one needs a signature made with your app secret, so the join goes through your backend:

  1. The browser connects and the server assigns it a connection id.
  2. The client asks your backend to authorise that id for the channel.
  3. Your backend decides if this user may join, picks their member data, signs it and returns a join string.
  4. The client sends the join string. The server checks the signature and lets the connection in.

Because the connection id is part of what gets signed, a join string cannot be reused by another connection. Because the member data is signed too, a user cannot change their own name or role.

On the client

app.ts
import Pikoman from "@pikoman/ws";

const pikoman = new Pikoman("your-app-id", {
  auth_url: "/api/ws-auth",
});

const room = pikoman.listenPresence("presence-lobby");

With auth_url set, the client requests GET {auth_url}/{clientId}-{channelName} and expects JSON with one field:

JSON
{ "auth": "2:presence-lobby:9f2c…e1:3:{\"name\":\"Ada\"}" }

If your endpoint needs something else, such as a POST with a CSRF token, pass your own authenticator as the second argument. It is any object with an authenticate method that returns the join string:

TypeScript
const authenticator = {
  async authenticate(clientId: string, channelName: string) {
    const response = await fetch("/api/ws-auth", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ clientId, channel: channelName }),
    });
    const { auth } = await response.json();
    return auth;
  },
};

const room = pikoman.listenPresence("presence-lobby", authenticator);

Signing on your backend

The join string has five parts separated by colons:

text
2:{channel}:{signature}:{permissions}:{data}
  • data is the member data as a JSON string. Everyone in the channel can read it.
  • permissions is a number. Use 3 for a full member. Without the bit worth 2, the connection can still receive server messages but cannot send client messages and is not given the member list.
  • signature is the hex HMAC-SHA256, keyed with your app secret, of {clientId}:{channel}:{permissions}:{data}.

In Node, the client package can build the string for you:

server.js
import express from "express";
import { OfflineAuthenticator } from "@pikoman/ws";

const app = express();

app.get("/api/ws-auth/:clientId-:channelName", async (req, res) => {
  const { clientId, channelName } = req.params;
  const user = await requireUser(req); // your own session check

  const signer = new OfflineAuthenticator(
    { name: user.name },
    process.env.PIKOMAN_APP_SECRET,
    3,
  );
  res.json({ auth: await signer.authenticate(clientId, channelName) });
});

In any other language it is one HMAC. This is what the backend of this site does, in Rust:

auth.rs
use hmac::{Hmac, Mac};
use sha2::Sha256;

fn join_string(secret: &str, client_id: u64, channel: &str, data: &str) -> String {
    let permissions = 3;
    let message = format!("{client_id}:{channel}:{permissions}:{data}");

    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
    mac.update(message.as_bytes());
    let signature = hex::encode(mac.finalize().into_bytes());

    format!("2:{channel}:{signature}:{permissions}:{data}")
}

Keep the secret on the server. OfflineAuthenticator also runs in a browser, which is handy for a local experiment, but anything shipped to a browser is public.

Who is here

TypeScript
// The full list: on join, and again whenever it changes.
room.members((members) => {
  for (const [id, data] of members) console.log(id, data.name);
});

// One callback per arrival or departure.
room.subscribe((_event, count, id, data) => console.log(`${data.name} joined, ${count} here`));
room.unsubscribe((_event, count, id) => console.log(`${id} left, ${count} here`));

room.getMembers(); // Map of connection id to member data
room.getMember(id);
room.me; // your own member data

A member is a connection, not a person: someone with two tabs open appears twice. Put a user id in the member data if you need to group them.

Messages between browsers

Members can send events to the channel without going through your backend. Handlers for these receive the sender's connection id first.

TypeScript
room.on("move", (senderId, position) => {
  movePointer(room.getMember(senderId), position);
});

room.send("move", { x: 0.31, y: 0.62 }); // to everyone else
room.send("message", { text: "hi" }, true); // to everyone, including you

The server relays client messages as they are. It does not validate the payload, so treat what arrives as untrusted input, the same as anything else a user typed.

A connection may send one client message every 20 ms. Faster ones are dropped and answered with a 9:RATE_LIMIT frame, so throttle streams such as pointer positions. The cursors demo sends at most every 50 ms.

Server messages still work

You can publish over HTTP to a presence channel like any other. Those handlers get the payload only, with no sender id, and members cannot fake them. Use different event names for client and server messages so a handler always knows which shape to expect.

Previous: quickstart Next: publishing over HTTP

We are rebuilding the dashboard

Self-service sign-up is closed while we do. Until the new dashboard is ready we set up apps by hand: write to us with a line about what you are building, and we will send you an app id and a secret.

Write to hello@pikoman.se