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
@@ -0,0 +1,342 @@
1
+ # Tep::Scheduler -- a tiny fiber-based cooperative scheduler.
2
+ #
3
+ # Spinel ships Fiber today (ucontext-based, GC-aware, ivars persist
4
+ # across yields). What was missing was the layer above: a way to run
5
+ # multiple cooperating fibers within a single worker process so a
6
+ # long-running response (SSE stream, long-poll, slow batch) doesn't
7
+ # pin the worker for the whole connection lifetime.
8
+ #
9
+ # This covers two parking modes:
10
+ #
11
+ # * **Time**: register a fiber to be resumed at-or-after `wake_at`
12
+ # via `Tep::Scheduler.pause(seconds)`.
13
+ # * **I/O**: park a fiber on (fd, mode) via `Tep::Scheduler.io_wait`.
14
+ # tick() runs a poll(2) round, marks ready fibers, and resumes them
15
+ # (along with any time-ready ones) on the same pass.
16
+ #
17
+ # Storage shape
18
+ # -------------
19
+ # Parallel arrays on the Tep::APP singleton -- one entry per fiber:
20
+ # sched_fibers PtrArray<FiberSlot> the Fiber itself
21
+ # sched_wake_at IntArray unix-seconds; -1 = ready now
22
+ # sched_io_fd IntArray fd parked on; -1 = no I/O wait
23
+ # sched_io_mode IntArray requested mode bits (1=R, 2=W)
24
+ # sched_io_ready IntArray observed-ready bits (0=not yet)
25
+ #
26
+ # Spinel handles same-shaped typed arrays cleanly; using a single
27
+ # array of structs would force a poly_array. Same App-instance
28
+ # pattern as Tep::Assets.
29
+ #
30
+ # What it doesn't do (yet)
31
+ # ------------------------
32
+ # **Implicit yield on blocking calls.** Ruby 3.0's
33
+ # `Fiber::SchedulerInterface` makes every blocking I/O auto-yield
34
+ # to a registered scheduler. Spinel doesn't recognise that hook;
35
+ # fibers yield explicitly via `Tep::Scheduler.pause / io_wait`.
36
+ #
37
+ # **Non-blocking accept on the listening socket.** The Server's
38
+ # worker_loop still does a blocking accept(); fibers cooperate
39
+ # *within* a single request lifetime, not across requests. Adding
40
+ # poll-on-accept needs the worker_loop to opt into the scheduler.
41
+ module Tep
42
+ class Scheduler
43
+ # Mode bits for io_wait. Mirror sphttp's wire encoding so the
44
+ # C side and Ruby side stay aligned.
45
+ READ = 1
46
+ WRITE = 2
47
+
48
+ def self.spawn_fiber(f)
49
+ Tep::APP.sched_fibers.push(Tep::FiberSlot.new(f))
50
+ Tep::APP.sched_wake_at.push(-1)
51
+ Tep::APP.sched_io_fd.push(-1)
52
+ Tep::APP.sched_io_mode.push(0)
53
+ Tep::APP.sched_io_ready.push(0)
54
+ f
55
+ end
56
+
57
+ # One scheduler pass. If any fibers are parked on I/O, build a
58
+ # poll set, run poll(2) for up to `poll_timeout_ms`, and mark
59
+ # ready ones. Then resume the soonest-due fiber whose wake_at
60
+ # is <= now. Returns true if it resumed something.
61
+ #
62
+ # If a fiber is already time-due (wake_at <= now -- e.g. a newly
63
+ # spawned fiber with wake_at=-1, or a fiber that just called
64
+ # pause(0)), poll() must NOT block: we have runnable work and
65
+ # any wait is wasted wall time. This matters for the cooperative
66
+ # request path -- when an outer handler parks on io_wait and
67
+ # the accept fiber spawns an inner connection-fiber, the next
68
+ # tick has a wake_at=-1 fiber ready; without this short-circuit
69
+ # each "hand off to the freshly-spawned fiber" step costs a full
70
+ # poll-timeout's worth of latency.
71
+ def self.tick(poll_timeout_ms)
72
+ # Reclaim trailing dead slots. Without this, the parallel
73
+ # arrays grow once per accepted connection and never shrink --
74
+ # a slow leak and per-tick iteration tax in a long-running
75
+ # Scheduled server. Tail-only (stop at first alive) is
76
+ # deliberate: it keeps every surviving slot's index stable,
77
+ # so external captures of sched_current held across Fiber.yield
78
+ # (e.g. pg.rb's PG::Pool @waiter_idxs) stay valid. Middle
79
+ # dead slots aren't reclaimed until the tail catches up; for
80
+ # FIFO request lifecycles that's the common case.
81
+ i = Tep::APP.sched_fibers.length - 1
82
+ while i >= 0 && !Tep::APP.sched_fibers[i].f.alive?
83
+ Tep::APP.sched_fibers.delete_at(i)
84
+ Tep::APP.sched_wake_at.delete_at(i)
85
+ Tep::APP.sched_io_fd.delete_at(i)
86
+ Tep::APP.sched_io_mode.delete_at(i)
87
+ Tep::APP.sched_io_ready.delete_at(i)
88
+ i -= 1
89
+ end
90
+
91
+ ms = poll_timeout_ms
92
+ if Scheduler.any_time_ready
93
+ ms = 0
94
+ end
95
+ Scheduler.poll_round(ms)
96
+
97
+ now = Time.now.to_i
98
+ best = -1
99
+ i = 0
100
+ n = Tep::APP.sched_fibers.length
101
+ while i < n
102
+ if Tep::APP.sched_fibers[i].f.alive? && Tep::APP.sched_wake_at[i] <= now
103
+ if best < 0 || Tep::APP.sched_wake_at[i] < Tep::APP.sched_wake_at[best]
104
+ best = i
105
+ end
106
+ end
107
+ i += 1
108
+ end
109
+ if best < 0
110
+ return false
111
+ end
112
+ Tep::APP.sched_current = best
113
+ Tep::APP.sched_wake_at[best] = -1
114
+ Tep::APP.sched_fibers[best].f.resume
115
+ Tep::APP.sched_current = -1
116
+ true
117
+ end
118
+
119
+ # Build poll set from parked-on-I/O fibers, call poll(2), and
120
+ # write observed-ready bits back into the parallel arrays.
121
+ # `timeout_ms` is the poll() timeout (-1 = block forever,
122
+ # 0 = non-blocking peek). Idempotent for an empty set.
123
+ def self.poll_round(timeout_ms)
124
+ Sock.sphttp_poll_reset
125
+ slots = [-1] # slot index parallel to sched_fibers; -1 = not polled
126
+ slots.delete_at(0)
127
+ added = 0
128
+ i = 0
129
+ n = Tep::APP.sched_fibers.length
130
+ while i < n
131
+ slot = -1
132
+ if Tep::APP.sched_fibers[i].f.alive? &&
133
+ Tep::APP.sched_io_fd[i] >= 0 &&
134
+ Tep::APP.sched_io_ready[i] == 0
135
+ slot = Sock.sphttp_poll_add(Tep::APP.sched_io_fd[i],
136
+ Tep::APP.sched_io_mode[i])
137
+ added += 1
138
+ end
139
+ slots.push(slot)
140
+ i += 1
141
+ end
142
+ if added == 0
143
+ return 0
144
+ end
145
+ Sock.sphttp_poll_run(timeout_ms)
146
+ now = Time.now.to_i
147
+ i = 0
148
+ while i < n
149
+ if slots[i] >= 0
150
+ ready = Sock.sphttp_poll_ready(slots[i])
151
+ if ready > 0
152
+ Tep::APP.sched_io_ready[i] = ready
153
+ Tep::APP.sched_wake_at[i] = now
154
+ end
155
+ end
156
+ i += 1
157
+ end
158
+ added
159
+ end
160
+
161
+ # Drain. Resumes everything ready until the schedulable set
162
+ # is empty (every fiber finished or all are waiting for a
163
+ # future wake_at / I/O). Returns the number of resumes performed.
164
+ # Pure non-blocking; no poll() wait between passes.
165
+ def self.run_until_empty
166
+ n = 0
167
+ while Scheduler.tick(0)
168
+ n += 1
169
+ end
170
+ n
171
+ end
172
+
173
+ # Drain until `seconds` has elapsed OR every fiber's done.
174
+ # Between empty passes, blocks in poll(2) (or sleep, if no
175
+ # I/O waits) until the next wake-up.
176
+ def self.run_for(seconds)
177
+ deadline = Time.now.to_i + seconds
178
+ while Time.now.to_i < deadline
179
+ if !Scheduler.tick(0)
180
+ # Nothing ready this pass. Compute the next deadline:
181
+ # min(next_wake, overall_deadline). If any fiber is
182
+ # parked on I/O, block in poll() until that or the
183
+ # timer hits.
184
+ next_at = Scheduler.next_wake
185
+ gap = deadline - Time.now.to_i
186
+ if next_at >= 0
187
+ tgap = next_at - Time.now.to_i
188
+ if tgap < gap
189
+ gap = tgap
190
+ end
191
+ end
192
+ if gap < 0
193
+ gap = 0
194
+ end
195
+ if Scheduler.any_io_waiter
196
+ # Park in poll for up to `gap` seconds.
197
+ Scheduler.poll_round(gap * 1000)
198
+ elsif next_at < 0
199
+ return 0
200
+ elsif gap > 0
201
+ sleep gap
202
+ end
203
+ end
204
+ end
205
+ 0
206
+ end
207
+
208
+ def self.next_wake
209
+ best = -1
210
+ i = 0
211
+ n = Tep::APP.sched_fibers.length
212
+ while i < n
213
+ if Tep::APP.sched_fibers[i].f.alive?
214
+ if best < 0 || Tep::APP.sched_wake_at[i] < Tep::APP.sched_wake_at[best]
215
+ best = i
216
+ end
217
+ end
218
+ i += 1
219
+ end
220
+ if best < 0
221
+ return -1
222
+ end
223
+ Tep::APP.sched_wake_at[best]
224
+ end
225
+
226
+ def self.any_io_waiter
227
+ i = 0
228
+ n = Tep::APP.sched_fibers.length
229
+ while i < n
230
+ if Tep::APP.sched_fibers[i].f.alive? &&
231
+ Tep::APP.sched_io_fd[i] >= 0 &&
232
+ Tep::APP.sched_io_ready[i] == 0
233
+ return true
234
+ end
235
+ i += 1
236
+ end
237
+ false
238
+ end
239
+
240
+ # Is any alive fiber's wake_at already <= now? Used by tick() to
241
+ # decide whether poll() can block: if anyone is time-due, the
242
+ # poll timeout collapses to 0 (non-blocking peek) so we don't
243
+ # waste wall time idling when there's runnable work.
244
+ def self.any_time_ready
245
+ now = Time.now.to_i
246
+ i = 0
247
+ n = Tep::APP.sched_fibers.length
248
+ while i < n
249
+ if Tep::APP.sched_fibers[i].f.alive? && Tep::APP.sched_wake_at[i] <= now
250
+ return true
251
+ end
252
+ i += 1
253
+ end
254
+ false
255
+ end
256
+
257
+ # Called from within a fiber's body to suspend until at-or-
258
+ # after `seconds` from now. Named `pause` rather than `sleep`
259
+ # to keep the semantics distinct from `Kernel#sleep`: this is
260
+ # a fiber-aware yield that returns the cooperative scheduler to
261
+ # the dispatch loop, not an OS-level sleep. Outside a fiber it
262
+ # falls through to bare `sleep(seconds)`.
263
+ def self.pause(seconds)
264
+ idx = Tep::APP.sched_current
265
+ if idx < 0
266
+ # Called from outside any fiber -- fall back to POSIX sleep.
267
+ sleep(seconds)
268
+ return 0
269
+ end
270
+ Tep::APP.sched_wake_at[idx] = Time.now.to_i + seconds
271
+ Fiber.yield
272
+ 0
273
+ end
274
+
275
+ # Park the current fiber until `fd` is ready for the given
276
+ # `mode` bits (1=READ, 2=WRITE, 3=both) OR `timeout_seconds`
277
+ # elapses. Returns the observed-ready bits (0 on timeout).
278
+ # When called from outside a fiber, falls back to a single
279
+ # poll() call so the same code works at top level.
280
+ def self.io_wait(fd, mode, timeout_seconds)
281
+ idx = Tep::APP.sched_current
282
+ if idx < 0
283
+ # No fiber context -- single-shot poll inline.
284
+ Sock.sphttp_poll_reset
285
+ slot = Sock.sphttp_poll_add(fd, mode)
286
+ Sock.sphttp_poll_run(timeout_seconds * 1000)
287
+ return Sock.sphttp_poll_ready(slot)
288
+ end
289
+ Tep::APP.sched_io_fd[idx] = fd
290
+ Tep::APP.sched_io_mode[idx] = mode
291
+ Tep::APP.sched_io_ready[idx] = 0
292
+ if timeout_seconds < 0
293
+ # "Wait forever for I/O": -1 would mean "ready now" to the
294
+ # tick picker, so use a far-future wake_at as the sentinel.
295
+ Tep::APP.sched_wake_at[idx] = Time.now.to_i + 86400
296
+ else
297
+ Tep::APP.sched_wake_at[idx] = Time.now.to_i + timeout_seconds
298
+ end
299
+ Fiber.yield
300
+ ready = Tep::APP.sched_io_ready[idx]
301
+ Tep::APP.sched_io_fd[idx] = -1
302
+ Tep::APP.sched_io_mode[idx] = 0
303
+ Tep::APP.sched_io_ready[idx] = 0
304
+ ready
305
+ end
306
+
307
+ # Reset the schedulable set. Useful between worker-loop
308
+ # iterations or between tests.
309
+ def self.clear
310
+ while Tep::APP.sched_fibers.length > 0
311
+ Tep::APP.sched_fibers.delete_at(0)
312
+ Tep::APP.sched_wake_at.delete_at(0)
313
+ Tep::APP.sched_io_fd.delete_at(0)
314
+ Tep::APP.sched_io_mode.delete_at(0)
315
+ Tep::APP.sched_io_ready.delete_at(0)
316
+ end
317
+ 0
318
+ end
319
+
320
+ def self.alive_count
321
+ n = 0
322
+ i = 0
323
+ total = Tep::APP.sched_fibers.length
324
+ while i < total
325
+ if Tep::APP.sched_fibers[i].f.alive?
326
+ n += 1
327
+ end
328
+ i += 1
329
+ end
330
+ n
331
+ end
332
+
333
+ # True iff a Tep::Scheduler-managed fiber is currently executing.
334
+ # Set by tick() right before f.resume and reset right after, so
335
+ # this is the canonical "am I in cooperative context?" check for
336
+ # callers that want to pick a blocking vs. fiber-yielding path
337
+ # (e.g. Tep::Http -- see lib/tep/http.rb#send_req).
338
+ def self.scheduled_context?
339
+ Tep::APP.sched_current >= 0
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,140 @@
1
+ # Tep::Security -- before-filter helpers for the two middleware
2
+ # patterns Sinatra apps almost always reach for: CORS and a
3
+ # default-secure header bundle. The Rack::Cors / rack-protection
4
+ # gems do the same things via runtime middleware registration
5
+ # (`use Rack::Cors`); spinel can't do dynamic dispatch into a Rack
6
+ # stack, so we expose the behaviour as small filter classes the
7
+ # user wires with `Tep.before(...)`.
8
+ #
9
+ # Usage
10
+ # =====
11
+ #
12
+ # # CORS, allowing one origin.
13
+ # cors = Tep::Security::Cors.new
14
+ # cors.set_origin("https://app.example.com")
15
+ # cors.set_allowed_verbs("GET,POST,DELETE,OPTIONS")
16
+ # Tep.before cors
17
+ #
18
+ # # Default-secure headers on every response. Apps can still
19
+ # # override individual headers in handlers.
20
+ # Tep.after Tep::Security::Headers.new
21
+ #
22
+ # Both classes are explicit Filter subclasses so they slot into
23
+ # tep's existing single-before / single-after slots cleanly.
24
+ # Multi-filter chains stack via `Tep.before` setting one chain
25
+ # class (the bin/tep translator already composes multiple
26
+ # `before do ... end` blocks; library-side filters can be added
27
+ # alongside via subclassing).
28
+ module Tep
29
+ module Security
30
+
31
+ # CORS preflight + same-origin response decoration.
32
+ #
33
+ # Configurable bits:
34
+ # - origin: a single allowed origin URL ("*" allowed for
35
+ # fully open APIs; not recommended for any endpoint that
36
+ # uses cookies / Authorization headers).
37
+ # - methods: comma-separated. Default "GET,POST,OPTIONS".
38
+ # - headers: comma-separated. Default "Content-Type,Authorization".
39
+ # - max_age: number of seconds the browser caches the
40
+ # preflight result. Default 3600.
41
+ #
42
+ # Behaviour:
43
+ # - On any request: emits `Access-Control-Allow-Origin` plus
44
+ # credential / vary headers.
45
+ # - On `OPTIONS` preflight: short-circuits to a 204 with
46
+ # `Access-Control-Allow-Methods` / `-Headers` / `-Max-Age`.
47
+ class Cors < Tep::Filter
48
+ # Field names are deliberately distinctive (not `methods` /
49
+ # `headers`) -- spinel's per-method type inference unifies
50
+ # method names across classes, and `Object#methods` /
51
+ # `Tep::Response#headers` would widen the dispatch return
52
+ # to poly and break res.headers writes downstream.
53
+ attr_accessor :origin, :allowed_verbs, :allowed_headers, :max_age
54
+
55
+ def initialize
56
+ @origin = "*"
57
+ @allowed_verbs = "GET,POST,OPTIONS"
58
+ @allowed_headers = "Content-Type,Authorization"
59
+ @max_age = 3600
60
+ end
61
+
62
+ def set_origin(o); @origin = o; end
63
+ def set_allowed_verbs(m); @allowed_verbs = m; end
64
+ def set_allowed_headers(h); @allowed_headers = h; end
65
+ def set_max_age(n); @max_age = n; end
66
+
67
+ def before(req, res)
68
+ res.headers["Access-Control-Allow-Origin"] = @origin
69
+ res.headers["Vary"] = "Origin"
70
+ if req.verb == "OPTIONS"
71
+ res.headers["Access-Control-Allow-Methods"] = @allowed_verbs
72
+ res.headers["Access-Control-Allow-Headers"] = @allowed_headers
73
+ res.headers["Access-Control-Max-Age"] = @max_age.to_s
74
+ res.set_status(204)
75
+ res.set_body_if_empty("")
76
+ # `res.halted = true` short-circuits the dispatch loop
77
+ # (see App#dispatch) so the no-route fallthrough doesn't
78
+ # overwrite our 204 with a 404.
79
+ res.halted = true
80
+ end
81
+ 0
82
+ end
83
+ end
84
+
85
+ # Default-secure response headers. Mirrors what
86
+ # rack-protection sets out of the box, minus the parts that
87
+ # need stateful middleware (CSRF token threading is its own
88
+ # feature; tep handlers can opt in with `<form><input type=
89
+ # "hidden" name="_csrf" value="..."></form>` + a session
90
+ # check on POST routes).
91
+ #
92
+ # Headers set:
93
+ # X-Content-Type-Options: nosniff
94
+ # X-Frame-Options: SAMEORIGIN
95
+ # Referrer-Policy: strict-origin-when-cross-origin
96
+ # X-XSS-Protection: 0 (modern browsers ignore; "0"
97
+ # is current OWASP guidance over
98
+ # "1; mode=block" which causes
99
+ # reflected XSS injection bugs)
100
+ #
101
+ # Optional, off by default:
102
+ # Strict-Transport-Security
103
+ # -- enable via `set_hsts(seconds)`. Setting on plain HTTP
104
+ # is ineffective; only emit when you've actually got
105
+ # TLS termination upstream.
106
+ #
107
+ # Wiring: register as an `after` filter so it runs after the
108
+ # handler can override Content-Type etc.
109
+ class Headers < Tep::Filter
110
+ attr_accessor :hsts_seconds
111
+
112
+ def initialize
113
+ @hsts_seconds = 0
114
+ end
115
+
116
+ def set_hsts(seconds); @hsts_seconds = seconds; end
117
+
118
+ def after(req, res)
119
+ if !res.headers.key?("X-Content-Type-Options")
120
+ res.headers["X-Content-Type-Options"] = "nosniff"
121
+ end
122
+ if !res.headers.key?("X-Frame-Options")
123
+ res.headers["X-Frame-Options"] = "SAMEORIGIN"
124
+ end
125
+ if !res.headers.key?("Referrer-Policy")
126
+ res.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
127
+ end
128
+ if !res.headers.key?("X-XSS-Protection")
129
+ res.headers["X-XSS-Protection"] = "0"
130
+ end
131
+ if @hsts_seconds > 0 && !res.headers.key?("Strict-Transport-Security")
132
+ res.headers["Strict-Transport-Security"] =
133
+ "max-age=" + @hsts_seconds.to_s + "; includeSubDomains"
134
+ end
135
+ 0
136
+ end
137
+ end
138
+
139
+ end
140
+ end