Skip to content

WebSocket ​

hazejs has native WebSocket support built on crossws. You declare a socket next to your HTTP routes, the upgrade request goes through your middleware, and the same code runs in the dev server, on Node, Bun, Deno, Docker and on Cloudflare Workers (through a Durable Object).

If your frontend uses socket.io-client, see Socket.IO instead: it speaks a different protocol.

Paths

Examples on this page use the API template layout (route/..., app/..., config/...). In a full-stack project these live under server/.

Defining a socket ​

Use r.ws(path, hooks) in any route file:

ts
// route/ws.ts
import { defineRoutes } from '@hazejs/core'

export default defineRoutes((r) => {
  r.ws('/api/ws', {
    open(peer) {
      peer.subscribe('users')
      peer.send(JSON.stringify({ type: 'connected' }))
    },
    message(peer, message) {
      if (message.text() === 'ping')
        peer.send(JSON.stringify({ type: 'pong' }))
    },
  })
})

Connect with new WebSocket('ws://localhost:3000/api/ws'). A plain GET to the same path gets 426 Upgrade Required.

For larger handlers, move them into their own file with defineWebSocket():

ts
// app/websocket/ChatSocket.ts
import { defineWebSocket } from '@hazejs/core'

export default defineWebSocket({
  open(peer, ctx) {
    peer.subscribe('chat')
  },
  message(peer, message) {
    // Send to every other subscriber of "chat"
    peer.publish('chat', message.text())
  },
  close(peer, details) {
    console.log(`closed ${peer.id}`, details.code, details.reason)
  },
  error(peer, error) {
    console.error(error)
  },
})
ts
// route/ws.ts
import ChatSocket from '~/app/websocket/ChatSocket'

r.ws('/api/chat', ChatSocket)

Any directory works, except app/socket/, which is reserved for Socket.IO namespaces.

Hooks ​

HookArgumentsDescription
upgrade(request, ctx)Return a Response to reject the upgrade, or { headers } to add response headers
open(peer, ctx)Connection established
message(peer, message, ctx)Message received
close(peer, { code, reason }, ctx)Connection closed
error(peer, error, ctx)Error on the connection

Every hook receives the upgrade request's ctx as its last argument.

Peers and messages ​

peer and message are crossws objects. The members you will use most:

peer.idUnique connection ID
peer.send(data)Send to this connection
peer.subscribe(topic) / peer.unsubscribe(topic)Join or leave a topic
peer.publish(topic, data)Send to all other subscribers of a topic
peer.contextPer-connection storage object
peer.close(code?, reason?)Close the connection
message.text() / message.json()Read the payload as a string or parsed JSON

Authentication and middleware ​

The upgrade request runs through global and route middleware. If a middleware returns a response (such as 401 from auth), the upgrade is rejected with it:

ts
// route/ws.ts
r.ws('/api/notifications', {
  async open(peer, ctx) {
    const user = await ctx.auth.user()
    // Keep what you need: the request container is released after the upgrade
    peer.context.userId = user?.id
    peer.subscribe(`user:${user?.id}`)
  },
}).middleware('auth')

Read request data in open

Once the upgrade completes, the request-scoped container is disposed. Read the user, config or anything else from ctx in upgrade / open and store it on peer.context.

Browsers cannot send an Authorization header with new WebSocket(), so for browser clients a cookie-based guard (such as the session guard) is usually the simplest choice. See Authentication.

Publishing from anywhere ​

Socket.publish(topic, data) sends to every connection subscribed to a topic. Call it from controllers, jobs or event listeners. Non-string data is JSON-encoded.

ts
// app/listener/BroadcastRegistration.ts
import { defineListener, Socket } from '@hazejs/core'
import UserRegistered from '~/app/event/UserRegistered'

export default defineListener(UserRegistered, ({ userId }) => {
  Socket.publish('users', { type: 'user.registered', userId })
})

Socket.connections() returns the number of open connections in the current process.

Multiple instances ​

By default a publish only reaches connections on the same process. To fan out across instances, configure Redis broadcasting (requires ioredis):

ts
// config/websocket.ts
import { defineWebSocketConfig, env } from '@hazejs/core'

export default defineWebSocketConfig(() => env('WS_BROADCAST', '') === 'redis'
  ? {
      broadcast: {
        driver: 'redis',
        // A connection from config/redis.ts (default: its `default` connection)
        connection: 'default',
        // Separate apps sharing one Redis (default haze:ws)
        channel: 'my-app:ws',
      },
    }
  : {})

Both Socket.publish() and peer.publish() are forwarded to every instance. Publishing uses the connection's shared command client, and one dedicated subscriber connection is opened per server. The connection's keyPrefix does not apply to channel. The older inline url (with options) still works and takes precedence over connection; see Redis.

Runtime support ​

EnvironmentSupport
haze dev / Vite full-stack dev serverYes. Vite's own HMR socket is left alone; new connections pick up code changes
node / bun / deno / docker buildsYes. haze preview forwards upgrades to the built server too
cloudflareYes, through a Durable Object (see below)
vercel / netlify / aws-lambdaNo. The platforms do not support WebSocket; upgrades get 426

Cloudflare Workers ​

Nothing to wire up by hand. When the build finds .ws( in your routes, the Worker entry creates a Durable Object with createWebSocketDurableObject() from @hazejs/core/cloudflare, exports it as HazeWebSocket, and forwards upgrade requests to it. dist/wrangler.json gets the HAZE_WEBSOCKET binding and the SQLite Durable Object migration. Hooks still run inside your app, including middleware.

All connections share one Durable Object, so topics are global and Socket.publish() reaches them via RPC. Force generation on or off with cloudflare.websocket in haze.config.ts:

ts
// haze.config.ts
import { defineHazeConfig } from '@hazejs/vite'

export default defineHazeConfig({
  cloudflare: {
    websocket: true,
  },
})

TIP

Miniflare-based preview does not proxy WebSocket; use wrangler dev -c dist/wrangler.json to try it locally.

Testing ​

The in-process test client handles HTTP only, so it can check that a plain GET is refused:

ts
// tests/ws.test.ts
import { useTestApp } from '@hazejs/testing'
import { expect, it } from 'vitest'

const t = useTestApp()

it('requires an upgrade', async () => {
  expect((await t.get('/api/ws')).status).toBe(426)
})

For real connections, start the app on a random port and use the global WebSocket client, the same approach the framework's own tests take:

ts
// tests/ws.test.ts
import { useTestApp } from '@hazejs/testing'
import { expect, it } from 'vitest'

const t = useTestApp()

it('answers ping with pong', async () => {
  const server = t.app.serve({ port: 0, hostname: '127.0.0.1', silent: true })
  await server.ready()
  const socket = new WebSocket(`${server.url!.replace(/^http/, 'ws').replace(/\/$/, '')}/api/ws`)
  const messages: string[] = []
  socket.addEventListener('message', event => messages.push(String(event.data)))
  await new Promise(resolve => socket.addEventListener('open', resolve))

  socket.send('ping')
  await expect.poll(() => messages.at(-1)).toBe(JSON.stringify({ type: 'pong' }))
  socket.close()
  await server.close(true)
})

Limitations ​

  • Connections opened before a hot reload keep the old hooks; reconnect to use new code.
  • Data from the upgrade request must be captured in upgrade / open.

Released under the MIT License.