Skip to content

WebSocket

Stratal provides first-class WebSocket support through the stratal/websocket module, built on top of Cloudflare Workers WebSocket API. You define WebSocket endpoints using a decorator-based gateway pattern that mirrors the controller pattern you already know.

Use the @Gateway() decorator to mark a class as a WebSocket gateway. The decorator takes a route path, just like @Controller():

import { Gateway, OnMessage, OnClose, type GatewayContext } from 'stratal/websocket'
@Gateway('/ws/chat')
class ChatGateway {
@OnMessage()
handleMessage(evt: MessageEvent, ctx: GatewayContext) {
ctx.send(`echo:${evt.data as string}`)
}
@OnClose()
handleClose(evt: CloseEvent, ctx: GatewayContext) {
console.log('Client disconnected')
}
}

The @Gateway() decorator internally applies @Transient(), so each WebSocket connection gets a fresh instance resolved from the DI container. You can inject dependencies through the constructor just like any other provider.

Decorate methods with event decorators to handle WebSocket lifecycle events:

DecoratorEvent signatureDescription
@OnMessage()(evt: MessageEvent, ctx: GatewayContext)Called when a message is received
@OnClose()(evt: CloseEvent, ctx: GatewayContext)Called when the connection closes
@OnError()(evt: Event, ctx: GatewayContext)Called when a WebSocket error occurs

Each gateway can have at most one handler per event type. All three are optional — define only the ones you need.

@Gateway('/ws/chat')
class ChatGateway {
@OnMessage()
handleMessage(evt: MessageEvent, ctx: GatewayContext) {
const data = evt.data as string
ctx.send(`ack:${data}`)
}
@OnClose()
handleClose(evt: CloseEvent, ctx: GatewayContext) {
console.log(`Closed with code ${evt.code}`)
}
@OnError()
handleError(evt: Event, ctx: GatewayContext) {
console.error('WebSocket error', evt)
}
}

GatewayContext extends RouterContext with WebSocket-specific methods. Your event handlers receive it as the second argument.

Method / PropertyTypeDescription
send(data)voidSend a string, ArrayBuffer, or Uint8Array to the client
close(code?, reason?)voidClose the WebSocket connection
readyStateWSReadyStateCurrent connection state
wsWSContextThe underlying Hono WSContext for advanced use
MethodDescription
header(name)Read a header from the upgrade request
param(name)Read a route parameter
query(name)Read a query string parameter
getContainer()Access the DI container
getLocale()Get the current locale from the i18n context
@Gateway('/ws/rooms/:roomId')
class RoomGateway {
@OnMessage()
handleMessage(evt: MessageEvent, ctx: GatewayContext) {
const roomId = ctx.param('roomId')
const token = ctx.header('Authorization')
ctx.send(`Room ${roomId} received: ${evt.data as string}`)
}
}

Gateways are registered in the controllers array of a module, alongside regular controllers. Stratal automatically detects which classes are gateways and registers them as WebSocket routes.

import { Module } from 'stratal/module'
@Module({
controllers: [ChatGateway, RoomGateway],
})
export class ChatModule {}

@UseGuards() works on gateways the same way it works on controllers. Guards execute during the HTTP upgrade request, before the WebSocket connection is established:

import { UseGuards } from 'stratal/router'
import { AuthGuard } from './auth.guard'
@UseGuards(AuthGuard)
@Gateway('/ws/chat')
class ChatGateway {
@OnMessage()
handleMessage(evt: MessageEvent, ctx: GatewayContext) {
ctx.send(`echo:${evt.data as string}`)
}
}

If a guard rejects the request, the upgrade is denied and the WebSocket connection is never opened.

Versioned paths work the same as controllers. Pass a version option in the second argument to @Gateway() to register the gateway under a version prefix:

@Gateway('/ws/chat', { version: '1' })
class ChatGatewayV1 {
// Registered at /v1/ws/chat
}

The version option accepts a string, string[] for multiple versions, or VERSION_NEUTRAL to opt out of versioning — identical to the @Controller() pattern. See API Versioning for full details.