-
Notifications
You must be signed in to change notification settings - Fork 1
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.
| 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.
WebSocket requires the cooperative server. Opt in once at top of the app:
set :scheduler, :scheduledUnder 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.
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
endSix 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
endInside 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 101Close 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┌─ 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).
| 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.
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.
# 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) }
endbin/tep build examples/websocket_echo.rb
./examples/websocket_echo -p 4567 &
websocat ws://127.0.0.1:4567/echo
> hello
< welcome
< echo: helloThe 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.
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 serverThe DSL is the exact same shape with the boilerplate generated for you.
-
Forgetting
set :scheduler, :scheduled. The blocking server responds501to WS upgrades. The error body points at the flag. -
Long-running per-message work. Each connection is one fiber;
if your
on_messagedoes 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 toTep::Job. -
ws.binary(bytes)with embedded NUL. Truncates at the first0x00until the binary-payload follow-up lands. Text-only chat apps aren't affected. -
Closures over outer locals. The DSL extracts each
on_Xblock at build time into its own class. References to driver/event are rebound automatically; references to outer-scope locals inside thewebsocketblock but outside theon_Xblock aren't. Stash shared per-connection state on the driver subclass instead, or in the request scope above.
- RFC 6455: https://datatracker.ietf.org/doc/html/rfc6455
- Test coverage:
test/test_websocket.rb(10 codec + handshake unit tests) andtest/test_websocket_echo.rb(1 integration round-trip). - Example:
examples/websocket_echo.rb. - Tracking: tep#8.