Skip to content
Built with

WebSockets ​

Build & Deploy

Add real-time, two-way connections with typed WebSocket routes. Create a .ws.ts route to send typed messages between your server and connected clients. Each route instance gets its own Durable Object for shared state, and WebSocket routes need the Cloudflare target.

On this pageDefine a room or socket route · Connect from the browser · Send typed messages

SDK apps only

Typed WebSocket routes currently work in native SDK apps. They aren't available in meta-framework mode yet.

For example, /rooms/[id] gives each chat room its own instance. Use it for chat, presence, collaborative documents, or notifications.

Route files ​

This step creates a WebSocket route. Add a file with the .ws.ts suffix in routes/.

text
routes/
  chat/[room].ws.ts
  notifications.ws.ts

Filename rules match regular server routes:

  • index.ws.ts becomes the parent path
  • [id].ws.ts becomes :id
  • [...slug].ws.ts becomes a catch-all
  • route groups like (marketing)/chat.ws.ts are ignored in the URL
  • chat.dev.ws.ts / chat.prod.ws.ts restrict the route to one environment

Put the environment suffix before .ws. chat.ws.dev.ts is not recognized.

defineRoom() ​

Use defineRoom() when clients should share a room or document identified by the route.

ts
// routes/chat/[room].ws.ts
import * as v from 'valibot';
import { defineRoom } from 'void/ws';

const ClientMessage = v.variant('type', [
  v.object({ type: v.literal('chat.message'), text: v.string() }),
]);

const ServerMessage = v.variant('type', [
  v.object({
    type: v.literal('chat.message'),
    id: v.string(),
    text: v.string(),
    userId: v.string(),
  }),
  v.object({ type: v.literal('chat.joined'), userId: v.string() }),
]);

export default defineRoom({
  messages: {
    client: ClientMessage,
    server: ServerMessage,
  },
  onBeforeConnect(ctx) {
    if (!ctx.user) {
      return new Response('Unauthorized', { status: 401 });
    }
  },
  async onConnect(ctx) {
    await ctx.room.broadcast({ type: 'chat.joined', userId: ctx.user!.id }, [ctx.connection.id]);
  },
  async onMessage(ctx, event) {
    await ctx.room.broadcast({
      type: 'chat.message',
      id: crypto.randomUUID(),
      text: event.text,
      userId: ctx.user!.id,
    });
  },
});

defineRoom() adds room helpers to the hook context:

  • ctx.room.broadcast(event, excludeIds?)
  • ctx.room.getConnections()
  • ctx.room.getConnection(id)
  • ctx.connection.send(event)
  • ctx.connection.close(code?, reason?)
  • ctx.connection.setState(data)

defineWebSocket() ​

Use defineWebSocket() when each connection stands alone, with no shared room.

ts
// routes/notifications.ws.ts
import * as v from 'valibot';
import { defineWebSocket } from 'void/ws';

export default defineWebSocket({
  messages: {
    client: v.object({ type: v.literal('notifications.ack'), id: v.string() }),
    server: v.object({ type: v.literal('notifications.item'), title: v.string() }),
  },
  onBeforeConnect(ctx) {
    if (!ctx.user) {
      return new Response('Unauthorized', { status: 401 });
    }
  },
  async onConnect(ctx) {
    await ctx.socket.send({ type: 'notifications.item', title: 'Connected' });
  },
});

Client ​

This step connects from the browser with connect() from void/ws.

ts
import { connect } from 'void/ws';

const socket = connect('/chat/:room', {
  params: { room: 'general' },
});

socket.on('message', (event) => {
  if (event.type === 'chat.message') {
    console.log(event.text);
  }
});

socket.send({ type: 'chat.message', text: 'hello' });

connect() resolves relative URLs against the current origin and picks ws: or wss:. It buffers messages until the socket opens. It reconnects by default.

Typed messages ​

This step defines schemas for client and server messages.

  • messages.client validates what the browser may send
  • messages.server validates what the server may send
  • onMessage() receives the parsed, validated client event
  • ctx.room.broadcast(), ctx.connection.send(), and ctx.socket.send() are typed from messages.server
  • connect() infers route params, outgoing client messages, and incoming server messages from generated route types

