Skip to content

Search docs

Jump between documentation pages.

Browse docs

WebSocket primitives

DaloyJS ships runtime-agnostic WebSocket primitives in src/websocket.ts (re-exported from @daloyjs/core/websocket) plus adapter wiring for @daloyjs/core/node and @daloyjs/core/bun. Both adapters accept the same handler shape: the Bun-style open / message / close / drain / error callbacks, so the same app.ws(path, handler) registration works on either runtime without changes.

On Node the adapter only installs an upgrade listener when at least one WS route is registered, so apps that don't use WebSockets pay zero overhead. On Bun the adapter forwards to Bun.serve's native websocket config.

app.ws() also normalizes safe runtime defaults: closeOnBackpressureLimit: true, a 1 MiB backpressureLimit, perMessageDeflate: false, a non-zero idleTimeout, and a 1 MiB maxPayloadLength. Production apps running with secureDefaults refuse perMessageDeflate: true.

Production routes also require an Origin policy with allowedOrigins or an explicit acknowledgeCrossOriginUpgrade: true. This closes the Cross-Site WebSocket Hijacking pattern behind Storybook's CVE-2026-27148: browsers attach cookies to WS handshakes, even when another site opened the socket.

Quick start

ts
import { App } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";

const app = new App();

app.ws("/chat/:room", {
  open(conn, ctx) {
    conn.data = { user: ctx.query.user ?? "anon", room: ctx.params.room };
    conn.send(`welcome ${(conn.data as { user: string }).user}`);
  },
  message(conn, data, isBinary) {
    // Echo back, upper-cased for text frames
    // send() picks text or binary from the value's type.
    conn.send(typeof data === "string" ? data.toUpperCase() : data);
  },
  close(conn, code, reason) {
    // release any per-connection resources here
  },
});

serve(app, { port: 3000 });

Handler shape

Use defineWebSocket() for full type-inference on path params (ctx.params), query (ctx.query), and per-connection state (conn.data):

ts
import { defineWebSocket } from "@daloyjs/core";

// Name the path pattern so ctx.params is typed from it.
const chatHandler = defineWebSocket<"/chat/:room">({
  open(conn, ctx) {
    conn.data = { room: ctx.params.room, joinedAt: Date.now() };
  },
  message(conn, data) {
    conn.send(typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer));
  },
  close(conn, code, reason) {
    // cleanup
  },
  error(conn, err) {
    // user error handler - does not affect close code
  },
  drain(conn) {
    // backpressure released
  },
});

app.ws("/chat/:room", chatHandler);

WebSocket paths go through the same router as HTTP routes, so ctx.params never holds a ./.. component or a control character (%00, %0A, ...). WebSocket routes are stricter than HTTP routes in one way: a capture that decodes an encoded / or \ (%2F, %5C) is never accepted, and there is no allowEncodedSlash opt-in. Such an upgrade request is treated as an unknown path.

Validating inbound messages

Declare request.body and Daloy enforces it on every inbound message before message() runs (since 1.6.0). Text frames are parsed with the same prototype-pollution-safe, depth- and key-bounded JSON parser as HTTP bodies, then validated against your Standard Schema. The validated value arrives as the fourth argument, typed from the schema output:

ts
import { z } from "zod";

const ChatMessage = z.object({ text: z.string().max(500) }).strict();

app.ws("/chat/:room", {
  allowedOrigins: "same-origin",
  beforeUpgrade(req) {
    if (!req.headers.get("cookie")) return new Response("Unauthorized", { status: 401 });
  },
  request: { body: ChatMessage },
  message(conn, _data, _isBinary, body) {
    // body is { text: string }; invalid messages never get here
    conn.send(JSON.stringify({ echo: body.text }));
  },
});

A message that fails is not delivered, and the connection closes:

Inbound messageClose code
Not valid JSON, or too deep/wide for the bounded parser1007 (invalid payload)
Valid JSON that fails the schema1007 (invalid payload)
A binary frame1003 (unsupported data)

The close reasons are fixed strings, so validator issue details never reach the peer. Messages queued behind a rejected one are dropped. Async schemas are supported, and messages still reach message() in arrival order.

The arrival-order queue is bounded per connection (since 1.23.0): while an async validation is pending, at most maxQueuedMessages (default 64) and maxQueuedBytes (default 2 * maxPayloadLength) accumulate behind it. A peer that sends faster than validation drains is closed with 1009 (message too big) instead of retaining its traffic in server memory without limit. Both caps must be positive integers.

