Documentation

WebSocket

Rooms

Send VitNode WebSocket messages only to the browser tabs that joined a room, such as one chat thread, with wsRegistry.join and c.get("realtime").toRoom().

A room is a named group of browser tabs, such as everyone looking at one chat thread or editing one record. Send a message to a room and only its members get it, on every API instance. A room is any string your feature picks, for example chat:42. This guide adds rooms to the fictional @acme/chat plugin from WebSocket.

Before you begin

  • The /api/ws endpoint, from Set up the endpoint.
  • The chatChannel from Define a channel, which carries the chat messages to the browser.
  • A top-level API module for the server socket. Here it is rooms, built with buildModule from your plugin's @/api/builders.

Handle join and leave on the server

All tabs of a browser share one socket, so a socket joins a room on behalf of a tab. Each tab sends its own random clientId with every message, and the server joins or leaves with it. Create a server socket with buildWebSocket:

plugins/acme-chat/src/api/modules/rooms/rooms.socket.ts
import { buildWebSocket } from "@vitnode/core/api/lib/websocket"
import { wsRegistry } from "@vitnode/core/ws/registry"

export interface RoomMessage {
  clientId: string
  roomId: string
  type: "join" | "leave"
}

export const roomsSocket = buildWebSocket<RoomMessage>({
  id: "presence",
  onMessage: ({ data, ws }) => {
    const room = `chat:${data.roomId}`

    if (data.type === "join") {
      wsRegistry.join(ws, room, data.clientId)
    } else {
      wsRegistry.leave(ws, room, data.clientId)
    }
  },
})

Check who may join (the session on c, a permission) before calling join, because a room is only as private as that check. The socket stays in the room until its last tab leaves, so a closed tab never cuts off the others.

Register the socket on its module

plugins/acme-chat/src/api/modules/rooms/rooms.module.ts
import { buildModule } from "@/api/builders"

import { roomsSocket } from "./rooms.socket"

export const roomsModule = buildModule({
  name: "rooms",
  webSockets: [roomsSocket],
})

Pass roomsModule to buildApiPlugin({ modules }). Only top-level modules are read. The socket's public id is {pluginId}_{module}_{id}, here @acme/chat_rooms_presence.

Share the channel with the browser

Next to chatChannel, add a channel with the same plugin, module and id as the socket. Its first type parameter is what the browser sends:

plugins/acme-chat/src/ws/chat.channel.ts
import { createWebSocketChannel } from "@vitnode/core/ws/types"

import type { RoomMessage } from "../api/modules/rooms/rooms.socket"

export const roomsChannel = createWebSocketChannel<RoomMessage>({
  pluginId: "@acme/chat",
  module: "rooms",
  id: "presence",
})

A type-only import keeps the server file out of the browser bundle.

Join from the tab, again after every reconnect

A new connection starts with no rooms on the server. useVitNodeWebSocket returns a connectionId that changes every time the shared connection opens, in every tab, so join from an effect that depends on it:

plugins/acme-chat/src/views/chat-room.tsx
import { useVitNodeWebSocket } from "@vitnode/core/ws/use-websocket"
import { useEffect, useState } from "react"

import { chatChannel, roomsChannel } from "../ws/chat.channel"

export const ChatRoom = ({ roomId }: { roomId: string }) => {
  const [clientId] = useState(() => crypto.randomUUID())
  const [messages, setMessages] = useState<string[]>([])
  const { connectionId, send } = useVitNodeWebSocket(roomsChannel)

  useVitNodeWebSocket(chatChannel, {
    onMessage: payload => {
      if (payload.roomId === roomId) {
        setMessages(prev => [...prev, payload.message])
      }
    },
  })

  useEffect(() => {
    if (!connectionId) return
    send({ clientId, roomId, type: "join" })

    return () => {
      send({ clientId, roomId, type: "leave" })
    }
  }, [clientId, connectionId, roomId, send])

  return (
    <ul>
      {messages.map((message, i) => (
        <li key={i}>{message}</li>
      ))}
    </ul>
  )
}

Reconnects and missed messages covers reloading what the tab missed while it was away.

Send to the room

From any API handler, for example the route that saves a chat message:

c.get("realtime").toRoom(`chat:${roomId}`, chatChannel, {
  roomId,
  message: "Hello!",
  senderId: user.id,
})

Each socket in the room receives the payload once, however many of its tabs joined. Put the room or record in the payload so every tab can ignore what is not its own.

Check the result

Open the chat room page for room 42 in two browsers, and room 7 in a third. Send a message to room 42. The first two show it right away, and the third shows nothing. Close one tab of a browser that has two tabs on room 42, send again, and its other tab still gets the message.

Clean up when a socket closes

A socket that closes leaves all of its rooms. Use wsRegistry.onConnectionClose to update your own state, such as an "online now" list. Register the listener once, at module load:

plugins/acme-chat/src/api/modules/rooms/presence.ts
import { wsRegistry } from "@vitnode/core/ws/registry"

const online = new Map<string, Set<string>>()

wsRegistry.onConnectionClose((ws, left) => {
  for (const { memberId, room } of left) {
    online.get(room)?.delete(memberId)
  }
})

left holds every { memberId, room } the socket was in.

Inspect rooms

MethodReturnsBehavior
wsRegistry.join(ws, room, memberId)booleanAdds the member on this socket; true when it was not there yet
wsRegistry.leave(ws, room, memberId)booleanRemoves the member; true when it was there
wsRegistry.membersOf(room){ memberId, ws }[]The room's members on this instance
wsRegistry.roomsOf(ws)string[]The rooms of one socket
wsRegistry.onConnectionClose(listener)unsubscribe functionCalls listener(ws, left) when a socket closes

All wsRegistry methods act on the current instance only.

Rooms on several instances

With REDIS_URL set, toRoom also publishes the message over Redis, and every other instance delivers it to its own members. Room membership itself stays local to each instance.

When an instance keeps per-room state in memory, it can follow the other instances with helpers from @vitnode/core/ws/registry:

import {
  onInstanceMessage,
  onRemoteRoomMessage,
  publishToInstances,
} from "@vitnode/core/ws/registry"
  • onRemoteRoomMessage((room, id, data) => ...) sees room messages from other instances before they reach local sockets. A collaborative document uses it to apply the same edit to its own copy.
  • publishToInstances(topic, data) and onInstanceMessage(({ topic, data, origin }) => ...) exchange data between instances that never reaches a socket, such as each instance's list of members. Republish it periodically and let entries from a silent instance expire, so a crashed instance leaves no ghosts.

Both listeners return a function that removes them. Without Redis, all of this works on a single instance with no extra setup. toRoom delivers locally, and instance messages go nowhere.