Skip to Content
Documentation

WebSocket

Setup

Serve VitNode's /api/ws WebSocket from a single TanStack Start app with Nitro, Vercel included, or from a standalone Node or Bun API server.

VitNode's WebSocket lives at /api/ws. A single app serves it from the same deployment as the site and the HTTP API, through Nitro. A standalone API serves it from its own Node or Bun server. Both run the same handshake, protocol and handlers, so plugins and useVitNodeWebSocket work the same either way.

New single apps from create-vitnode-app already include everything on this page. Follow it to add WebSockets to an older single app. On Cloudflare Workers, finish this page and then follow Cloudflare Workers, where a Durable Object hub owns the sockets.

Single app

The browser upgrades /api/ws. Nitro owns that route, so the upgrade never reaches the TanStack Start catch-all, while every other /api/* request still does. Nitro then sends the handshake through the embedded Hono API as an ordinary GET /api/ws, so the Origin check, the rate limiter and the session cookie apply before the socket opens.

Enable WebSockets in the Vite config

Tell VitNode this app serves its own socket:

vite.config.ts
plugins: [
  vitnode({ appRoot: import.meta.dirname, webSocket: true }), 
  devtools(),
  tailwindcss(),
  tanstackStart(),
  viteReact(),
],

That one flag turns on Nitro's WebSocket support, mounts VitNode's Nitro route at /api/ws and lets the browser open the socket. Without it the browser never connects in an app that embeds the API.

The route ships with @vitnode/core, so there is no file to copy. It runs in Nitro's bundle, loads your server bundle on the first upgrade a fresh instance gets, and hands every handshake to the API your server bundle registered with configureInProcessApi. If that bundle cannot load, the handshake gets 503 and the client retries.

Older single apps mounted their own src/server/websocket.ts through nitro({ handlers }). Delete that file and the nitro() call when you switch to webSocket: true, or two handlers fight over /api/ws.

Mount the handshake on the embedded API

Add the /ws route after VitNodeAPI, so the API's middleware runs first:

src/server/vitnode-api.server.ts
import { websocketOriginMiddleware } from "@vitnode/core/api/middlewares/websocket-origin.middleware"
import { upgradeWebSocket } from "@vitnode/core/ws/crossws.server"
import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle"

const createVitNodeApi = () => {
  const app = new OpenAPIHono().basePath("/api")

  VitNodeAPI({ app, vitNodeApiConfig })

  app.get(
    "/ws",
    websocketOriginMiddleware(),
    upgradeWebSocket(handleVitNodeWebSocket()),
  )

  return app
}

upgradeWebSocket from @vitnode/core/ws/crossws.server does not upgrade anything itself. It hands the connection's handlers back to the Nitro route, which opens the socket. A plain GET /api/ws gets 426 Upgrade Required.

Standalone API

A separate API server runs on @hono/node-server and upgrades with its own helper and a ws server. Mount the route after VitNodeAPI, and pass the web app's origin when it is not VITNODE_WEB_URL:

apps/api/src/index.ts (excerpt)
import { serve, upgradeWebSocket } from "@hono/node-server"
import { websocketOriginMiddleware } from "@vitnode/core/api/middlewares/websocket-origin.middleware"
import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle"
import { type WebSocket, WebSocketServer } from "ws"

// `app` is your OpenAPIHono app, already passed to VitNodeAPI({ app, ... })
const webOrigin = process.env.VITNODE_WEB_URL ?? "http://localhost:3000"
const wss = new WebSocketServer({ noServer: true })

// Ping every 30 s and drop sockets that did not answer the previous ping.
const awaitingPong = new WeakSet<WebSocket>()
wss.on("connection", ws => {
  ws.on("pong", () => awaitingPong.delete(ws))
})
const heartbeat = setInterval(() => {
  for (const ws of wss.clients) {
    if (awaitingPong.has(ws)) {
      ws.terminate()
      continue
    }
    awaitingPong.add(ws)
    ws.ping()
  }
}, 30_000)
heartbeat.unref()
wss.on("close", () => clearInterval(heartbeat))

app.get(
  "/ws",
  websocketOriginMiddleware({ origin: [webOrigin] }),
  upgradeWebSocket(handleVitNodeWebSocket()),
)

serve({
  fetch: app.fetch,
  port: Number(process.env.PORT ?? 8000),
  websocket: { server: wss },
})

The ws server does not ping on its own. Without the heartbeat, a client that vanished without a close frame (a sleeping laptop, dropped Wi-Fi) stays in the registry forever. The full file is apps/api/src/index.ts in the VitNode repository.

A web app pointed at that API with VITNODE_API_URL connects to it on its own; it needs no webSocket option and no Nitro route.

On Bun

An API that runs on Bun lets Bun upgrade the socket. Use the helpers from hono/bun and export Bun's server object instead of calling serve:

apps/api/src/index.ts (excerpt)
import { websocketOriginMiddleware } from "@vitnode/core/api/middlewares/websocket-origin.middleware"
import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle"
import { upgradeWebSocket, websocket } from "hono/bun"

// `app` is your OpenAPIHono app, already passed to VitNodeAPI({ app, ... })
app.get("/ws", websocketOriginMiddleware(), upgradeWebSocket(handleVitNodeWebSocket()))

export default {
  fetch: app.fetch,
  hostname: process.env.HOST,
  port: Number(process.env.PORT ?? 8000),
  websocket,
}

Bun pings idle sockets and drops dead ones on its own, so there is no heartbeat to write. API apps that create-vitnode-app generates for Bun ship this file already.

Who may connect

Both setups apply the same rules before a socket opens:

  • Origin: the configured web and API origins, any extra origin you pass, and the handshake's own origin (a page on the same host, such as a preview deployment). A handshake without an Origin header is not a browser and is allowed. Anything else gets 403.
  • Session: the session cookie decides the user. Guests connect too, as null; a handler that needs a user checks c.get("user") itself, and live editing checks the AdminCP session on every message.
  • Redis: with REDIS_URL set, the instance answers 503 until it is subscribed to the realtime channel, so it never accepts a socket that other instances cannot reach. The client retries. Cloudflare Workers do not use Redis for this; the hub owns every socket.

A rejected handshake registers nothing. Identity is fixed for the life of a socket. After sign-in or sign-out the client reconnects on its own. Revoking a session (signing out a device, changing a password, an admin revoke) closes that device's sockets on every instance, or in the Cloudflare hub.

Check the result

Run the app, open the site, and look at the browser's network panel: /api/ws shows 101 Switching Protocols, followed by vitnode_ping and vitnode_pong frames every 25 seconds. AdminCP → Core → System → Integrations shows WebSocket as active.