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.