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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
data/lib/tep/http.rb ADDED
@@ -0,0 +1,599 @@
1
+ # Tep::Http -- a small outbound HTTP client, Faraday-shaped.
2
+ #
3
+ # Why
4
+ # ---
5
+ # Pure-Ruby HTTP clients (net/http, faraday, http.rb, httparty) all
6
+ # pull in things spinel can't lower: `Net::HTTP`'s subclass-per-verb
7
+ # wiring, faraday's middleware DSL (closures + define_method), the
8
+ # `http.rb` gem's MIME parser. tep's runtime already has the socket
9
+ # plumbing (sphttp_connect, sphttp_set_nonblock, sphttp_recv_*); the
10
+ # missing piece is the HTTP/1.0 client on top.
11
+ #
12
+ # Scope (v1)
13
+ # ----------
14
+ # * **HTTP only** -- no TLS. Talk to internal services, the local
15
+ # Ollama API, vLLM, your own tep-backed sidecars.
16
+ # * **HTTP/1.0 + Connection: close** -- one socket per request,
17
+ # read until EOF. No keep-alive, no pipelining.
18
+ # * **No chunked-transfer reading** -- assumes Content-Length or
19
+ # close-delimited body. Most APIs do one or the other.
20
+ # * **No automatic redirects** -- callers inspect `.status` and
21
+ # `.headers["location"]` and re-issue if they want.
22
+ # * **No streaming** -- whole response materialises in memory
23
+ # (sphttp_recv_all caps at ~64 KB).
24
+ #
25
+ # These limits cover the dashboard's needs (talking to local
26
+ # inference backends) and the bulk of "hit an internal API"
27
+ # workloads. HTTPS + keep-alive + chunked land as a v2 surface.
28
+ #
29
+ # API shape
30
+ # ---------
31
+ # Faraday-style class shortcuts:
32
+ #
33
+ # res = Tep::Http.get("http://api.local/users/42")
34
+ # res = Tep::Http.post("http://api.local/users", '{"name":"a"}')
35
+ # res.status # Integer
36
+ # res.headers # Hash<String,String> (downcased keys)
37
+ # res.body # String
38
+ #
39
+ # Reusable client with a base URL + default headers:
40
+ #
41
+ # c = Tep::Http.new("http://api.local")
42
+ # c.set_header("Authorization", "Bearer tok")
43
+ # res = c.do_get("/users/42")
44
+ # res = c.do_post("/users", body)
45
+ #
46
+ # The instance verbs are `do_get` / `do_post` / `do_put` etc. (not
47
+ # the bare Faraday names) so a call like `http.get(path)` doesn't
48
+ # read as a Sinatra route inside an app. The class-level shortcuts
49
+ # (`Tep::Http.get(url)`) keep the Faraday spelling because cmeth
50
+ # names live in a separate namespace.
51
+ #
52
+ # Request-specific headers pass through the lower-level `send_req`:
53
+ #
54
+ # h = Tep.str_hash
55
+ # h["Accept"] = "application/json"
56
+ # res = Tep::Http.send_req("GET", url, "", h)
57
+ module Tep
58
+ class Http
59
+ attr_accessor :base_url, :default_headers
60
+
61
+ def initialize(base_url)
62
+ @base_url = base_url
63
+ @default_headers = Tep.str_hash
64
+ end
65
+
66
+ def set_header(k, v)
67
+ @default_headers[k] = v
68
+ end
69
+
70
+ # Instance verbs. `path` is appended to `base_url` if it starts
71
+ # with "/", or used as-is if it's a full URL. Prefixed with
72
+ # `do_` to avoid the cmeth / imeth ambiguity at the call site:
73
+ # `http.get(path)` reads like a Sinatra route in apps, whereas
74
+ # `http.do_get(path)` does not.
75
+ def do_get(path); do_req(path, "GET", ""); end
76
+ def do_head(path); do_req(path, "HEAD", ""); end
77
+ def do_delete(path); do_req(path, "DELETE", ""); end
78
+ def do_post(path, body); do_req(path, "POST", body); end
79
+ def do_put(path, body); do_req(path, "PUT", body); end
80
+ def do_patch(path, body); do_req(path, "PATCH", body); end
81
+
82
+ def do_req(path, verb, body)
83
+ url = path
84
+ if path.length > 0 && path[0] == "/"
85
+ url = @base_url + path
86
+ end
87
+ Http.send_req(verb, url, body, @default_headers)
88
+ end
89
+
90
+ # Class-level one-shots. Build a default empty headers hash and
91
+ # dispatch through send_req.
92
+ def self.get(url); Http.send_req("GET", url, "", Http.empty_headers); end
93
+ def self.head(url); Http.send_req("HEAD", url, "", Http.empty_headers); end
94
+ def self.delete(url); Http.send_req("DELETE", url, "", Http.empty_headers); end
95
+ def self.post(url, body); Http.send_req("POST", url, body, Http.empty_headers); end
96
+ def self.put(url, body); Http.send_req("PUT", url, body, Http.empty_headers); end
97
+ def self.patch(url, body); Http.send_req("PATCH", url, body, Http.empty_headers); end
98
+
99
+ def self.empty_headers
100
+ Tep.str_hash
101
+ end
102
+
103
+ # Per-recv timeout in the cooperative path. Bounds how long a
104
+ # parked fiber will wait for the next chunk from the peer before
105
+ # giving up and returning status=0. 30s matches the scheduled
106
+ # server's KEEPALIVE_TIMEOUT; loud failure beats a wedged fiber.
107
+ COOP_RECV_TIMEOUT = 30
108
+
109
+ # Hard cap on total response bytes accumulated by the cooperative
110
+ # path. Mirrors sphttp_recv_all's static-buffer cap (~64 KiB) so
111
+ # the two paths impose the same upper bound. Bigger responses
112
+ # need streaming, which v1 doesn't ship.
113
+ COOP_RESPONSE_MAX = 65535
114
+
115
+ # Recv timeout (ms) on pooled keep-alive sockets. Bounds a response
116
+ # read so a no-Content-Length / chunked keep-alive upstream can't
117
+ # hang the worker waiting for an EOF that never comes (the recv
118
+ # returns and we bail with what we have, un-pooled). 30s matches
119
+ # COOP_RECV_TIMEOUT.
120
+ POOL_RECV_TIMEOUT_MS = 30000
121
+
122
+ # The workhorse. Returns a Tep::Http::Response in all cases --
123
+ # on connect or send failure, `.status` is 0 and `.body` is "".
124
+ #
125
+ # When called from inside a Tep::Scheduler fiber (i.e. running
126
+ # under Tep::Server::Scheduled), routes through `send_req_coop`,
127
+ # which parks on `Tep::Scheduler.io_wait` between recv calls so
128
+ # the worker fiber doesn't hog the scheduler while waiting for
129
+ # peer bytes. Outside scheduler context the call falls through to
130
+ # `send_req_blocking`, which is the original sphttp_recv_all path.
131
+ #
132
+ # Why split rather than always-async: outside a scheduled context
133
+ # (the default Tep::Server prefork model, scripts, REPL), io_wait
134
+ # falls back to a single-shot poll per call which would add an
135
+ # extra poll(2) round per chunk for no benefit. Keeping the
136
+ # blocking path keeps the cheap case cheap.
137
+ def self.send_req(verb, url, body, headers)
138
+ # Under Tep::Server::Scheduled, route BOTH http and https through
139
+ # the cooperative path. Plaintext parks on io_wait between recvs;
140
+ # https (tep#150) additionally drives a non-blocking TLS handshake
141
+ # (sphttp_tls_connect_start + handshake_step) and a want-aware
142
+ # SSL_read loop, so an outbound HTTPS call no longer blocks the
143
+ # whole worker. Outside a scheduled context the blocking path stays
144
+ # cheap (no extra poll(2) per chunk).
145
+ if Tep::Scheduler.scheduled_context?
146
+ Http.send_req_coop(verb, url, body, headers)
147
+ else
148
+ Http.send_req_blocking(verb, url, body, headers)
149
+ end
150
+ end
151
+
152
+ def self.send_req_blocking(verb, url, body, headers)
153
+ out = Tep::Http::Response.new
154
+ parts = Tep::Url.split_url(url)
155
+ scheme = parts["scheme"]
156
+ if scheme != "http" && scheme != "https"
157
+ # Unknown scheme.
158
+ return out
159
+ end
160
+ host = parts["host"]
161
+ port = parts["port"].to_i
162
+ path = parts["path"]
163
+ if parts["query"].length > 0
164
+ path = path + "?" + parts["query"]
165
+ end
166
+
167
+ # HTTPS: no pooling. The fd carries an SSL* in sphttp's registry;
168
+ # pooling TLS sockets is out of scope for 6.7b (#126). HTTP/1.0 +
169
+ # Connection: close + recv-until-EOF over a fresh verified socket.
170
+ if scheme == "https"
171
+ fd = Sock.sphttp_connect_tls(host, port) # port 443 via Tep::Url
172
+ if fd < 0
173
+ return out
174
+ end
175
+ # Head inlined (not a helper): spinel picks one type per param
176
+ # name file-wide, and path/host collide with int uses elsewhere.
177
+ head = verb + " " + path + " HTTP/1.0\r\n" +
178
+ "Host: " + host + "\r\n" +
179
+ "Connection: close\r\n"
180
+ headers.each do |k, v|
181
+ head = head + k + ": " + v + "\r\n"
182
+ end
183
+ if body.length > 0
184
+ head = head + "Content-Length: " + body.length.to_s + "\r\n"
185
+ end
186
+ head = head + "\r\n"
187
+ if Sock.sphttp_write_str(fd, head) < 0
188
+ Sock.sphttp_close(fd)
189
+ return out
190
+ end
191
+ if body.length > 0
192
+ if Sock.sphttp_write_str(fd, body) < 0
193
+ Sock.sphttp_close(fd)
194
+ return out
195
+ end
196
+ end
197
+ raw = Sock.sphttp_recv_all(fd, 0)
198
+ Sock.sphttp_close(fd)
199
+ return Http.parse_response(raw)
200
+ end
201
+
202
+ # HTTP: HTTP/1.1 keep-alive over a pooled (reused) socket (6.7b).
203
+ # Claim an idle fd for (host, port) or connect fresh; frame the
204
+ # response by Content-Length; reuse the socket (return it to the
205
+ # pool) only when it's cleanly framed, the peer didn't ask to
206
+ # close, and the status isn't a retry-worthy 5xx. A pool HIT that
207
+ # fails (stale socket the upstream already closed) is retried once
208
+ # on a fresh connection.
209
+ attempt = 0
210
+ while attempt < 2
211
+ from_pool = 0
212
+ fd = Tep::Http::Pool.claim(host, port)
213
+ if fd >= 0
214
+ from_pool = 1
215
+ else
216
+ fd = Sock.sphttp_connect(host, port)
217
+ end
218
+ if fd < 0
219
+ return out
220
+ end
221
+ Sock.sphttp_set_recv_timeout(fd, Http::POOL_RECV_TIMEOUT_MS)
222
+
223
+ head = verb + " " + path + " HTTP/1.1\r\n" +
224
+ "Host: " + host + "\r\n" +
225
+ "Connection: keep-alive\r\n"
226
+ headers.each do |k, v|
227
+ head = head + k + ": " + v + "\r\n"
228
+ end
229
+ if body.length > 0
230
+ head = head + "Content-Length: " + body.length.to_s + "\r\n"
231
+ end
232
+ head = head + "\r\n"
233
+
234
+ wrote = Sock.sphttp_write_str(fd, head)
235
+ if wrote >= 0 && body.length > 0
236
+ wrote = Sock.sphttp_write_str(fd, body)
237
+ end
238
+
239
+ if wrote < 0
240
+ Sock.sphttp_close(fd)
241
+ if from_pool == 0
242
+ return out
243
+ end
244
+ attempt = attempt + 1 # stale pooled socket -- retry fresh
245
+ else
246
+ fr = Http.recv_framed(fd)
247
+ if fr.raw.length == 0 && from_pool == 1
248
+ Sock.sphttp_close(fd)
249
+ attempt = attempt + 1 # stale pooled socket gave nothing -- retry fresh
250
+ else
251
+ resp = Http.parse_response(fr.raw)
252
+ reuse = fr.framed_clean && !fr.conn_close && resp.status > 0 && resp.status < 500
253
+ if reuse
254
+ Tep::Http::Pool.release(fd, host, port)
255
+ else
256
+ Sock.sphttp_close(fd)
257
+ end
258
+ return resp
259
+ end
260
+ end
261
+ end
262
+ return out
263
+ end
264
+
265
+ # Cooperative variant. Same wire shape, same parse, but:
266
+ # * flips the fd to non-blocking after connect, and
267
+ # * replaces the synchronous sphttp_recv_all with a parked
268
+ # io_wait(READ) + sphttp_recv_some loop that yields the
269
+ # worker fiber back to the scheduler between recvs.
270
+ #
271
+ # This is what closes the macOS self-call deadlock: while the
272
+ # outer handler fiber is parked here, the worker's accept fiber
273
+ # can run, accept the inner request, dispatch its handler, and
274
+ # write the response -- which unblocks our io_wait. See
275
+ # docs/MACOS-CONCURRENCY.md for the why.
276
+ def self.send_req_coop(verb, url, body, headers)
277
+ out = Tep::Http::Response.new
278
+ parts = Tep::Url.split_url(url)
279
+ scheme = parts["scheme"]
280
+ if scheme != "http" && scheme != "https"
281
+ return out
282
+ end
283
+ host = parts["host"]
284
+ port = parts["port"].to_i
285
+ path = parts["path"]
286
+ if parts["query"].length > 0
287
+ path = path + "?" + parts["query"]
288
+ end
289
+
290
+ # Connect. https (tep#150): non-blocking TLS -- set up the SSL then
291
+ # drive SSL_do_handshake, parking on io_wait for the direction
292
+ # OpenSSL asks for until the handshake completes. http: a plain
293
+ # non-blocking connect. Either way `fd` ends up non-blocking with
294
+ # write/recv routed appropriately (SSL_* for https via the registry).
295
+ if scheme == "https"
296
+ fd = Sock.sphttp_tls_connect_start(host, port)
297
+ if fd < 0
298
+ return out
299
+ end
300
+ hs = Sock.sphttp_tls_handshake_step(fd)
301
+ while hs == 1 || hs == 2
302
+ mode = Tep::Scheduler::READ
303
+ if hs == 2
304
+ mode = Tep::Scheduler::WRITE
305
+ end
306
+ ready = Tep::Scheduler.io_wait(fd, mode, COOP_RECV_TIMEOUT)
307
+ if ready == 0
308
+ Sock.sphttp_close(fd)
309
+ return out
310
+ end
311
+ hs = Sock.sphttp_tls_handshake_step(fd)
312
+ end
313
+ if hs < 0
314
+ # Handshake/verify failure -- handshake_step already freed the
315
+ # SSL*; close the fd. (cert/hostname mismatch lands here.)
316
+ Sock.sphttp_close(fd)
317
+ return out
318
+ end
319
+ else
320
+ fd = Sock.sphttp_connect(host, port)
321
+ if fd < 0
322
+ return out
323
+ end
324
+ Sock.sphttp_set_nonblock(fd)
325
+ end
326
+
327
+ # Same head shape as send_req_blocking; inlined for the same
328
+ # spinel-type-inference reason (see that path's comment).
329
+ head = verb + " " + path + " HTTP/1.0\r\n" +
330
+ "Host: " + host + "\r\n" +
331
+ "Connection: close\r\n"
332
+ headers.each do |k, v|
333
+ head = head + k + ": " + v + "\r\n"
334
+ end
335
+ if body.length > 0
336
+ head = head + "Content-Length: " + body.length.to_s + "\r\n"
337
+ end
338
+ head = head + "\r\n"
339
+
340
+ # send(2) on a non-blocking localhost socket with a small
341
+ # request (start line + few headers, well under the kernel's
342
+ # ~16 KiB socket buffer) returns immediately. If it ever
343
+ # surfaces EAGAIN we'll need a write-side park; for v1 the
344
+ # bounded request size makes that path dead code.
345
+ if Sock.sphttp_write_str(fd, head) < 0
346
+ Sock.sphttp_close(fd)
347
+ return out
348
+ end
349
+ if body.length > 0
350
+ if Sock.sphttp_write_str(fd, body) < 0
351
+ Sock.sphttp_close(fd)
352
+ return out
353
+ end
354
+ end
355
+
356
+ raw = ""
357
+ while raw.length < COOP_RESPONSE_MAX
358
+ ready = Tep::Scheduler.io_wait(fd, Tep::Scheduler::READ, COOP_RECV_TIMEOUT)
359
+ if ready == 0
360
+ # Timeout -- bail with whatever we have so far. An
361
+ # incomplete response will surface as status=0 from
362
+ # parse_response if the status line never arrived.
363
+ break
364
+ end
365
+ chunk = Sock.sphttp_recv_some(fd, 4096)
366
+ if chunk.length == 0
367
+ # An empty read is ambiguous: for TLS it may mean "no full
368
+ # record decoded yet" (SSL_read WANT_READ/WANT_WRITE), not EOF.
369
+ # Consult the want-status: 1 = want-read (re-park on READ at the
370
+ # loop top), 2 = want-write (TLS renegotiation -- park on WRITE
371
+ # then retry), anything else (3 EOF / -1 error, or a plaintext
372
+ # peer close) is the end of the HTTP/1.0 + Connection: close
373
+ # response.
374
+ st = Sock.sphttp_io_status
375
+ if st == 1
376
+ next
377
+ end
378
+ if st == 2
379
+ Tep::Scheduler.io_wait(fd, Tep::Scheduler::WRITE, COOP_RECV_TIMEOUT)
380
+ next
381
+ end
382
+ break
383
+ end
384
+ raw = raw + chunk
385
+ end
386
+
387
+ Sock.sphttp_close(fd)
388
+ Http.parse_response(raw)
389
+ end
390
+
391
+ # Parse a raw HTTP/1.0 or 1.1 response. Status line + headers
392
+ # (terminated by \r\n\r\n) + body. Header names are downcased
393
+ # so callers don't have to worry about case. Allocates and
394
+ # returns a fresh Response (rather than mutating one passed in
395
+ # by reference -- the mutation pattern widens the param to
396
+ # poly in spinel's analyzer).
397
+ def self.parse_response(raw)
398
+ out = Tep::Http::Response.new
399
+ if raw.length < 12
400
+ return out
401
+ end
402
+ # Status line: "HTTP/1.x SSS reason\r\n"
403
+ eol = Tep.str_find(raw, "\r\n", 0)
404
+ if eol < 0
405
+ return out
406
+ end
407
+ line = raw[0, eol]
408
+ sp1 = Tep.str_find(line, " ", 0)
409
+ if sp1 < 0
410
+ return out
411
+ end
412
+ rest = line[sp1 + 1, line.length - sp1 - 1]
413
+ sp2 = Tep.str_find(rest, " ", 0)
414
+ code_str = ""
415
+ if sp2 >= 0
416
+ code_str = rest[0, sp2]
417
+ else
418
+ code_str = rest
419
+ end
420
+ out.status = code_str.to_i
421
+
422
+ # Walk header lines until empty line.
423
+ pos = eol + 2
424
+ while pos < raw.length
425
+ next_eol = Tep.str_find(raw, "\r\n", pos)
426
+ if next_eol < 0
427
+ return out
428
+ end
429
+ if next_eol == pos
430
+ # blank line -- body starts at pos+2
431
+ body_start = pos + 2
432
+ if body_start < raw.length
433
+ out.body = raw[body_start, raw.length - body_start]
434
+ end
435
+ return out
436
+ end
437
+ line2 = raw[pos, next_eol - pos]
438
+ ci = Tep.str_find(line2, ":", 0)
439
+ if ci > 0
440
+ k = line2[0, ci].downcase
441
+ # Skip leading space after the colon.
442
+ v_start = ci + 1
443
+ if v_start < line2.length && line2[v_start] == " "
444
+ v_start += 1
445
+ end
446
+ v = line2[v_start, line2.length - v_start]
447
+ out.headers[k] = v
448
+ end
449
+ pos = next_eol + 2
450
+ end
451
+ out
452
+ end
453
+
454
+ # Read a full HTTP response, framing the body by Content-Length when
455
+ # present so a kept-alive socket stops at the message boundary and
456
+ # stays reusable. Without Content-Length we read until EOF or the
457
+ # recv timeout (socket not reusable). Returns a FramedResp.
458
+ # Bounded at 4 MiB (matches sphttp's SPHTTP_RESP_MAX) so a runaway
459
+ # upstream can't grow buf unboundedly.
460
+ def self.recv_framed(fd)
461
+ out = Tep::Http::FramedResp.new
462
+ buf = ""
463
+ hdr_end = -1
464
+ clen = -1
465
+ conn_close = false
466
+ while buf.length < 4194304
467
+ if hdr_end < 0
468
+ idx = Tep.str_find(buf, "\r\n\r\n", 0)
469
+ if idx >= 0
470
+ hdr_end = idx + 4
471
+ # Header-block scanning is inlined (not extracted to helper
472
+ # methods): spinel types a param by name file-wide, so a
473
+ # String param that isn't forced String at the boundary
474
+ # defaults to mrb_int and the call mismatches. Operating on
475
+ # `buf` slices here keeps everything unambiguously String.
476
+ lowh = buf[0, hdr_end].downcase
477
+ # Content-Length (to_i tolerates the leading space, stops at CR).
478
+ ci = Tep.str_find(lowh, "content-length:", 0)
479
+ if ci >= 0
480
+ crest = lowh[ci + 15, lowh.length]
481
+ ceol = Tep.str_find(crest, "\r\n", 0)
482
+ cline = crest
483
+ if ceol >= 0
484
+ cline = crest[0, ceol]
485
+ end
486
+ clen = cline.to_i
487
+ end
488
+ # Connection: close
489
+ ki = Tep.str_find(lowh, "connection:", 0)
490
+ if ki >= 0
491
+ krest = lowh[ki + 11, lowh.length]
492
+ keol = Tep.str_find(krest, "\r\n", 0)
493
+ kline = krest
494
+ if keol >= 0
495
+ kline = krest[0, keol]
496
+ end
497
+ if Tep.str_find(kline, "close", 0) >= 0
498
+ conn_close = true
499
+ end
500
+ end
501
+ end
502
+ end
503
+ if hdr_end >= 0 && clen >= 0 && (buf.length - hdr_end) >= clen
504
+ break
505
+ end
506
+ chunk = Sock.sphttp_recv_some(fd, 65536)
507
+ if chunk.length == 0
508
+ break
509
+ end
510
+ buf = buf + chunk
511
+ end
512
+ framed = false
513
+ if hdr_end >= 0 && clen >= 0 && (buf.length - hdr_end) == clen
514
+ framed = true
515
+ end
516
+ out.raw = buf
517
+ out.framed_clean = framed
518
+ out.conn_close = conn_close
519
+ out
520
+ end
521
+
522
+ class Response
523
+ attr_accessor :status, :headers, :body
524
+ def initialize
525
+ @status = 0
526
+ @headers = Tep.str_hash
527
+ @body = ""
528
+ end
529
+ end
530
+
531
+ # Result of recv_framed: the raw bytes, whether the body was cleanly
532
+ # Content-Length-framed (so the socket sits at a clean boundary and
533
+ # can be pooled), and whether the peer asked to close.
534
+ class FramedResp
535
+ attr_accessor :raw, :framed_clean, :conn_close
536
+ def initialize
537
+ @raw = ""
538
+ @framed_clean = false
539
+ @conn_close = false
540
+ end
541
+ end
542
+
543
+ # HTTP/1.1 outbound connection pool (chunk 6.7a -- design lands
544
+ # ahead of Tep::Http.send_req integration in 6.7b). Per-process,
545
+ # keyed by (host, port). Thin Ruby surface over the C primitives
546
+ # in sphttp.c.
547
+ #
548
+ # Method names are `claim` / `release` (NOT `checkout` / `checkin`)
549
+ # to avoid colliding with `PG::Pool#checkin` -- spinel unifies
550
+ # parameter types across same-named methods, so reusing the names
551
+ # widens PG::Pool's `c` (a PG::Connection) and our `fd` (an int)
552
+ # into a poly type and breaks the codegen.
553
+ #
554
+ # In 6.7a, this module is callable but NOT YET wired into
555
+ # send_req -- Tep::Http still opens a fresh socket per request +
556
+ # closes it. The 6.7b chunk lands the integration once the
557
+ # HTTP/1.1 keep-alive recv-N-bytes path is in place. Apps can
558
+ # already use Pool directly for their own outbound clients.
559
+ class Pool
560
+ # Try to claim an idle keep-alive fd for (host, port). Returns
561
+ # the fd (>=0) on hit, -1 on miss. The caller owns the fd on
562
+ # hit -- close it explicitly if the request fails, or
563
+ # `release` it for reuse.
564
+ def self.claim(host, port)
565
+ Sock.sphttp_pool_checkout(host, port)
566
+ end
567
+
568
+ # Register `fd` as an idle keep-alive socket for (host, port).
569
+ # Returns 0 on success, -1 on failure (pool full -- the LRU
570
+ # gets evicted internally, so failures are rare). Don't release
571
+ # after a 5xx that triggered retries (the half-broken socket
572
+ # would poison the pool) -- close directly via Sock.sphttp_close.
573
+ def self.release(fd, host, port)
574
+ Sock.sphttp_pool_checkin(fd, host, port)
575
+ end
576
+
577
+ # Close idle fds older than `idle_seconds`. Returns the count
578
+ # closed. Call periodically from the server's idle path; not
579
+ # called automatically yet.
580
+ def self.close_idle(idle_seconds)
581
+ Sock.sphttp_pool_close_idle(idle_seconds)
582
+ end
583
+
584
+ # Stats snapshot -- returns a Tep.str_hash with the four
585
+ # counters. checkouts/checkins are total calls; hits/misses
586
+ # are subsets of checkouts (hit + miss = checkouts). The
587
+ # C-side primitives keep the underlying counter names; this
588
+ # surface uses the same names for clarity.
589
+ def self.stats
590
+ h = Tep.str_hash
591
+ h["checkouts"] = Sock.sphttp_pool_stat_checkouts.to_s
592
+ h["checkins"] = Sock.sphttp_pool_stat_checkins.to_s
593
+ h["hits"] = Sock.sphttp_pool_stat_hits.to_s
594
+ h["misses"] = Sock.sphttp_pool_stat_misses.to_s
595
+ h
596
+ end
597
+ end
598
+ end
599
+ end
@@ -0,0 +1,67 @@
1
+ # Tep::Identity -- principal + delegate identity. Identifies who
2
+ # made a request and, optionally, the agent acting on their behalf.
3
+ #
4
+ # Set on `req.identity` by the Tep::Auth provider chain (see
5
+ # docs/BATTERIES-DESIGN.md for the full Auth surface). Consumers
6
+ # (Broadcast, Presence, LiveView) read req.identity and key authz
7
+ # decisions off #may?(cap) plus the human?/agent? split.
8
+ #
9
+ # Agentic shape: a "delegated" identity is one where a non-human
10
+ # agent (LLM-driven bot, scheduled worker, automation client)
11
+ # acts on behalf of a human principal. Both layers are visible:
12
+ # - principal_id is the human (e.g. "user:42").
13
+ # - acting_via.agent_id is the agent ("summarizer-bot").
14
+ # Authz checks via #may? gate on the granted capability set, which
15
+ # the issuer is expected to keep as a subset of the principal's
16
+ # own caps -- never a superset. The split lets app code branch
17
+ # tighter on req.identity (e.g. "only humans may revoke other
18
+ # agents") rather than treating every authenticated caller alike.
19
+ module Tep
20
+ class Identity
21
+ attr_reader :principal_id # String, opaque to tep (apps own the format)
22
+ attr_reader :acting_via # Tep::AgentDelegation or nil
23
+ attr_reader :capabilities # Array of symbols
24
+
25
+ def initialize(principal_id, acting_via, capabilities)
26
+ @principal_id = principal_id
27
+ @acting_via = acting_via
28
+ @capabilities = capabilities
29
+ end
30
+
31
+ # The unauthenticated identity. Used by the Tep::Auth before-
32
+ # filter when no provider sniffed a credential off the request.
33
+ # Apps that gate routes on identity check the principal_id ==
34
+ # "" shape; #may? returns false for everything since the cap
35
+ # array is empty.
36
+ def self.anonymous
37
+ seed = [:_seed]
38
+ seed.delete_at(0)
39
+ Identity.new("", nil, seed)
40
+ end
41
+
42
+ def human?
43
+ @acting_via == nil
44
+ end
45
+
46
+ def agent?
47
+ @acting_via != nil
48
+ end
49
+
50
+ def may?(cap)
51
+ @capabilities.include?(cap)
52
+ end
53
+
54
+ # Audit-friendly string. Humans render as "user:<principal>";
55
+ # agents render as "agent:<agent_id>/<principal>" -- the slash
56
+ # makes the principal-of-record visible at a glance and is the
57
+ # standard shape every log line and Broadcast `from` field
58
+ # should carry.
59
+ def subject
60
+ if @acting_via == nil
61
+ "user:" + @principal_id
62
+ else
63
+ "agent:" + @acting_via.agent_id + "/" + @principal_id
64
+ end
65
+ end
66
+ end
67
+ end