#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
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}
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}
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);
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.
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.
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:
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.
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:
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 anAuthorizationheader.
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:
{
"durable_objects": {
"bindings": [{ "name": "ALEPHA_WEBSOCKET", "class_name": "AlephaWebSocketDurableObject" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["AlephaWebSocketDurableObject"] }],
}
Deploy and test locally with:
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.