bsdkrun/ws
A hand-rolled graphql-transport-ws client over raw TCP/TLS — no
gleam_erlang, gleam_otp, mist, or any other WebSocket/HTTP Hex
package, per this SDK’s “erlang-only, no new dependencies” constraint.
This module is split in two, deliberately:
- Pure protocol logic (frame encode/decode, the handshake accept-key
computation, and the tiny
graphql-transport-wsJSON envelope build/parse functions) — ordinary Gleam functions, unit-tested directly againstBitArray/Stringliterals intest/ws_test.gleamwith no socket involved at all. - The connection process (
connect/ensure/subscribe/…) — a thin Gleam wrapper around a background process spawned bybsdkrun_remote_ffi.erl. That process owns the raw socket and runs a genuine blocking Erlangreceiveloop, dispatching both socket traffic and this SDK’s own control messages; Gleam has noreceiveconstruct of its own; this is the “small amount of Erlang FFI to spawn a receiving process” the feature’s design note anticipated. The stateful parts (the socket, the ack flag, the pending-subscribe queue, the subscription-id → reply-Subjectmap) live entirely in that Erlang process; it calls back into this module’s pure functions (by qualified name,'bsdkrun@ws':function_name/arity— Gleam compiles to plain Erlang modules, so this is an ordinary intra-VM call) for every actual protocol decision, so the protocol logic itself still lives, and is tested, in Gleam.
Known, deliberate shortcut: fragmented frames (FIN=0 continuations)
are not supported — decode_frame reports them as a FrameError
rather than reassembling them. The daemon’s messages here (small JSON
control envelopes and shell-output chunks the daemon itself already
caps) are not expected to need it in practice; this was judged not
worth the extra state machine relative to the value, per the feature’s
own note that this is an acceptable thing to document and skip.
Types
An opaque handle to the background process that owns one WebSocket to one
daemon. Get one with ensure (shared, cached by url+token) or connect
(always fresh).
pub opaque type Connection
One decoded (or, for encode_frame, to-be-encoded) WebSocket frame.
Continuation frames are not represented — see the module doc.
pub type Frame {
TextFrame(String)
BinaryFrame(BitArray)
CloseFrame(code: Int, reason: String)
PingFrame(BitArray)
PongFrame(BitArray)
}
Constructors
-
TextFrame(String) -
BinaryFrame(BitArray) -
CloseFrame(code: Int, reason: String) -
PingFrame(BitArray) -
PongFrame(BitArray)
The result of trying to decode one frame off the front of a buffer.
pub type FrameResult {
Decoded(frame: Frame, rest: BitArray)
Incomplete
FrameError(String)
}
Constructors
-
Decoded(frame: Frame, rest: BitArray)A whole frame was decoded;
restis whatever was left in the buffer after it (zero or more further frames, or a partial one). -
IncompleteNot enough bytes yet for even the frame header, or for its full payload — wait for more data and try again with the same buffer plus whatever arrived.
-
FrameError(String)The buffer starts with something that is not a supported frame at all (a bad header, or a fragmented/continuation frame).
One parsed graphql-transport-ws protocol message.
pub type Incoming {
Ack
Next(id: String, data: dynamic.Dynamic)
ErrorMsg(id: String, message: String)
Complete(id: String)
Ping
}
Constructors
-
Ack -
Next(id: String, data: dynamic.Dynamic) -
ErrorMsg(id: String, message: String) -
Complete(id: String) -
Ping
An event delivered to a subscriber’s Subject, as the Erlang connection
process observes them off the socket. bsdkrun/client translates these
into the richer ShellEvent / SubscriptionEvent types callers see.
pub type RawEvent {
RawNext(dynamic.Dynamic)
RawError(String)
RawAuthError(String)
RawComplete
}
Constructors
-
RawNext(dynamic.Dynamic) -
RawError(String)A GraphQL
errormessage for this subscription, or the socket closing afterconnection_ack— aGraphqlError, not an auth failure. -
RawAuthError(String)The socket closed before
connection_ackever arrived — the contract’s signal to treat this asAuthError, since the daemon closes unacknowledged sockets exactly when it rejects the token. -
RawComplete
Where a ws:///wss:// URL points, in the shape the raw-socket FFI
needs: host, port (with the scheme’s default filled in), path, and
whether to speak TLS.
pub type Target {
Target(host: String, port: Int, path: String, tls: Bool)
}
Constructors
-
Target(host: String, port: Int, path: String, tls: Bool)
Values
pub fn alive(conn: Connection) -> Bool
Whether the connection process is still alive. False after the socket
closed for any reason.
pub fn build_complete(id: String) -> String
{"id":..,"type":"complete"} — how a client unsubscribes.
pub fn build_connection_init(token: String) -> String
{"type":"connection_init","payload":{"authorization":"Bearer <token>"}}
pub fn build_pong() -> String
{"type":"pong"} — the reply to a server {"type":"ping"} keepalive.
pub fn build_subscribe(
id: String,
query: String,
variables_json: String,
) -> String
{"id":..,"type":"subscribe","payload":{"query":..,"variables":..}}.
variables_json is spliced in verbatim — it must already be a valid,
serialized JSON value (an object, or "{}"), which is what every caller
in bsdkrun/client produces via gleam_json’s builders before handing
it here. This (string-based, rather than composed from json.Json
values) shape is what lets the Erlang connection process build the same
frame text the pure Gleam functions here would, without needing to link
gleam_json’s Json builder type across the Gleam/Erlang FFI boundary.
pub fn close(conn: Connection) -> Nil
Close the connection outright, regardless of open subscriptions.
pub fn compute_accept_key(client_key: String) -> String
base64(sha1(client_key <> magic_guid)) — what a compliant server must
echo back as Sec-WebSocket-Accept, and what a client must therefore
verify the response against.
pub fn connect(
url: String,
token: String,
) -> Result(Connection, error.Error)
Open a fresh WebSocket to url (a ws:///wss:// URL — see
derive_url), complete the RFC 6455 handshake, and send
connection_init. Blocks until the handshake either succeeds or fails;
does not wait for connection_ack — later subscribe calls are
queued internally until the ack arrives (see the module doc).
pub fn decode_frame(buffer: BitArray) -> FrameResult
Decode one frame off the front of buffer, per RFC 6455 §5.2. Every
branch that does not have enough bytes yet falls through to Incomplete
rather than crashing, so a caller can feed this the same (growing) buffer
across repeated TCP reads.
pub fn derive_url(http_url: String) -> String
Derive the subscriptions URL from the GraphQL HTTP endpoint URL:
http:// → ws://, https:// → wss://, trailing slashes on the path
stripped, /ws appended. Mirrors web/src/lib/graphql.ts’s wsUrl.
pub fn encode_frame(frame: Frame) -> BitArray
Encode frame as a masked client→server frame — RFC 6455 requires
every frame a client sends to be masked with a fresh random 4-byte key.
Server→client frames (what decode_frame reads) are never masked by a
spec-compliant server, but decode_frame honours the mask bit if a
server sets it anyway, since unmasking is symmetric with masking.
pub fn ensure(
url: String,
token: String,
) -> Result(Connection, error.Error)
Like connect, but reuses an existing live connection for the same
url/token pair rather than opening a new socket — “one shared socket
per client connection” (the design note’s phrase), implemented as a
small persistent_term-backed cache in bsdkrun_remote_ffi.erl keyed by
url <> token. bsdkrun/client’s Client is a plain, immutable
url/token pair with nowhere of its own to stash a live pid — this cache
is what gives repeated calls against the same Client a shared socket
without making Client itself mutable.
pub fn generate_client_key() -> String
A fresh Sec-WebSocket-Key: 16 random bytes, base64-encoded.
pub fn parse_incoming(text: String) -> Result(Incoming, Nil)
pub fn subscribe(
conn: Connection,
id: String,
query: String,
variables_json: String,
reply: subject.Subject(RawEvent),
) -> Nil
Start a subscription. Events (RawNext/RawError/RawAuthError/
RawComplete) are delivered to reply as they arrive — including
whatever was already buffered before this call, per the daemon’s
“output is buffered from the moment the session opens” guarantee.
pub fn unsubscribe(conn: Connection, id: String) -> Nil
Send {"id":..,"type":"complete"} to end a subscription. Closes the
socket once it was the last one open, per the design note.