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,265 @@
|
|
|
1
|
+
# Tep::Broadcast -- in-process pub-sub topic broker.
|
|
2
|
+
#
|
|
3
|
+
# Foundation of the Broadcast battery (Battery 2 in
|
|
4
|
+
# docs/BATTERIES-DESIGN.md). Apps + later batteries (Presence,
|
|
5
|
+
# LiveView) layer on top: WebSocket connections subscribe to
|
|
6
|
+
# topics; publish(topic, payload) writes payload to every
|
|
7
|
+
# subscribed fd.
|
|
8
|
+
#
|
|
9
|
+
# Public API:
|
|
10
|
+
#
|
|
11
|
+
# sub_id = Tep::Broadcast.subscribe(topic, fd)
|
|
12
|
+
# Tep::Broadcast.publish(topic, payload)
|
|
13
|
+
# Tep::Broadcast.unsubscribe(sub_id)
|
|
14
|
+
# Tep::Broadcast.unsubscribe_fd(fd) # drop ALL subs for an fd
|
|
15
|
+
#
|
|
16
|
+
# Subscription model is fd-based rather than block/callback-based
|
|
17
|
+
# (spinel can't reliably round-trip blocks-as-values across module
|
|
18
|
+
# boundaries, see memory [[spinel_widening_dispatch]]). The
|
|
19
|
+
# concrete v1 use case is "deliver to a WS connection" -- the WS
|
|
20
|
+
# layer keeps its accepted-socket fd, calls subscribe, and
|
|
21
|
+
# Tep::Broadcast.publish writes the payload bytes to that fd.
|
|
22
|
+
# Apps that need a different delivery surface (HTTP SSE, log
|
|
23
|
+
# fan-out) use the same subscribe-fd shape with a different fd.
|
|
24
|
+
#
|
|
25
|
+
# Storage scope is per-process: subscriptions live on Tep::APP,
|
|
26
|
+
# which under prefork is per-worker. Cross-worker pub-sub goes
|
|
27
|
+
# through PG LISTEN/NOTIFY (Tep::Broadcast.enable_pg_backend) --
|
|
28
|
+
# subscribers always register fd-local; publish() additionally
|
|
29
|
+
# NOTIFY's the configured channel so peer workers' local
|
|
30
|
+
# subscribers see the message too.
|
|
31
|
+
#
|
|
32
|
+
# `subscribe` returns an opaque subscription id (the registry
|
|
33
|
+
# index at insertion time). Callers can pass it back to
|
|
34
|
+
# `unsubscribe` for a single-sub drop. For WS connections that
|
|
35
|
+
# subscribe to multiple topics, `unsubscribe_fd(fd)` drops every
|
|
36
|
+
# subscription tied to that fd in one call -- the right shape for
|
|
37
|
+
# the WS on-close hook.
|
|
38
|
+
module Tep
|
|
39
|
+
module Broadcast
|
|
40
|
+
# Register a subscription for `fd` on `topic`. Returns an
|
|
41
|
+
# opaque sub_id for later unsubscribe. The fd receives raw
|
|
42
|
+
# bytes on publish -- suits SSE / log fan-out / anything that
|
|
43
|
+
# doesn't need WebSocket framing. For WS connections, prefer
|
|
44
|
+
# subscribe_ws.
|
|
45
|
+
def self.subscribe(topic, fd)
|
|
46
|
+
subs = Tep::APP.broadcast_subs
|
|
47
|
+
sub = Tep::BroadcastSubscription.new(topic, fd, 0)
|
|
48
|
+
subs.push(sub)
|
|
49
|
+
subs.length - 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# WebSocket-bridged variant of subscribe. The fd is expected
|
|
53
|
+
# to be an established WS connection (typically a
|
|
54
|
+
# Tep::WebSocket::Connection's #fd). On publish, payload is
|
|
55
|
+
# wrapped in a WS TEXT frame via Tep::WebSocket::Driver
|
|
56
|
+
# before delivery -- the peer sees a well-formed WS message,
|
|
57
|
+
# not raw bytes that would close the connection.
|
|
58
|
+
#
|
|
59
|
+
# Cleanup is automatic: when the WS connection closes,
|
|
60
|
+
# Tep::WebSocket::Connection.dispatch_close runs the user's
|
|
61
|
+
# on_close handler and then calls unsubscribe_fd(driver.fd),
|
|
62
|
+
# dropping every subscription tied to the closed connection.
|
|
63
|
+
# Apps don't need to add their own unsubscribe; if they do,
|
|
64
|
+
# the second call just finds 0 matches (harmless).
|
|
65
|
+
def self.subscribe_ws(topic, fd)
|
|
66
|
+
subs = Tep::APP.broadcast_subs
|
|
67
|
+
sub = Tep::BroadcastSubscription.new(
|
|
68
|
+
topic, fd, Tep::WebSocket::OPCODE_TEXT)
|
|
69
|
+
subs.push(sub)
|
|
70
|
+
subs.length - 1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Drop the subscription at `sub_id`. Note that ids are
|
|
74
|
+
# registry indexes; subsequent drops shift everything past it
|
|
75
|
+
# downward. For multi-sub drop, prefer `unsubscribe_fd`.
|
|
76
|
+
def self.unsubscribe(sub_id)
|
|
77
|
+
subs = Tep::APP.broadcast_subs
|
|
78
|
+
if sub_id < 0 || sub_id >= subs.length
|
|
79
|
+
return 0
|
|
80
|
+
end
|
|
81
|
+
subs.delete_at(sub_id)
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Drop every subscription whose fd matches. Returns the count
|
|
86
|
+
# dropped. Used by WS on-close to clean up everything a closing
|
|
87
|
+
# connection had subscribed to. Back-to-front so delete_at
|
|
88
|
+
# indices stay valid mid-loop.
|
|
89
|
+
def self.unsubscribe_fd(fd)
|
|
90
|
+
subs = Tep::APP.broadcast_subs
|
|
91
|
+
dropped = 0
|
|
92
|
+
i = subs.length - 1
|
|
93
|
+
while i >= 0
|
|
94
|
+
if subs[i].fd == fd
|
|
95
|
+
subs.delete_at(i)
|
|
96
|
+
dropped += 1
|
|
97
|
+
end
|
|
98
|
+
i -= 1
|
|
99
|
+
end
|
|
100
|
+
dropped
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Write `payload` to every subscribed fd for `topic`. Returns
|
|
104
|
+
# the number of subscriptions matched (NOT the number of
|
|
105
|
+
# successful writes -- a closed / bad fd still counts as
|
|
106
|
+
# matched; the underlying sphttp_write_str returns -1 silently
|
|
107
|
+
# on that fd). Apps that need delivery confirmation should
|
|
108
|
+
# track their own ack channel.
|
|
109
|
+
#
|
|
110
|
+
# When the PG backend is enabled (Tep::Broadcast.enable_pg_backend),
|
|
111
|
+
# publish ALSO NOTIFY's the configured channel so other workers
|
|
112
|
+
# subscribed via poll_pg_once can deliver to their local
|
|
113
|
+
# subscribers. Match count returned is the LOCAL match count;
|
|
114
|
+
# remote deliveries are best-effort and not counted here.
|
|
115
|
+
def self.publish(topic, payload)
|
|
116
|
+
matched = Tep::Broadcast.publish_local_only(topic, payload)
|
|
117
|
+
if Tep::APP.broadcast_pg_enabled != 0
|
|
118
|
+
wire = Tep::Broadcast.encode_wire(topic, payload)
|
|
119
|
+
Tep::APP.broadcast_pg_conn.notify(
|
|
120
|
+
Tep::APP.broadcast_pg_channel, wire)
|
|
121
|
+
end
|
|
122
|
+
matched
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Total subscription count across all topics. Useful for
|
|
126
|
+
# diagnostics and the v1 test surface.
|
|
127
|
+
def self.subscriber_count
|
|
128
|
+
Tep::APP.broadcast_subs.length
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Count of subscribers for one topic. O(n) over the registry;
|
|
132
|
+
# acceptable for v1 (n is typically small per worker).
|
|
133
|
+
def self.subscribers_for(topic)
|
|
134
|
+
subs = Tep::APP.broadcast_subs
|
|
135
|
+
n = 0
|
|
136
|
+
i = 0
|
|
137
|
+
while i < subs.length
|
|
138
|
+
if subs[i].topic == topic
|
|
139
|
+
n += 1
|
|
140
|
+
end
|
|
141
|
+
i += 1
|
|
142
|
+
end
|
|
143
|
+
n
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Drop every subscription. Used by tests between fixtures, and
|
|
147
|
+
# available to apps that need to fully reset (e.g. during
|
|
148
|
+
# graceful shutdown). Returns the count dropped.
|
|
149
|
+
def self.clear
|
|
150
|
+
subs = Tep::APP.broadcast_subs
|
|
151
|
+
n = subs.length
|
|
152
|
+
while subs.length > 0
|
|
153
|
+
subs.delete_at(0)
|
|
154
|
+
end
|
|
155
|
+
n
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ---- PG backend (cross-worker pub/sub) ----
|
|
159
|
+
#
|
|
160
|
+
# Opens a dedicated PG connection and issues `LISTEN <channel>`.
|
|
161
|
+
# Subsequent publishes NOTIFY this channel too -- other workers
|
|
162
|
+
# subscribed to the same channel can receive the message via
|
|
163
|
+
# poll_pg_once.
|
|
164
|
+
#
|
|
165
|
+
# `conninfo` is the libpq connect string. `channel` must be a
|
|
166
|
+
# safe SQL identifier (e.g. "tep_broadcast") since it lands
|
|
167
|
+
# inside a LISTEN / NOTIFY command unescaped.
|
|
168
|
+
#
|
|
169
|
+
# Returns 0 on success, -1 on connection or LISTEN failure.
|
|
170
|
+
def self.enable_pg_backend(conninfo, channel)
|
|
171
|
+
conn = PG::Connection.new(conninfo)
|
|
172
|
+
if conn.pgh < 0
|
|
173
|
+
return -1
|
|
174
|
+
end
|
|
175
|
+
if conn.listen(channel) < 0
|
|
176
|
+
return -1
|
|
177
|
+
end
|
|
178
|
+
Tep::APP.set_broadcast_pg_conn(conn)
|
|
179
|
+
Tep::APP.set_broadcast_pg_channel(channel)
|
|
180
|
+
Tep::APP.set_broadcast_pg_enabled(1)
|
|
181
|
+
0
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def self.disable_pg_backend
|
|
185
|
+
if Tep::APP.broadcast_pg_enabled == 0
|
|
186
|
+
return 0
|
|
187
|
+
end
|
|
188
|
+
Tep::APP.broadcast_pg_conn.unlisten(Tep::APP.broadcast_pg_channel)
|
|
189
|
+
Tep::APP.broadcast_pg_conn.finish
|
|
190
|
+
Tep::APP.set_broadcast_pg_enabled(0)
|
|
191
|
+
0
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Process one notification from the PG channel: parse the wire
|
|
195
|
+
# format, dispatch to local subscribers as if `publish` had
|
|
196
|
+
# been called locally (but WITHOUT re-NOTIFYing -- that would
|
|
197
|
+
# loop). Returns 1 if a notification was processed, 0 on
|
|
198
|
+
# timeout, -1 on connection error or unenabled backend.
|
|
199
|
+
def self.poll_pg_once(timeout_ms)
|
|
200
|
+
if Tep::APP.broadcast_pg_enabled == 0
|
|
201
|
+
return -1
|
|
202
|
+
end
|
|
203
|
+
r = Tep::APP.broadcast_pg_conn.poll_notification(timeout_ms)
|
|
204
|
+
if r != 1
|
|
205
|
+
return r
|
|
206
|
+
end
|
|
207
|
+
wire = Tep::APP.broadcast_pg_conn.last_notify_payload
|
|
208
|
+
Tep::Broadcast.deliver_wire_local(wire)
|
|
209
|
+
1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Wire format: "<topic_byte_length>:<topic><payload>".
|
|
213
|
+
# Length-prefixed so topics and payloads with arbitrary chars
|
|
214
|
+
# (commas, colons, embedded quotes, newlines) round-trip
|
|
215
|
+
# unambiguously. Encoded by `publish` when the PG backend is
|
|
216
|
+
# enabled; decoded by `deliver_wire_local`.
|
|
217
|
+
def self.encode_wire(topic, payload)
|
|
218
|
+
topic.length.to_s + ":" + topic + payload
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def self.deliver_wire_local(wire)
|
|
222
|
+
colon = Tep.str_find(wire, ":", 0)
|
|
223
|
+
if colon <= 0
|
|
224
|
+
return -1
|
|
225
|
+
end
|
|
226
|
+
len_str = wire[0, colon]
|
|
227
|
+
tlen = len_str.to_i
|
|
228
|
+
if tlen < 0 || colon + 1 + tlen > wire.length
|
|
229
|
+
return -1
|
|
230
|
+
end
|
|
231
|
+
topic = wire[colon + 1, tlen]
|
|
232
|
+
payload = wire[colon + 1 + tlen, wire.length - colon - 1 - tlen]
|
|
233
|
+
Tep::Broadcast.publish_local_only(topic, payload)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Same fan-out as #publish but skips the PG NOTIFY step. Used
|
|
237
|
+
# internally by poll_pg_once when delivering a cross-worker
|
|
238
|
+
# message that already came in via PG -- re-NOTIFY would cause
|
|
239
|
+
# an infinite loop.
|
|
240
|
+
#
|
|
241
|
+
# Branches on each subscription's `mode`:
|
|
242
|
+
# * mode 0 -> raw bytes via Sock.sphttp_write_str (default,
|
|
243
|
+
# for SSE / log fan-out / non-framed consumers).
|
|
244
|
+
# * mode != 0 -> WebSocket frame via Tep::WebSocket::Driver.send_frame,
|
|
245
|
+
# using the mode value as the WS opcode (1=TEXT, 2=BINARY).
|
|
246
|
+
def self.publish_local_only(topic, payload)
|
|
247
|
+
subs = Tep::APP.broadcast_subs
|
|
248
|
+
matched = 0
|
|
249
|
+
i = 0
|
|
250
|
+
while i < subs.length
|
|
251
|
+
if subs[i].topic == topic
|
|
252
|
+
if subs[i].mode == 0
|
|
253
|
+
Sock.sphttp_write_str(subs[i].fd, payload)
|
|
254
|
+
else
|
|
255
|
+
Tep::WebSocket::Driver.send_frame(
|
|
256
|
+
subs[i].fd, subs[i].mode, payload)
|
|
257
|
+
end
|
|
258
|
+
matched += 1
|
|
259
|
+
end
|
|
260
|
+
i += 1
|
|
261
|
+
end
|
|
262
|
+
matched
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Tep::BroadcastSubscription -- one entry in the Tep::Broadcast
|
|
2
|
+
# subscriber registry. Pairs a topic name with an output fd. When a
|
|
3
|
+
# publish matches the topic, the fd gets the payload bytes via
|
|
4
|
+
# Sock.sphttp_write_str.
|
|
5
|
+
#
|
|
6
|
+
# fd is just an integer file descriptor: typically a WebSocket
|
|
7
|
+
# connection's accepted socket fd, but the registry doesn't care
|
|
8
|
+
# about the protocol on top -- it'll write to any open fd. Apps
|
|
9
|
+
# integrating with WS (via Tep::WebSocket) subscribe their
|
|
10
|
+
# connection fds; non-WS use cases (server-sent events, log
|
|
11
|
+
# fan-out, etc.) work the same way.
|
|
12
|
+
#
|
|
13
|
+
# Each subscription lives in a single worker's registry. Cross-
|
|
14
|
+
# worker pub-sub goes through PG LISTEN/NOTIFY (see
|
|
15
|
+
# Tep::Broadcast.enable_pg_backend) which fans publishes out
|
|
16
|
+
# without moving subscription state; subscribers always register
|
|
17
|
+
# fd-local. See docs/BATTERIES-DESIGN.md for the broader Broadcast
|
|
18
|
+
# battery design.
|
|
19
|
+
module Tep
|
|
20
|
+
class BroadcastSubscription
|
|
21
|
+
attr_reader :topic # String
|
|
22
|
+
attr_reader :fd # Integer file descriptor
|
|
23
|
+
# Delivery mode controls how Tep::Broadcast.publish writes
|
|
24
|
+
# `payload` to `fd`:
|
|
25
|
+
#
|
|
26
|
+
# 0 = raw bytes (Sock.sphttp_write_str). The default; suits
|
|
27
|
+
# SSE / log fan-out / anything that doesn't need framing.
|
|
28
|
+
# 1 = WebSocket TEXT frame (Tep::WebSocket::OPCODE_TEXT).
|
|
29
|
+
# 2 = WebSocket BINARY frame (Tep::WebSocket::OPCODE_BINARY).
|
|
30
|
+
#
|
|
31
|
+
# Modes 1 and 2 route through Tep::WebSocket::Driver.send_frame,
|
|
32
|
+
# so payloads land as proper WS frames the peer will accept.
|
|
33
|
+
# Apps register mode-1 subscriptions via subscribe_ws.
|
|
34
|
+
attr_reader :mode
|
|
35
|
+
|
|
36
|
+
def initialize(topic, fd, mode)
|
|
37
|
+
@topic = topic
|
|
38
|
+
@fd = fd
|
|
39
|
+
@mode = mode
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/tep/cache.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Tep::Cache -- HTTP conditional-GET evaluation (issue #152).
|
|
2
|
+
#
|
|
3
|
+
# A response opts in by setting a validator (res.etag / res.last_modified).
|
|
4
|
+
# The server then short-circuits to 304 Not Modified (no body) when the
|
|
5
|
+
# request carries a matching precondition, so the client reuses its
|
|
6
|
+
# cached copy. Responses that set no validator are unaffected.
|
|
7
|
+
module Tep
|
|
8
|
+
module Cache
|
|
9
|
+
# True iff `req`'s precondition says the client's cached copy of
|
|
10
|
+
# `res` is still fresh (=> answer 304). Safe methods (GET/HEAD) only.
|
|
11
|
+
def self.not_modified?(req, res)
|
|
12
|
+
v = req.verb
|
|
13
|
+
if v != "GET" && v != "HEAD"
|
|
14
|
+
return false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# ETag / If-None-Match. `*` matches anything; otherwise the quoted
|
|
18
|
+
# tag is matched as a substring so a comma-separated list of tags
|
|
19
|
+
# in If-None-Match works.
|
|
20
|
+
etag = res.headers["ETag"]
|
|
21
|
+
if etag.length > 0
|
|
22
|
+
inm = req.headers["if-none-match"]
|
|
23
|
+
if inm.length > 0
|
|
24
|
+
if inm == "*"
|
|
25
|
+
return true
|
|
26
|
+
end
|
|
27
|
+
if Tep.str_find(inm, etag, 0) >= 0
|
|
28
|
+
return true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Last-Modified / If-Modified-Since: fresh if our copy is no newer
|
|
34
|
+
# than the client's cached date.
|
|
35
|
+
lm = res.lastmod_epoch
|
|
36
|
+
if lm > 0
|
|
37
|
+
ims = req.headers["if-modified-since"]
|
|
38
|
+
if ims.length > 0
|
|
39
|
+
ims_epoch = Sock.sphttp_parse_http_date(ims)
|
|
40
|
+
if ims_epoch >= 0 && lm <= ims_epoch
|
|
41
|
+
return true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/tep/events.rb
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Tep::Events -- toy/v1 event-stream emitter.
|
|
2
|
+
#
|
|
3
|
+
# Appends newline-delimited JSON (JSONL) in the toy/v1 envelope
|
|
4
|
+
# (`docs/events-schema.md` in the toy project): one `run_start` at
|
|
5
|
+
# boot, one serving event (`kind:"eval"`, `phase:"serve"`,
|
|
6
|
+
# `name:"request"`) per served request, one `run_end` at shutdown.
|
|
7
|
+
# The serving stream is structurally indistinguishable from a
|
|
8
|
+
# training stream, so a single downstream ingest (the research-lab
|
|
9
|
+
# orchestrator) consumes both.
|
|
10
|
+
#
|
|
11
|
+
# Standalone on purpose: any tep handler can emit (the chatbot's
|
|
12
|
+
# existing OpenAI-compat endpoint, a future Tep::Llm::OpenAI::Server,
|
|
13
|
+
# a Tep::Proxy gateway via on_stream_end). The full server battery
|
|
14
|
+
# (Battery 7) builds on this rather than reimplementing it.
|
|
15
|
+
#
|
|
16
|
+
# EVENTS = Tep::Events.new(ENV["EVENTS_JSONL"]) # "" => disabled
|
|
17
|
+
# EVENTS.run_start("gx10", "cpu", "smollm2-135m",
|
|
18
|
+
# "/srv/models/smollm2-135m.gguf",
|
|
19
|
+
# "{\"server\":\"tep\",\"cap\":\"infer\"}")
|
|
20
|
+
# # ... per request, after generation completes:
|
|
21
|
+
# EVENTS.inference("smollm2-135m", 12, 8, 87000,
|
|
22
|
+
# "{\"request_id\":\"cmpl-abc\",\"principal_id\":\"user:42\"," +
|
|
23
|
+
# "\"sampling\":{\"temperature\":0.7,\"max_tokens\":256}}")
|
|
24
|
+
# # ... at shutdown:
|
|
25
|
+
# EVENTS.run_end("ok")
|
|
26
|
+
#
|
|
27
|
+
# Schema (supersedes #79): per-request serving telemetry is
|
|
28
|
+
# `kind:"eval", phase:"serve", name:"request"`. The original #79
|
|
29
|
+
# decision used a distinct `kind:"inference"` to avoid overloading
|
|
30
|
+
# toy/v1's `eval` (held-out training evaluation: loss/ppl/samples);
|
|
31
|
+
# that disambiguator moved onto `phase`/`name` instead -- a served
|
|
32
|
+
# completion is `eval`+`phase:"serve"`+`name:"request"`, training eval
|
|
33
|
+
# is `eval`+`phase:"eval"`, so tao keys on the (kind, phase, name)
|
|
34
|
+
# triple rather than `kind` alone. `inference` is retired. See
|
|
35
|
+
# `#inference` below for the emitted shape.
|
|
36
|
+
#
|
|
37
|
+
# Integer-only number fields by design (a tep choice, NOT a spinel
|
|
38
|
+
# constraint -- spinel supports Float fully; this module deliberately
|
|
39
|
+
# avoids it for serving telemetry):
|
|
40
|
+
# * `t` is integer seconds since run_start (a JSON number; consumers
|
|
41
|
+
# reading it as float get N.0). Sub-second ordering isn't needed
|
|
42
|
+
# for serving telemetry -- per-request latency rides in wall_us.
|
|
43
|
+
# * `wall_us` (microsecond latency) is caller-measured + passed in
|
|
44
|
+
# as an int.
|
|
45
|
+
# * Floats that the schema does carry (sampling.temperature) live in
|
|
46
|
+
# the caller-built `extra` JSON string, which tep emits verbatim.
|
|
47
|
+
# Apps that want native Float support in extra can build their
|
|
48
|
+
# own JSON encoder around the float values.
|
|
49
|
+
#
|
|
50
|
+
# `started_at` / `ended_at` are ISO-8601 UTC via Sock.sphttp_iso8601_utc
|
|
51
|
+
# (spinel's Time.now exposes only integer epoch seconds).
|
|
52
|
+
module Tep
|
|
53
|
+
class Events
|
|
54
|
+
def initialize(path)
|
|
55
|
+
@path = path # "" disables emission (zero I/O, zero alloc)
|
|
56
|
+
@run_started = 0 # epoch seconds at run_start; basis for relative t
|
|
57
|
+
@req_count = 0
|
|
58
|
+
@err_count = 0
|
|
59
|
+
@tok_out = 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# True when a non-empty path was configured. Apps that build the
|
|
63
|
+
# emitter unconditionally can cheaply skip work when disabled.
|
|
64
|
+
def enabled?
|
|
65
|
+
@path.length > 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Emit `run_start` once, before any request. Establishes the
|
|
69
|
+
# relative-t origin even when emission is disabled (so a later
|
|
70
|
+
# enable mid-run wouldn't be needed; cheap either way). host /
|
|
71
|
+
# backend_kind / model_name / model_path are plain strings;
|
|
72
|
+
# config_json is a caller-built JSON object emitted verbatim.
|
|
73
|
+
def run_start(host, backend_kind, model_name, model_path, config_json)
|
|
74
|
+
@run_started = Time.now.to_i
|
|
75
|
+
if @path.length == 0
|
|
76
|
+
return 0
|
|
77
|
+
end
|
|
78
|
+
started = Sock.sphttp_iso8601_utc(@run_started)
|
|
79
|
+
# toy/v1 says host is {name, os, arch} (docs/events-schema.md);
|
|
80
|
+
# was a bare string before #115. os + arch come from uname() via
|
|
81
|
+
# Sock.sphttp_os_kind / sphttp_arch_kind.
|
|
82
|
+
line = "{" +
|
|
83
|
+
Json.encode_pair_str("kind", "run_start") + "," +
|
|
84
|
+
Json.encode_pair_str("schema", "toy/v1") + "," +
|
|
85
|
+
Json.encode_pair_int("t", 0) + "," +
|
|
86
|
+
Json.encode_pair_str("started_at", started) + "," +
|
|
87
|
+
"\"host\":{" +
|
|
88
|
+
Json.encode_pair_str("name", host) + "," +
|
|
89
|
+
Json.encode_pair_str("os", Sock.sphttp_os_kind) + "," +
|
|
90
|
+
Json.encode_pair_str("arch", Sock.sphttp_arch_kind) +
|
|
91
|
+
"}," +
|
|
92
|
+
"\"backend\":{" + Json.encode_pair_str("kind", backend_kind) + "}," +
|
|
93
|
+
"\"model\":{" +
|
|
94
|
+
Json.encode_pair_str("name", model_name) + "," +
|
|
95
|
+
Json.encode_pair_str("path", model_path) +
|
|
96
|
+
"}," +
|
|
97
|
+
"\"config\":" + config_json +
|
|
98
|
+
"}"
|
|
99
|
+
append_line(line)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Emit one inference-time telemetry event in the toy/v1 spec
|
|
103
|
+
# shape (#136): kind:"eval", phase:"serve", name:"request",
|
|
104
|
+
# with model + token counts + latency_us nested inside `extra`
|
|
105
|
+
# alongside whatever the caller passed in extra_json. The
|
|
106
|
+
# producer-facing API stays the same (callers pass
|
|
107
|
+
# prompt_tokens, completion_tokens, wall_us); we rename
|
|
108
|
+
# wall_us -> latency_us at the wire level.
|
|
109
|
+
#
|
|
110
|
+
# extra_json is a caller-built JSON object ("{}" if none)
|
|
111
|
+
# carrying sampling / request_id / principal_id. We strip its
|
|
112
|
+
# outer braces and merge with the spec's per-completion fields
|
|
113
|
+
# to produce the final extra object.
|
|
114
|
+
def inference(model, prompt_tokens, completion_tokens, wall_us, extra_json)
|
|
115
|
+
@req_count = @req_count + 1
|
|
116
|
+
@tok_out = @tok_out + completion_tokens
|
|
117
|
+
if @path.length == 0
|
|
118
|
+
return 0
|
|
119
|
+
end
|
|
120
|
+
# Build the merged extra: spec fields first, then caller's
|
|
121
|
+
# fields appended (if non-empty).
|
|
122
|
+
extra = "{" +
|
|
123
|
+
Json.encode_pair_str("model", model) + "," +
|
|
124
|
+
Json.encode_pair_int("prompt_tokens", prompt_tokens) + "," +
|
|
125
|
+
Json.encode_pair_int("completion_tokens", completion_tokens) + "," +
|
|
126
|
+
Json.encode_pair_int("latency_us", wall_us)
|
|
127
|
+
caller_inner = ""
|
|
128
|
+
if extra_json.length > 2
|
|
129
|
+
# Strip the outer braces -- "{...}" -> "...".
|
|
130
|
+
caller_inner = extra_json[1, extra_json.length - 2]
|
|
131
|
+
end
|
|
132
|
+
if caller_inner.length > 0
|
|
133
|
+
extra = extra + "," + caller_inner
|
|
134
|
+
end
|
|
135
|
+
extra = extra + "}"
|
|
136
|
+
line = "{" +
|
|
137
|
+
Json.encode_pair_str("kind", "eval") + "," +
|
|
138
|
+
Json.encode_pair_str("phase", "serve") + "," +
|
|
139
|
+
Json.encode_pair_int("t", rel_t) + "," +
|
|
140
|
+
Json.encode_pair_str("name", "request") + "," +
|
|
141
|
+
"\"extra\":" + extra +
|
|
142
|
+
"}"
|
|
143
|
+
append_line(line)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Count one server-side error (surfaced in run_end.stats.errors).
|
|
147
|
+
# Separate from emission so the counter advances even when
|
|
148
|
+
# emission is disabled.
|
|
149
|
+
def record_error
|
|
150
|
+
@err_count = @err_count + 1
|
|
151
|
+
0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Emit `run_end` once at shutdown using LOCAL counters. reason is
|
|
155
|
+
# "completed" (clean) or "errored" (uncaught failure) -- per
|
|
156
|
+
# toy/v1, quality verdicts on the run are downstream decisions,
|
|
157
|
+
# not encoded here. Used for single-process / workers=1 deployments
|
|
158
|
+
# where the writer is the same process that handled the inferences.
|
|
159
|
+
# For workers>1, see run_end_aggregated below.
|
|
160
|
+
def run_end(reason)
|
|
161
|
+
if @path.length == 0
|
|
162
|
+
return 0
|
|
163
|
+
end
|
|
164
|
+
ended = Sock.sphttp_iso8601_utc(Time.now.to_i)
|
|
165
|
+
line = "{" +
|
|
166
|
+
Json.encode_pair_str("kind", "run_end") + "," +
|
|
167
|
+
Json.encode_pair_int("t", rel_t) + "," +
|
|
168
|
+
Json.encode_pair_str("ended_at", ended) + "," +
|
|
169
|
+
Json.encode_pair_str("reason", reason) + "," +
|
|
170
|
+
"\"stats\":{" +
|
|
171
|
+
Json.encode_pair_int("requests", @req_count) + "," +
|
|
172
|
+
Json.encode_pair_int("errors", @err_count) + "," +
|
|
173
|
+
Json.encode_pair_int("tokens_out", @tok_out) +
|
|
174
|
+
"}" +
|
|
175
|
+
"}"
|
|
176
|
+
append_line(line)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Cross-worker run_end: re-read the JSONL + sum inference events
|
|
180
|
+
# so the emitted stats cover every worker's contribution, then
|
|
181
|
+
# emit ONE run_end with aggregated counters. Used by Tep.on_shutdown
|
|
182
|
+
# in the prefork parent (workers>1) -- worker children stop calling
|
|
183
|
+
# run_end at all; only the parent emits, after all workers have
|
|
184
|
+
# exited. Avoids cross-worker IPC entirely.
|
|
185
|
+
def run_end_aggregated(reason)
|
|
186
|
+
if @path.length == 0
|
|
187
|
+
return 0
|
|
188
|
+
end
|
|
189
|
+
reqs = 0
|
|
190
|
+
toks = 0
|
|
191
|
+
# errors aren't yet event-encoded (record_error only bumps a
|
|
192
|
+
# local counter), so cross-worker errors aren't visible here.
|
|
193
|
+
# If a future chunk emits "error" events, sum them too. For
|
|
194
|
+
# now: 0 in aggregated mode.
|
|
195
|
+
errs = 0
|
|
196
|
+
content = File.read(@path)
|
|
197
|
+
lines = content.split("\n")
|
|
198
|
+
i = 0
|
|
199
|
+
while i < lines.length
|
|
200
|
+
line_s = lines[i]
|
|
201
|
+
# #136: inference events are kind:"eval" + phase:"serve" +
|
|
202
|
+
# name:"request". Match the joint shape to avoid counting
|
|
203
|
+
# future non-request eval events (e.g. training-time eval).
|
|
204
|
+
if Tep.str_find(line_s, "\"kind\":\"eval\"", 0) >= 0 &&
|
|
205
|
+
Tep.str_find(line_s, "\"name\":\"request\"", 0) >= 0
|
|
206
|
+
reqs += 1
|
|
207
|
+
# completion_tokens now lives nested inside the `extra`
|
|
208
|
+
# object. Tep::Json.find_value_start walks only the
|
|
209
|
+
# top-level keys (it skips over nested objects), so we
|
|
210
|
+
# have to extract extra first, then get_int within it.
|
|
211
|
+
extra_pos = Json.find_value_start(line_s, "extra")
|
|
212
|
+
if extra_pos >= 0
|
|
213
|
+
obj_end = Json.skip_container(line_s, extra_pos)
|
|
214
|
+
extra_obj = line_s[extra_pos, obj_end - extra_pos]
|
|
215
|
+
toks += Json.get_int(extra_obj, "completion_tokens")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
i += 1
|
|
219
|
+
end
|
|
220
|
+
ended = Sock.sphttp_iso8601_utc(Time.now.to_i)
|
|
221
|
+
out = "{" +
|
|
222
|
+
Json.encode_pair_str("kind", "run_end") + "," +
|
|
223
|
+
Json.encode_pair_int("t", rel_t) + "," +
|
|
224
|
+
Json.encode_pair_str("ended_at", ended) + "," +
|
|
225
|
+
Json.encode_pair_str("reason", reason) + "," +
|
|
226
|
+
"\"stats\":{" +
|
|
227
|
+
Json.encode_pair_int("requests", reqs) + "," +
|
|
228
|
+
Json.encode_pair_int("errors", errs) + "," +
|
|
229
|
+
Json.encode_pair_int("tokens_out", toks) +
|
|
230
|
+
"}" +
|
|
231
|
+
"}"
|
|
232
|
+
append_line(out)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Seconds since run_start, clamped at 0 (a clock that goes
|
|
236
|
+
# backwards, or events before run_start, read as t=0).
|
|
237
|
+
def rel_t
|
|
238
|
+
d = Time.now.to_i - @run_started
|
|
239
|
+
if d < 0
|
|
240
|
+
d = 0
|
|
241
|
+
end
|
|
242
|
+
d
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Append one JSON line. Best-effort, append mode -- mirrors
|
|
246
|
+
# Tep::Logger's file sink. Telemetry must never fail a request, so
|
|
247
|
+
# a malformed/unwritable path degrades to a dropped line rather
|
|
248
|
+
# than a raised error reaching the handler. Callers gate on a
|
|
249
|
+
# non-empty @path before reaching here.
|
|
250
|
+
def append_line(line)
|
|
251
|
+
File.open(@path, "a") do |f|
|
|
252
|
+
f.puts(line)
|
|
253
|
+
end
|
|
254
|
+
0
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/tep/filter.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Tep::Filter -- before/after hooks. Override #before(req, res) and/or
|
|
2
|
+
# #after(req, res). The default base methods are non-empty (they touch
|
|
3
|
+
# their parameters) so Spinel correctly registers them as the dispatch
|
|
4
|
+
# fallback; an empty base method body confuses the codegen and causes
|
|
5
|
+
# overrides to be silently dropped.
|
|
6
|
+
#
|
|
7
|
+
# class TimerFilter < Tep::Filter
|
|
8
|
+
# def after(req, res); res.headers["X-Took"] = "ok"; end
|
|
9
|
+
# end
|
|
10
|
+
# Tep.before TimerFilter.new
|
|
11
|
+
module Tep
|
|
12
|
+
class Filter
|
|
13
|
+
def before(req, res)
|
|
14
|
+
0 # explicit no-op return; non-empty body keeps spinel happy
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def after(req, res)
|
|
18
|
+
0
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/tep/handler.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Tep::Handler -- subclass and override #handle(req, res). Return a
|
|
2
|
+
# string to set the body, or mutate `res` directly.
|
|
3
|
+
#
|
|
4
|
+
# Sinatra-style block syntax (`get '/' do ... end`) is supported via
|
|
5
|
+
# the `bin/tep` build-time translator, which rewrites the block body
|
|
6
|
+
# textually and wraps each block in a Handler subclass.
|
|
7
|
+
#
|
|
8
|
+
# Regex routes (`get %r{...} do ... end`) also live as Handler
|
|
9
|
+
# subclasses: the translator emits `is_regex?` returning true plus
|
|
10
|
+
# `re_match?(path)` / `re_capture(path)` baking the literal regex
|
|
11
|
+
# into both methods. The literal is required because spinel can't
|
|
12
|
+
# build a Regexp from a string at runtime.
|
|
13
|
+
module Tep
|
|
14
|
+
class Handler
|
|
15
|
+
def handle(req, res)
|
|
16
|
+
""
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def is_regex?
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def re_match?(path)
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Default returns an empty str_array. Subclasses for regex routes
|
|
28
|
+
# return up to 9 capture strings.
|
|
29
|
+
def re_capture(path)
|
|
30
|
+
empty = [""]
|
|
31
|
+
empty.delete_at(0)
|
|
32
|
+
empty
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|