Messages are JSON events. Raw string and binary messages are not supported.

Authentication ​

When the SDK auth is enabled, every WebSocket hook receives the current user as ctx.user, or null for an anonymous connection. Use onBeforeConnect to reject unauthenticated clients.

Connections from another origin are anonymous unless that origin is in your auth trustedOrigins. If you read your own cookies from ctx.request in onBeforeConnect, check its Origin header yourself.

ctx.user is set when the socket connects. It does not change, even if the user signs out or the session is revoked. For actions that must respect a ban or role change, re-check your records in onMessage. Then close the connection with ctx.connection.close() in rooms, or ctx.socket.close() in defineWebSocket().

Hooks ​

Both defineRoom() and defineWebSocket() support:

  • onBeforeConnect(ctx): return a Response to reject the upgrade
  • onConnect(ctx): runs after the socket is accepted
  • onMessage(ctx, event): receives the validated client event
  • onClose(ctx, details): receives { code, reason, wasClean }
  • onRequest(ctx): handles ordinary HTTP requests to the same path

The SDK completes the close handshake for you. Use onClose for app cleanup only. Do not close the socket again there.

Every hook receives a context with:

  • ctx.id: deterministic route instance id
  • ctx.params: matched route params
  • ctx.user: resolved auth user or null
  • ctx.request: the HTTP request in onBeforeConnect, onConnect, and onRequest. In onMessage and onClose, it is a placeholder without headers, cookies, or query. To keep request data, save it in onConnect with ctx.connection.setState() in rooms or ctx.socket.setState() in defineWebSocket(). Read it later from ctx.connection.state or ctx.socket.state.
  • ctx.env
  • ctx.storage

If a route does not define onRequest(), non-WebSocket requests return 426 Upgrade Required.

Move a route while keeping its state ​

This step finds the route's current name before a move. Run void info. For routes/chat/[room].ws.ts, the output includes:

text
To preserve this resource when moving its code, add this to 'defineRoom':
  name: "chat-room",

This step pins that name in your existing definition.

ts
export default defineRoom({
  name: 'chat-room',
  messages: { client: ClientMessage, server: ServerMessage },
  // Keep your existing hooks.
});

Move the file to routes/rooms/[room].ws.ts, update clients to connect to /rooms/:room, and deploy normally. Keep the same parameter names and values. Room general keeps its storage. The Worker class, binding, and migration history stay the same on both Cloudflare and SDK platform deployments.

Both defineRoom() and defineWebSocket() accept optional name. Use a static string, inline or in a local const. Each route needs a distinct Worker class. A new name selects a different resource. If you already moved a route, void info also shows unmatched identities recorded in void.lock.json. Identify the original resource before you adopt a suggested name. Otherwise, restore the original source and find the name there.

Rename a route parameter ​

By default, the instance key includes parameter names. /chat/:room with room: 'general' uses room=general. A route without parameters uses default. Multiple parameters are joined in route order, for example team=acme&room=general. If a multi-parameter route has a value containing &, its key uses a versioned encoding to keep rooms separate.

This step keeps the old key when you also rename [room] to [id].

ts
// routes/rooms/[id].ws.ts
export default defineRoom({
  name: 'chat-room',
  key: ({ params }) => `room=${params.id}`,
  messages: { client: ClientMessage, server: ServerMessage },
  // Keep your existing hooks.
});

Clients now connect to /rooms/:id with params: { id: 'general' }. The key remains room=general, so storage and ctx.id stay the same. Returning only params.id would select a different instance.

key is optional on both WebSocket helpers. It must return a string synchronously and consistently for the same parameters. Preserve the complete old key when migrating multiple parameters. Keep the callback stable after deployment.

Constraints ​

Each socket connects to one route instance. To switch rooms, close the current connection and open another. For publishing to multiple topics, see Live Event Streams.

Deployment ​

WebSocket routes work on Cloudflare and SDK platform deployments. Commit void.lock.json when the SDK adds a binding or migration. Do not delete or reorder deployed migration steps.

Next steps ​

implemented.stream AI