WebSockets
⚠️ Void Apps Only
Typed WebSocket routes currently work in native Void apps. They aren't available in meta-framework mode yet.
Create a .ws.ts route to add a typed WebSocket endpoint. Void runs each route instance in a Cloudflare Durable Object, which coordinates the clients connected to it. These routes require the Cloudflare target; Node.js, Bun, and Deno builds reject them.
New route classes use SQLite-backed Durable Objects on both deployment platforms. Void preserves legacy key-value-backed migration history for existing hosted projects and never reclassifies an already-created class.
For example, a chat route at /rooms/[id] gives each room its own instance. Use it for chat, presence, collaborative documents, or notifications.
Route files
Create WebSocket routes in routes/ with the .ws.ts suffix:
routes/
chat/[room].ws.ts
notifications.ws.tsFilename rules match regular server routes:
index.ws.tsbecomes the parent path[id].ws.tsbecomes:id[...slug].ws.tsbecomes a catch-all- route groups like
(marketing)/chat.ws.tsare ignored in the URL chat.dev.ws.ts/chat.prod.ws.tsrestrict the route to one environment
The environment suffix goes before .ws. chat.ws.dev.ts is not recognised.
defineRoom()
Use defineRoom() when clients should share a room or document identified by the route.
// 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 is handled independently instead of as a shared room.
// 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' });
},
});Typed messages
Define schemas for messages sent by the client and server:
messages.clientvalidates what the browser may sendmessages.servervalidates what the server may sendonMessage()receives the parsed, validated client eventctx.room.broadcast(),ctx.connection.send(), andctx.socket.send()are typed frommessages.serverconnect()infers route params, outgoing client messages, and incoming server messages from generated route types
The default protocol is JSON events. Raw string or binary framing is not the primary API.
Ambient auth
WebSocket hooks use the same built-in session resolution as HTTP auth. When Void auth is enabled, ctx.user is available in:
onBeforeConnectonConnectonMessageonCloseonRequest
This makes cookie-authenticated sockets work without re-parsing the session manually.
Hooks
Both defineRoom() and defineWebSocket() support:
onBeforeConnect(ctx): return aResponseto reject the upgradeonConnect(ctx): runs after the socket is acceptedonMessage(ctx, event): receives the validated client eventonClose(ctx, details): receives{ code, reason, wasClean }onRequest(ctx): handles ordinary HTTP requests to the same path
Every hook receives a context with:
ctx.id: deterministic route instance idctx.params: matched route paramsctx.user: resolved auth user ornullctx.requestctx.envctx.storage
If a route does not define onRequest(), non-WebSocket requests return 426 Upgrade Required.
Client
Use connect() from void/ws on the client:
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 automatically uses ws: or wss:. It also buffers messages until the socket opens and reconnects by default.
Constraints
WebSocket routes currently support:
- Cloudflare-only
- one route-derived connection target per socket
- no Socket.IO-style dynamic room join/leave API
- no global pub/sub abstraction
- JSON event messages only
Each socket connects to one route instance. Applications that need to switch rooms should manage that connection change explicitly.
Deployment
void deploy --platform cloudflare persists the required binding and append-only SQLite class migration in wrangler.jsonc, then deploys the generated Worker directly to your account. Commit this migration history and never delete or reorder a step after deployment.
void deploy --platform void uses the same shared migration planner in the hosted uploader. Existing hosted WebSocket classes created on legacy storage remain there; only genuinely new classes use SQLite.