WebSockets: upgrades, middleware and limits
A WebSocket route is decided before a socket exists.
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:
- the guards: URL length, method (GET), allowed host;
- the handshake headers (
Upgrade,Connection,Sec-WebSocket-Key, version 13); - the origin check (same origin or a trusted origin; no
Originis refused unlesssecurity.csrf.allowMissingOrigin); - the route: a path that matches no
WSroute is a 404; - the connection caps, below;
- your app-wide
middleware; - the route's
WS.upgradehook; - 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
Responseand the upgrade is refused with it. A 401 withwww-authenticate, a 403, aredirect()or a 200 are all refusals: the client gets that response and no socket opens. Only the response thatnext()handed back accepts the upgrade, so a middleware that builds a different response, or returnsrewrite(...), 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'sWS.upgradehook, which may still refuse.next()resolves with aResponsealso when the hook throws, as on every route, so a middleware can log the status of a refused upgrade.
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();
};
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.
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.