WebSockets

Lucent provides built-in WebSocket support via a singleton wsManager and an Elysia plugin.

Setup

Mount LucentWebsocket.plugin() in your main entry point:

// src/index.ts
import { Elysia } from "elysia";
import { lucent, LucentWebsocket } from "@codesordinatestudio/lucent";
import config from "./lucent.config";

const app = new Elysia()
  .use(await lucent(config))
  .use(LucentWebsocket.plugin({ path: "/ws" }))
  .listen(3000);

Usage in Hooks

Broadcast messages from anywhere — hooks, jobs, HTTP handlers — via the wsManager singleton:

// src/collections/Posts.ts
import { wsManager, defineCollection } from "@codesordinatestudio/lucent";

export const Posts = defineCollection({
  slug: "posts",
  fields: [
    /* ... */
  ],
  hooks: {
    afterCreate: [
      async ({ doc }) => {
        wsManager.broadcastToRoom("posts", { type: "post:created", doc });
      },
    ],
    afterUpdate: [
      async ({ doc }) => {
        wsManager.broadcastToRoom("posts", { type: "post:updated", doc });
      },
    ],
  },
});

Manager API

wsManager is a singleton available throughout your app. You can also access it via LucentWebsocket.manager.

Rooms

MethodDescription
joinRoom(id, room)Add a connection to a room
leaveRoom(id, room)Remove a connection from a room
getRoomMembers(room)Returns an array of connection IDs in the room
getConnectionRooms(id)Returns an array of room names a connection is in
listRooms()Lists all active rooms

Sending Messages

MethodDescription
sendTo(id, payload)Send a private message to a specific connection ID
broadcastToRoom(room, payload, options?)Send to everyone in a room (options.exclude?: string[])
broadcastAll(payload, options?)Send to every connected client

Metadata

MethodDescription
setMeta(id, key, value)Store arbitrary metadata on a connection
getMeta(id, key)Read metadata from a connection

Stats

wsManager.getStats();
// { connections: 42, rooms: { posts: 12, chat: 30 }, heartbeatActive: true }

Client-Side Usage

const ws = new WebSocket("ws://localhost:3000/ws");

ws.onopen = () => {
  // Join a room to receive targeted broadcasts
  ws.send(JSON.stringify({ type: "join", room: "posts" }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  switch (msg.type) {
    case "ping":
      // Acknowledge the server heartbeat — keeps the connection alive
      ws.send(JSON.stringify({ type: "pong" }));
      break;

    case "post:created":
      console.log("New post:", msg.doc);
      break;

    case "post:updated":
      console.log("Updated post:", msg.doc);
      break;
  }
};

Supported Message Types

TypePayloadDescription
join{ room: "name" }Join a room; server replies { type: "joined" }
leave{ room: "name" }Leave a room; server replies { type: "left" }
broadcast{ room: "name", payload: { ... } }Broadcast to room members (excluding sender)
pingServer replies { type: "pong" }
pongAcknowledge a server-side heartbeat ping

Heartbeat

Lucent automatically keeps connections healthy with a server-side heartbeat. Every 30 seconds (by default) the server pings all active connections. Clients that do not respond within 10 seconds are evicted from all rooms and removed from the pool.

How it works

  1. Server sends { "type": "ping" } to every connected client.
  2. Client replies with { "type": "pong" } (or the browser handles it natively via WS ping frames).
  3. If no pong is received within the timeout, the connection is silently evicted.

The heartbeat starts automatically when the first client connects. You do not need to configure anything.

Customising the heartbeat

// 60-second interval, 15-second response timeout
LucentWebsocket.plugin({
  path: "/ws",
  heartbeat: { interval: 60_000, timeout: 15_000 },
});

// Disable the heartbeat entirely (not recommended in production)
LucentWebsocket.plugin({
  path: "/ws",
  heartbeat: false,
});

Client-side pong handler

Always respond to server pings so your connections are not evicted:

const ws = new WebSocket("ws://localhost:3000/ws");

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  if (msg.type === "ping") {
    // Acknowledge the heartbeat
    ws.send(JSON.stringify({ type: "pong" }));
    return;
  }

  // Handle your application messages
};

API

MethodDescription
wsManager.startHeartbeat(ms?, ms?)Start the heartbeat manually (auto-called on connect)
wsManager.stopHeartbeat()Stop the heartbeat interval
wsManager.pong(id)Mark a connection as alive (called automatically)
wsManager.getStats()Returns { connections, rooms, heartbeatActive }

Per-Room Authorization

Use the onJoinRoom hook to control which connections may join a room. Return true to allow and false to deny. Async callbacks are fully supported.

app.use(
  LucentWebsocket.plugin({
    path: "/ws",

    /**
     * Called every time a client sends { type: "join", room: "..." }.
     * Return false to send back { type: "error", message: "Access denied to room '...'" }
     * and abort the join.
     *
     * ws.data contains the Elysia WS connection data — store your user/session
     * in WS connection metadata (wsManager.setMeta) so you can read it here.
     */
    onJoinRoom: async (room, ws) => {
      // Example: only allow authenticated users to join "private-*" rooms
      const userId = wsManager.getMeta(ws.id, "userId") as string | undefined;
      if (room.startsWith("private-") && !userId) {
        return false; // deny
      }
      return true; // allow
    },
  }),
);

Storing user identity on connect

Because the WS plugin runs outside Lucent's HTTP route auth pipeline, the simplest way to authenticate a socket connection is to reuse resolveLucentUser() against the handshake request and then store the resolved user in WS metadata:

import { Elysia } from "elysia";
import { LucentWebsocket, getLucent, resolveLucentUser, wsManager } from "@codesordinatestudio/lucent";
import config from "./lucent.config";

const lucent = await getLucent({ config });

app.ws(
  "/ws",
  LucentWebsocket.handler({
    async onJoinRoom(room, ws) {
      const user = await resolveLucentUser(ws.data.request, lucent);
      if (user) {
        wsManager.setMeta(ws.id, "userId", user.id);
        wsManager.setMeta(ws.id, "role", user.role);
      }

      if (room.startsWith("private-") && !user) {
        return false;
      }

      return true;
    },
  }),
);

This works with Lucent bearer tokens, Lucent JWT cookies, session cookies, and API keys.