alepha@docs:~/docs/guides/server$
cat 9-websocket.md | pretty
10 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"; 5  6import { ChatChannels } from "./channels/ChatChannels.ts"; 7  8export class AppChatServer { 9  protected readonly channels = $inject(ChatChannels);10  protected readonly dateTime = $inject(DateTimeProvider);11 12  chat = $websocket({13    channel: this.channels.chatChannel,14    handler: async ({ connectionId, message, reply }) => {15      await reply({16        message: {17          username: connectionId.slice(0, 8),18          content: message.content,19          timestamp: this.dateTime.nowMillis(),20        },21      });22    },23  });24}
typescript
 1// main.server.ts 2import { Alepha, run } from "alepha"; 3import { AlephaWebSocket } from "alepha/websocket"; 4  5import { AppChatServer } from "./AppChatServer.ts"; 6  7const alepha = Alepha.create(); 8alepha.with(AlephaWebSocket); 9alepha.with(AppChatServer);10run(alepha);
tsx
 1// components/Chat.tsx 2import { useInject } from "alepha/react"; 3import { useRoom } from "alepha/react/websocket"; 4import { useState } from "react"; 5  6import { ChatChannels } from "../channels/ChatChannels.ts"; 7  8export function Chat() { 9  const channels = useInject(ChatChannels);10  const [messages, setMessages] = useState<any[]>([]);11  const roomId = "lobby";12 13  const chat = useRoom(14    {15      roomId,16      channel: channels.chatChannel,17      handler: (message) => setMessages((prev) => [message, ...prev]),18    },19    [roomId],20  );21 22  return (23    <button24      onClick={() => chat.send({ content: "hello" })}25      disabled={!chat.isConnected}26    >27      Send28    </button>29  );30}

#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.literal("append"),13          content: z.text(),14          username: z.text(),15        }),16        z.object({ type: z.literal("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, when a $secure in use resolved one (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.

Besides roomId, channel and handler, useRoom takes url, autoReconnect, reconnectInterval, maxReconnectAttempts and the callbacks onConnect, onDisconnect and onError.

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

Put $secure() in the endpoint's use to require authentication, exactly as on an action, 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  use: [$secure()],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.
  • An API key (with the realm's apiKeys feature on) can travel as the ?api_key= query parameter, since it can't go in an Authorization header. There is no query parameter for a JWT: a non-browser client that can set headers sends Authorization: Bearer ... on the handshake instead.

The middlewares run on the upgrade request, before the socket exists. An unauthenticated handshake is refused with 401, and a user without a role or permission $secure asks for (use: [$secure({ roles: ["moderator"] })]) with 403. 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 (or a $room's 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 frame larger than maxPayload on the websocketOptions atom (1 MiB by default) closes the connection with code 1009 before it is parsed:

typescript
1Alepha.create({ [websocketOptions.key]: { maxPayload: 4 * 1024 * 1024 } });

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 without REDIS_URL, Redis once it is set): 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 to retain accepted connections when the host is recreated. Room state and connection data bags are still volatile. 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.

Host configuration is generated. alepha build --runtime workerd collects generic host declarations from $websocket, $room and $actor. A socket-only or room-only app keeps the existing class and binding. Without an explicit legacy migration history, the generated wrangler.jsonc uses declarative SQLite exports:

jsonc
{
  "durable_objects": {
    "bindings": [
      {
        "name": "ALEPHA_WEBSOCKET",
        "class_name": "AlephaWebSocketDurableObject",
      },
    ],
  },
  "exports": {
    "AlephaWebSocketDurableObject": {
      "type": "durable-object",
      "storage": "sqlite",
    },
  },
}

The class remains AlephaWebSocketDurableObject, the binding remains ALEPHA_WEBSOCKET, and the instance identity remains channelPath + ":" + roomId. Shared actor host/runtime facilities do not persist RoomEngine.state or conn.data.

Use a Wrangler toolchain supporting declarative exports. An explicit build.cloudflare.config.migrations array keeps the legacy history path instead; DO exports and migrations cannot coexist. Conflicting bindings or a required-host tombstone reject the build. Switching a deployed Worker to exports is a one-way control-plane transition, not an application-state migration. Both Cloudflare deploy transports consume the same manifest declarations. See actor state and namespace provisioning for the configuration rules, prebuilt path and durability limits.

Build and test locally with:

bash
yarn alepha build --runtime workerd
npx wrangler dev

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

With an explicit environment configured through alepha/cli/infra, deploy through alepha deploy. alepha deploy --prebuilt uses the existing artifact.