WebSockets¶
You declare a WebSocket protocol once – what the client sends and what the server sends – and serve it from a route or open it from a client. Each end gets a socket typed from its own side.
An echo server¶
(* A WebSocket: GET /echo opens one, and whatever the client says comes
back to it.
What each side sends is declared once, as a protocol, and the route
serves it: the framework runs the protocol itself -- the handshake, the
frames, ping and pong, the close -- and the handler only receives and
sends. A loop written with [let*] ends at the first thing that went
wrong, and how it ended decides how the socket is closed: a message the
description cannot read closes it with 1007. *)
module Ws = Spindle.Websocket
let ( let* ) = Result.bind
type said = { text : string } [@@deriving wiretype]
let protocol =
Ws.protocol ~client:(Ws.json said_json) ~server:(Ws.json said_json) ()
let echo ws =
let rec loop () =
let* said = Ws.receive ws in
let* () = Ws.send ws said in
loop ()
in
loop ()
let routes =
[
Spindle.get
Spindle.Path.(s "echo")
(Spindle.Returns.websocket protocol)
(Spindle.Dep.return (Ok echo));
]
let () =
Spindle.Log.setup ();
Eio_main.run @@ fun env -> Spindle.serve env routes
Ws.protocol ~client ~server ()says what each side sends:Ws.jsonof a wiretype description,Ws.textorWs.binary(each astring). Add~subprotocol:"name"to require aSec-WebSocket-Protocol.Spindle.Returns.websocket protocolmakes the route a socket. Its dependency answersOk handler, or a refusal before the upgrade.- The handler calls
Ws.receiveandWs.sendand returns aresult. Alet*loop stops at the first error:Closed(with its code and reason),Lost,Unreadable, orUnwritablefor a message that cannot be sent – JSON its description cannot encode, or text that is not UTF-8.
A plain request to the route is refused with 426, as below. So is an
upgrade from another site’s page (403 cross_origin; see
~trusted_origins), since the browser would
attach your site’s cookies to it:
HTTP/1.1 426 Upgrade Required
upgrade: websocket
...
{"error":"upgrade_required","message":"This address opens a WebSocket, and nothing else."}
Connecting as a client¶
(* The other end of websocket_server.ml: a client that opens its /echo, says three
things and hears each come back. Run the server first, then this:
dune exec examples/websocket_server.exe
dune exec examples/websocket_client.exe
The protocol is the server's, from the client's side: this socket sends
what the client sends and receives what the server does. Two programs
declare it twice here; an application whose two ends are separate
programs puts it in a library both link, so they cannot drift apart.
The call is a bracket: the connection lives while the function runs and
is closed after it, and the answer is what the function returned -- here,
what it heard -- or why the socket, or the handshake, ended first, which
the log has already said. *)
module Ws = Spindle.Websocket
let ( let* ) = Result.bind
type said = { text : string } [@@deriving wiretype]
let protocol =
Ws.protocol ~client:(Ws.json said_json) ~server:(Ws.json said_json) ()
let converse ws =
let rec say heard = function
| [] -> Ok (List.rev heard)
| text :: rest ->
let* () = Ws.send ws { text } in
let* back = Ws.receive ws in
say (back.text :: heard) rest
in
say [] [ "hello"; "is anybody there"; "goodbye" ]
let () =
Spindle.Log.setup ();
Eio_main.run @@ fun env ->
Spindle_client.run env @@ fun client ->
match
Spindle_client.websocket client protocol "ws://localhost:8080/echo" converse
with
| Ok heard -> List.iter (fun text -> print_endline ("heard: " ^ text)) heard
| Error _ -> () (* the socket's log line has said why *)
Spindle_client.websocket client protocol url f opens a ws:// or wss://
URL, runs f on the socket and closes it when f returns. It answers f’s
result, or a call’s `Unsent or `Lost (the handshake could not be
made), `Not_upgraded (the server answered without a 101, with that
answer) or `Websocket (how the socket ended). ~timeout bounds the
handshake and each write.
Both programs declare the same protocol. When the two ends are separate programs, put it in a library both link so they cannot drift apart.
Closing a socket¶
How the handler’s loop ended is the close code:
| The handler | Close code |
|---|---|
returns Ok |
1000 |
| got a message its description could not read | 1007 |
| had a message its description could not write | 1011 |
| raised (logged as a bug) | 1011 |
returns Error (Closed _) or Error (Lost _) |
none: the socket has already ended |
To close with another code, call Ws.close ~code ~reason ws and return.
Nothing raises into your handler: a socket whose peer has gone answers its
next receive or send with an error. A send you cut off – under
Eio.Time.Timeout.run, or in an Eio.Fiber.first it lost – may have
written part of its frame, so it ends the socket: the cancellation goes on,
and every later send answers Lost.
The framework handles the rest of the protocol for you:
- ping and pong: a ping after
~keep_alive(15 s) of silence, and a peer silent for twice that isLost; - a peer that takes nothing written for the server’s
send_timeout(30 s) isLost, and one still reading, however slowly, is sent the whole message; - a message over
~max_message(1 MiB) closes the socket with 1009, and one of a kind the socket does not carry with 1003; - a stopping server closes every socket with 1001;
- one log line when a socket ends: how long it lived and why.
~keep_alive and ~max_message are arguments of
Spindle.Returns.websocket and Spindle_client.websocket.
Broadcasting to many sockets¶
To send one message to many sockets, encode it once with
Ws.encode (Ws.server protocol) value and send that to each with
Ws.send_encoded. Make the hub a
Spindle.Broadcast of encoded
messages. A handler that both receives and forwards runs two loops, here
under Eio.Fiber.first, which ends both when either does:
let hub : (unit, said Ws.encoded) Spindle.Broadcast.t =
Spindle.Broadcast.create ()
let chat ws =
let sub = Spindle.Broadcast.subscribe hub ~topic:"lobby" () in
Fun.protect ~finally:(fun () -> Spindle.Broadcast.unsubscribe hub sub)
@@ fun () ->
let rec listen () =
let* said = Ws.receive ws in
Spindle.Broadcast.publish hub ~topic:"lobby"
(Ws.encode (Ws.server protocol) said);
listen ()
in
let rec forward () =
match Spindle.Broadcast.next sub with
| Some message ->
let* () = Ws.send_encoded ws message in
forward ()
| None -> Ok ()
in
Eio.Fiber.first listen forward
When the client leaves, listen ends and forward is cancelled. If it was
cancelled partway through a send, the socket ends, as
Closing a socket says; the client has gone anyway.
Testing and OpenAPI¶
Spindle.Test.websocket app protocol "/echo" fopens a socket as a browser does and runsfon the client’s end, with no port. A route that refuses the upgrade answersRefused. Keep-alive never fires in a test.- The OpenAPI document lists the route as a
101, with each side’s schema underx-websocket; the Zod module types it as{ websocket: { client, server } }.
To speak another protocol over a connection, see Spindle.Response.takeover
and Spindle.Websocket.run_server / run_client in the reference.