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:
- The browser connects and the server assigns it a connection id.
- The client asks your backend to authorise that id for the channel.
- Your backend decides if this user may join, picks their member data, signs it and returns a join string.
- 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
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:
{ "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:
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:
2:{channel}:{signature}:{permissions}:{data}datais the member data as a JSON string. Everyone in the channel can read it.-
permissionsis a number. Use3for a full member. Without the bit worth2, the connection can still receive server messages but cannot send client messages and is not given the member list. -
signatureis 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:
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:
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
// 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 dataA 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.
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 youThe 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.