[]previous and next page

WebSockets: upgrades, middleware and limits

A WebSocket route is decided before a socket exists.

For agentsUpdated

A WebSocket route is decided before a socket exists. handler.upgrade(request) answers a request that asks for an upgrade with a refusal (an ordinary HTTP response, never a 101) or with the handshake, and the transport writes the 101 only for the second. The checks run in a fixed order, and a request that an earlier step refuses never reaches a later one, so nothing you wrote runs for it:

  1. the guards: URL length, method (GET), allowed host;
  2. the handshake headers (Upgrade, Connection, Sec-WebSocket-Key, version 13);
  3. the origin check (same origin or a trusted origin; no Origin is refused unless security.csrf.allowMissingOrigin);
  4. the route: a path that matches no WS route is a 404;
  5. the connection caps, below;
  6. your app-wide middleware;
  7. the route's WS.upgrade hook;
  8. the 101, then WS.open.

Middleware runs for upgrades#

The middleware list of createHandler (outermost first) runs on the upgrade request, around the route's WS.upgrade hook. It is the same (event, next) => Response you use for pages and API routes, so one authentication middleware protects the WebSocket routes too. Two outcomes are possible:

  • Return a Response and the upgrade is refused with it. A 401 with www-authenticate, a 403, a redirect() or a 200 are all refusals: the client gets that response and no socket opens. Only the response that next() handed back accepts the upgrade, so a middleware that builds a different response, or returns rewrite(...), refuses it (a rewrite is a 404, because an upgrade has no other route to move to).
  • Call await next() and return what it gave you. The chain then reaches the route's WS.upgrade hook, which may still refuse. next() resolves with a Response also when the hook throws, as on every route, so a middleware can log the status of a refused upgrade.
src/middleware.tsts
import { HttpError, type Middleware } from "@shadow-js/dusk/server";

declare module "@shadow-js/dusk/server" {
  interface DuskLocals {
    user: string | undefined;
  }
}

/** Signed-in users only, for pages, API routes and WebSocket upgrades alike. */
export const requireSession: Middleware = async (event, next) => {
  const user = await event.cookies.getSigned("session");
  if (user === undefined) throw new HttpError(401, "Sign in first");
  event.locals.user = user;
  return next();
};
src/routes/chat.tsts
import type { WebSocketHandlers } from "@shadow-js/dusk/server";

export const WS: WebSocketHandlers = {
  open(ws, event) {
    // `event.locals` is the one `requireSession` filled.
    ws.data.user = event.locals.user;
  },
  message(ws, message) {
    ws.send(`${String(ws.data.user)}: ${String(message)}`);
  },
};

event.locals and everything else the middleware stores on the event are visible to the route's upgrade hook and to open, message and close (the same event is passed to all of them). event.route.kind is "stream" in the middleware.

Headers and cookies a middleware sets on a refusal are kept: event.responseHeaders, event.cookies and headers set on the response next() returned all reach the client with the 401, 403 or redirect. They are not kept on an accepted upgrade. The 101 response carries only the handshake headers (Upgrade, Connection, Sec-WebSocket-Accept); a cookie that a visitor must receive belongs on the HTTP request that loads the page, before the socket is opened.

An app without app-wide middleware behaves as before: the route's WS.upgrade hook decides. Keep the check you already have there if you want a route-specific rule (a room the user may join, say); the middleware is the place for what every route needs.

Limits on a connection#

A connection is limited in four ways. Every limit has a default, and a value out of range is refused when the handler is created.

streams option Default When it is exceeded
wsMaxMessageBytes 1 MiB the connection closes with 1009
wsMaxMessagesPerSecond 100 the connection closes with 1008
wsMaxInFlightMessages 16 the connection closes with 1013
wsMaxConnectionsPerAddress 100 the upgrade is refused with 503 and Retry-After
wsMaxConnections 10 000 the upgrade is refused with 503 and Retry-After

Messages in flight. The message rate limit bounds how fast messages arrive, not how much work they leave running. A message handler that awaits a slow database call can have a call outstanding for every message a fast client sends. wsMaxInFlightMessages bounds those calls per connection. A call counts from the moment the handler returns a promise until that promise settles; a handler that returns synchronously never counts. When the bound is reached the next message is not handled: the connection is closed with 1013 ("try again later") and the close hook hears that code. There is no queue, so a slow handler cannot make the server hold a backlog of messages; the calls already running finish, and ws.signal is aborted so they can stop early. A well-behaved client reconnects.

If one connection legitimately has more than 16 slow calls at once, raise the bound; do not turn it off. A handler that does long work is better written to answer quickly and report the result later with ws.send.

Connections per address. One client address may hold wsMaxConnectionsPerAddress upgraded connections at once, and the handler wsMaxConnections in all. The count is taken when the upgrade is decided and given back when the connection ends, whoever ended it, or when the transport gives up on the decision, so refused and failed upgrades never use up a slot. The refusal is 503 Service Unavailable with Retry-After: 5, written as a plain HTTP response before a socket is upgraded; your middleware and WS.upgrade do not run for it. The address is the one the rate limiter uses (security.trustProxy decides it, and an IPv6 client counts by its /64). A request with no address is counted in the total only, never in a shared bucket.

Behind a reverse proxy, set security.trustProxy. Without it every visitor has the proxy's address, and 100 connections is the limit for the whole site. wsMaxConnectionsPerAddress: false and wsMaxConnections: false turn a cap off; the table that counts addresses then grows with the number of connections instead of being bounded by wsMaxConnections.

src/entry-server.tsts
export const options = {
  security: { trustProxy: { hops: 1 } },
  streams: {
    wsMaxInFlightMessages: 8,
    wsMaxConnectionsPerAddress: 20,
    wsMaxConnections: 5_000,
  },
};

The Node server also treats a peer that hangs up without a close frame as a closed connection at once. Before, an abrupt disconnect was noticed only when the next ping went unanswered.