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:

Known, deliberate shortcut: fragmented frames (FIN=0 continuations) are not supporteddecode_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; rest is whatever was left in the buffer after it (zero or more further frames, or a partial one).

  • Incomplete

    Not 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 error message for this subscription, or the socket closing after connection_ack — a GraphqlError, not an auth failure.

  • RawAuthError(String)

    The socket closed before connection_ack ever arrived — the contract’s signal to treat this as AuthError, 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.

Search Document