alepha@docs:~/docs/guides/server$
cat 9-websocket.md | pretty
6 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 { $websocket } from "alepha/websocket"; 4import { ChatChannels } from "./channels/ChatChannels.ts"; 5  6export class AppChatServer { 7  protected readonly channels = $inject(ChatChannels); 8  9  chat = $websocket({10    channel: this.channels.chatChannel,11    handler: async ({ connectionId, message, reply }) => {12      await reply({13        message: {14          username: connectionId.slice(0, 8),15          content: message.content,16          timestamp: Date.now(),17        },18      });19    },20  });21}
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    <button onClick={() => chat.send({ content: "hello" })} disabled={!chat.isConnected}>23      Send24    </button>25  );26}

#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({ type: z.const("append"), content: z.text(), username: z.text() }),12        z.object({ type: z.const("system"), message: z.text() }),13      ]),14      // Client -> server messages15      out: z.object({ content: z.text() }),16      // Optional: validate roomId shape (defaults to any string)17      roomId: z.uuid(),18    },19  });20}
Option Type Description
path string Required. The WebSocket endpoint path (e.g. /ws/chat).
description string Optional documentation.
schema.in TObject | TUnion Messages sent from server to client.
schema.out TObject | TUnion Messages sent from client to server.
schema.roomId TString Optional room ID validation (e.g. z.uuid()). Defaults to any string.

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

#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: { type: "append", username: userId ?? "anon", content: message.content }, 9        exceptSelf: true,10      });11    },12    onConnect: ({ connectionId, userId, roomIds }) => {13      console.log(`${connectionId} joined ${roomIds.join(", ")}`);14    },15    onDisconnect: ({ connectionId }) => {16      console.log(`${connectionId} left`);17    },18  });19}

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 Static<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    <button onClick={() => chat.send({ content: "hi" })} disabled={!chat.isConnected}>22      Send23    </button>24  );25}

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  secure: true,5  maxConnectionsPerUser: 3,6});

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.

#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.