Docs

WebSockets

app.ws(path, handler) registers a WebSocket route. It mirrors .get()/.post() - chainable, with per-connection state typed through a generic - and runs on every runtime Nifra serves: Bun, Deno, Node, and Cloudflare Workers.

The WebSocket runtime ships as an opt-in plugin, so apps that never use it don’t bundle it. Enable it with .use(websocket()) from @nifrajs/core/ws; app.ws() without it fails loud at registration with WS_RUNTIME_MISSING. It installs on that server instance only - the same .use() opt-in as mcp() and streaming().

TS
import { server } from "@nifrajs/core/server"
import { websocket } from "@nifrajs/core/ws" // .use(websocket()) enables app.ws()

const app = server()
  .use(websocket())
  .get("/", () => ({ ok: true }))
  .ws("/echo", {
    open: (ws) => ws.send("welcome"),
    message: (ws, data) => ws.send(data), // data: string | Uint8Array
  })

app.listen(3000) // Bun

Lifecycle

Every callback is optional - { message } alone is a valid echo server. upgrade is the only callback that runs before the socket opens, so it’s where a route rejects a connection or seeds its data.

TS
// doc-check: skip - illustrative lifecycle (empty close/error bodies, cookie shape).
app.ws<{ user: string }>("/chat", {
  // upgrade(c) runs in the full HTTP request context, BEFORE the socket opens, after the
  // guards registered before this route. Return the per-connection data (→ ws.data, typed),
  // or a Response or status(...) to REJECT the upgrade. A thrown Response rejects with it;
  // any other throw goes to onError, then a 500.
  upgrade(c) {
    const user = c.cookies.session
    if (user === undefined) return new Response("unauthorized", { status: 401 })
    return { user }
  },
  open: (ws) => ws.send(`welcome ${ws.data.user}`), // ws.data is { user: string }
  message: (ws, data) => ws.send(data),              // string | Uint8Array, normalized
  close: (ws, code, reason) => {},
  error: (ws, err) => {}, // a throw in any callback routes here, never crashing the connection
})

Guards

A handshake is a GET, and it runs the chain an HTTP GET registered at the same point runs: the authenticate() stages, derives and beforeHandles added before app.ws(), then upgrade. So auth middleware such as basicAuth or jwt guards the sockets registered after it, any guard can refuse the handshake with the response it would send an HTTP request, and upgrade reads what the guards derived. The origin check runs first. A socket registered before a guard stays public, like an HTTP route there. A browser cannot set a header on the handshake, so authenticate() can read a short-lived, single-use ticket from input.query - see Authentication runs first.

TS
// doc-check: skip - illustrative: an existing auth guard and session lookup.
app
  .ws("/status", {})                       // registered before the guard: public
  .authenticate(sessionAuth)               // or .use(basicAuth(...)), .derive(...), .beforeHandle(...)
  .get("/me", (c) => c.principal)
  .ws("/chat", {
    // Reached only when sessionAuth accepted the handshake, exactly as GET /me is.
    upgrade: (c) => ({ user: c.principal.id }),
  })

A socket declares the effects its code may reach with capabilities, as an HTTP route does. nifra capabilities check compares them with what the registering module can reach, and the handshake context carries the runtime guard for upgrade(). Reflection lists every socket under the pseudo-method "WS", so nifra assure classifies sockets too.

NifraWebSocket

The ws handed to each callback is a portable wrapper over the runtime’s native socket:

MemberWhat
send(data)text (string) or binary (ArrayBuffer/typed-array)
close(code?, reason?)close the connection
readyState0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED
subscribe(topic) / unsubscribe(topic)pub/sub (below)
dataper-connection state, seeded by upgrade(), mutable
rawescape hatch to the native socket

Contract-validated messages

Inbound frames arrive raw (string | Uint8Array) by default. Add a messageSchema - any Standard Schema (t, zod, valibot) - and Nifra parses each frame as JSON, validates it, and hands message the typed value; anything that fails goes to onInvalidMessage instead (so a malformed frame can never reach your handler).

TS
// doc-check: skip - illustrative: validated inbound frames on an existing app.
import { t } from "@nifrajs/schema"

app.ws("/chat", {
  messageSchema: t.Object({ kind: t.Literal("say"), text: t.String({ maxLength: 500 }) }),
  message(ws, msg) {
    // msg is typed { kind: "say"; text: string } - already parsed + validated.
    app.publish("room", msg.text)
  },
  onInvalidMessage(ws, issues) {
    ws.send(JSON.stringify({ error: "bad message", issues }))
  },
})

Optional outbound validation

sendSchema is type-level by default: it documents and types what the client receives with no runtime cost. Set validateSend: true to check server-sent JSON text and UTF-8 binary frames before the native socket sends them. Invalid frames and async validators fail closed and route a diagnostic to error when present. This is a contract check for your own output, not an inbound security boundary; the flag requires a sendSchema and is intentionally off by default.

TS
// doc-check: skip - illustrative: outbound contract on an existing app.
import { t } from "@nifrajs/schema"

app.ws("/events", {
  sendSchema: t.Object({ kind: t.Literal("ready"), id: t.String() }),
  validateSend: true, // opt in; default remains type-level only
  open(ws) {
    ws.send(JSON.stringify({ kind: "ready", id: "evt-1" }))
  },
})

