std.ws

WebSocket client.

import std.ws

conn = ws.connect("wss://example.com/socket")
ws.send(conn, "hello")
msg = ws.recv(conn)          # blocks
ws.close(conn)

Needs net.

Receiving

recv blocks until a message arrives, and returns null when the connection closes. So the idiomatic loop ends on close:

loop {
    msg = ws.recv(conn)
    if msg == null { break }
    handle(msg)
}

Server side

The upgrade lives in std.http, which can turn a request into a WebSocket connection. ECKO_HTTP_MAX_WS_CONNS (default 1024) bounds concurrent connections - a bound that matters, since each one holds resources for as long as the client keeps it open.

State across connections

Each connection gets its own copy of the variables on_ws captures, made when it opens - the same share-nothing model as an HTTP handler. A write to a captured mut lasts for that connection's messages and no other connection sees it. To share across connections, use a cell:

mut sent = 0  # per connection
online = cell(0)  # shared by every connection
http.serve(8080, handler, on_ws: fn(c) {
    n = cell_update(online, fn(v) v + 1)
    mut m = ws.recv(c)
    while m != null {
        sent = sent + 1
        ws.send(c, "message {sent} of yours, {n} connected")
        m = ws.recv(c)
    }
    cell_update(online, fn(v) v - 1)
})

What the client asked for

ws.request(c) gives you the request the socket was opened with - the same map an HTTP handler receives, so one piece of code can read either:

http.serve(8080, handler, on_ws: fn(c) {
    r = ws.request(c)

    if not starts_with(r.path, "/rooms/") {
        ws.close(c)
        return
    }
    if not session_ok(r.headers.cookie) {
        ws.close(c)
        return
    }

    join_room(r.path, c)
})

method, path, params and headers, carrying whatever the browser sent - its cookies included, which is how a socket opened from a page identifies itself.

A client connection answers null. It was not upgraded from anything, so there is no request to give; asking is reasonable, and code holding either kind should not have to know which.

on_ws runs after the handshake, so closing from there is a close code rather than a status. To answer an unwanted upgrade with a real HTTP status, use on_upgrade.

Refusing before the handshake

on_upgrade runs before the 101 with the same request map. Return null to accept; return a response to refuse with it:

http.serve(8080, handler,
    on_upgrade: fn(r) {
        if r.path != "/live" { return http.not_found() }
        if get(r.headers, "cookie") == null { return http.response(403, "sign in") }
        null                                  # null accepts: send the 101
    },
    on_ws: fn(c) { ws.send(c, "welcome") })

The client sees a 404 or a 403 the way it would from any other route, instead of a socket that opens and immediately closes. A refused upgrade costs no connection slot, so refusing is not a way to exhaust ECKO_HTTP_MAX_WS_CONNS.

It is a second callback rather than a return value on on_ws because the two have different lifetimes: on_upgrade answers one question and returns, while on_ws serves the socket for as long as it lives. A hook that raises refuses with a 500 - it has not said yes, and an upgrade is not something to grant on a maybe.

An on_ws that throws closes the socket. The client sees only the close; the server's stderr gets the reason, with the line that raised it:

ecko ws: handler failed: map has no key 'room' (at app.ecko:12:15)

Which origins may connect

An upgrade whose Origin does not match the server's own Host is refused with 403. Upgrades bypass the router, so this is the only place such a request can be turned away before it is accepted - and without it any page on any site could open your socket from a visitor's browser, that visitor's cookies attached.

# same-origin only - the default, nothing to configure
http.serve(8080, handler, on_ws: fn(c) ws.recv(c))

# plus a named front end
http.serve(8080, handler, on_ws: fn(c) ws.recv(c),
           origins: ["https://app.example"])

# public socket, opted into explicitly
http.serve(8080, handler, on_ws: fn(c) ws.recv(c), origins: ["*"])

Clients that send no Origin at all - everything that is not a browser - are always allowed: without a browser there are no ambient cookies to ride on, and refusing them would break every CLI and service-to-service client.

Concurrency

A connection is not a value you copy - do not use one from several tasks at once. The usual arrangement is one task reading and a channel carrying messages to whatever processes them, which also gives you backpressure.

Errors

Failures raise { kind: "net" }. Network connections drop, so a long-lived client needs reconnection logic - ws.recv returning null is where you notice.

When SSE is the better choice

For one-directional streaming from server to client, server-sent events over plain HTTP are simpler: no upgrade, no framing, no separate protocol for proxies to handle, and browsers reconnect automatically. See Streaming responses & SSE.

Use WebSockets when the client genuinely needs to send messages mid-stream - a chat, a collaborative editor, a game.

Higher level

For a Redis-style protocol client written over raw sockets, see std.net and the client packages built on it.