alepha@docs:~/docs/framework/guides/server$
cat 9-websocket.md | pretty
8 min read
Last commit:

#WebSocket

Alepha provides real-time, bidirectional messaging through the $channel and $websocket primitives. You define a typed message schema once, write a single handler, and the same application code runs unchanged on a long-lived Node process (a VPS, backed by ws) or on Cloudflare Workers (backed by Durable Objects). The browser client - useRoom - is identical on both.

Need in-memory state and a server-side tick loop (a game world, a live simulation) rather than a stateless per-message handler? See Stateful Rooms ($room).

#Quick Start

typescript
 1// channels/ChatChannels.ts 2import { z } from "alepha"; 3import { $channel } from "alepha/websocket"; 4  5export const chatMessageSchema = z.object({ 6  username: z.text(), 7  content: z.text(), 8  timestamp: z.integer(), 9});10 11export class ChatChannels {12  chatChannel = $channel({13    path: "/ws/chat",14    description: "Simple chat channel",15    schema: {16      in: chatMessageSchema, // server -> client17      out: z.object({ content: z.text() }), // client -> server18    },19  });20}
typescript
 1// AppChatServer.ts 2import { $inject } from "alepha"; 3import { DateTimeProvider } from "alepha/datetime"; 4import { $websocket } from "alepha/websocket"; 5import { ChatChannels } from "./channels/ChatChannels.ts"; 6  7export class AppChatServer { 8  protected readonly channels = $inject(ChatChannels); 9  protected readonly dateTime = $inject(DateTimeProvider);10 11  chat = $websocket({12    channel: this.channels.chatChannel,13    handler: async ({ connectionId, message, reply }) => {14      await reply({15        message: {16          username: connectionId.slice(0, 8),17          content: message.content,18          timestamp: this.dateTime.nowMillis(),19        },20      });21    },22  });23}
typescript
1// main.server.ts2import { Alepha, run } from "alepha";3import { AlephaWebSocket } from "alepha/websocket";4import { AppChatServer } from "./AppChatServer.ts";5 6const alepha = Alepha.create();7alepha.with(AlephaWebSocket);8alepha.with(AppChatServer);9run(alepha);
tsx
 1// components/Chat.tsx 2import { useInject } from "alepha/react"; 3import { useRoom } from "alepha/react/websocket"; 4import { useState } from "react"; 5import { ChatChannels } from "../channels/ChatChannels.ts"; 6  7export function Chat() { 8  const channels = useInject(ChatChannels); 9  const [messages, setMessages] = useState<any[]>([]);10  const roomId = "lobby";11 12  const chat = useRoom(13    {14      roomId,15      channel: channels.chatChannel,16      handler: (message) => setMessages((prev) => [message, ...prev]),17    },18    [roomId],19  );20 21  return (22    <button23      onClick={() => chat.send({ content: "hello" })}24      disabled={!chat.isConnected}25    >26      Send27    </button>28  );29}

#Defining a Channel

$channel declares the "vocabulary" for a WebSocket endpoint - its path and the message shapes flowing in both directions. Channels are just schema definitions; they must be defined as a class property so Alepha can register them.