Pub/sub - app.publish

ws.subscribe(topic) joins a topic; app.publish(topic, data) broadcasts to everyone in it. Subscriptions drop automatically when a connection closes.

TS
// doc-check: skip - illustrative pub/sub on an existing app.
app.ws("/room/:id", {
  open: (ws) => ws.subscribe("room"),
  message: (ws, text) => app.publish("room", text), // fan out to all subscribers
})

Bun, Deno, and Node are long-lived processes, so this works directly on a single instance. Across a load balancer, app.publish only reaches sockets on the same instance - bridge an external fan-out (Redis pub/sub, NATS, a queue) to broadcast across all of them. On Cloudflare Workers, a stateless isolate can’t broadcast across connections, so Nifra ships a Durable Object hub: createWebSocketHub(app) holds the connections, and toFetchHandler(app, { webSocketHub }) routes upgrades to it - then ws.subscribe / app.publish behave exactly as on Bun. An upgrade request for a path with no WebSocket route still gets that path's HTTP answer, as on every other runtime.

TS
// doc-check: skip - Workers entry: a Durable Object hub holds the connections.
import { createWebSocketHub, toFetchHandler } from "@nifrajs/workers"

export const NifraWebSocketHub = createWebSocketHub(app) // bind as NIFRA_WS_HUB in wrangler.toml
export default toFetchHandler(app, { webSocketHub: (env) => env.NIFRA_WS_HUB })

Serving - adapter-integrated, not app.fetch

A WebSocket upgrade can’t go through app.fetch(Request) - it needs the live socket, which only the runtime’s serving layer holds. So WS is wired by each serving entry; app.ws() and the handler are identical everywhere.

RuntimeServe withUpgrade primitive
Bunapp.listen(port)Bun.serve server.upgrade + websocket config
Denoserve(app, …) from @nifrajs/denoDeno.upgradeWebSocket
Nodeserve(app, …) from @nifrajs/nodethe upgrade event + the optional ws package
Cloudflare Workersexport default toFetchHandler(app)WebSocketPair + a 101 response

Node has no built-in WebSocket server, so @nifrajs/node uses ws - an optional peer dependency, lazy-imported on the first upgrade (a non-WS Node app never loads it). Install it when you use app.ws(); without it a WS upgrade gets a clean 501 and the HTTP routes are unaffected.

TS
npm i ws

server({ wsMaxPayloadBytes }) caps one inbound message (default maxBodyBytes, 1 MB): a socket that sends more is closed with 1009 and the message never reaches your handler. Bun and Node refuse it while it arrives. Deno's Deno.upgradeWebSocket takes no size limit, so Deno receives each message whole (up to its own limit, 64 MiB on Deno 2.9) before nifra can check it: the cap still keeps the message from your handler, but it does not bound the memory Deno spent receiving it. Where that matters, cap message size in a proxy in front of a Deno app.

In a composed app

mount({ path, app }) carries WebSocket upgrades as well as HTTP: when the serving adapter sees an upgrade under the mount's path, it asks the child's resolveWebSocketUpgrade and upgrades its own socket with the outcome. Any nifra server with websocket() installed has one. A child that is not a nifra server can implement it too, building the outcome from @nifrajs/core/ws: attachWebSocket wires the socket to a handler and a TopicRegistry owns its pub/sub. On Bun, install websocket() on the parent or on a mounted nifra child, or listen() refuses to start with INVALID_WS_RUNTIME.

Each mounted nifra app keeps its own wsMaxPayloadBytes: a message over the child's cap closes that socket with 1009, whether the cap is below or above the parent's. On Bun one listener serves them all, so its frame limit is the largest cap among the parent and the apps mounted on it, and a socket whose app declares less checks its own.

mountFetch(path, handler) and a plain fetch handler carry HTTP only. An upgrade request under such a path reaches the handler as an ordinary request and never becomes a socket. Serve that path's WebSockets from a mounted child, or keep that server beside nifra.

TS
// server.ts - one listener; the chat child and a non-nifra child both take upgrades.
import { server } from "@nifrajs/core/server"
import { attachWebSocket, TopicRegistry, websocket, type WebSocketUpgradeOutcome } from "@nifrajs/core/ws"

const chat = server()
  .use(websocket())
  .ws("/room", { message: (ws, data) => ws.send(data) })

// Not a nifra server: it answers HTTP through fetch and builds its own upgrade outcome.
const topics = new TopicRegistry()
const legacy = {
  fetch: () => new Response("legacy"),
  resolveWebSocketUpgrade: (request: Request): WebSocketUpgradeOutcome =>
    new URL(request.url).pathname === "/feed"
      ? { kind: "upgrade", handler: { open: (ws) => ws.send("hello") }, data: undefined, pubsub: topics, attach: attachWebSocket }
      : { kind: "pass" },
}

server()
  .use(websocket()) // Bun: the parent holds the socket table every mounted upgrade runs through
  .mount({ path: "/chat", app: chat, stripPrefix: true })
  .mount({ path: "/legacy", app: legacy, stripPrefix: true })
  .listen(3000)