tep 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- metadata +264 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Tep::WebSocket::Driver -- Faye-shape state machine + event dispatch.
|
|
2
|
+
#
|
|
3
|
+
# Constructed AFTER the handshake completes, before the recv loop
|
|
4
|
+
# starts. Holds per-connection state + outbound write methods +
|
|
5
|
+
# the event-callback registry that the handler (or the Phase 3 DSL)
|
|
6
|
+
# populates.
|
|
7
|
+
#
|
|
8
|
+
# Faye-shape API (matches faye/websocket-driver-ruby's surface for
|
|
9
|
+
# the parts tep ships -- single-frame text/binary, ping/pong, close):
|
|
10
|
+
#
|
|
11
|
+
# drv = Tep::WebSocket::Driver.new(fd)
|
|
12
|
+
# drv.on_message do |evt| ... end # block-based on:open/on:message etc
|
|
13
|
+
# drv.on_close do |evt| ... end # are syntactic sugar; tep ships
|
|
14
|
+
# drv.text("hi") # the explicit setters instead
|
|
15
|
+
# drv.binary(bytes)
|
|
16
|
+
# drv.ping("")
|
|
17
|
+
# drv.close(1000, "bye")
|
|
18
|
+
#
|
|
19
|
+
# In Phase 2, callbacks are set via explicit setters (`set_on_message`)
|
|
20
|
+
# rather than `on(:message) { block }` since spinel's block-with-
|
|
21
|
+
# closure-on-locals support is still uneven outside Fiber.new bodies.
|
|
22
|
+
# Phase 3's DSL hides this behind `ws.on(:message) { ... }` once we
|
|
23
|
+
# decide on the lowering shape.
|
|
24
|
+
module Tep
|
|
25
|
+
module WebSocket
|
|
26
|
+
class Driver
|
|
27
|
+
attr_accessor :fd, :max_frame_size, :subprotocol
|
|
28
|
+
# Callback slots. Each holds a subclass of Tep::WebSocket::Handler
|
|
29
|
+
# (or the base) that gets `handle_event(event)` called when the
|
|
30
|
+
# corresponding wire event arrives. Defaults to a no-op base
|
|
31
|
+
# so the slot is type-safe pre-set.
|
|
32
|
+
attr_accessor :h_open, :h_message, :h_close, :h_ping, :h_pong, :h_error
|
|
33
|
+
|
|
34
|
+
def initialize(fd)
|
|
35
|
+
@fd = fd
|
|
36
|
+
@max_frame_size = Tep::WebSocket::DEFAULT_MAX_FRAME
|
|
37
|
+
@subprotocol = ""
|
|
38
|
+
@h_open = Tep::WebSocket::Handler.new
|
|
39
|
+
@h_message = Tep::WebSocket::Handler.new
|
|
40
|
+
@h_close = Tep::WebSocket::Handler.new
|
|
41
|
+
@h_ping = Tep::WebSocket::Handler.new
|
|
42
|
+
@h_pong = Tep::WebSocket::Handler.new
|
|
43
|
+
@h_error = Tep::WebSocket::Handler.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def set_max_frame_size(n)
|
|
47
|
+
@max_frame_size = n
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reassign the underlying fd. Used by the server-side upgrade
|
|
51
|
+
# path: the user handler builds the Driver with a placeholder
|
|
52
|
+
# fd (since the client fd isn't visible at handler-dispatch
|
|
53
|
+
# time), and the write_response branch sets the real fd here
|
|
54
|
+
# right before constructing the Connection.
|
|
55
|
+
def set_fd(new_fd)
|
|
56
|
+
@fd = new_fd
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def set_subprotocol(name)
|
|
60
|
+
@subprotocol = name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def set_on_open(h); @h_open = h; end
|
|
64
|
+
def set_on_message(h); @h_message = h; end
|
|
65
|
+
def set_on_close(h); @h_close = h; end
|
|
66
|
+
def set_on_ping(h); @h_ping = h; end
|
|
67
|
+
def set_on_pong(h); @h_pong = h; end
|
|
68
|
+
def set_on_error(h); @h_error = h; end
|
|
69
|
+
|
|
70
|
+
# Send a text frame.
|
|
71
|
+
def text(s)
|
|
72
|
+
Driver.send_frame(@fd, Tep::WebSocket::OPCODE_TEXT, s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Streamer-shape alias for `text` so a Driver can stand in
|
|
76
|
+
# anywhere `Tep::Streamer`-style code calls `out.write(s)`.
|
|
77
|
+
# Used by Tep::Llm.chat_stream to write LLM deltas as WS
|
|
78
|
+
# frames (one frame per SSE-shaped chunk).
|
|
79
|
+
def write(s)
|
|
80
|
+
Driver.send_frame(@fd, Tep::WebSocket::OPCODE_TEXT, s)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Send a binary frame.
|
|
84
|
+
def binary(bytes)
|
|
85
|
+
Driver.send_frame(@fd, Tep::WebSocket::OPCODE_BINARY, bytes)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Send a ping with optional payload (<=125 bytes).
|
|
89
|
+
def ping(payload)
|
|
90
|
+
Driver.send_frame(@fd, Tep::WebSocket::OPCODE_PING, payload)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Send a pong with the matching ping's payload (per §5.5.3).
|
|
94
|
+
def pong(payload)
|
|
95
|
+
Driver.send_frame(@fd, Tep::WebSocket::OPCODE_PONG, payload)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Send a close frame with code + reason. Reason capped at
|
|
99
|
+
# 123 bytes so the 2-byte code + reason fits in a control
|
|
100
|
+
# frame's 125-byte payload limit.
|
|
101
|
+
def close(code, reason)
|
|
102
|
+
body = Driver.encode_close_payload(code, reason)
|
|
103
|
+
Driver.send_frame(@fd, Tep::WebSocket::OPCODE_CLOSE, body)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Build the frame bytes (unmasked, server-side) and write via
|
|
107
|
+
# sphttp_write_bytes (binary-safe, explicit length).
|
|
108
|
+
def self.send_frame(fd, opcode, payload)
|
|
109
|
+
frame = Tep::WebSocket::Frame.new(true, opcode, payload)
|
|
110
|
+
bytes = frame.encode_unmasked
|
|
111
|
+
Sock.sphttp_write_bytes(fd, bytes, bytes.length)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Close payload: 2-byte big-endian code + UTF-8 reason. Per
|
|
115
|
+
# §5.5.1 the payload may be omitted (close with no body); if
|
|
116
|
+
# `code == 0` we emit an empty payload.
|
|
117
|
+
def self.encode_close_payload(code, reason)
|
|
118
|
+
if code == 0
|
|
119
|
+
return ""
|
|
120
|
+
end
|
|
121
|
+
out = Tep::WebSocket::Frame.byte_to_chr((code >> 8) & 0xff) +
|
|
122
|
+
Tep::WebSocket::Frame.byte_to_chr(code & 0xff)
|
|
123
|
+
if reason.length > 123
|
|
124
|
+
out + reason[0, 123]
|
|
125
|
+
else
|
|
126
|
+
out + reason
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Event passed to handler callbacks. Holds `data` (the payload
|
|
132
|
+
# as String for text/binary, raw bytes for ping/pong, or the
|
|
133
|
+
# close code+reason for close) and a numeric `code` for close.
|
|
134
|
+
class Event
|
|
135
|
+
attr_accessor :data, :code, :reason
|
|
136
|
+
|
|
137
|
+
def initialize
|
|
138
|
+
@data = ""
|
|
139
|
+
@code = 0
|
|
140
|
+
@reason = ""
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Base class for event handlers. Subclass + override
|
|
145
|
+
# `handle_event(event)`. The Driver stores one Handler instance
|
|
146
|
+
# per event type and dispatches via `@h_message.handle_event(evt)`.
|
|
147
|
+
# The explicit-Handler shape (vs faye's block-based `driver.on(:msg)
|
|
148
|
+
# { ... }`) is chosen because it stays compatible with future
|
|
149
|
+
# Fiber.storage per-connection state plumbing without re-typing
|
|
150
|
+
# the callback boundary.
|
|
151
|
+
#
|
|
152
|
+
# `req` is set at WS upgrade time by the route handler the
|
|
153
|
+
# translator emits, giving on_X handler bodies access to the
|
|
154
|
+
# request that initiated the connection (req.identity,
|
|
155
|
+
# req.session, headers, ...). It stays the same across every
|
|
156
|
+
# event on the connection -- there's no per-frame "request".
|
|
157
|
+
class Handler
|
|
158
|
+
attr_accessor :req
|
|
159
|
+
|
|
160
|
+
def initialize
|
|
161
|
+
@req = Tep::Request.new
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle_event(event)
|
|
165
|
+
0
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Tep::WebSocket::Frame -- single-frame codec.
|
|
2
|
+
#
|
|
3
|
+
# Surface:
|
|
4
|
+
# - Frame.new(fin, opcode, payload) build for emit
|
|
5
|
+
# - frame.encode_unmasked -> String server-side emit bytes
|
|
6
|
+
# - Frame.parse_from_buf(bytes_at, bytes_len) parse a recv'd frame
|
|
7
|
+
# returns a ParseResult (frame + bytes_consumed, OR an error code).
|
|
8
|
+
#
|
|
9
|
+
# Server-side emit: never masks (RFC 6455 §5.3 -- server MUST NOT
|
|
10
|
+
# mask). Client-side emit isn't shipped here; tep is server-shaped.
|
|
11
|
+
#
|
|
12
|
+
# Parse handles three length encodings (7-bit / 16-bit / 64-bit),
|
|
13
|
+
# the 4-byte mask key, and applies the mask to recover the plaintext
|
|
14
|
+
# payload. Returns a structural error code (close-code-shaped) for
|
|
15
|
+
# the family of malformed-frame cases that warrant a 1002 close:
|
|
16
|
+
# - reserved bits set
|
|
17
|
+
# - reserved opcode
|
|
18
|
+
# - client frame not masked
|
|
19
|
+
# - control frame payload > 125
|
|
20
|
+
# - control frame fragmented
|
|
21
|
+
module Tep
|
|
22
|
+
module WebSocket
|
|
23
|
+
class Frame
|
|
24
|
+
attr_accessor :fin, :opcode, :payload
|
|
25
|
+
|
|
26
|
+
def initialize(fin, opcode, payload)
|
|
27
|
+
@fin = fin
|
|
28
|
+
@opcode = opcode
|
|
29
|
+
@payload = payload
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Build the unmasked server-side wire bytes. Length-encoding
|
|
33
|
+
# picks the smallest form that fits the payload. No mask.
|
|
34
|
+
def encode_unmasked
|
|
35
|
+
head = ""
|
|
36
|
+
b0 = (@fin ? 0x80 : 0x00) | (@opcode & 0x0f)
|
|
37
|
+
head = head + Frame.byte_to_chr(b0)
|
|
38
|
+
|
|
39
|
+
plen = @payload.length
|
|
40
|
+
if plen <= 125
|
|
41
|
+
head = head + Frame.byte_to_chr(plen)
|
|
42
|
+
elsif plen <= 65535
|
|
43
|
+
head = head + Frame.byte_to_chr(126)
|
|
44
|
+
head = head + Frame.byte_to_chr((plen >> 8) & 0xff)
|
|
45
|
+
head = head + Frame.byte_to_chr(plen & 0xff)
|
|
46
|
+
else
|
|
47
|
+
head = head + Frame.byte_to_chr(127)
|
|
48
|
+
i = 7
|
|
49
|
+
while i >= 0
|
|
50
|
+
head = head + Frame.byte_to_chr((plen >> (i * 8)) & 0xff)
|
|
51
|
+
i -= 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
head + @payload
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Convert a single byte value (0..255) to a 1-char String.
|
|
58
|
+
def self.byte_to_chr(n)
|
|
59
|
+
(n & 0xff).chr
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Parse one frame from the sphttp recv frame buffer. `start`
|
|
63
|
+
# is the byte offset to begin reading; `avail` is the count of
|
|
64
|
+
# valid bytes in the buffer. Byte reads go through the Ruby
|
|
65
|
+
# String binding sphttp_recv_frame_buf returns; matz/spinel#657
|
|
66
|
+
# made slice / bytes[i] survive embedded NULs so binary
|
|
67
|
+
# payloads parse correctly without the per-byte C accessor we
|
|
68
|
+
# used before.
|
|
69
|
+
#
|
|
70
|
+
# Returns a ParseResult with one of three shapes:
|
|
71
|
+
# .status == "ok" -> .frame populated + .consumed bytes used
|
|
72
|
+
# .status == "need" -> need more bytes (consumed == 0)
|
|
73
|
+
# .status == "close" -> protocol violation; close with .close_code
|
|
74
|
+
def self.parse_from_buf(start, avail)
|
|
75
|
+
out = Tep::WebSocket::ParseResult.new
|
|
76
|
+
if avail - start < 2
|
|
77
|
+
out.outcome = "need"
|
|
78
|
+
return out
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
buf = Sock.sphttp_recv_frame_buf
|
|
82
|
+
bs = buf.bytes
|
|
83
|
+
b0 = bs[start]
|
|
84
|
+
b1 = bs[start + 1]
|
|
85
|
+
fin = (b0 & 0x80) != 0
|
|
86
|
+
rsv = b0 & 0x70
|
|
87
|
+
opcode = b0 & 0x0f
|
|
88
|
+
masked = (b1 & 0x80) != 0
|
|
89
|
+
len7 = b1 & 0x7f
|
|
90
|
+
|
|
91
|
+
if rsv != 0
|
|
92
|
+
out.outcome = "close"
|
|
93
|
+
out.close_code = Tep::WebSocket::CLOSE_PROTOCOL_ERROR
|
|
94
|
+
return out
|
|
95
|
+
end
|
|
96
|
+
if Frame.reserved_opcode?(opcode)
|
|
97
|
+
out.outcome = "close"
|
|
98
|
+
out.close_code = Tep::WebSocket::CLOSE_PROTOCOL_ERROR
|
|
99
|
+
return out
|
|
100
|
+
end
|
|
101
|
+
if Frame.control_opcode?(opcode)
|
|
102
|
+
if !fin
|
|
103
|
+
out.outcome = "close"
|
|
104
|
+
out.close_code = Tep::WebSocket::CLOSE_PROTOCOL_ERROR
|
|
105
|
+
return out
|
|
106
|
+
end
|
|
107
|
+
if len7 > 125
|
|
108
|
+
out.outcome = "close"
|
|
109
|
+
out.close_code = Tep::WebSocket::CLOSE_PROTOCOL_ERROR
|
|
110
|
+
return out
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
if !masked
|
|
114
|
+
# Server MUST close on unmasked client frame (§5.3).
|
|
115
|
+
out.outcome = "close"
|
|
116
|
+
out.close_code = Tep::WebSocket::CLOSE_PROTOCOL_ERROR
|
|
117
|
+
return out
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Decode payload length.
|
|
121
|
+
pos = start + 2
|
|
122
|
+
plen = 0
|
|
123
|
+
if len7 < 126
|
|
124
|
+
plen = len7
|
|
125
|
+
elsif len7 == 126
|
|
126
|
+
if avail - pos < 2
|
|
127
|
+
out.outcome = "need"
|
|
128
|
+
return out
|
|
129
|
+
end
|
|
130
|
+
h = bs[pos]
|
|
131
|
+
l = bs[pos + 1]
|
|
132
|
+
plen = (h << 8) | l
|
|
133
|
+
pos += 2
|
|
134
|
+
else
|
|
135
|
+
# 64-bit length
|
|
136
|
+
if avail - pos < 8
|
|
137
|
+
out.outcome = "need"
|
|
138
|
+
return out
|
|
139
|
+
end
|
|
140
|
+
plen = 0
|
|
141
|
+
i = 0
|
|
142
|
+
while i < 8
|
|
143
|
+
plen = (plen << 8) | bs[pos + i]
|
|
144
|
+
i += 1
|
|
145
|
+
end
|
|
146
|
+
pos += 8
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# 4-byte mask key.
|
|
150
|
+
if avail - pos < 4
|
|
151
|
+
out.outcome = "need"
|
|
152
|
+
return out
|
|
153
|
+
end
|
|
154
|
+
m0 = bs[pos]
|
|
155
|
+
m1 = bs[pos + 1]
|
|
156
|
+
m2 = bs[pos + 2]
|
|
157
|
+
m3 = bs[pos + 3]
|
|
158
|
+
pos += 4
|
|
159
|
+
|
|
160
|
+
# Payload bytes.
|
|
161
|
+
if avail - pos < plen
|
|
162
|
+
out.outcome = "need"
|
|
163
|
+
return out
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Decode + unmask in one pass.
|
|
167
|
+
payload = ""
|
|
168
|
+
i = 0
|
|
169
|
+
while i < plen
|
|
170
|
+
b = bs[pos + i]
|
|
171
|
+
mask_byte = 0
|
|
172
|
+
if (i & 3) == 0
|
|
173
|
+
mask_byte = m0
|
|
174
|
+
elsif (i & 3) == 1
|
|
175
|
+
mask_byte = m1
|
|
176
|
+
elsif (i & 3) == 2
|
|
177
|
+
mask_byte = m2
|
|
178
|
+
else
|
|
179
|
+
mask_byte = m3
|
|
180
|
+
end
|
|
181
|
+
payload = payload + Frame.byte_to_chr(b ^ mask_byte)
|
|
182
|
+
i += 1
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
out.outcome = "ok"
|
|
186
|
+
out.frame = Tep::WebSocket::Frame.new(fin, opcode, payload)
|
|
187
|
+
out.consumed = pos + plen - start
|
|
188
|
+
out
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.reserved_opcode?(op)
|
|
192
|
+
if op == Tep::WebSocket::OPCODE_CONTINUATION
|
|
193
|
+
return false
|
|
194
|
+
end
|
|
195
|
+
if op == Tep::WebSocket::OPCODE_TEXT
|
|
196
|
+
return false
|
|
197
|
+
end
|
|
198
|
+
if op == Tep::WebSocket::OPCODE_BINARY
|
|
199
|
+
return false
|
|
200
|
+
end
|
|
201
|
+
if op == Tep::WebSocket::OPCODE_CLOSE
|
|
202
|
+
return false
|
|
203
|
+
end
|
|
204
|
+
if op == Tep::WebSocket::OPCODE_PING
|
|
205
|
+
return false
|
|
206
|
+
end
|
|
207
|
+
if op == Tep::WebSocket::OPCODE_PONG
|
|
208
|
+
return false
|
|
209
|
+
end
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.control_opcode?(op)
|
|
214
|
+
op == Tep::WebSocket::OPCODE_CLOSE ||
|
|
215
|
+
op == Tep::WebSocket::OPCODE_PING ||
|
|
216
|
+
op == Tep::WebSocket::OPCODE_PONG
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ParseResult carries either a parsed frame, a "need more
|
|
221
|
+
# bytes" signal, or a close-code for a protocol violation.
|
|
222
|
+
# Field is named `outcome` (not `status`) because attr_accessor
|
|
223
|
+
# :status collides with Tep::Response.status (Integer) under
|
|
224
|
+
# spinel's same-name-attr unification family
|
|
225
|
+
# (matz/spinel#537 / #538), widening Tep.reason(status) to
|
|
226
|
+
# accept poly and breaking the build.
|
|
227
|
+
class ParseResult
|
|
228
|
+
attr_accessor :outcome, :frame, :consumed, :close_code
|
|
229
|
+
|
|
230
|
+
def initialize
|
|
231
|
+
@outcome = ""
|
|
232
|
+
@frame = Tep::WebSocket::Frame.new(true, 0, "")
|
|
233
|
+
@consumed = 0
|
|
234
|
+
@close_code = 0
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Tep::WebSocket::Handshake -- RFC 6455 §1.3 server-side handshake.
|
|
2
|
+
#
|
|
3
|
+
# `check(req)`:
|
|
4
|
+
# Returns a Result with `.valid` true if the request is a proper
|
|
5
|
+
# WebSocket upgrade, `.accept_key` set to the Sec-WebSocket-Accept
|
|
6
|
+
# value the server should echo, and `.protocols` parsed from
|
|
7
|
+
# Sec-WebSocket-Protocol. Invalid uses set `.valid = false` +
|
|
8
|
+
# `.reason` for logging.
|
|
9
|
+
#
|
|
10
|
+
# `build_response(accept_key, protocol)`:
|
|
11
|
+
# Returns the raw HTTP/1.1 101 Switching Protocols response bytes,
|
|
12
|
+
# ready to write to the socket. `protocol` is the subprotocol to
|
|
13
|
+
# echo (empty string = omit the header per RFC §1.3 -- the safe
|
|
14
|
+
# default per rubys's pushback on tep#8).
|
|
15
|
+
module Tep
|
|
16
|
+
module WebSocket
|
|
17
|
+
class Handshake
|
|
18
|
+
def self.check(req)
|
|
19
|
+
out = Tep::WebSocket::Handshake::Result.new
|
|
20
|
+
|
|
21
|
+
# Verb must be GET.
|
|
22
|
+
if req.verb != "GET"
|
|
23
|
+
out.valid = false
|
|
24
|
+
out.reason = "bad verb"
|
|
25
|
+
return out
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Upgrade + Connection headers (downcased per Tep::Request).
|
|
29
|
+
upgrade = req.headers["upgrade"]
|
|
30
|
+
if Handshake.icontains(upgrade, "websocket") == false
|
|
31
|
+
out.valid = false
|
|
32
|
+
out.reason = "missing/invalid Upgrade"
|
|
33
|
+
return out
|
|
34
|
+
end
|
|
35
|
+
conn = req.headers["connection"]
|
|
36
|
+
if Handshake.icontains(conn, "upgrade") == false
|
|
37
|
+
out.valid = false
|
|
38
|
+
out.reason = "missing/invalid Connection"
|
|
39
|
+
return out
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Sec-WebSocket-Version must be 13.
|
|
43
|
+
ver = req.headers["sec-websocket-version"]
|
|
44
|
+
if ver != "13"
|
|
45
|
+
out.valid = false
|
|
46
|
+
out.reason = "bad/missing Sec-WebSocket-Version"
|
|
47
|
+
return out
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Sec-WebSocket-Key: 24-char base64 (16-byte nonce).
|
|
51
|
+
key = req.headers["sec-websocket-key"]
|
|
52
|
+
if key.length == 0
|
|
53
|
+
out.valid = false
|
|
54
|
+
out.reason = "missing Sec-WebSocket-Key"
|
|
55
|
+
return out
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
out.valid = true
|
|
59
|
+
out.accept_key = Crypto.sp_crypto_websocket_accept(key)
|
|
60
|
+
|
|
61
|
+
# Parse Sec-WebSocket-Protocol (comma-separated). Handler
|
|
62
|
+
# gets the offered list; can opt-in via Driver.accept_protocol.
|
|
63
|
+
out.protocols = Handshake.split_csv(req.headers["sec-websocket-protocol"])
|
|
64
|
+
out
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Build the 101 Switching Protocols response. `protocol` empty
|
|
68
|
+
# = omit Sec-WebSocket-Protocol entirely (spec-correct per
|
|
69
|
+
# RFC 6455 §4.2.2; better than echoing a protocol the server
|
|
70
|
+
# doesn't actually implement).
|
|
71
|
+
def self.build_response(accept_key, protocol)
|
|
72
|
+
out = "HTTP/1.1 101 Switching Protocols\r\n" +
|
|
73
|
+
"Upgrade: websocket\r\n" +
|
|
74
|
+
"Connection: Upgrade\r\n" +
|
|
75
|
+
"Sec-WebSocket-Accept: " + accept_key + "\r\n"
|
|
76
|
+
if protocol.length > 0
|
|
77
|
+
out = out + "Sec-WebSocket-Protocol: " + protocol + "\r\n"
|
|
78
|
+
end
|
|
79
|
+
out + "\r\n"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Case-insensitive substring contains. Hand-rolled because
|
|
83
|
+
# Tep::Request normalises header names to lowercase but
|
|
84
|
+
# leaves values as-is, and clients sometimes send
|
|
85
|
+
# `Connection: keep-alive, Upgrade` capitalised.
|
|
86
|
+
def self.icontains(hay, needle)
|
|
87
|
+
if hay.length == 0 || needle.length == 0
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
hl = Handshake.downcase(hay)
|
|
91
|
+
nl = Handshake.downcase(needle)
|
|
92
|
+
Tep.str_find(hl, nl, 0) >= 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.downcase(s)
|
|
96
|
+
out = ""
|
|
97
|
+
i = 0
|
|
98
|
+
while i < s.length
|
|
99
|
+
c = s[i]
|
|
100
|
+
if c >= "A" && c <= "Z"
|
|
101
|
+
out = out + (c.ord + 32).chr
|
|
102
|
+
else
|
|
103
|
+
out = out + c
|
|
104
|
+
end
|
|
105
|
+
i += 1
|
|
106
|
+
end
|
|
107
|
+
out
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Parse comma-separated header value into an Array<String>.
|
|
111
|
+
# Trims whitespace around each entry.
|
|
112
|
+
def self.split_csv(s)
|
|
113
|
+
out = [""]
|
|
114
|
+
out.delete_at(0)
|
|
115
|
+
if s.length == 0
|
|
116
|
+
return out
|
|
117
|
+
end
|
|
118
|
+
pos = 0
|
|
119
|
+
while pos < s.length
|
|
120
|
+
comma = Tep.str_find(s, ",", pos)
|
|
121
|
+
if comma < 0
|
|
122
|
+
out.push(Handshake.trim(s[pos, s.length - pos]))
|
|
123
|
+
return out
|
|
124
|
+
end
|
|
125
|
+
out.push(Handshake.trim(s[pos, comma - pos]))
|
|
126
|
+
pos = comma + 1
|
|
127
|
+
end
|
|
128
|
+
out
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.trim(s)
|
|
132
|
+
i = 0
|
|
133
|
+
while i < s.length && (s[i] == " " || s[i] == "\t")
|
|
134
|
+
i += 1
|
|
135
|
+
end
|
|
136
|
+
j = s.length - 1
|
|
137
|
+
while j >= i && (s[j] == " " || s[j] == "\t")
|
|
138
|
+
j -= 1
|
|
139
|
+
end
|
|
140
|
+
if j < i
|
|
141
|
+
return ""
|
|
142
|
+
end
|
|
143
|
+
s[i, j - i + 1]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class Result
|
|
147
|
+
attr_accessor :valid, :reason, :accept_key, :protocols
|
|
148
|
+
|
|
149
|
+
def initialize
|
|
150
|
+
@valid = false
|
|
151
|
+
@reason = ""
|
|
152
|
+
@accept_key = ""
|
|
153
|
+
@protocols = [""]
|
|
154
|
+
@protocols.delete_at(0)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Tep::WebSocket -- RFC 6455 WebSocket support for spinel-AOT'd apps.
|
|
2
|
+
#
|
|
3
|
+
# Protocol substrate (this file's directory):
|
|
4
|
+
# - Tep::WebSocket::Frame single-frame codec (parse + emit)
|
|
5
|
+
# - Tep::WebSocket::Handshake server-side handshake check + response
|
|
6
|
+
# - Tep::WebSocket::Driver state machine + event dispatch + writers
|
|
7
|
+
# - Tep::WebSocket::Connection fiber-driven recv loop (one fiber per conn)
|
|
8
|
+
#
|
|
9
|
+
# Sinatra-style DSL (lowered by bin/tep):
|
|
10
|
+
#
|
|
11
|
+
# set :scheduler, :scheduled
|
|
12
|
+
# websocket '/chat' do |ws|
|
|
13
|
+
# on_open { |evt| ws.text("welcome") }
|
|
14
|
+
# on_message { |evt| ws.text("echo: " + evt.data) }
|
|
15
|
+
# on_close { |evt| ... }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Requires the scheduled server (the recv loop parks on
|
|
19
|
+
# Tep::Scheduler.io_wait); the blocking server returns 501 on a WS
|
|
20
|
+
# upgrade attempt. See examples/websocket_echo.rb for a full app and
|
|
21
|
+
# test/test_websocket_echo.rb for the end-to-end smoke harness.
|
|
22
|
+
#
|
|
23
|
+
# Compliance posture (per the OriPekelman/tep#8 strict/lenient table):
|
|
24
|
+
# strict-emit: server NEVER masks; reserved bits 0 on emit
|
|
25
|
+
# strict-accept (close 1002):
|
|
26
|
+
# - client frames MUST be masked
|
|
27
|
+
# - reserved bits RSV1-3 MUST be 0
|
|
28
|
+
# - reserved opcodes (3-7, B-F) reject
|
|
29
|
+
# - control frame payload > 125 reject
|
|
30
|
+
# - control frames MUST NOT fragment
|
|
31
|
+
# - continuation without prior fragment reject
|
|
32
|
+
# strict-accept (close 1007): text frames MUST be UTF-8 (deferred to
|
|
33
|
+
# Phase 2.1 -- the codec ships the structural strictness first;
|
|
34
|
+
# the UTF-8 validator is its own ~50 LOC).
|
|
35
|
+
# liberal-accept: close codes, pong payload contents, unsolicited pong.
|
|
36
|
+
module Tep
|
|
37
|
+
module WebSocket
|
|
38
|
+
# Standard opcodes.
|
|
39
|
+
OPCODE_CONTINUATION = 0
|
|
40
|
+
OPCODE_TEXT = 1
|
|
41
|
+
OPCODE_BINARY = 2
|
|
42
|
+
OPCODE_CLOSE = 8
|
|
43
|
+
OPCODE_PING = 9
|
|
44
|
+
OPCODE_PONG = 10
|
|
45
|
+
|
|
46
|
+
# Close codes (RFC 6455 §7.4). Caller-facing ones only -- the
|
|
47
|
+
# internal-error / protocol-error codes are emitted by the
|
|
48
|
+
# Driver directly, not exposed.
|
|
49
|
+
CLOSE_NORMAL = 1000
|
|
50
|
+
CLOSE_GOING_AWAY = 1001
|
|
51
|
+
CLOSE_PROTOCOL_ERROR = 1002
|
|
52
|
+
CLOSE_UNSUPPORTED = 1003
|
|
53
|
+
CLOSE_INVALID_UTF8 = 1007
|
|
54
|
+
CLOSE_POLICY_VIOLATION = 1008
|
|
55
|
+
CLOSE_MESSAGE_TOO_BIG = 1009
|
|
56
|
+
|
|
57
|
+
# Frame-size cap. Configurable via Driver#set_max_frame_size;
|
|
58
|
+
# default is 16 MiB (large enough for any realistic chat /
|
|
59
|
+
# Action Cable payload, bounded so an oversized frame can be
|
|
60
|
+
# closed with 1009 rather than OOM-ing the worker).
|
|
61
|
+
DEFAULT_MAX_FRAME = 16 * 1024 * 1024
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
require_relative "websocket/frame"
|
|
66
|
+
require_relative "websocket/handshake"
|
|
67
|
+
require_relative "websocket/driver"
|
|
68
|
+
require_relative "websocket/connection"
|