Module: Bsdkrun::WebSocketFrame

Defined in:
lib/bsdkrun/websocket_frame.rb

Overview

RFC 6455 §5.2 frame encode/decode, hand-rolled because the Ruby standard library has no WebSocket client.

Deliberately narrow: only what WsClient needs to speak graphql-transport-ws, which exchanges small JSON text frames. Fragmented messages (a logical message split across CONTINUATION frames) are unsupported — WebSocketFrame.read returns whatever single frame arrives, and a caller that gets a non-FIN frame is expected to treat it as an error. In practice every graphql-ws message this SDK deals with is small enough that no server has ever been observed to fragment one.

Constant Summary collapse

OPCODES =
{
  continuation: 0x0,
  text: 0x1,
  binary: 0x2,
  close: 0x8,
  ping: 0x9,
  pong: 0xA
}.freeze

Class Method Summary collapse

Class Method Details

.apply_mask(payload, key) ⇒ String

XOR-mask (or, symmetrically, unmask) payload against a 4-byte key.

Parameters:

  • payload (String)
  • key (String)

    exactly 4 bytes.

Returns:

  • (String)


80
81
82
83
84
85
86
# File 'lib/bsdkrun/websocket_frame.rb', line 80

def apply_mask(payload, key)
  out = payload.b
  key_bytes = key.bytes
  bytes = out.bytes
  bytes.each_index { |i| bytes[i] ^= key_bytes[i % 4] }
  bytes.pack("C*")
end

.build(opcode:, payload: "".b, mask: true) ⇒ String

Build a complete frame.

Parameters:

  • opcode (Symbol)

    a key of OPCODES.

  • payload (String) (defaults to: "".b)

    the frame body (binary-safe).

  • mask (Boolean) (defaults to: true)

    true for client->server frames (RFC 6455 requires it — the mask key is randomly generated per frame); false for server->client frames, which MUST NOT be masked.

Returns:

  • (String)

    the raw bytes to write to the socket.



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/bsdkrun/websocket_frame.rb', line 34

def build(opcode:, payload: "".b, mask: true)
  payload = payload.to_s.dup.force_encoding(Encoding::BINARY)
  code = OPCODES.fetch(opcode) { raise ArgumentError, "unknown opcode #{opcode.inspect}" }

  frame = +"".b
  frame << [0x80 | code].pack("C") # FIN=1, RSV1-3=0
  frame << length_bytes(payload.bytesize, mask)

  if mask
    key = Random.bytes(4)
    frame << key
    frame << apply_mask(payload, key)
  else
    frame << payload
  end
  frame
end

.read(io) ⇒ Array(Boolean, Integer, String)

Read exactly one frame from io (anything responding to #read(n) — a TCPSocket, an OpenSSL::SSL::SSLSocket, or a StringIO in tests).

Parameters:

Returns:

  • (Array(Boolean, Integer, String))

    [fin, opcode, payload]payload is already unmasked.

Raises:

  • (EOFError)

    if the socket closes mid-frame.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/bsdkrun/websocket_frame.rb', line 59

def read(io)
  b0, b1 = read_exact(io, 2).unpack("C2")
  fin = (b0 & 0x80) != 0
  opcode = b0 & 0x0F
  masked = (b1 & 0x80) != 0
  len = b1 & 0x7F

  len = read_exact(io, 2).unpack1("n") if len == 126
  len = read_exact(io, 8).unpack1("Q>") if len == 127

  mask_key = masked ? read_exact(io, 4) : nil
  payload = len.positive? ? read_exact(io, len) : "".b
  payload = apply_mask(payload, mask_key) if masked

  [fin, opcode, payload]
end