On the Node adapter, once a close has started (yours or the peer's) no further message reaches message() or the schema validator, and a peer that does not complete the close handshake within 5 seconds has its socket destroyed. A CLOSE frame whose reason is not valid UTF-8 is answered with 1002.

Upgrades follow the same forwarded-header rule as HTTP requests: in production, with no behindProxy / trustProxy posture declared, an upgrade carrying X-Forwarded-For or a similar header is refused with 500 on the Node and Bun adapters, so beforeUpgrade never sees a client-supplied address.

Routes without request.body behave as before: message() gets the raw string or bytes and body is undefined. Leave it off for binary protocols, validate inside message() yourself, and use meta.receive to document the format in AsyncAPI.

Connection API

The WebSocketConnection passed to your handlers mirrors the WHATWG WebSocket interface where it makes sense on the server side:

  • conn.readyState: one of WS_READY_STATE.CONNECTING / OPEN / CLOSING / CLOSED.
  • conn.send(data, options?): send a text frame for string, or a binary frame for Uint8Array / ArrayBuffer. Pass { binary: true } to force binary framing of a string.
  • conn.ping(data?) / conn.pong(data?): control frames. Payload must be ≤ 125 bytes per RFC 6455.
  • conn.close(code?, reason?): graceful close (sends a CLOSE frame, fires your close handler, then closes the underlying socket).
  • conn.terminate(): immediate transport-level close, no CLOSE frame.
  • conn.bufferedAmount, conn.protocol, conn.extensions, conn.binaryType.
  • conn.data: opaque per-connection slot for your app state.

Protocol negotiation & upgrade hook

The adapter handles the upgrade before the HTTP pipeline, so app-level hooks and middleware do not run on WebSocket upgrades: app.use(), rateLimit(), auth hooks, WAF, IP restriction, and bot guards configured for your REST routes never see the handshake. Put auth and throttling in beforeUpgrade (and use wsRateLimit() to share an HTTP rate-limit bucket). In production under secure defaults, app.ws() refuses a route with no beforeUpgrade unless you pass acknowledgeUnauthenticated: true.

Optional beforeUpgrade(req, ctx) runs after the path match and Origin policy, but before the 101 response. Return a Response to reject (handy for auth or rate-limiting), or return a string to pick a subprotocol from Sec-WebSocket-Protocol:

Upgrade, message, close
ClientDaloyJS adapterYour handler
  1. 01requestClientDaloyJS adapterGET with Upgrade: websocketOrigin policy checked first, then beforeUpgrade
  2. 02responseDaloyJS adapterClient101 Switching Protocols (or a rejection Response)Sec-WebSocket-Accept, optional negotiated subprotocol
  3. 03asyncDaloyJS adapterYour handleropen(conn, ctx)set conn.data, send a welcome frame
  4. 04asyncClientYour handlermessage(conn, data, isBinary)text or binary frames, capped by maxPayloadLength
  5. 05noteClientYour handlerclose(conn, code, reason)CLOSE frame, then the socket is torn down
The adapter runs the Origin policy and beforeUpgrade hook before replying 101. After the upgrade your open, message, and close callbacks run with the same shape on both Node and Bun.
ts
app.ws("/api", {
  allowedOrigins: "same-origin",
  beforeUpgrade(req, ctx) {
    const token = ctx.headers["authorization"]?.replace(/^Bearer /, "");
    if (!token || !isValid(token)) {
      return new Response("unauthorized", { status: 401 });
    }
    // Pick a subprotocol the client offered
    const offered = (ctx.headers["sec-websocket-protocol"] ?? "").split(",").map((s) => s.trim());
    return offered.includes("daloy.v1") ? "daloy.v1" : undefined;
  },
  open(conn) {
    conn.send("ready");
  },
  message(conn, data) {
    conn.send(data);
  },
});

requireAuth and group auth

Because app and group hooks skip the upgrade, app({ requireAuth: true }) and a group(prefix, { auth }) cannot authenticate a socket for you. Since 1.20.0, app.ws() in either scope throws at registration, in every environment, unless the route's beforeUpgrade is wrapped with markWebSocketAuth() or the route passes acknowledgeUnauthenticated: true. Any other beforeUpgrade (for example wsRateLimit() on its own) does not count.

ts
import { App, markWebSocketAuth } from "@daloyjs/core";

const app = new App({ requireAuth: true });

app.ws("/feed", {
  allowedOrigins: "same-origin",
  beforeUpgrade: markWebSocketAuth(async (req) => {
    if (!(await verifyToken(req.headers.get("authorization")))) {
      return new Response(null, { status: 401 });
    }
  }),
  open(c) { c.send("hello"); },
});

app.ws("/lobby", { acknowledgeUnauthenticated: true, allowedOrigins: "same-origin", open() {} }); // deliberately public

Mark only a hook that really rejects unauthenticated callers before the 101 response.

Origin policy and CSWSH

allowedOrigins is checked before beforeUpgrade in both Node and Bun. Use "same-origin" for browser clients served from the same scheme, host, and port as the WS endpoint, use an array for explicit cross-origin browser clients, or use a predicate when machine clients must also send an Origin header.

"same-origin" compares Origin with the request's own authority, which comes from the Host header. Since 1.17.0 the Node and Bun upgrade paths apply the App's allowedHosts allowlist (and the development allowlist serve() installs) before routing, exactly like the HTTP dispatcher, so an upgrade for a host the app does not serve is answered 400 Invalid Host header and cannot satisfy a same-origin check with a matching attacker-controlled Host. Set allowedHosts in production so this comparison is anchored to the hostnames you actually serve.

ts
app.ws("/session", {
  allowedOrigins: "same-origin",
  beforeUpgrade(req, ctx) {
    const session = readSession(ctx.headers.cookie);
    if (!session) return new Response("unauthorized", { status: 401 });
  },
  open(conn) {
    conn.send("ready");
  },
});

app.ws("/partner-feed", {
  allowedOrigins: ["https://partner.example.com"],
  beforeUpgrade(req) {
    return verifyPartner(req) ? undefined : new Response("forbidden", { status: 403 });
  },
  message(conn, data) {
    conn.send(data);
  },
});

app.ws("/cli", {
  allowedOrigins: (origin) => origin !== null && origin === "https://admin.example.com",
  beforeUpgrade(req) {
    return verifyBearerToken(req) ? undefined : new Response("unauthorized", { status: 401 });
  },
  open(conn) {
    conn.send("ready");
  },
});

Missing Origin is allowed by the "same-origin" and array policies because browsers send Origin on WS handshakes. No Origin usually means a CLI or server-to-server client. Use the predicate form when your route should reject clients that omit the header.

Upgrade rate limiting

Use wsRateLimit() in beforeUpgrade when a WebSocket route belongs to the same login or session-establishment surface as HTTP endpoints. It spends from the same rateLimit({ groupId }) bucket and preserves rate-limit headers on rejection.

A limiter failure never leaks to the client (since 1.3.7). A store or keyGenerator error that is not an HttpError is rethrown, so the adapter logs it and answers with its own generic 500; the error message (which can carry connection strings or hostnames) is never echoed. A 5xx HttpError has its detail scrubbed, because some adapters return the hook's response verbatim.

ts
import { wsRateLimit } from "@daloyjs/core";

app.ws("/session", {
  beforeUpgrade: wsRateLimit({
    windowMs: 60_000,
    max: 10,
    groupId: "auth-entry",
    // Default key: the trusted forwarded client IP (with trustedProxies), or
    // the TCP peer. Never key on a raw client header such as x-user-key: a
    // client that rotates it gets a fresh budget on every attempt.
    trustedProxies: ["10.0.0.0/8"],
  }),
  open(conn) {
    conn.send("ready");
  },
});

Payload and backpressure limits

Override safe defaults per route when a connection needs tighter bounds. idleTimeout, backpressureLimit, and maxPayloadLength must be positive integers. If your WebSocket handler declares a body schema with a maximum size, Daloy refuses a larger maxPayloadLength at registration time.

maxPayloadLength caps both a single frame and a reassembled fragmented message. The Node adapter's frame parser also refuses a message split across more than DEFAULT_WS_MAX_MESSAGE_FRAGMENTS (4096) frames with close code 1002 (protocol error), since 1.3.7. Without that cap a peer could hold a message open forever with empty continuation frames, which never trip the byte limit.

ts
app.ws("/events", {
  idleTimeout: 120,
  maxPayloadLength: 64 * 1024,
  closeOnBackpressureLimit: true,
  backpressureLimit: 1 * 1024 * 1024,
  perMessageDeflate: false,
  message(conn, data) {
    conn.send(data);
  },
});

Graceful shutdown

The Node adapter tracks every upgraded socket. When close() is invoked (or the app receives SIGTERM / SIGINT with handleSignals: true), all active WebSocket sockets are destroyed before server.close() resolves so the process exits promptly even if clients linger.

Custom adapters

If you target a runtime other than Node or Bun, import the primitives directly from @daloyjs/core/websocket:

ts
import {
  validateUpgrade,
  computeAcceptKey,
  FrameSink,
  encodeFrame,
  encodeSendPayload,
  encodeClosePayload,
  WS_OPCODE,
  WS_CLOSE_CODE,
  WS_READY_STATE,
} from "@daloyjs/core/websocket";

FrameSink is a streaming RFC 6455 parser: feed it bytes via sink.push(chunk) and it dispatches onMessage (with reassembled payload + isBinary flag), onPing, onPong, onClose, and onProtocolError. UTF-8 validation on text frames is handled for you.

Edit this page on GitHub

Last updated