typescript
 1import { z } from "alepha"; 2import { $channel } from "alepha/websocket"; 3  4class ChatChannels { 5  chatChannel = $channel({ 6    path: "/ws/chat", 7    description: "Real-time chat channel", 8    schema: { 9      // Server -> client messages10      in: z.union([11        z.object({12          type: z.const("append"),13          content: z.text(),14          username: z.text(),15        }),16        z.object({ type: z.const("system"), message: z.text() }),17      ]),18      // Client -> server messages19      out: z.object({ content: z.text() }),20      // Optional: validate roomId shape (defaults to any string)21      roomId: z.uuid(),22    },23  });24}
Option Type Description
path string Required. The WebSocket endpoint path (e.g. /ws/chat).
description string Optional documentation.
schema.in ZObject | ZodUnion Messages sent from server to client.
schema.out ZObject | ZodUnion Messages sent from client to server.
schema.roomId ZodString Optional room ID validation (e.g. z.uuid()). Defaults to any string.

Schemas use the z builder - the same one used by $action.

schema.roomId is enforced at the handshake on both engines: a join naming a room the schema rejects is closed with code 1008 and the reason Invalid room id, and every id of a multi-room join is checked, not just the first. A client that names no room at all joins default, which is never validated - it is the framework's fallback, not a choice, so declaring z.uuid() does not refuse connections that simply omit the parameter.

#Server Handler

$websocket turns a channel into a live server endpoint: it accepts connections, validates inbound messages against schema.out, and calls your handler.

typescript
 1import { $websocket } from "alepha/websocket"; 2  3class ChatController { 4  chat = $websocket({ 5    channel: this.channels.chatChannel, 6    handler: async ({ connectionId, userId, roomId, message, reply }) => { 7      await reply({ 8        message: { 9          type: "append",10          username: userId ?? "anon",11          content: message.content,12        },13        exceptSelf: true,14      });15    },16    onConnect: ({ connectionId, userId, roomIds }) => {17      console.log(`${connectionId} joined ${roomIds.join(", ")}`);18    },19    onDisconnect: ({ connectionId }) => {20      console.log(`${connectionId} left`);21    },22  });23}

The handler context:

Field Description
connectionId Unique ID for this connection.
userId Authenticated user ID, if secure: true and a user resolved (see Authentication).
roomId Room the incoming message was sent from.
message The parsed, schema-validated client message.
reply(options) Send a message back to the room, scoped to this connection's context.

reply() options:

Option Type Description
message Infer<TClient> Required. The message to send.
roomId string Target room. Defaults to the sender's room.
exceptSelf boolean Exclude the sender's own connection.
exceptConnectionIds string[] Exclude specific connections.
exceptUserIds string[] Exclude specific users. Requires alepha/security. Not honored on the Cloudflare provider - see below.

#Server-Initiated Messages

Beyond replying to an incoming message, a $websocket instance exposes emit() to push messages from anywhere in your app - a cron job, an $action, a database hook:

typescript
 1class NotificationService { 2  protected readonly chat = $inject(ChatController).chat; 3  4  async broadcastAnnouncement(roomId: string, text: string) { 5    await this.chat.emit({ 6      roomId, 7      message: { type: "system", message: text }, 8    }); 9  }10}

emit() accepts:

Option Description
message Required.
roomId / roomIds Target one or more rooms.
userId / userIds Target a user's connections (Node only - see below).
connectionId / connectionIds Target specific connections (Node only - see below).
exceptConnectionIds / exceptUserIds Exclusions.

#Client

useRoom is the React hook for connecting to a room. Multiple useRoom calls on the same channel share a single underlying WebSocket connection.

tsx
 1import { useInject } from "alepha/react"; 2import { useRoom } from "alepha/react/websocket"; 3  4function Chat() { 5  const channels = useInject(ChatChannels); 6  7  const chat = useRoom( 8    { 9      roomId: "lobby",10      channel: channels.chatChannel,11      handler: (message) => {12        if (message.type === "append") {13          // ... append to state14        }15      },16    },17    ["lobby"], // deps - reconnects when these change18  );19 20  return (21    <button22      onClick={() => chat.send({ content: "hi" })}23      disabled={!chat.isConnected}24    >25      Send26    </button>27  );28}

useRoom returns { send, isConnected, isConnecting, isError, error, reconnect, disconnect }. On the server (SSR), it no-ops safely.

The connection URL is auto-detected from window.location by default. Override it with the url option, or via environment variables:

Env Var Default Description
WEBSOCKET_URL "" (auto-detect) WebSocket server URL, e.g. ws://localhost:3001.
WEBSOCKET_RECONNECT_INTERVAL 3000 Milliseconds between reconnect attempts.
WEBSOCKET_MAX_RECONNECT_ATTEMPTS 10 Set to -1 for infinite retries.

#Authentication

Set secure: true on $websocket to require authentication, and maxConnectionsPerUser to cap how many concurrent connections a user may hold:

typescript
1chat = $websocket({2  channel: this.channels.chatChannel,3  handler: async ({ userId, message, reply }) => {4    /* ... */5  },6  secure: true,7  maxConnectionsPerUser: 3,8});

Identity is resolved from the WebSocket handshake through alepha/security's usual resolver chain, fed with the handshake's URL and headers (including cookie). Browsers cannot set custom headers on a WebSocket handshake, so in practice this means:

  • A session cookie is sent automatically by the browser and works out of the box.
  • Any other credential (e.g. a bearer token) must travel as a query parameter - ?token= or ?api_key= - since it can't go in an Authorization header.

An unauthenticated connection to a secure: true endpoint is rejected before the upgrade completes. This works identically on both the Node and Cloudflare providers.

What maxConnectionsPerUser counts differs by engine. On Node the server holds every connection, so the cap is per endpoint: three connections total, whichever rooms they joined. On Cloudflare a Durable Object IS one room and only knows its own sockets, so the cap is per room: the same user may hold three in each room they join. Counting across rooms would put a second coordinator object on the path of every upgrade, which is a real cost for a limit that exists to stop one user opening tabs without end. Both engines refuse the same way, closing the socket with code 1008 and the reason Max connections per user exceeded, so a client cannot tell them apart. An unauthenticated connection is never capped on either, since there is no identity to count against.

#Authorizing a machine, not a user

secure answers "is a person signed in". Some endpoints are opened by a machine holding a secret rather than by a person with a session: a build agent, a fleet of servers reporting in. Give those an authorize hook instead:

typescript
 1estates = $websocket({ 2  channel: this.channels.estates, 3  handler: async ({ roomId, message }) => { 4    /* ... */ 5  }, 6  authorize: async ({ headers }) => { 7    const estate = await this.estates.verify(headers.authorization); 8    return estate ? { roomId: estate.id } : undefined; 9  },10});

Three things follow from returning { roomId } rather than a user:

  • The hook runs before the socket is accepted, on Node and on Cloudflare alike. Returning undefined refuses the handshake with a 401 and no socket ever exists. A hook that throws answers 503 instead: an outage in the credential store is not a revocation, and a client should keep retrying rather than give up.
  • The room it names replaces the one the URL asked for. A client's ?roomId= is ignored on an endpoint with authorize, so a caller cannot enter a room its credential does not own, and a room-targeted emit() reaches only the sockets the credential put there.
  • The connection has no userId unless the hook returns one. It never resolves through alepha/security, so the secret is not a session and grants nothing on any HTTP endpoint. emit({ userId }) cannot reach it; emit({ roomId }) can.

$room takes the same hook, and the room it returns is the one the socket joins. Declaring authorize together with secure on one endpoint is refused at registration: an endpoint has one way in.

#Node / VPS

On Node, alepha/websocket runs on top of the ws package, attached to the same HTTP server as the rest of your app. A connection can join multiple rooms at once (e.g. ?roomIds=room-1,room-2) - useRoom/WebSocketClient sends all active room subscriptions as query params when it connects.

For horizontal scaling across multiple Node instances, server-initiated messages (reply(), emit()) are distributed via alepha/topic (in-memory locally, Redis in production via alepha/topic/redis): one instance publishes, every instance receives, and each forwards to its own local connections that match.

Because Cloudflare allows only one room per connection (see below), joining multiple rooms on Node is not portable. The Node provider logs a dev-only warning when a connection joins more than one room, so you notice before deploying to Cloudflare.

#Cloudflare (Durable Objects)

On Cloudflare, alepha/websocket is backed by one Durable Object per channelPath:roomId, using the WebSocket Hibernation API so idle rooms cost nothing and survive isolate eviction. Your $websocket handler runs inside that Durable Object, so reply() is a local fan-out over the DO's own sockets - there is no cross-isolate hop, and no Redis or alepha/topic bus is needed. The Durable Object is the topic bus.

This gives the same channel/handler code as Node, with a few v1 limitations worth knowing:

emit is room-scoped only. emit({ roomId }) and emit({ roomIds }) work - each resolves to a Durable Object stub and calls its broadcast RPC. Targeting a userId/connectionId, or a channel-wide broadcast (no target at all), throws an AlephaError instead of silently doing nothing.

One room per connection. A client socket is accepted by exactly one Durable Object, so a connection belongs to exactly one room - there is no equivalent of Node's multi-room connections. This is the portable contract: code that only ever joins a single room per connection behaves identically on both providers. If you rely on Node's multi-room support, watch for its dev-mode warning before deploying to Cloudflare.

exceptUserIds is not honored on Cloudflare. reply()'s and emit()'s exceptConnectionIds work as expected; exceptUserIds is silently ignored by the Cloudflare provider (it only tracks connections, not the user index Node maintains). Use exceptConnectionIds if you need to exclude specific clients.

Deployment is automatic. alepha build -t cloudflare detects $websocket usage and generates the Durable Object binding and its SQLite migration into wrangler.jsonc - no manual wrangler configuration needed:

jsonc
{
  "durable_objects": {
    "bindings": [
      {
        "name": "ALEPHA_WEBSOCKET",
        "class_name": "AlephaWebSocketDurableObject",
      },
    ],
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["AlephaWebSocketDurableObject"] },
  ],
}

Deploy and test locally with:

bash
yarn alepha build -t cloudflare
npx wrangler dev

Open two browser tabs on the same room to see messages broadcast between them; a different room stays isolated.