A WebSocket route is a GET whose handler returns a WebSocket. The parameters are bound and the middleware runs first,
exactly as for any other route, so authentication, validation errors and refusals are ordinary HTTP responses. Then the
connection is upgraded (101 Switching Protocols) and the body talks to the client. Over HTTP/2 the WebSocket is a stream
of the connection instead (RFC 8441); the handler is the same, see HTTP/2.
// check
@safe:
import andaime;
WebSocket echo(string room, Header!"Authorization" auth)
{
if (auth.value != "Bearer secret")
throw new HttpError(403, "forbidden"); // a plain 403: no upgrade
return WebSocket((scope WsConn ws) {
ws.send("welcome to " ~ room);
foreach (msg; ws) // every message until the client closes
{
if (msg.isText)
ws.send(msg.text);
else
ws.sendBinary(msg.data);
}
}); // returning closes the connection (1000)
}
App buildApp()
{
auto app = new App;
app.ws!("/rooms/{room}", echo);
return app;
}
void main() { serve(&buildApp, 8080); }app.ws!("/path/{param}", handler)on anAppor aGroup, or@Ws("/path")on a controller method. The handler must returnWebSocket, and aget!/post!handler cannot (both are one-line compile errors).@statusdoes not apply.- Parameters work as for a GET: path, query,
Header!,Cookie!,Local!(set by middleware),ClientIp,Context. A missing or invalid one is a 422, before any upgrade. - Headers the handler sets on the context go out with the 101 (the 200 on HTTP/2):
ctx.setHeader("Sec-WebSocket-Protocol", "chat.v1")picks a subprotocol (read the client's offer withHeader!"Sec-WebSocket-Protocol"),setCookieworks too. - A request that is not a WebSocket upgrade gets a 426; a wrong
Sec-WebSocket-Versiongets a 426 withSec-WebSocket-Version: 13; a missing or malformedSec-WebSocket-Keygets a 400. An HTTP/2 WebSocket request (an extended CONNECT) on a route that is not a WebSocket route gets a 400. - WebSocket routes are not in the OpenAPI document (OpenAPI cannot describe them).
- A function of your own named
wshides andaime's (likeget); useandaime.ws!(...)(app)then.
| Member | What it does |
|---|---|
receive() |
The next message: text (isText, text; andaime has checked it is UTF-8), binary (isBinary, data), or the close (isClose, closeCode, the reason in text). After the close it returns the close again. |
tryReceive(out msg, timeout) |
The same, waiting at most timeout: false when nothing came. One fiber can mix receiving with periodic pushes. |
foreach (msg; ws) |
Every text and binary message until the close. |
send(text), sendBinary(bytes) |
A whole message. Waits while the client is slow (backpressure). |
ping(payload) |
A ping; the pong is consumed by receive. |
close(code, reason) |
Starts the close handshake and returns at once; the owner's receive then returns the client's answer. |
isOpen, closeCode |
Whether neither side closed yet; the close code once it is closing. |
disconnect(), abort() |
Close and wait for the answer / close at once. The server calls disconnect for you when the body returns. |
msg.data and msg.text are views into the connection's buffer, valid until the next receive: copy them (.idup) to keep
them. Pings are answered, fragmented messages are reassembled and the protocol's checks are done for you: a violation closes
the connection with the right code (1002 protocol error, 1007 invalid UTF-8, 1009 message too big), and receive returns that
close.
- The 101 (200 on HTTP/2) is sent when the handler returns the
WebSocket; the body runs inside the middleware chain, so middleware sees the whole session (an access log records 101 or 200 and its duration) and itsscope(exit)runs after the connection ends. - When the body returns, the connection is closed with 1000 (if it is still open) and andaime waits up to a second for the client's
answer. When it throws, the client gets a close with 1011 and the error is logged, as any handler error. A client that
disappears ends
receivewith a close whose code is 1006. - Receiving belongs to the fiber that runs the body.
send,sendBinary,pingandclosemay be called from any fiber of the same worker: see the next section. - The protocol is RFC 6455, over HTTP/1.1 or as an HTTP/2 stream (RFC 8441), cleartext and TLS (
wss://).
A browser that loaded the page over HTTP/2 opens its WebSockets on the same connection, as streams (RFC 8441, "extended
CONNECT"): andaime announces SETTINGS_ENABLE_CONNECT_PROTOCOL, the browser sends a CONNECT with :protocol websocket, and
andaime answers 200 instead of 101. Chromium and Firefox do it; a client that does not simply uses HTTP/1.1.
- Nothing changes for the handler: the same route, parameters, middleware,
WsConn, limits and compression.ctx.request.protocolisProtocol.http2andctx.request.connectProtocolis"websocket"; the request's method is GET. - No TCP handshake, no TLS handshake: opening a WebSocket costs one round trip on a page that is already open.
- The stream's flow control is the backpressure: the client can send at most one stream window
(
http2StreamWindowBytes, 256 KiB) that the handler has not read yet, and asendwaits while the client has not opened its window for more thanstreamHighWaterBytes. A client that reads nothing forwriteTimeoutMsgets its stream reset (CANCEL), not the whole connection. - A WebSocket counts as a stream: at most
http2MaxStreams(100) per connection, together with the page's own requests. - Closing is the WebSocket close handshake, then
END_STREAMboth ways. A stream reset by the client endsreceivewith 1006, like a TCP connection that vanished; if the whole connection drops, the handler getsRequestCancelled. ServerConfig.http2WebSockets = falsestops announcing it: browsers then open their own HTTP/1.1 connection for each WebSocket, as before.wsConnect(the client) always uses HTTP/1.1.
A connection can be written to by any request fiber of the worker that owns it: keep the open connections of a room in a list and send to each.
// check
@safe:
import andaime;
WsConn[] room; // module variables are per worker (thread-local): this is the room of one worker
WebSocket chat()
{
return WebSocket((scope WsConn ws) {
room ~= ws;
scope (exit)
foreach (i, x; room)
if (x is ws) { room = room[0 .. i] ~ room[i + 1 .. $]; break; }
foreach (msg; ws)
foreach (peer; room.dup)
if (peer !is ws)
{
try
peer.send(msg.text);
catch (WsClosed)
{
} // that peer is leaving: its own fiber removes it
}
});
}
App buildApp()
{
auto app = new App;
app.ws!("/chat", chat);
return app;
}
void main() { serve(&buildApp, 8080); }- A send to a connection that already ended throws
WsClosedin the sending fiber. The fiber that owns the connection getsRequestCancelledinstead (it unwinds like a request whose client left). AWsConnkept after its connection ended never reaches another client. - Frames of two fibers never interleave: every
sendhands its whole frame to the connection before it can wait. - Workers do not share connections (one App per worker). With
workers > 1a room has one list per worker; to reach the clients of every worker, publish through something all workers read (a databaseLISTEN/NOTIFY, a message broker) and let each worker send to its own clients.
WebSocket field |
Default | Meaning |
|---|---|---|
maxMessageBytes |
1 MiB | A larger message (after decompression) closes the connection with 1009. |
pingInterval |
30 s | While the body waits in receive, a ping goes out after this long without input, and the connection is dropped (1006) after as long again without an answer. Duration.zero disables both. |
compression |
false | permessage-deflate, see below. |
- Backpressure: a
sendwaits once more thanServerConfig.streamHighWaterBytes(64 KiB) is queued for the connection; a client that reads nothing forwriteTimeoutMsis dropped. requestTimeoutMsends at the 101. The protocol's own liveness check is the ping; there is no other limit on a session.- Each open WebSocket holds a fiber and counts toward
maxInFlight(1,024 per worker by default) andmaxConnections. An idle one holds no input buffer: 1,000 open WebSockets take about 21 MB of RSS on one worker (tools/check.shchecks it stays under 30 MB). - Graceful stop: open WebSockets get a close with 1001 (going away) and the server stops once they answer, or after
shutdownGraceMs. LoopStats.webSocketscounts the open ones,webSocketsTotalevery one since the start.
Set compression = true on the WebSocket and messages are compressed when the client offers permessage-deflate (browsers
always do). zlib is loaded at run time (libz.so.1); without it the extension is simply not negotiated. It costs about 300 KB of
zlib state per connection that sends compressed messages (the compressor keeps its 32 KB window between messages, which is
what makes small JSON messages compress well), so it is off by default. Messages under 64 bytes are sent uncompressed.
// check
import andaime;
void main()
{
WsClientOptions opts;
opts.compression = true;
auto ws = wsConnect("wss://echo.example.com/ws", [HeaderPair("Authorization", "Bearer secret")], opts);
scope (exit)
ws.disconnect();
ws.send("hello");
auto reply = ws.receive();
}ws://andwss://; TLS useshttpClient()'s settings (certificate and host name checks,caFile).- Inside a request every wait suspends only the fiber; outside one (a script,
main) it blocks. - The connection belongs to the fiber that opened it (it receives); other fibers of the worker may
send. A send that finds the socket full while the owner is waiting stays queued until the owner's next wait. WsClientOptions:connectTimeout(DNS, TCP, TLS and the handshake; 10 s),maxMessageBytes,pingInterval,writeTimeout,compression. Failures areHttpClientError(HttpClientTimeoutpastconnectTimeout).
TestClient.websocket runs the handler in memory: it sends a script of client frames and returns what the server sent.
auto c = TestClient(buildApp());
auto r = c.websocket("/rooms/a", WsScript().text("hi").ping("p").close(), hdr("Authorization", "Bearer secret"));
assert(r.status == 101);
assert(r.texts == ["welcome to a", "hi"]);
assert(r.pongs == ["p"] && r.closeCode == 1000);A refused upgrade shows up as r.status (403, 422, 426) with r.response holding the HTTP answer. Once the script is used up
the client's input ends (the server sees 1006 unless the script ended with close). Set c.http2 = true to send the
requests as HTTP/2 ones: websocket then opens the WebSocket as a browser does on an HTTP/2 connection (an extended
CONNECT), and a successful one has r.status == 200.
The proxy must forward the upgrade. nginx:
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s; # longer than pingInterval: the pings keep the connection alive
}Caddy's reverse_proxy forwards WebSockets without extra configuration. A proxy terminates the browser's HTTP/2 itself:
whether the browser's WebSocket rides its HTTP/2 connection is between the browser and the proxy, and the proxy then speaks
HTTP/1.1 (the upgrade) to andaime.