Skip to content

WebSockets

3 min read

Use @websocket inside an explicitly mounted router.

import system;
import system.webapi;

@router("")
class Routes {
    @websocket("/ws")
    public static async fun echo(ws: ServerWebSocket): void {
        ws.accept().await.unwrap();

        await for (let message in ws.messages()) {
            switch (message) {
                Text(text) => {
                    ws.send_text(text).await.unwrap();
                },
                Binary(bytes) => {
                    ws.send_binary(bytes).await.unwrap();
                },
                Close(_, _) => {},
            }
        }
    }
}

async fun main(): void {
    let app = WebApp();
    app.include_router(Routes.router()).unwrap();
    app.run("127.0.0.1", 8000).await.unwrap();
}

Connect to ws://127.0.0.1:8000/ws with a WebSocket client. A handler must take a ServerWebSocket parameter. The connection is offered to the handler before the handshake completes: call accept() to answer with 101 Switching Protocols, and only then send or receive messages. A request to the route that is not a WebSocket upgrade gets 400 Bad Request without running the handler. Sockets are native server resources and close on cancellation or app shutdown.

Checking Origin

Browsers attach the page's cookies to a WebSocket handshake no matter which site opened it, and CORS does not apply to WebSockets. A socket that relies on cookie authentication is therefore open to cross-site WebSocket hijacking unless the handler checks the Origin header. The server performs no automatic Origin check, so do it before accept():

import system;
import system.webapi;

@router("")
class ChatRoutes {
    @websocket("/chat")
    public static async fun chat(ws: ServerWebSocket, req: HttpIncoming): void {
        let origin = req.headers.get("Origin").unwrap_or("");
        if origin != "https://chat.example.com" {
            ws.close_with(1008, "origin not allowed").await.unwrap();
            return;
        }

        ws.accept().await.unwrap();

        await for (let message in ws.messages()) {
            switch (message) {
                Text(text) => {
                    ws.send_text("you said: " + text).await.unwrap();
                },
                _ => {},
            }
        }
    }
}

Closing the socket before accept() rejects the handshake with 403 Forbidden, and the browser reports a failed connection. A handler that returns without calling accept() rejects it the same way. Clients that are not browsers, such as command-line tools, may omit Origin entirely, so decide whether a missing header is allowed for your endpoint.

Socket methods

Every method takes an optional trailing CancellationToken.

ServerWebSocket method Returns Effect
accept() Future<Result<Unit, IoError>> Completes the handshake. Fails when the client disconnected or the socket was already accepted or closed.
messages() AsyncIterable<WebSocketMessage> Yields each message through the final Close. A transport failure ends iteration.
receive() Future<Result<WebSocketMessage, IoError>> Waits for one complete message.
send_text(text) Future<Result<Unit, IoError>> Sends a text message.
send_binary(bytes) Future<Result<Unit, IoError>> Sends a binary message.
close() Future<Result<Unit, IoError>> Sends a normal closing handshake. Before accept(), rejects the handshake with 403.
close_with(code, reason) Future<Result<Unit, IoError>> Sends a closing handshake with a status code and reason. Before accept(), rejects the handshake with 403.

Sending or receiving before accept() fails with error code EINVAL.

WebSocketMessage variant Payload
Text(text) string
Binary(bytes) byte[]
Close(code, reason) int close code and string reason

Limits and keepalive

ServerConfig sets the limits for every server WebSocket; see Server settings.

Event What the handler sees
A message larger than ws_max_size (16 MiB by default) Close(1009, "message too big"), then the socket ends.
No answer to a server ping within ws_ping_timeout_ms Close(1011, "keepalive ping timeout"), then the socket ends.
The app shuts down The server sends the client 1012 (service restart); the client's reply arrives as Close.
The handler falls ws_max_queue messages behind The server stops reading from the client until the handler catches up.

The server pings every ws_ping_interval_ms (20 seconds by default) and answers client pings itself, so idle connections stay open through proxies and dead clients are detected.

A route may use dependencies and middleware before upgrade. After upgrading, HTTP response status changes are no longer available. WebSocket operations are absent from ordinary OpenAPI paths. The client-side WebSocket API belongs to system.net and supports its documented native, Node, and browser targets.

See the API lookup and complete example.