Module: Hyperion::WebSocket
- Defined in:
- lib/hyperion/websocket/frame.rb,
lib/hyperion/websocket/handshake.rb,
lib/hyperion/websocket/connection.rb,
lib/hyperion/websocket/close_codes.rb,
ext/hyperion_http/websocket.c
Overview
WS-4 (2.1.0) — per-connection WebSocket wrapper.
Sits on top of WS-1 (the hijacked socket), WS-2 (the validated handshake), and WS-3 (frame ser/de) and exposes a simple message-oriented API:
ws = Hyperion::WebSocket::Connection.new(socket,
buffered: env['hyperion.hijack_buffered'],
subprotocol: env['hyperion.websocket.handshake'][2])
loop do
type, payload = ws.recv
break if type == :close || type.nil?
ws.send(payload, opcode: type) # echo
end
Responsibilities:
-
Continuation reassembly. The peer can split a single application message across many frames (‘text` + `continuation`* + final `FIN=1`); `recv` only returns when the message is complete. Control frames (`ping`, `pong`, `close`) MAY be interleaved between fragments per RFC 6455 §5.4 — we handle them inline without disrupting the reassembly buffer.
-
Auto-pong. RFC 6455 §5.5.2 — server SHOULD reply to a ping with a pong carrying the same payload. The default behaviour fires the pong before returning control to the caller; ‘on_ping` lets the app observe the event but does NOT replace the auto-response (the server stays compliant even if the app’s hook does nothing).
-
Close handshake. Either side initiating a close gets the bidirectional shutdown right: an inbound close triggers an outbound close echo (RFC 6455 §5.5.1) and ‘recv` returns `[:close, code, reason]`; calling `close(code: 1000)` writes our close frame and waits up to `drain_timeout` seconds for the peer’s matching close before tearing down the socket.
-
Per-message size cap. ‘max_message_bytes` (default 1 MiB) bounds the reassembly buffer; the moment a continuation frame would push the running total past the cap we send close 1009 (Message Too Big) and surface the close to the caller.
-
UTF-8 validation. Text frames whose payload isn’t valid UTF-8 trip close 1007 (Invalid Frame Payload Data) per RFC 6455 §8.1.
Things deliberately NOT in this class (deferred to 2.1.x):
-
permessage-deflate (RFC 7692). The handshake-time negotiation would live in WS-2 and the per-frame compression here; out of scope for 2.1.0.
-
Send-side fragmentation. ‘send` writes a single FIN=1 frame regardless of payload size. Browsers / well-behaved clients have no trouble with multi-MB single frames; if a use case shows up we can add an opt-in `fragment_threshold:` later.
-
Backpressure / outbound queueing. Writes are synchronous on the caller’s thread; ‘socket.write` blocks if the kernel buffer is full. The `IO.select`-based read loop already cooperates with async-io when a fiber scheduler is installed (Ruby 3.3 redirects `select` automatically), so the recv side is fiber-friendly out of the box.
Defined Under Namespace
Modules: Builder, CFrame, CloseCodes, Handshake, Parser, RubyFrame Classes: Connection, Frame, HandshakeError, ProtocolError, StateError
Constant Summary collapse
- OPCODES =
Symbolic opcode table. Reverse table built lazily for the parse-side lookup. Frozen so accidental mutation can’t corrupt the parse hot path.
{ continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xA }.freeze
- OPCODE_NAMES =
OPCODES.invert.freeze
- NATIVE_AVAILABLE =
defined?(::Hyperion::WebSocket::CFrame) && ::Hyperion::WebSocket::CFrame.respond_to?(:parse)
- EMPTY_BIN_PAYLOAD =
2.4-B (S5): the empty-payload Frame body. Pre-2.4-B every empty text/binary/control frame allocated ‘(+”).b` — two String allocations (the unfrozen `+”` and its `.b` re-encoding clone). Frames carry frozen empty payloads idempotently — a downstream caller that mutates would have been silently broken pre-2.4-B because the allocation was already non-shared. Sharing one frozen binary empty String per process is a strict win.
String.new('', encoding: Encoding::ASCII_8BIT).freeze
- BINARY_ENCODING =
2.4-B (S4): pre-allocated Encoding identity check. ‘payload.b` always allocates a new String, even when payload is already ASCII-8BIT. Skipping the no-op clone saves one String per send().
Encoding::ASCII_8BIT
- GUID =
GUID from RFC 6455 §1.3 — concatenated with the client’s Sec-WebSocket-Key, SHA-1’d, base64’d to compute the accept value.
'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'- SUPPORTED_VERSION =
RFC 6455 §4.2.1: only protocol version 13 is supported. The handshake responds 426 Upgrade Required + ‘Sec-WebSocket-Version: 13` so the client knows the right version to retry with.
'13'- READ_CHUNK_BYTES =
The 16 KB read chunk size matches what Hyperion::Connection uses for HTTP/1.1 — small enough to keep memory pressure low under many idle WS connections, big enough that a 1 MiB message arrives in ~64 syscalls.
16 * 1024
- DEFLATE_SYNC_TRAILER =
2.3-C — RFC 7692 §7.2.1 sync trailer. The 4-byte deflate-block terminator that the deflater emits between messages and the inflater needs prepended back. Frozen so the per-frame strip / append paths share one constant rather than allocating a fresh Array of bytes each time.
"\x00\x00\xff\xff".b.freeze
- CLOSE_NORMAL =
RFC 6455 §7.4.1 close codes we emit. The peer is free to send any registered code; we surface their integer verbatim in ‘recv`.
1000- CLOSE_GOING_AWAY =
1001- CLOSE_PROTOCOL_ERROR =
1002- CLOSE_UNSUPPORTED =
1003- CLOSE_INVALID_PAYLOAD =
1007- CLOSE_POLICY =
1008- CLOSE_MESSAGE_TOO_BIG =
1009- CLOSE_INTERNAL_ERROR =
1011