Skip to content

WebSocket

Ori Pekelman edited this page May 18, 2026 · 1 revision

Tep::WebSocket

RFC 6455 server-side WebSocket. The DSL hook lowers to a per-connection Driver (faye/websocket-driver-ruby-shape: text / binary / ping / pong / close writers + event-callback slots) running inside a Tep::Scheduler-managed fiber, with the recv loop parking on Tep::Scheduler.io_wait so one worker process can hold many long-lived connections concurrently.

Scope

Feature v1
RFC 6455 handshake (server) yes
Subprotocol negotiation yes
Single-frame text / binary yes
Fragmented messages (continuation) no
Ping / Pong (auto-pong on ping) yes
Close (code + reason round-trip) yes
Strict close on protocol violation yes (1002)
UTF-8 validation on text frames no (deferred)
64-bit length frames parse only
permessage-deflate extension no
TLS (wss://) no
Client-side no

Tep is server-shaped. For wss://, terminate TLS at an upstream proxy (nginx, caddy, ALB) and serve plaintext WS to Tep.

Requirements

WebSocket requires the cooperative server. Opt in once at top of the app:

set :scheduler, :scheduled

Under the default prefork-blocking server, a WS handler returns 501 Not Implemented with a one-line body pointing at the flag — the recv loop needs Tep::Scheduler.io_wait, which the blocking server doesn't run.

DSL

require 'sinatra'

set :scheduler, :scheduled

websocket '/chat' do |ws|
  on_open do |evt|
    ws.text("welcome")
  end

  on_message do |evt|
    ws.text("echo: " + evt.data)
  end

  on_close do |evt|
    # evt.code, evt.reason
  end
end

Six event slots map to Tep::WebSocket::Driver's setter API:

DSL block Driver setter Event payload (evt.*)
on_open set_on_open(h) (no data — fires before first recv)
on_message set_on_message(h) evt.data — text or binary payload
on_close set_on_close(h) evt.code, evt.reason
on_ping set_on_ping(h) evt.data — ping payload (≤125 bytes)
on_pong set_on_pong(h) evt.data — pong payload
on_error set_on_error(h) reserved for protocol-level errors

Each on_X block is extracted by bin/tep at build time and lowered to its own Tep::WebSocket::Handler subclass — the generated class binds <your-ws-param-name> = @ws at the top of handle_event(evt) so the block body's references just work.

The block parameter name is yours to choose:

websocket '/feed' do |socket|       # name it whatever you want
  on_message do |msg|
    socket.text("got: " + msg.data)
  end
end

Driver API

Inside an event block, ws (or whatever you named the param) is the per-connection Tep::WebSocket::Driver.

ws.text("hello")               # send a text frame
ws.binary(bytes)               # send a binary frame (text-payload-only today)
ws.ping("")                    # send a ping (peer should pong back)
ws.pong(payload)               # explicit pong; auto-sent on recv'd ping anyway
ws.close(1000, "bye")          # initiate close handshake
ws.set_max_frame_size(8 * 1024 * 1024)
ws.set_subprotocol("chat")     # echoes in Sec-WebSocket-Protocol on 101

Close codes from RFC 6455 §7.4.1 are exposed as constants:

Tep::WebSocket::CLOSE_NORMAL          # 1000
Tep::WebSocket::CLOSE_GOING_AWAY      # 1001
Tep::WebSocket::CLOSE_PROTOCOL_ERROR  # 1002  (set by the framework on malformed frames)
Tep::WebSocket::CLOSE_UNSUPPORTED     # 1003
Tep::WebSocket::CLOSE_INVALID_UTF8    # 1007
Tep::WebSocket::CLOSE_POLICY_VIOLATION # 1008
Tep::WebSocket::CLOSE_MESSAGE_TOO_BIG # 1009

Lifecycle

┌─ client GET / Upgrade: websocket ────┐
│                                      ▼
│  Tep::WebSocket::Handshake.check(req)
│    │ valid → drv = Driver.new; install handlers;
│    │         res.start_websocket(accept_key, drv)
│    │ invalid → res.set_status(400)
│
└─ Server::Scheduled.write_response
      │ res.upgrading_ws == true →
      │   1. write the 101 + Sec-WebSocket-Accept response
      │   2. drv.set_fd(client)
      │   3. Connection.new(drv).run
      │        │ synthetic open event
      │        │ loop {
      │        │   Tep::Scheduler.io_wait(fd, READ, idle_timeout)
      │        │   sphttp_recv_into_frame
      │        │   Frame.parse_from_buf → dispatch (text/binary/ping/pong/close)
      │        │ }
      │        └ exit on close / EOF / idle timeout
      └ handle_connection closes the fd

Idle timeout defaults to 300 seconds (no frames received). Override on the connection from a Tep::WebSocket::Connection reference (advanced; the DSL doesn't surface this — open an issue if you need it).

Compliance

Class Behavior
Strict-emit Server frames are never masked (§5.3). Reserved bits 0.
Strict-accept (1002) Client frames must be masked. Reserved bits / opcodes rejected. Control frames > 125 bytes rejected. Fragmented control frames rejected.
Liberal-accept Close codes, pong contents, unsolicited pong.

UTF-8 validation on text frames (a Strict-accept that closes 1007 on invalid sequences) is deferred — the codec ships the structural strictness first; the UTF-8 validator is a ~50 LOC follow-up.

NUL-byte caveat (text payloads)

Spinel's Ruby String storage is NUL-bound at the value level — 0.chr.length == 0 and "a" + 0.chr + "b" truncates at the embedded NUL. The send path works around this for frame headers by routing header bytes through a C-side accumulator (sphttp_send_append_byte / sphttp_send_flush), so headers containing 0x00 length bytes (16-bit / 64-bit length encodings) emit correctly on the wire.

The user-facing payload, however, still flows through Ruby Strings — so binary frames whose payload contains an embedded 0x00 byte will truncate at that NUL before they reach the network. Today's tep WS surface covers TEXT, PING, PONG, CLOSE with non-NUL payloads. Full binary-payload support is a follow-up that needs either a Ruby-side length-prefixed bytes value (waiting on spinel) or a C-side per-byte copy from a byte-indexed source.

End-to-end example

# examples/websocket_echo.rb
require_relative "../lib/tep"

set :scheduler, :scheduled

get "/" do
  '<p>WebSocket echo. Connect to ws://host:port/echo.</p>'
end

websocket "/echo" do |ws|
  on_open    { |evt| ws.text("welcome") }
  on_message { |evt| ws.text("echo: " + evt.data) }
end
bin/tep build examples/websocket_echo.rb
./examples/websocket_echo -p 4567 &
websocat ws://127.0.0.1:4567/echo
> hello
< welcome
< echo: hello

The integration harness (test/test_websocket_echo.rb) drives the same example with a raw TCPSocket: RFC 6455 handshake (computes the Sec-WebSocket-Accept from the GUID-and-SHA1 dance), reads the synthetic welcome frame, sends a masked TEXT, asserts the unmasked echo comes back.

Lower-level surface (without the DSL)

If you prefer the Tep::Handler subclass form (no bin/tep translation), the manual wiring is:

class EchoOpen < Tep::WebSocket::Handler
  attr_accessor :ws
  def handle_event(evt)
    @ws.text("welcome")
    0
  end
end

class EchoMessage < Tep::WebSocket::Handler
  attr_accessor :ws
  def handle_event(evt)
    @ws.text("echo: " + evt.data)
    0
  end
end

class EchoRoute < Tep::Handler
  def handle(req, res)
    hs = Tep::WebSocket::Handshake.check(req)
    if !hs.valid
      res.set_status(400)
      return ""
    end
    drv = Tep::WebSocket::Driver.new(0)  # fd assigned by the server
    o = EchoOpen.new;    o.ws = drv; drv.set_on_open(o)
    m = EchoMessage.new; m.ws = drv; drv.set_on_message(m)
    res.start_websocket(hs.accept_key, drv)
    ""
  end
end

Tep.get "/echo", EchoRoute.new
Tep.run!(4567, 1, false, true)   # last arg = scheduled server

The DSL is the exact same shape with the boilerplate generated for you.

Pitfalls

  • Forgetting set :scheduler, :scheduled. The blocking server responds 501 to WS upgrades. The error body points at the flag.
  • Long-running per-message work. Each connection is one fiber; if your on_message does a blocking 30-second SQL query, that connection won't service other messages until it returns. Park on cooperative I/O (Tep::Http, Tep::Scheduler.io_wait) or hand off to Tep::Job.
  • ws.binary(bytes) with embedded NUL. Truncates at the first 0x00 until the binary-payload follow-up lands. Text-only chat apps aren't affected.
  • Closures over outer locals. The DSL extracts each on_X block at build time into its own class. References to driver/event are rebound automatically; references to outer-scope locals inside the websocket block but outside the on_X block aren't. Stash shared per-connection state on the driver subclass instead, or in the request scope above.

Reference

Clone this wiki locally