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
data/bin/tep
ADDED
|
@@ -0,0 +1,2156 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# tep -- build-time translator + driver for the Tep framework.
|
|
3
|
+
#
|
|
4
|
+
# `tep build app.rb [-o out]` -- translate Sinatra-style source to
|
|
5
|
+
# spinel-compilable Ruby and compile
|
|
6
|
+
# it to a native binary via spinel.
|
|
7
|
+
#
|
|
8
|
+
# `tep run app.rb [-p PORT] [-w WORKERS]` -- build, then run the result.
|
|
9
|
+
#
|
|
10
|
+
# Translation: top-level `get '/' do ... end` (and post/put/patch/
|
|
11
|
+
# delete/before/after/not_found) blocks are extracted by Prism and
|
|
12
|
+
# emitted as Tep::Handler / Tep::Filter subclasses. Common Sinatra
|
|
13
|
+
# DSL idioms inside blocks are textually rewritten:
|
|
14
|
+
#
|
|
15
|
+
# params -> req.params
|
|
16
|
+
# request -> req
|
|
17
|
+
# response -> res
|
|
18
|
+
# redirect 'x' -> res.set_status(302); res.headers['Location']='x'; ''
|
|
19
|
+
# halt N, 'msg' -> res.set_status(N); 'msg'
|
|
20
|
+
# content_type 'x' -> res.headers['Content-Type'] = 'x'
|
|
21
|
+
# set :public_dir, 'p' -> Tep.public_dir 'p'
|
|
22
|
+
#
|
|
23
|
+
# Spinel-incompatible patterns (template engines, helpers blocks,
|
|
24
|
+
# rack middleware, etc.) get warned about; they don't translate.
|
|
25
|
+
|
|
26
|
+
# The translator parses the user's Sinatra source with Prism. Prism
|
|
27
|
+
# ships with Ruby 3.4+, but tep keeps it a *development-only* gem
|
|
28
|
+
# dependency (it's a C extension Spinel can't compile, so a runtime dep
|
|
29
|
+
# would drag it into a spinelgems consumer's lock + fail the compat
|
|
30
|
+
# gate). So on a `gem install tep` under Ruby 3.2/3.3 it may be absent
|
|
31
|
+
# -- fail with an actionable message instead of a bare LoadError.
|
|
32
|
+
begin
|
|
33
|
+
require "prism"
|
|
34
|
+
rescue LoadError
|
|
35
|
+
abort "tep: the translator needs the `prism` parser, which isn't " \
|
|
36
|
+
"available here.\n" \
|
|
37
|
+
" - Ruby 3.4+ bundles it (no action needed).\n" \
|
|
38
|
+
" - On Ruby 3.2/3.3: gem install prism\n" \
|
|
39
|
+
"Prism is a build-time-only dep (a C extension Spinel can't " \
|
|
40
|
+
"compile), so tep deliberately doesn't pull it into your lock."
|
|
41
|
+
end
|
|
42
|
+
require "fileutils"
|
|
43
|
+
require "tmpdir"
|
|
44
|
+
require "pathname"
|
|
45
|
+
|
|
46
|
+
LIB_DIR = File.expand_path("../lib", __dir__)
|
|
47
|
+
REPO_ROOT = File.dirname(LIB_DIR)
|
|
48
|
+
|
|
49
|
+
# The @TEP_*@ placeholder shape lives in spinel-ext.json at the gem
|
|
50
|
+
# root -- the single source of truth shared with `spinel-compat
|
|
51
|
+
# vendor` (which wires the C-ext for downstream consumers), so the
|
|
52
|
+
# substitution list isn't duplicated per consumer. See
|
|
53
|
+
# OriPekelman/tep#97 + #98 and spinelgems' docs/c-ext.md.
|
|
54
|
+
require "json"
|
|
55
|
+
|
|
56
|
+
# Resolve spinel: explicit env var wins, then a sibling
|
|
57
|
+
# `../spinel/spinel` checkout (matches the typical dev layout where
|
|
58
|
+
# tep and spinel are checked out side by side), then plain `spinel`
|
|
59
|
+
# on PATH. The Makefile exports SPINEL=spinel by default; treat the
|
|
60
|
+
# literal placeholder as unset so the sibling fallback still fires.
|
|
61
|
+
def self.locate_spinel
|
|
62
|
+
e = ENV["SPINEL"]
|
|
63
|
+
return e if e && !e.empty? && e != "spinel"
|
|
64
|
+
sibling = File.expand_path("../../spinel/spinel", __dir__)
|
|
65
|
+
return sibling if File.executable?(sibling)
|
|
66
|
+
"spinel"
|
|
67
|
+
end
|
|
68
|
+
SPINEL = locate_spinel
|
|
69
|
+
|
|
70
|
+
DSL_VERBS = %w[get post put patch delete options head]
|
|
71
|
+
HOOK_NAMES = %w[before after not_found]
|
|
72
|
+
WS_EVENTS = %w[on_open on_message on_close on_ping on_pong on_error]
|
|
73
|
+
|
|
74
|
+
def fatal(msg)
|
|
75
|
+
warn "tep: #{msg}"
|
|
76
|
+
exit 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build the translated Ruby source from a Sinatra-style file.
|
|
80
|
+
def translate(input_path)
|
|
81
|
+
raw_source = File.read(input_path)
|
|
82
|
+
|
|
83
|
+
# Split off `__END__` inline templates. Sinatra's convention is
|
|
84
|
+
# `@@ name\n<template body>` repeated. Pre-__END__ goes through
|
|
85
|
+
# Prism; post-__END__ is parsed by us into a name->body map.
|
|
86
|
+
source, inline_views = split_inline_views(raw_source)
|
|
87
|
+
|
|
88
|
+
parsed = Prism.parse(source)
|
|
89
|
+
fatal "parse errors in #{input_path}\n #{parsed.errors.map(&:message).join("\n ")}" unless parsed.success?
|
|
90
|
+
|
|
91
|
+
prog = parsed.value
|
|
92
|
+
routes = []
|
|
93
|
+
websockets = []
|
|
94
|
+
live_views = []
|
|
95
|
+
mcp_tools = []
|
|
96
|
+
mcp_resources = []
|
|
97
|
+
proxies = [] # Tep::Proxy block-DSL (#88): one entry per
|
|
98
|
+
# `var = Tep::Proxy.new(...)` + its .before/.after/
|
|
99
|
+
# .on_stream_chunk/.on_stream_end/.stream_request?
|
|
100
|
+
# block calls, lowered to a generated subclass.
|
|
101
|
+
filters = { "before" => [], "after" => [] }
|
|
102
|
+
not_founds = []
|
|
103
|
+
startup_block = nil
|
|
104
|
+
config = { public_dir: nil, mcp_server_name: nil, mcp_server_version: nil }
|
|
105
|
+
warnings = []
|
|
106
|
+
anon_id = 0
|
|
107
|
+
|
|
108
|
+
# We only walk the top-level statements; handlers inside Sinatra's
|
|
109
|
+
# `class App < Sinatra::Base` are not supported in v0.1.
|
|
110
|
+
unwrapped_apps = []
|
|
111
|
+
passthrough = []
|
|
112
|
+
# Dedupe set for recursive require_relative inlining (deps.rb chains;
|
|
113
|
+
# shared deps must inline once). Keyed by absolute path; build-wide.
|
|
114
|
+
inlined_seen = {}
|
|
115
|
+
# Scan ALL `erb :name` and `mustache :name` references up front;
|
|
116
|
+
# every referenced view gets compiled and emitted at the top of
|
|
117
|
+
# the synthesized output. ERB views compile to `tep_view_<name>`,
|
|
118
|
+
# mustache views to `tep_mustache_<name>` -- separate namespaces
|
|
119
|
+
# so a project can mix engines without name collisions.
|
|
120
|
+
views_used = source.scan(/(?<![\w.])erb\s+:(\w+)/).flatten.uniq
|
|
121
|
+
mustaches_used = source.scan(/(?<![\w.])mustache\s+:(\w+)/).flatten.uniq
|
|
122
|
+
process = lambda do |node|
|
|
123
|
+
# `Tep.live "/path", ViewClass` -- the one Tep::*-receiver call
|
|
124
|
+
# the translator owns. Lowers to a GET route (render_page) +
|
|
125
|
+
# WS route (per-connection view instance, event dispatch +
|
|
126
|
+
# re-render). Other Tep.* calls (Tep.before, Tep.get, ...)
|
|
127
|
+
# pass through as runtime method calls.
|
|
128
|
+
if call?(node) && node.name == :live &&
|
|
129
|
+
node.receiver.is_a?(Prism::ConstantReadNode) &&
|
|
130
|
+
node.receiver.name == :Tep
|
|
131
|
+
handle_tep_live(node, live_views, warnings)
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Proxy block-DSL (#88), part 1: `var = Tep::Proxy.new(...)`.
|
|
136
|
+
# Record the proxy var + rewrite the assignment to instantiate
|
|
137
|
+
# the generated subclass (TepProxy_<idx>), which carries the
|
|
138
|
+
# before/after/stream hooks collected below. The base
|
|
139
|
+
# `Tep::Proxy.new` would forward with no customization; the
|
|
140
|
+
# subclass is only emitted when at least one hook block is seen,
|
|
141
|
+
# but we always rewrite so the var name stays consistent.
|
|
142
|
+
if node.is_a?(Prism::LocalVariableWriteNode) && proxy_new_call?(node.value)
|
|
143
|
+
idx = proxies.length
|
|
144
|
+
up_src = proxy_upstream_src(node.value, warnings)
|
|
145
|
+
proxies << { var: node.name.to_s, idx: idx, upstream: up_src, hooks: {} }
|
|
146
|
+
# Assign to a CONSTANT, not the original local. A Tep::Proxy
|
|
147
|
+
# subclass instance flowing through a *local* into Tep.<verb>
|
|
148
|
+
# widens rewrite_path's virtual `path` param to poly (poisoning
|
|
149
|
+
# Tep::App.guess_mime's `path` file-wide); the same instance via
|
|
150
|
+
# a constant compiles clean. Mounts are rewritten to the
|
|
151
|
+
# constant below.
|
|
152
|
+
passthrough << "TEPPROXY_#{idx} = TepProxy_#{idx}.new(#{up_src})"
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Proxy block-DSL (#88), part 2: `proxyvar.before do ... end`
|
|
157
|
+
# (and .after / .on_stream_chunk / .on_stream_end /
|
|
158
|
+
# .stream_request?). Collect the block onto the proxy entry +
|
|
159
|
+
# suppress from passthrough (a receiver-method call with a block
|
|
160
|
+
# can't lower -- spinel has no PtrArray<Block>; the block becomes
|
|
161
|
+
# an imeth on the generated subclass instead).
|
|
162
|
+
if call?(node) && node.receiver.is_a?(Prism::LocalVariableReadNode) &&
|
|
163
|
+
PROXY_HOOK_IMETH.key?(node.name.to_s) && node.block.is_a?(Prism::BlockNode)
|
|
164
|
+
pv = proxies.find { |p| p[:var] == node.receiver.name.to_s }
|
|
165
|
+
if pv
|
|
166
|
+
pv[:hooks][node.name.to_s] = {
|
|
167
|
+
params: block_param_list(node.block),
|
|
168
|
+
src: block_source(node.block),
|
|
169
|
+
}
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Proxy block-DSL (#88), part 3: rewrite a mount of a proxy var --
|
|
175
|
+
# `Tep.<verb> "path", proxyvar` or bare `<verb> "path", proxyvar`
|
|
176
|
+
# (no block) -- to reference the generated constant instead of the
|
|
177
|
+
# (dropped) local. Reconstructed structurally so the handler arg
|
|
178
|
+
# becomes TEPPROXY_<idx>.
|
|
179
|
+
if call?(node) && PROXY_MOUNT_VERBS.include?(node.name.to_s) && node.block.nil?
|
|
180
|
+
margs = node.arguments&.arguments || []
|
|
181
|
+
if margs.length == 2 && margs[1].is_a?(Prism::LocalVariableReadNode)
|
|
182
|
+
pv = proxies.find { |p| p[:var] == margs[1].name.to_s }
|
|
183
|
+
if pv
|
|
184
|
+
recv = node.receiver ? "#{node.receiver.location.slice}." : ""
|
|
185
|
+
passthrough << "#{recv}#{node.name}(#{margs[0].location.slice}, TEPPROXY_#{pv[:idx]})"
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Only bare top-level method calls (no receiver) are candidates
|
|
192
|
+
# for DSL handling -- `get '/'`, `before do`, `set :public_dir`,
|
|
193
|
+
# etc. A call with a receiver (`$arr.delete_at(0)`,
|
|
194
|
+
# `something.frob`) is just user code: pass it through verbatim
|
|
195
|
+
# so spinel sees it at program load.
|
|
196
|
+
if call?(node) && node.receiver.nil?
|
|
197
|
+
result = handle_top_call(node, routes, websockets, mcp_tools, mcp_resources, filters, not_founds, config, warnings, passthrough, input_path, inlined_seen)
|
|
198
|
+
startup_block = result if result.is_a?(Hash) && result[:kind] == :startup
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Modular Sinatra::Base: `class MyApp < Sinatra::Base; ...; end`.
|
|
203
|
+
# Unwrap the body so `get '/'`, `before do`, etc. inside register
|
|
204
|
+
# against the global Tep app -- v0.1 doesn't run multiple apps
|
|
205
|
+
# in parallel, but the source-level shape is preserved.
|
|
206
|
+
if node.is_a?(Prism::ClassNode) && sinatra_base_class?(node)
|
|
207
|
+
unwrapped_apps << node.constant_path.location.slice
|
|
208
|
+
stmts = node.body
|
|
209
|
+
if stmts.is_a?(Prism::StatementsNode)
|
|
210
|
+
stmts.body.each { |inner| process.call(inner) }
|
|
211
|
+
end
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Drop `MyApp.run!` (etc.) when MyApp was just unwrapped.
|
|
216
|
+
# Without this the passthrough emits `MyApp.run!` and spinel
|
|
217
|
+
# has no class by that name.
|
|
218
|
+
if call?(node) && node.receiver
|
|
219
|
+
recv_src = node.receiver.location.slice
|
|
220
|
+
if unwrapped_apps.include?(recv_src)
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
passthrough << node.location.slice
|
|
226
|
+
end
|
|
227
|
+
prog.statements.body.each { |n| process.call(n) }
|
|
228
|
+
|
|
229
|
+
# Spinel's require_relative is finicky about long paths and absolute
|
|
230
|
+
# paths -- both fail silently. So we inline the whole tep library
|
|
231
|
+
# text into the generated file instead of relying on require_relative
|
|
232
|
+
# at build time.
|
|
233
|
+
out = []
|
|
234
|
+
out << "# Generated by tep #{Time.now.strftime('%Y-%m-%d %H:%M:%S')} from #{File.basename(input_path)}"
|
|
235
|
+
out << inlined_tep_library
|
|
236
|
+
out << ""
|
|
237
|
+
|
|
238
|
+
# Compile-time asset bundling: anything under `<app_dir>/assets/`
|
|
239
|
+
# is read into a Tep::Assets _add call. The bytes ride in the
|
|
240
|
+
# binary as Ruby string literals; mime is inferred by extension.
|
|
241
|
+
# See lib/tep/assets.rb for the runtime side.
|
|
242
|
+
asset_lines = embed_assets(input_path, warnings)
|
|
243
|
+
unless asset_lines.empty?
|
|
244
|
+
out << "# --- bundled assets ---"
|
|
245
|
+
out.concat(asset_lines)
|
|
246
|
+
out << ""
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Top-level non-DSL statements (constants, classes, defs, ...).
|
|
250
|
+
# Emitted verbatim before any DSL registrations so handler bodies
|
|
251
|
+
# can refer to them.
|
|
252
|
+
# Proxy block-DSL subclasses (#88). Emitted BEFORE passthrough so
|
|
253
|
+
# the rewritten `var = TepProxy_<idx>.new(...)` assignment (which
|
|
254
|
+
# stays in passthrough, in its original position so its upstream
|
|
255
|
+
# argument can reference earlier top-level code) finds the class
|
|
256
|
+
# defined. Each subclass's imeths are the user's `.before` /
|
|
257
|
+
# `.after` / `.on_stream_chunk` / `.on_stream_end` /
|
|
258
|
+
# `.stream_request?` block bodies, lowered verbatim with the user's
|
|
259
|
+
# param names -- a thin 1:1 sugar over the subclass-override form.
|
|
260
|
+
# Method bodies run at request time, so referencing user constants
|
|
261
|
+
# defined later in passthrough is fine. A hookless proxy still gets
|
|
262
|
+
# a (trivial) subclass so its assignment resolves.
|
|
263
|
+
unless proxies.empty?
|
|
264
|
+
out << "# --- proxy block-DSL subclasses (#88) ---"
|
|
265
|
+
proxies.each do |p|
|
|
266
|
+
out << "class TepProxy_#{p[:idx]} < Tep::Proxy"
|
|
267
|
+
p[:hooks].each do |dsl_name, info|
|
|
268
|
+
imeth = PROXY_HOOK_IMETH[dsl_name]
|
|
269
|
+
out << " def #{imeth}(#{info[:params]})"
|
|
270
|
+
out << indent(rewrite_block(info[:src], force_string: false), 4)
|
|
271
|
+
out << " end"
|
|
272
|
+
end
|
|
273
|
+
out << "end"
|
|
274
|
+
end
|
|
275
|
+
out << ""
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
unless passthrough.empty?
|
|
279
|
+
out << "# --- top-level passthrough from #{File.basename(input_path)} ---"
|
|
280
|
+
passthrough.each { |s| out << s }
|
|
281
|
+
out << ""
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Build-time ERB compile. Each referenced view becomes a top-level
|
|
285
|
+
# method `tep_view_<name>(locals)` that returns the rendered string.
|
|
286
|
+
# Resolution order: file-based (views/<name>.erb) > inline (after
|
|
287
|
+
# `__END__`, `@@ name` blocks).
|
|
288
|
+
unless views_used.empty?
|
|
289
|
+
views_dir = config[:views] || File.join(File.dirname(input_path), "views")
|
|
290
|
+
views_used.each do |vn|
|
|
291
|
+
vp = File.join(views_dir, "#{vn}.erb")
|
|
292
|
+
tmpl = nil
|
|
293
|
+
if File.exist?(vp)
|
|
294
|
+
tmpl = File.read(vp)
|
|
295
|
+
elsif inline_views.key?(vn)
|
|
296
|
+
tmpl = inline_views[vn]
|
|
297
|
+
end
|
|
298
|
+
if tmpl.nil?
|
|
299
|
+
warnings << "view :#{vn} -- not found in #{vp} nor in inline `@@` templates; emitting empty stub"
|
|
300
|
+
out << "def tep_view_#{vn}(locals, ivars); \"\"; end"
|
|
301
|
+
next
|
|
302
|
+
end
|
|
303
|
+
out << "def tep_view_#{vn}(locals, ivars)"
|
|
304
|
+
out << " " + erb_to_ruby_body(tmpl)
|
|
305
|
+
out << "end"
|
|
306
|
+
out << ""
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Same loop for mustache views: file-based (.mustache) > inline
|
|
311
|
+
# (`@@ name` blocks). Documented subset only -- `{{var}}`,
|
|
312
|
+
# `{{{var}}}`, `{{!comment}}`, `{{@ivar}}`. Sections / partials /
|
|
313
|
+
# lambdas are deliberately not implemented (they'd need iterable
|
|
314
|
+
# locals, which clashes with tep's String=>String view-arg model).
|
|
315
|
+
unless mustaches_used.empty?
|
|
316
|
+
views_dir = config[:views] || File.join(File.dirname(input_path), "views")
|
|
317
|
+
mustaches_used.each do |vn|
|
|
318
|
+
vp = File.join(views_dir, "#{vn}.mustache")
|
|
319
|
+
tmpl = nil
|
|
320
|
+
if File.exist?(vp)
|
|
321
|
+
tmpl = File.read(vp)
|
|
322
|
+
elsif inline_views.key?(vn)
|
|
323
|
+
tmpl = inline_views[vn]
|
|
324
|
+
end
|
|
325
|
+
if tmpl.nil?
|
|
326
|
+
warnings << "mustache :#{vn} -- not found in #{vp} nor in inline `@@` templates; emitting empty stub"
|
|
327
|
+
out << "def tep_mustache_#{vn}(locals, ivars); \"\"; end"
|
|
328
|
+
next
|
|
329
|
+
end
|
|
330
|
+
out << "def tep_mustache_#{vn}(locals, ivars)"
|
|
331
|
+
out << " " + mustache_to_ruby_body(tmpl)
|
|
332
|
+
out << "end"
|
|
333
|
+
out << ""
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Build Handler subclasses for routes. For regex routes we bake the
|
|
338
|
+
# regex literal directly into the same Handler subclass, exposing
|
|
339
|
+
# is_regex? / re_match? / re_capture for the router to use; this
|
|
340
|
+
# lets us keep a single routes table (one class type) and rely on
|
|
341
|
+
# spinel's cls_id virtual dispatch on the handler.
|
|
342
|
+
routes.each_with_index do |r, i|
|
|
343
|
+
cname = "TepRoute_#{i}"
|
|
344
|
+
body = rewrite_block(r[:block_src])
|
|
345
|
+
out << "class #{cname} < Tep::Handler"
|
|
346
|
+
out << " def handle(req, res)"
|
|
347
|
+
out << indent(body, 4)
|
|
348
|
+
out << " end"
|
|
349
|
+
if r[:regex]
|
|
350
|
+
lit = "%r{#{r[:regex]}}"
|
|
351
|
+
out << " def is_regex?; true; end"
|
|
352
|
+
out << " def re_match?(path); !!(path =~ #{lit}); end"
|
|
353
|
+
out << " def re_capture(path)"
|
|
354
|
+
out << " path =~ #{lit}"
|
|
355
|
+
out << " [$1.to_s, $2.to_s, $3.to_s, $4.to_s, $5.to_s, $6.to_s, $7.to_s, $8.to_s, $9.to_s]"
|
|
356
|
+
out << " end"
|
|
357
|
+
end
|
|
358
|
+
out << "end"
|
|
359
|
+
out << ""
|
|
360
|
+
r[:cls] = cname
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Multiple `before do` / `after do` blocks fold into a single
|
|
364
|
+
# composite Filter subclass per kind -- spinel's PtrArray of
|
|
365
|
+
# mixed Filter subclasses can't dispatch virtually, and the App
|
|
366
|
+
# holds a single before/after slot.
|
|
367
|
+
filter_classes = {}
|
|
368
|
+
filters.each do |kind, fs|
|
|
369
|
+
next if fs.empty?
|
|
370
|
+
cname = "TepFilters_#{kind}"
|
|
371
|
+
out << "class #{cname} < Tep::Filter"
|
|
372
|
+
out << " def #{kind}(req, res)"
|
|
373
|
+
fs.each_with_index do |f, i|
|
|
374
|
+
body = rewrite_block(f[:block_src], force_string: false)
|
|
375
|
+
out << " # filter #{kind} ##{i}"
|
|
376
|
+
out << indent(body, 4)
|
|
377
|
+
end
|
|
378
|
+
out << " 0"
|
|
379
|
+
out << " end"
|
|
380
|
+
out << "end"
|
|
381
|
+
out << ""
|
|
382
|
+
filter_classes[kind] = cname
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
not_founds.each_with_index do |n, i|
|
|
386
|
+
cname = "TepNotFound_#{i}"
|
|
387
|
+
body = rewrite_block(n[:block_src])
|
|
388
|
+
out << "class #{cname} < Tep::Handler"
|
|
389
|
+
out << " def handle(req, res)"
|
|
390
|
+
out << indent(body, 4)
|
|
391
|
+
out << " end"
|
|
392
|
+
out << "end"
|
|
393
|
+
out << ""
|
|
394
|
+
n[:cls] = cname
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# WebSocket route synthesis. Each `websocket '/path' do |ws|
|
|
398
|
+
# on_open { ... }; on_message { ... }; ... end` becomes:
|
|
399
|
+
# - one Tep::WebSocket::Handler subclass per on_X event with the
|
|
400
|
+
# user's block body as handle_event(evt), with `<ws_name> = @ws`
|
|
401
|
+
# bound at the top so the user's references just work.
|
|
402
|
+
# - one Tep::Handler subclass for the upgrade route that runs the
|
|
403
|
+
# handshake check, wires the per-event callbacks onto a fresh
|
|
404
|
+
# Driver, and flips res.start_websocket so the server runs the
|
|
405
|
+
# recv loop in lieu of writing a normal response.
|
|
406
|
+
# WS only works under Tep::Server::Scheduled; the runtime warns
|
|
407
|
+
# (and the blocking server returns 501) if the route fires there.
|
|
408
|
+
websockets.each_with_index do |w, i|
|
|
409
|
+
base = "TepWS_#{i}"
|
|
410
|
+
cbs = {}
|
|
411
|
+
w[:events].each do |ev, info|
|
|
412
|
+
ev_cls = "#{base}_#{ev}"
|
|
413
|
+
body = rewrite_block(info[:src], force_string: false)
|
|
414
|
+
out << "class #{ev_cls} < Tep::WebSocket::Handler"
|
|
415
|
+
out << " def initialize"
|
|
416
|
+
out << " super"
|
|
417
|
+
out << " @ws = Tep::WebSocket::Driver.new(0)"
|
|
418
|
+
out << " end"
|
|
419
|
+
out << " attr_accessor :ws"
|
|
420
|
+
out << " def handle_event(#{info[:evt_param]})"
|
|
421
|
+
out << " #{w[:ws_param]} = @ws"
|
|
422
|
+
out << " req = @req"
|
|
423
|
+
out << indent(body, 4)
|
|
424
|
+
out << " 0"
|
|
425
|
+
out << " end"
|
|
426
|
+
out << "end"
|
|
427
|
+
out << ""
|
|
428
|
+
cbs[ev] = ev_cls
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
rt_cls = "#{base}_Route"
|
|
432
|
+
out << "class #{rt_cls} < Tep::Handler"
|
|
433
|
+
out << " def handle(req, res)"
|
|
434
|
+
out << " hs = Tep::WebSocket::Handshake.check(req)"
|
|
435
|
+
out << " if !hs.valid"
|
|
436
|
+
out << " res.set_status(400)"
|
|
437
|
+
out << " return \"\""
|
|
438
|
+
out << " end"
|
|
439
|
+
out << " drv = Tep::WebSocket::Driver.new(0)"
|
|
440
|
+
cbs.each do |ev, cls|
|
|
441
|
+
slot = ev.sub("on_", "set_on_")
|
|
442
|
+
out << " _cb_#{ev} = #{cls}.new"
|
|
443
|
+
out << " _cb_#{ev}.ws = drv"
|
|
444
|
+
out << " _cb_#{ev}.req = req"
|
|
445
|
+
out << " drv.#{slot}(_cb_#{ev})"
|
|
446
|
+
end
|
|
447
|
+
out << " res.start_websocket(hs.accept_key, drv)"
|
|
448
|
+
out << " \"\""
|
|
449
|
+
out << " end"
|
|
450
|
+
out << "end"
|
|
451
|
+
out << ""
|
|
452
|
+
w[:cls] = rt_cls
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Tep.live emission. For each `Tep.live "/path", ViewClass`:
|
|
456
|
+
# * TepLive_N_Get handles GET /path -- instantiate view per
|
|
457
|
+
# request, mount(req), wrap in render_page targeting /path/ws.
|
|
458
|
+
# * TepLive_N_WsOpen / TepLive_N_WsMessage are Handler subclasses;
|
|
459
|
+
# each connection's route handler creates ONE ViewClass instance
|
|
460
|
+
# and stashes it on both handlers so on_open and on_message
|
|
461
|
+
# share state. on_open mounts + subscribes to view.topic (when
|
|
462
|
+
# non-empty) + sends initial render; on_message dispatches the
|
|
463
|
+
# JSON event + sends re-render.
|
|
464
|
+
# * TepLive_N_WsRoute handles the WS upgrade at /path/ws.
|
|
465
|
+
live_views.each_with_index do |lv, i|
|
|
466
|
+
base = "TepLive_#{i}"
|
|
467
|
+
klass = lv[:view_class]
|
|
468
|
+
path = lv[:path]
|
|
469
|
+
ws_path = path + "/ws"
|
|
470
|
+
|
|
471
|
+
get_cls = "#{base}_Get"
|
|
472
|
+
out << "class #{get_cls} < Tep::Handler"
|
|
473
|
+
out << " def handle(req, res)"
|
|
474
|
+
out << " v = #{klass}.new"
|
|
475
|
+
out << " v.mount(req)"
|
|
476
|
+
out << " Tep::LiveView.render_page(v.render, #{ws_path.inspect})"
|
|
477
|
+
out << " end"
|
|
478
|
+
out << "end"
|
|
479
|
+
out << ""
|
|
480
|
+
|
|
481
|
+
open_cls = "#{base}_WsOpen"
|
|
482
|
+
out << "class #{open_cls} < Tep::WebSocket::Handler"
|
|
483
|
+
out << " attr_accessor :ws, :view"
|
|
484
|
+
out << " def initialize"
|
|
485
|
+
out << " super"
|
|
486
|
+
out << " @ws = Tep::WebSocket::Driver.new(0)"
|
|
487
|
+
out << " @view = #{klass}.new"
|
|
488
|
+
out << " end"
|
|
489
|
+
out << " def handle_event(evt)"
|
|
490
|
+
out << " ws = @ws"
|
|
491
|
+
out << " req = @req"
|
|
492
|
+
out << " view = @view"
|
|
493
|
+
out << " view.mount(req)"
|
|
494
|
+
out << " t = view.topic"
|
|
495
|
+
out << " if t.length > 0"
|
|
496
|
+
out << " Tep::Broadcast.subscribe_ws(t, ws.fd)"
|
|
497
|
+
out << " end"
|
|
498
|
+
out << " ws.text(view.render)"
|
|
499
|
+
out << " 0"
|
|
500
|
+
out << " end"
|
|
501
|
+
out << "end"
|
|
502
|
+
out << ""
|
|
503
|
+
|
|
504
|
+
msg_cls = "#{base}_WsMessage"
|
|
505
|
+
out << "class #{msg_cls} < Tep::WebSocket::Handler"
|
|
506
|
+
out << " attr_accessor :ws, :view"
|
|
507
|
+
out << " def initialize"
|
|
508
|
+
out << " super"
|
|
509
|
+
out << " @ws = Tep::WebSocket::Driver.new(0)"
|
|
510
|
+
out << " @view = #{klass}.new"
|
|
511
|
+
out << " end"
|
|
512
|
+
out << " def handle_event(evt)"
|
|
513
|
+
out << " ws = @ws"
|
|
514
|
+
out << " req = @req"
|
|
515
|
+
out << " view = @view"
|
|
516
|
+
out << " view.dispatch_event_json(evt.data, req)"
|
|
517
|
+
out << " ws.text(view.render)"
|
|
518
|
+
out << " 0"
|
|
519
|
+
out << " end"
|
|
520
|
+
out << "end"
|
|
521
|
+
out << ""
|
|
522
|
+
|
|
523
|
+
ws_route_cls = "#{base}_WsRoute"
|
|
524
|
+
out << "class #{ws_route_cls} < Tep::Handler"
|
|
525
|
+
out << " def handle(req, res)"
|
|
526
|
+
out << " hs = Tep::WebSocket::Handshake.check(req)"
|
|
527
|
+
out << " if !hs.valid"
|
|
528
|
+
out << " res.set_status(400)"
|
|
529
|
+
out << " return \"\""
|
|
530
|
+
out << " end"
|
|
531
|
+
out << " drv = Tep::WebSocket::Driver.new(0)"
|
|
532
|
+
out << " v = #{klass}.new"
|
|
533
|
+
out << " _cb_on_open = #{open_cls}.new"
|
|
534
|
+
out << " _cb_on_open.ws = drv"
|
|
535
|
+
out << " _cb_on_open.req = req"
|
|
536
|
+
out << " _cb_on_open.view = v"
|
|
537
|
+
out << " drv.set_on_open(_cb_on_open)"
|
|
538
|
+
out << " _cb_on_message = #{msg_cls}.new"
|
|
539
|
+
out << " _cb_on_message.ws = drv"
|
|
540
|
+
out << " _cb_on_message.req = req"
|
|
541
|
+
out << " _cb_on_message.view = v"
|
|
542
|
+
out << " drv.set_on_message(_cb_on_message)"
|
|
543
|
+
out << " res.start_websocket(hs.accept_key, drv)"
|
|
544
|
+
out << " \"\""
|
|
545
|
+
out << " end"
|
|
546
|
+
out << "end"
|
|
547
|
+
out << ""
|
|
548
|
+
|
|
549
|
+
lv[:get_cls] = get_cls
|
|
550
|
+
lv[:ws_cls] = ws_route_cls
|
|
551
|
+
lv[:ws_path] = ws_path
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Tep::MCP emission. For each `mcp_tool 'name', "desc" do ... end`:
|
|
555
|
+
#
|
|
556
|
+
# * TepMCP_Tools.call_<i>(req, args_json) -- static cmeth that
|
|
557
|
+
# parses args out of args_json (via Tep::Json.get_str/get_int),
|
|
558
|
+
# binds them as locals, and runs the user's on_call body. The
|
|
559
|
+
# body is rewritten the same way route bodies are (req / res /
|
|
560
|
+
# params rewrites). Returns Tep::MCP::Result.
|
|
561
|
+
# * TepMCP_<i>_HttpRoute -- POST /tools/<name>, takes the args
|
|
562
|
+
# as the JSON request body, returns the Result's text directly
|
|
563
|
+
# (with the right status / content-type).
|
|
564
|
+
#
|
|
565
|
+
# After all tools, two more classes:
|
|
566
|
+
#
|
|
567
|
+
# * TepMCP_Dispatcher -- POST /mcp, parses the JSON-RPC envelope
|
|
568
|
+
# and dispatches `initialize` / `tools/list` / `tools/call` by
|
|
569
|
+
# statically-elif'd name. No PtrArray<Tool> involvement -- each
|
|
570
|
+
# tool's call cmeth is referenced literally so spinel can
|
|
571
|
+
# trace types end-to-end.
|
|
572
|
+
# * TepMCP_LlmsTxt -- GET /llms.txt, returns the tool catalog as
|
|
573
|
+
# a markdown index any LLM-friendly client can fetch.
|
|
574
|
+
if !mcp_tools.empty? || !mcp_resources.empty?
|
|
575
|
+
# Build the tools/list JSON once, at translate time. Each entry
|
|
576
|
+
# is {name, description, inputSchema}; inputSchema follows the
|
|
577
|
+
# JSON Schema object form with property type/description per
|
|
578
|
+
# param. Keep the schema flat (no $ref, no nested objects); MCP
|
|
579
|
+
# clients all accept this minimal shape.
|
|
580
|
+
tools_list_json_parts = []
|
|
581
|
+
mcp_tools.each do |t|
|
|
582
|
+
props_parts = []
|
|
583
|
+
t[:params].each do |p|
|
|
584
|
+
json_type =
|
|
585
|
+
case p[:type]
|
|
586
|
+
when "Integer" then "integer"
|
|
587
|
+
when "Float" then "number"
|
|
588
|
+
else "string"
|
|
589
|
+
end
|
|
590
|
+
props_parts << %("#{p[:name]}":{"type":"#{json_type}","description":#{p[:desc].inspect}})
|
|
591
|
+
end
|
|
592
|
+
required_arr = t[:params].map { |p| %("#{p[:name]}") }.join(",")
|
|
593
|
+
schema_json = %({"type":"object","properties":{#{props_parts.join(",")}},"required":[#{required_arr}]})
|
|
594
|
+
tools_list_json_parts << %({"name":"#{t[:name]}","description":#{t[:description].inspect},"inputSchema":#{schema_json}})
|
|
595
|
+
end
|
|
596
|
+
tools_list_json = "[" + tools_list_json_parts.join(",") + "]"
|
|
597
|
+
|
|
598
|
+
out << "module TepMCP_Tools"
|
|
599
|
+
mcp_tools.each_with_index do |t, i|
|
|
600
|
+
body = rewrite_block(t[:call_body], force_string: false)
|
|
601
|
+
out << " def self.call_#{i}(req, args_json)"
|
|
602
|
+
# Per-cap may? checks (chunk 5.2). One inline check per cap
|
|
603
|
+
# rather than a loop -- spinel's symbol-array iteration is
|
|
604
|
+
# uneven, and a flat sequence of identical branches is the
|
|
605
|
+
# safest emit. Anonymous identity has empty caps so any
|
|
606
|
+
# capped tool denies anonymous callers cleanly.
|
|
607
|
+
t[:caps].each do |cap|
|
|
608
|
+
out << " if !req.identity.may?(:#{cap})"
|
|
609
|
+
out << " return Tep::MCP.error(\"missing capability: #{cap}\")"
|
|
610
|
+
out << " end"
|
|
611
|
+
end
|
|
612
|
+
t[:params].each do |p|
|
|
613
|
+
if p[:type] == "Integer"
|
|
614
|
+
out << " #{p[:name]} = Tep::Json.get_int(args_json, #{p[:name].inspect})"
|
|
615
|
+
elsif p[:type] == "Float"
|
|
616
|
+
out << " #{p[:name]} = Tep::Json.get_str(args_json, #{p[:name].inspect}).to_f"
|
|
617
|
+
else
|
|
618
|
+
out << " #{p[:name]} = Tep::Json.get_str(args_json, #{p[:name].inspect})"
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
out << indent(body, 4)
|
|
622
|
+
out << " end"
|
|
623
|
+
out << ""
|
|
624
|
+
end
|
|
625
|
+
out << "end"
|
|
626
|
+
out << ""
|
|
627
|
+
|
|
628
|
+
mcp_tools.each_with_index do |t, i|
|
|
629
|
+
http_cls = "TepMCP_#{i}_HttpRoute"
|
|
630
|
+
out << "class #{http_cls} < Tep::Handler"
|
|
631
|
+
out << " def handle(req, res)"
|
|
632
|
+
out << " args_json = req.raw_body"
|
|
633
|
+
out << " r = TepMCP_Tools.call_#{i}(req, args_json)"
|
|
634
|
+
out << " res.headers[\"Content-Type\"] = \"text/plain; charset=utf-8\""
|
|
635
|
+
out << " if r.is_error == 1"
|
|
636
|
+
out << " res.set_status(400)"
|
|
637
|
+
out << " end"
|
|
638
|
+
out << " r.text"
|
|
639
|
+
out << " end"
|
|
640
|
+
out << "end"
|
|
641
|
+
out << ""
|
|
642
|
+
t[:http_cls] = http_cls
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
tools_const = "TepMCP_TOOLS_LIST_JSON"
|
|
646
|
+
out << "#{tools_const} = #{tools_list_json.inspect}"
|
|
647
|
+
out << ""
|
|
648
|
+
|
|
649
|
+
# Per-resource read cmeths + HTTP-direct Handler subclasses
|
|
650
|
+
# (chunk 5.3). One Handler per resource for GET /resources/<name>
|
|
651
|
+
# plus a sibling cmeth `TepMCP_Resources.read_<i>(req)` that the
|
|
652
|
+
# dispatcher's resources/read arm calls statically (no
|
|
653
|
+
# PtrArray<Resource> dispatch, same pattern as tools).
|
|
654
|
+
unless mcp_resources.empty?
|
|
655
|
+
out << "module TepMCP_Resources"
|
|
656
|
+
mcp_resources.each_with_index do |rdef, i|
|
|
657
|
+
body = rewrite_block(rdef[:read_body], force_string: false)
|
|
658
|
+
out << " def self.read_#{i}(req)"
|
|
659
|
+
out << indent(body, 4)
|
|
660
|
+
out << " end"
|
|
661
|
+
out << ""
|
|
662
|
+
end
|
|
663
|
+
out << "end"
|
|
664
|
+
out << ""
|
|
665
|
+
|
|
666
|
+
mcp_resources.each_with_index do |rdef, i|
|
|
667
|
+
http_cls = "TepMCP_Res_#{i}_HttpRoute"
|
|
668
|
+
out << "class #{http_cls} < Tep::Handler"
|
|
669
|
+
out << " def handle(req, res)"
|
|
670
|
+
out << " c = TepMCP_Resources.read_#{i}(req)"
|
|
671
|
+
out << " res.headers[\"Content-Type\"] = c.mime"
|
|
672
|
+
out << " c.text"
|
|
673
|
+
out << " end"
|
|
674
|
+
out << "end"
|
|
675
|
+
out << ""
|
|
676
|
+
rdef[:http_cls] = http_cls
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# resources/list array, pre-built at translate time.
|
|
680
|
+
res_list_parts = mcp_resources.map do |rdef|
|
|
681
|
+
%({"uri":#{rdef[:name].inspect},"name":#{rdef[:name].inspect},"description":#{rdef[:description].inspect},"mimeType":"text/plain"})
|
|
682
|
+
end
|
|
683
|
+
resources_list_json = "[" + res_list_parts.join(",") + "]"
|
|
684
|
+
resources_const = "TepMCP_RESOURCES_LIST_JSON"
|
|
685
|
+
out << "#{resources_const} = #{resources_list_json.inspect}"
|
|
686
|
+
out << ""
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
server_name = (config[:mcp_server_name] || "tep-mcp-server").to_s
|
|
690
|
+
server_version = config[:mcp_server_version] || begin
|
|
691
|
+
vfile = File.read(File.expand_path("../lib/tep/version.rb", __dir__))
|
|
692
|
+
vfile[/VERSION\s*=\s*"([^"]+)"/, 1] || "0.0.0"
|
|
693
|
+
rescue
|
|
694
|
+
"0.0.0"
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
out << "class TepMCP_Dispatcher < Tep::Handler"
|
|
698
|
+
out << " def handle(req, res)"
|
|
699
|
+
out << " res.headers[\"Content-Type\"] = \"application/json\""
|
|
700
|
+
out << " body = req.raw_body"
|
|
701
|
+
out << " method = Tep::Json.get_str(body, \"method\")"
|
|
702
|
+
out << " req_id = Tep::Json.get_int(body, \"id\")"
|
|
703
|
+
out << " if method == \"initialize\""
|
|
704
|
+
out << " return Tep::MCP.initialize_envelope(req_id, #{server_name.inspect}, #{server_version.inspect})"
|
|
705
|
+
out << " end"
|
|
706
|
+
# notifications/initialized is a JSON-RPC notification (no
|
|
707
|
+
# response per spec). Return 204 No Content with an empty body
|
|
708
|
+
# to satisfy HTTP while honoring the no-response semantics.
|
|
709
|
+
out << " if method == \"notifications/initialized\""
|
|
710
|
+
out << " res.set_status(204)"
|
|
711
|
+
out << " return \"\""
|
|
712
|
+
out << " end"
|
|
713
|
+
out << " if method == \"tools/list\""
|
|
714
|
+
out << " return Tep::MCP.tools_list_envelope(req_id, #{tools_const})"
|
|
715
|
+
out << " end"
|
|
716
|
+
out << " if method == \"tools/call\""
|
|
717
|
+
out << " params = Tep::MCP.nested_extract(body, \"params\")"
|
|
718
|
+
out << " tool_name = Tep::Json.get_str(params, \"name\")"
|
|
719
|
+
out << " args = Tep::MCP.nested_extract(params, \"arguments\")"
|
|
720
|
+
mcp_tools.each_with_index do |t, i|
|
|
721
|
+
out << " if tool_name == #{t[:name].inspect}"
|
|
722
|
+
out << " r = TepMCP_Tools.call_#{i}(req, args)"
|
|
723
|
+
out << " return Tep::MCP.tools_call_envelope(req_id, r.text, r.is_error)"
|
|
724
|
+
out << " end"
|
|
725
|
+
end
|
|
726
|
+
out << " return Tep::MCP.unknown_tool_envelope(req_id, tool_name)"
|
|
727
|
+
out << " end"
|
|
728
|
+
unless mcp_resources.empty?
|
|
729
|
+
out << " if method == \"resources/list\""
|
|
730
|
+
out << " return Tep::MCP.resources_list_envelope(req_id, TepMCP_RESOURCES_LIST_JSON)"
|
|
731
|
+
out << " end"
|
|
732
|
+
out << " if method == \"resources/read\""
|
|
733
|
+
out << " res_params = Tep::MCP.nested_extract(body, \"params\")"
|
|
734
|
+
out << " res_uri = Tep::Json.get_str(res_params, \"uri\")"
|
|
735
|
+
mcp_resources.each_with_index do |rdef, i|
|
|
736
|
+
out << " if res_uri == #{rdef[:name].inspect}"
|
|
737
|
+
out << " c = TepMCP_Resources.read_#{i}(req)"
|
|
738
|
+
out << " return Tep::MCP.resources_read_envelope(req_id, c.uri, c.mime, c.text)"
|
|
739
|
+
out << " end"
|
|
740
|
+
end
|
|
741
|
+
out << " return Tep::MCP.unknown_resource_envelope(req_id, res_uri)"
|
|
742
|
+
out << " end"
|
|
743
|
+
end
|
|
744
|
+
out << " Tep::MCP.method_not_found_envelope(req_id, method)"
|
|
745
|
+
out << " end"
|
|
746
|
+
out << "end"
|
|
747
|
+
out << ""
|
|
748
|
+
|
|
749
|
+
# openapi.json: OpenAPI 3.0 description of the HTTP-direct
|
|
750
|
+
# surface (tools at POST /tools/<name>, resources at GET
|
|
751
|
+
# /resources/<name>). Generated at translate time as a single
|
|
752
|
+
# constant string the handler returns verbatim. Mirrors what
|
|
753
|
+
# MCP clients see at tools/list + resources/list, but in the
|
|
754
|
+
# universal OpenAPI shape so non-MCP agents (and ordinary
|
|
755
|
+
# OpenAPI tooling) can consume the same catalog.
|
|
756
|
+
paths_parts = []
|
|
757
|
+
mcp_tools.each do |t|
|
|
758
|
+
props_parts = t[:params].map do |p|
|
|
759
|
+
json_type =
|
|
760
|
+
case p[:type]
|
|
761
|
+
when "Integer" then "integer"
|
|
762
|
+
when "Float" then "number"
|
|
763
|
+
else "string"
|
|
764
|
+
end
|
|
765
|
+
%("#{p[:name]}":{"type":"#{json_type}","description":#{p[:desc].inspect}})
|
|
766
|
+
end
|
|
767
|
+
required_arr = t[:params].map { |p| %("#{p[:name]}") }.join(",")
|
|
768
|
+
schema_json = %({"type":"object","properties":{#{props_parts.join(",")}},"required":[#{required_arr}]})
|
|
769
|
+
paths_parts << %("/tools/#{t[:name]}":{"post":{"summary":#{t[:description].inspect},"requestBody":{"required":true,"content":{"application/json":{"schema":#{schema_json}}}},"responses":{"200":{"description":"tool text result","content":{"text/plain":{}}},"400":{"description":"tool error"}}}})
|
|
770
|
+
end
|
|
771
|
+
mcp_resources.each do |rdef|
|
|
772
|
+
paths_parts << %("/resources/#{rdef[:name]}":{"get":{"summary":#{rdef[:description].inspect},"responses":{"200":{"description":"resource content","content":{"text/plain":{}}}}}})
|
|
773
|
+
end
|
|
774
|
+
openapi_json =
|
|
775
|
+
%({"openapi":"3.0.3","info":{"title":#{server_name.inspect},"version":#{server_version.inspect}},) +
|
|
776
|
+
%("paths":{#{paths_parts.join(",")}}})
|
|
777
|
+
openapi_const = "TepMCP_OPENAPI_JSON"
|
|
778
|
+
out << "#{openapi_const} = #{openapi_json.inspect}"
|
|
779
|
+
out << ""
|
|
780
|
+
out << "class TepMCP_OpenAPI < Tep::Handler"
|
|
781
|
+
out << " def handle(req, res)"
|
|
782
|
+
out << " res.headers[\"Content-Type\"] = \"application/json\""
|
|
783
|
+
out << " #{openapi_const}"
|
|
784
|
+
out << " end"
|
|
785
|
+
out << "end"
|
|
786
|
+
out << ""
|
|
787
|
+
|
|
788
|
+
# llms.txt: minimal markdown index of the tool + resource
|
|
789
|
+
# catalogs. Stable ASCII so even a non-LLM reader can scan it.
|
|
790
|
+
llms_lines = ["# " + server_name, "", "MCP-endpoint: /mcp", "OpenAPI: /openapi.json", ""]
|
|
791
|
+
unless mcp_tools.empty?
|
|
792
|
+
llms_lines << "## Tools"
|
|
793
|
+
llms_lines << ""
|
|
794
|
+
mcp_tools.each do |t|
|
|
795
|
+
llms_lines << "- " + t[:name] + " -- " + t[:description]
|
|
796
|
+
end
|
|
797
|
+
llms_lines << ""
|
|
798
|
+
end
|
|
799
|
+
unless mcp_resources.empty?
|
|
800
|
+
llms_lines << "## Resources"
|
|
801
|
+
llms_lines << ""
|
|
802
|
+
mcp_resources.each do |rdef|
|
|
803
|
+
llms_lines << "- " + rdef[:name] + " -- " + rdef[:description]
|
|
804
|
+
end
|
|
805
|
+
llms_lines << ""
|
|
806
|
+
end
|
|
807
|
+
llms_body = llms_lines.join("\n")
|
|
808
|
+
|
|
809
|
+
out << "class TepMCP_LlmsTxt < Tep::Handler"
|
|
810
|
+
out << " def handle(req, res)"
|
|
811
|
+
out << " res.headers[\"Content-Type\"] = \"text/markdown; charset=utf-8\""
|
|
812
|
+
out << " #{llms_body.inspect}"
|
|
813
|
+
out << " end"
|
|
814
|
+
out << "end"
|
|
815
|
+
out << ""
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Registrations.
|
|
819
|
+
out << Tep_public_dir(config[:public_dir]) if config[:public_dir]
|
|
820
|
+
out << %(Tep.before #{filter_classes["before"]}.new) if filter_classes["before"]
|
|
821
|
+
out << %(Tep.after #{filter_classes["after"]}.new) if filter_classes["after"]
|
|
822
|
+
not_founds.each { |n| out << %(Tep.not_found #{n[:cls]}.new) }
|
|
823
|
+
routes.each do |r|
|
|
824
|
+
if r[:regex]
|
|
825
|
+
# Regex routes go through the literal-route DSL with an unused
|
|
826
|
+
# `pattern` arg (the matcher uses re_match? on the handler).
|
|
827
|
+
out << %(Tep.#{r[:verb].downcase} "" , #{r[:cls]}.new)
|
|
828
|
+
else
|
|
829
|
+
# Optional segments `(/:foo)`: expand to the cartesian product
|
|
830
|
+
# of "include each optional or not", registering one route per
|
|
831
|
+
# combination with the same handler class. Bodies share state
|
|
832
|
+
# via the single class; missing optional captures show up as
|
|
833
|
+
# empty strings in `params[...]`.
|
|
834
|
+
paths = expand_optional_segments(r[:path])
|
|
835
|
+
paths.each do |p|
|
|
836
|
+
out << %(Tep.#{r[:verb].downcase} #{p.inspect}, #{r[:cls]}.new)
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
websockets.each do |w|
|
|
841
|
+
out << %(Tep.get #{w[:path].inspect}, #{w[:cls]}.new)
|
|
842
|
+
end
|
|
843
|
+
live_views.each do |lv|
|
|
844
|
+
out << %(Tep.get #{lv[:path].inspect}, #{lv[:get_cls]}.new)
|
|
845
|
+
out << %(Tep.get #{lv[:ws_path].inspect}, #{lv[:ws_cls]}.new)
|
|
846
|
+
end
|
|
847
|
+
if !mcp_tools.empty? || !mcp_resources.empty?
|
|
848
|
+
mcp_tools.each do |t|
|
|
849
|
+
out << %(Tep.post "/tools/#{t[:name]}", #{t[:http_cls]}.new)
|
|
850
|
+
end
|
|
851
|
+
mcp_resources.each do |rdef|
|
|
852
|
+
out << %(Tep.get "/resources/#{rdef[:name]}", #{rdef[:http_cls]}.new)
|
|
853
|
+
end
|
|
854
|
+
out << %(Tep.post "/mcp", TepMCP_Dispatcher.new)
|
|
855
|
+
out << %(Tep.get "/llms.txt", TepMCP_LlmsTxt.new)
|
|
856
|
+
out << %(Tep.get "/openapi.json", TepMCP_OpenAPI.new)
|
|
857
|
+
end
|
|
858
|
+
out << ""
|
|
859
|
+
|
|
860
|
+
# `on_start do ... end` body runs once before the accept loop.
|
|
861
|
+
# Inline as plain top-level code so spinel sees it as program-load
|
|
862
|
+
# statements, no class/handler wrapping needed.
|
|
863
|
+
if startup_block
|
|
864
|
+
out << "# --- on_start ---"
|
|
865
|
+
out << rewrite_block(startup_block[:src], force_string: false)
|
|
866
|
+
out << ""
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# CLI option parsing -- emitted at top level so spinel's codegen
|
|
870
|
+
# actually declares `sp_argv` (it skips it for method-local ARGV
|
|
871
|
+
# references). Every translated app picks up `-p`, `-w`, `-q`.
|
|
872
|
+
scheduled = config[:scheduler] == :scheduled
|
|
873
|
+
out << <<~RB
|
|
874
|
+
__port = #{config[:port] || 4567}
|
|
875
|
+
__workers = #{config[:workers] || 1}
|
|
876
|
+
__quiet = false
|
|
877
|
+
__scheduled = #{scheduled ? 'true' : 'false'}
|
|
878
|
+
__i = 0
|
|
879
|
+
while __i < ARGV.length
|
|
880
|
+
__a = ARGV[__i]
|
|
881
|
+
if __a == "-p" && __i + 1 < ARGV.length
|
|
882
|
+
__port = ARGV[__i + 1].to_i
|
|
883
|
+
__i += 2
|
|
884
|
+
elsif __a == "-w" && __i + 1 < ARGV.length
|
|
885
|
+
__workers = ARGV[__i + 1].to_i
|
|
886
|
+
__i += 2
|
|
887
|
+
elsif __a == "-q"
|
|
888
|
+
__quiet = true
|
|
889
|
+
__i += 1
|
|
890
|
+
elsif __a == "-s"
|
|
891
|
+
# Phase 1.5 opt-in: scheduled fiber-per-connection server
|
|
892
|
+
# (vs the default prefork-blocking-one-conn-per-worker shape).
|
|
893
|
+
__scheduled = true
|
|
894
|
+
__i += 1
|
|
895
|
+
else
|
|
896
|
+
__i += 1
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
Tep.run!(__port, __workers, __quiet, __scheduled)
|
|
900
|
+
RB
|
|
901
|
+
out << ""
|
|
902
|
+
|
|
903
|
+
warnings.each { |w| warn "tep: #{w}" }
|
|
904
|
+
|
|
905
|
+
out.join("\n")
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def Tep_public_dir(path)
|
|
909
|
+
abs = File.expand_path(path, Dir.pwd)
|
|
910
|
+
%(Tep.public_dir #{abs.inspect})
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def call?(node)
|
|
914
|
+
node.is_a?(Prism::CallNode)
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
# `mcp_tool 'name', "description" do ... end` — collect the tool's
|
|
918
|
+
# input-param declarations + the on_call body. Each `param :sym,
|
|
919
|
+
# Type, "desc"` line inside the block records one parameter (with
|
|
920
|
+
# optional `default:` and `enum:` keyword args, kept verbatim into
|
|
921
|
+
# the JSON Schema we emit). Each `on_call do |kwargs| ... end`
|
|
922
|
+
# block becomes the tool's invocation body.
|
|
923
|
+
# `mcp_resource 'name', "description" do; on_read do ... end; end`
|
|
924
|
+
# (chunk 5.3). Each resource gets a read-only fetch endpoint at
|
|
925
|
+
# `GET /resources/<name>` plus a `resources/list` + `resources/read`
|
|
926
|
+
# dispatch arm via the /mcp dispatcher. The on_read body returns
|
|
927
|
+
# Tep::MCP::ResourceContent (built via Tep::MCP.resource_text). No
|
|
928
|
+
# URI templating + no streaming in this chunk -- both defer.
|
|
929
|
+
def handle_mcp_resource(node, mcp_resources, warnings)
|
|
930
|
+
args = node.arguments&.arguments || []
|
|
931
|
+
block = node.block
|
|
932
|
+
unless block.is_a?(Prism::BlockNode)
|
|
933
|
+
return warnings << "skipped mcp_resource -- needs a `do ... end` block"
|
|
934
|
+
end
|
|
935
|
+
if args.length < 2
|
|
936
|
+
return warnings << "skipped mcp_resource -- needs (\"name\", \"description\", &block)"
|
|
937
|
+
end
|
|
938
|
+
name_node = args[0]
|
|
939
|
+
desc_node = args[1]
|
|
940
|
+
unless name_node.is_a?(Prism::StringNode)
|
|
941
|
+
return warnings << "skipped mcp_resource -- first arg must be a string literal name"
|
|
942
|
+
end
|
|
943
|
+
unless desc_node.is_a?(Prism::StringNode)
|
|
944
|
+
return warnings << "skipped mcp_resource -- second arg must be a string literal description"
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
read_body = nil
|
|
948
|
+
body = block.body
|
|
949
|
+
if body.is_a?(Prism::StatementsNode)
|
|
950
|
+
body.body.each do |stmt|
|
|
951
|
+
next unless call?(stmt) && stmt.receiver.nil?
|
|
952
|
+
cname = stmt.name.to_s
|
|
953
|
+
if cname == "on_read"
|
|
954
|
+
inner = stmt.block
|
|
955
|
+
unless inner.is_a?(Prism::BlockNode)
|
|
956
|
+
warnings << "mcp_resource #{name_node.unescaped}: on_read needs a `do ... end` block"
|
|
957
|
+
next
|
|
958
|
+
end
|
|
959
|
+
read_body = block_source(inner)
|
|
960
|
+
else
|
|
961
|
+
warnings << "mcp_resource #{name_node.unescaped}: ignored unrecognized statement `#{cname}`"
|
|
962
|
+
end
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
if read_body.nil?
|
|
967
|
+
return warnings << "skipped mcp_resource #{name_node.unescaped} -- missing on_read do ... end"
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
mcp_resources << {
|
|
971
|
+
name: name_node.unescaped,
|
|
972
|
+
description: desc_node.unescaped,
|
|
973
|
+
read_body: read_body,
|
|
974
|
+
}
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def handle_mcp_tool(node, mcp_tools, warnings)
|
|
978
|
+
args = node.arguments&.arguments || []
|
|
979
|
+
block = node.block
|
|
980
|
+
unless block.is_a?(Prism::BlockNode)
|
|
981
|
+
return warnings << "skipped mcp_tool -- needs a `do ... end` block"
|
|
982
|
+
end
|
|
983
|
+
if args.length < 2
|
|
984
|
+
return warnings << "skipped mcp_tool -- needs (\"name\", \"description\", &block)"
|
|
985
|
+
end
|
|
986
|
+
name_node = args[0]
|
|
987
|
+
desc_node = args[1]
|
|
988
|
+
unless name_node.is_a?(Prism::StringNode)
|
|
989
|
+
return warnings << "skipped mcp_tool -- first arg must be a string literal name"
|
|
990
|
+
end
|
|
991
|
+
unless desc_node.is_a?(Prism::StringNode)
|
|
992
|
+
return warnings << "skipped mcp_tool -- second arg must be a string literal description"
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
# Optional caps: [:foo, :bar] keyword (chunk 5.2). Required
|
|
996
|
+
# capabilities checked against req.identity.may?(...) at the
|
|
997
|
+
# top of call_<i>. Missing any -> Tep::MCP.error("missing
|
|
998
|
+
# capability: <name>") returned without running the on_call
|
|
999
|
+
# body.
|
|
1000
|
+
caps = []
|
|
1001
|
+
if args.length >= 3 && args[2].is_a?(Prism::KeywordHashNode)
|
|
1002
|
+
args[2].elements.each do |el|
|
|
1003
|
+
next unless el.is_a?(Prism::AssocNode)
|
|
1004
|
+
k = el.key
|
|
1005
|
+
next unless k.is_a?(Prism::SymbolNode) && k.value.to_s == "caps"
|
|
1006
|
+
v = el.value
|
|
1007
|
+
unless v.is_a?(Prism::ArrayNode)
|
|
1008
|
+
warnings << "mcp_tool #{name_node.unescaped}: caps: must be an array literal of symbols"
|
|
1009
|
+
next
|
|
1010
|
+
end
|
|
1011
|
+
v.elements.each do |sym|
|
|
1012
|
+
unless sym.is_a?(Prism::SymbolNode)
|
|
1013
|
+
warnings << "mcp_tool #{name_node.unescaped}: caps: entry must be a symbol literal"
|
|
1014
|
+
next
|
|
1015
|
+
end
|
|
1016
|
+
caps << sym.value.to_s
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
params = []
|
|
1022
|
+
call_body = nil
|
|
1023
|
+
call_kwargs = []
|
|
1024
|
+
body = block.body
|
|
1025
|
+
if body.is_a?(Prism::StatementsNode)
|
|
1026
|
+
body.body.each do |stmt|
|
|
1027
|
+
next unless call?(stmt) && stmt.receiver.nil?
|
|
1028
|
+
cname = stmt.name.to_s
|
|
1029
|
+
if cname == "param"
|
|
1030
|
+
pargs = stmt.arguments&.arguments || []
|
|
1031
|
+
if pargs.length < 3
|
|
1032
|
+
warnings << "mcp_tool #{name_node.unescaped}: skipped param -- needs (:sym, Type, \"desc\", default:, enum:)"
|
|
1033
|
+
next
|
|
1034
|
+
end
|
|
1035
|
+
unless pargs[0].is_a?(Prism::SymbolNode)
|
|
1036
|
+
warnings << "mcp_tool #{name_node.unescaped}: param name must be a symbol"
|
|
1037
|
+
next
|
|
1038
|
+
end
|
|
1039
|
+
unless pargs[1].is_a?(Prism::ConstantReadNode)
|
|
1040
|
+
warnings << "mcp_tool #{name_node.unescaped}: param type must be a bare constant (String/Integer/Float)"
|
|
1041
|
+
next
|
|
1042
|
+
end
|
|
1043
|
+
unless pargs[2].is_a?(Prism::StringNode)
|
|
1044
|
+
warnings << "mcp_tool #{name_node.unescaped}: param description must be a string literal"
|
|
1045
|
+
next
|
|
1046
|
+
end
|
|
1047
|
+
type_name = pargs[1].name.to_s
|
|
1048
|
+
unless %w[String Integer Float].include?(type_name)
|
|
1049
|
+
warnings << "mcp_tool #{name_node.unescaped}: param type #{type_name} not supported (use String / Integer / Float)"
|
|
1050
|
+
next
|
|
1051
|
+
end
|
|
1052
|
+
# Optional `default:` keyword (chunk 5.1 only honors default
|
|
1053
|
+
# for input-schema annotation; the on_call body still needs
|
|
1054
|
+
# to handle missing values defensively via the zero default).
|
|
1055
|
+
params << {
|
|
1056
|
+
name: pargs[0].value.to_s,
|
|
1057
|
+
type: type_name,
|
|
1058
|
+
desc: pargs[2].unescaped,
|
|
1059
|
+
}
|
|
1060
|
+
elsif cname == "on_call"
|
|
1061
|
+
inner = stmt.block
|
|
1062
|
+
unless inner.is_a?(Prism::BlockNode)
|
|
1063
|
+
warnings << "mcp_tool #{name_node.unescaped}: on_call needs a `do |kwargs| ... end` block"
|
|
1064
|
+
next
|
|
1065
|
+
end
|
|
1066
|
+
call_body = block_source(inner)
|
|
1067
|
+
# Capture the keyword-param names from the block signature
|
|
1068
|
+
# so the generated cmeth can pass them by-keyword to the
|
|
1069
|
+
# rewritten body. v1 supports keyword params only.
|
|
1070
|
+
ibp = inner.parameters
|
|
1071
|
+
if ibp.is_a?(Prism::BlockParametersNode)
|
|
1072
|
+
ipp = ibp.parameters
|
|
1073
|
+
if ipp.is_a?(Prism::ParametersNode)
|
|
1074
|
+
ipp.keywords.each do |kw|
|
|
1075
|
+
call_kwargs << kw.name.to_s
|
|
1076
|
+
end
|
|
1077
|
+
end
|
|
1078
|
+
end
|
|
1079
|
+
else
|
|
1080
|
+
warnings << "mcp_tool #{name_node.unescaped}: ignored unrecognized statement `#{cname}`"
|
|
1081
|
+
end
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
if call_body.nil?
|
|
1086
|
+
return warnings << "skipped mcp_tool #{name_node.unescaped} -- missing on_call do ... end"
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
mcp_tools << {
|
|
1090
|
+
name: name_node.unescaped,
|
|
1091
|
+
description: desc_node.unescaped,
|
|
1092
|
+
caps: caps,
|
|
1093
|
+
params: params,
|
|
1094
|
+
call_kwargs: call_kwargs,
|
|
1095
|
+
call_body: call_body,
|
|
1096
|
+
}
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def handle_tep_live(node, live_views, warnings)
|
|
1100
|
+
args = node.arguments&.arguments || []
|
|
1101
|
+
if args.length < 2
|
|
1102
|
+
return warnings << "skipped Tep.live -- needs (\"/path\", ViewClass)"
|
|
1103
|
+
end
|
|
1104
|
+
path = args[0]
|
|
1105
|
+
view = args[1]
|
|
1106
|
+
unless path.is_a?(Prism::StringNode)
|
|
1107
|
+
return warnings << "skipped Tep.live -- first arg must be a string literal path"
|
|
1108
|
+
end
|
|
1109
|
+
unless view.is_a?(Prism::ConstantReadNode) || view.is_a?(Prism::ConstantPathNode)
|
|
1110
|
+
return warnings << "skipped Tep.live -- second arg must be a class constant"
|
|
1111
|
+
end
|
|
1112
|
+
live_views << {
|
|
1113
|
+
path: path.unescaped,
|
|
1114
|
+
view_class: view.slice,
|
|
1115
|
+
}
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
# Recursively inline an app-local `require_relative` target. Reads the
|
|
1119
|
+
# file; for each `require_relative` IT contains, resolves the path
|
|
1120
|
+
# relative to THAT file's own directory and inlines it too. This is what
|
|
1121
|
+
# lets `require_relative "vendor/spinel/deps"` work: bundler-spinel's
|
|
1122
|
+
# generated deps.rb is itself a list of `require_relative`s (one per
|
|
1123
|
+
# vendored gem, in lock order), so a single app-level require pulls in
|
|
1124
|
+
# the whole dependency set. `seen` (keyed by absolute path) dedupes
|
|
1125
|
+
# shared deps and breaks cycles. Returns the spliced source; a missing
|
|
1126
|
+
# target becomes a dropped-marker comment + a warning. Plain `require`
|
|
1127
|
+
# lines are left verbatim -- spinel drops them (no load path), which is
|
|
1128
|
+
# correct for a self-contained gem; a gem that needs an unvendored
|
|
1129
|
+
# require won't link, same as today.
|
|
1130
|
+
def inline_require_relative_tree(abs_path, seen, warnings)
|
|
1131
|
+
return "" if seen[abs_path]
|
|
1132
|
+
seen[abs_path] = true
|
|
1133
|
+
dir = File.dirname(abs_path)
|
|
1134
|
+
out = []
|
|
1135
|
+
File.read(abs_path).each_line do |line|
|
|
1136
|
+
m = line.match(/\A\s*require_relative\s+["']([^"']+)["']\s*\z/)
|
|
1137
|
+
if m
|
|
1138
|
+
target = m[1]
|
|
1139
|
+
cand = File.expand_path(target.end_with?(".rb") ? target : target + ".rb", dir)
|
|
1140
|
+
if File.file?(cand)
|
|
1141
|
+
out << "# --- inlined require_relative #{target.inspect} (#{File.basename(cand)}) ---"
|
|
1142
|
+
out << inline_require_relative_tree(cand, seen, warnings)
|
|
1143
|
+
else
|
|
1144
|
+
out << "# (require_relative #{target.inspect} -- not found at #{cand}, dropped)"
|
|
1145
|
+
warnings << "require_relative #{target.inspect} (from #{File.basename(abs_path)}) not found at #{cand}; dropped"
|
|
1146
|
+
end
|
|
1147
|
+
else
|
|
1148
|
+
out << line.rstrip
|
|
1149
|
+
end
|
|
1150
|
+
end
|
|
1151
|
+
out.join("\n")
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
def handle_top_call(node, routes, websockets, mcp_tools, mcp_resources, filters, not_founds, config, warnings, passthrough, input_path = nil, inlined_seen = nil)
|
|
1155
|
+
inlined_seen ||= {}
|
|
1156
|
+
name = node.name.to_s
|
|
1157
|
+
|
|
1158
|
+
if name == "mcp_tool"
|
|
1159
|
+
handle_mcp_tool(node, mcp_tools, warnings)
|
|
1160
|
+
return
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
if name == "mcp_resource"
|
|
1164
|
+
handle_mcp_resource(node, mcp_resources, warnings)
|
|
1165
|
+
return
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
if name == "websocket"
|
|
1169
|
+
args = node.arguments&.arguments || []
|
|
1170
|
+
block = node.block
|
|
1171
|
+
first = args.first
|
|
1172
|
+
unless block.is_a?(Prism::BlockNode)
|
|
1173
|
+
return warnings << "skipped websocket -- needs a `do |ws| ... end` block"
|
|
1174
|
+
end
|
|
1175
|
+
unless first.is_a?(Prism::StringNode)
|
|
1176
|
+
return warnings << "skipped websocket -- first arg must be a string literal path"
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
# Extract the block parameter name (`do |ws|` -> "ws"). Used to
|
|
1180
|
+
# let the user's on_X bodies reference the driver by their chosen
|
|
1181
|
+
# name -- we bind `<name> = @ws` at the top of every handler.
|
|
1182
|
+
ws_param = "ws"
|
|
1183
|
+
bparams = block.parameters
|
|
1184
|
+
if bparams.is_a?(Prism::BlockParametersNode)
|
|
1185
|
+
pp = bparams.parameters
|
|
1186
|
+
if pp.is_a?(Prism::ParametersNode) && pp.requireds.first.is_a?(Prism::RequiredParameterNode)
|
|
1187
|
+
ws_param = pp.requireds.first.name.to_s
|
|
1188
|
+
end
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
# Walk the block body looking for `on_<event> do |evt| ... end`
|
|
1192
|
+
# calls; collect each event's block body as its own callback class.
|
|
1193
|
+
events = {}
|
|
1194
|
+
body = block.body
|
|
1195
|
+
if body.is_a?(Prism::StatementsNode)
|
|
1196
|
+
body.body.each do |stmt|
|
|
1197
|
+
next unless call?(stmt) && stmt.receiver.nil?
|
|
1198
|
+
ev = stmt.name.to_s
|
|
1199
|
+
unless WS_EVENTS.include?(ev)
|
|
1200
|
+
warnings << "websocket #{first.unescaped}: unrecognised event `#{ev}` -- ignored"
|
|
1201
|
+
next
|
|
1202
|
+
end
|
|
1203
|
+
inner = stmt.block
|
|
1204
|
+
unless inner.is_a?(Prism::BlockNode)
|
|
1205
|
+
warnings << "websocket #{first.unescaped}: `#{ev}` needs a `do |evt| ... end` block"
|
|
1206
|
+
next
|
|
1207
|
+
end
|
|
1208
|
+
# Capture the event-block param name too, so a user who
|
|
1209
|
+
# writes `|message|` instead of `|evt|` keeps their name.
|
|
1210
|
+
evt_param = "evt"
|
|
1211
|
+
ibp = inner.parameters
|
|
1212
|
+
if ibp.is_a?(Prism::BlockParametersNode)
|
|
1213
|
+
ipp = ibp.parameters
|
|
1214
|
+
if ipp.is_a?(Prism::ParametersNode) && ipp.requireds.first.is_a?(Prism::RequiredParameterNode)
|
|
1215
|
+
evt_param = ipp.requireds.first.name.to_s
|
|
1216
|
+
end
|
|
1217
|
+
end
|
|
1218
|
+
events[ev] = { src: block_source(inner), evt_param: evt_param }
|
|
1219
|
+
end
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
websockets << {
|
|
1223
|
+
path: first.unescaped,
|
|
1224
|
+
ws_param: ws_param,
|
|
1225
|
+
events: events,
|
|
1226
|
+
}
|
|
1227
|
+
return
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
if DSL_VERBS.include?(name)
|
|
1231
|
+
args = node.arguments&.arguments || []
|
|
1232
|
+
block = node.block
|
|
1233
|
+
first = args.first
|
|
1234
|
+
return warnings << "skipped #{name} -- needs a `do ... end` block" unless block.is_a?(Prism::BlockNode)
|
|
1235
|
+
if first.is_a?(Prism::StringNode)
|
|
1236
|
+
routes << {
|
|
1237
|
+
verb: name.upcase,
|
|
1238
|
+
path: first.unescaped,
|
|
1239
|
+
block_src: block_source(block),
|
|
1240
|
+
}
|
|
1241
|
+
return
|
|
1242
|
+
end
|
|
1243
|
+
if first.is_a?(Prism::RegularExpressionNode)
|
|
1244
|
+
routes << {
|
|
1245
|
+
verb: name.upcase,
|
|
1246
|
+
regex: first.unescaped,
|
|
1247
|
+
block_src: block_source(block),
|
|
1248
|
+
}
|
|
1249
|
+
return
|
|
1250
|
+
end
|
|
1251
|
+
return warnings << "skipped #{name} -- first arg must be a string or regex literal"
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
if HOOK_NAMES.include?(name)
|
|
1255
|
+
block = node.block
|
|
1256
|
+
return warnings << "skipped #{name} -- needs a `do ... end` block" unless block.is_a?(Prism::BlockNode)
|
|
1257
|
+
if name == "not_found"
|
|
1258
|
+
not_founds << { block_src: block_source(block) }
|
|
1259
|
+
else
|
|
1260
|
+
filters[name] << { block_src: block_source(block) }
|
|
1261
|
+
end
|
|
1262
|
+
return
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
if name == "on_start"
|
|
1266
|
+
block = node.block
|
|
1267
|
+
return warnings << "skipped on_start -- needs a `do ... end` block" unless block.is_a?(Prism::BlockNode)
|
|
1268
|
+
return { kind: :startup, src: block_source(block) }
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
if name == "on_stop"
|
|
1272
|
+
# tep doesn't have a graceful shutdown path -- the server runs
|
|
1273
|
+
# until SIGINT/SIGTERM ends the process. Honour the spirit by
|
|
1274
|
+
# warning loudly; the block content is dropped.
|
|
1275
|
+
warnings << "on_stop -- tep has no graceful shutdown path; block ignored"
|
|
1276
|
+
return
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
if name == "configure"
|
|
1280
|
+
block = node.block
|
|
1281
|
+
return warnings << "skipped configure -- needs a `do ... end` block" unless block.is_a?(Prism::BlockNode)
|
|
1282
|
+
body = block_source(block)
|
|
1283
|
+
args = node.arguments&.arguments || []
|
|
1284
|
+
envs = args.map { |a| a.is_a?(Prism::SymbolNode) ? a.unescaped : nil }.compact
|
|
1285
|
+
if envs.empty?
|
|
1286
|
+
passthrough << "# --- configure ---"
|
|
1287
|
+
passthrough << body
|
|
1288
|
+
else
|
|
1289
|
+
# spinel doesn't have ENV.fetch with a default; ENV[k] returns
|
|
1290
|
+
# nil for missing keys, but we'd then `.include?(nil)` which
|
|
1291
|
+
# works (returns false). Default env "development" is matched
|
|
1292
|
+
# only when TEP_ENV is unset; users who want dev-specific
|
|
1293
|
+
# `configure :development` should set TEP_ENV explicitly.
|
|
1294
|
+
env_lits = envs.map(&:inspect).join(", ")
|
|
1295
|
+
passthrough << "# --- configure :#{envs.join(', :')} ---"
|
|
1296
|
+
passthrough << "_tep_env = ENV[\"TEP_ENV\"]"
|
|
1297
|
+
passthrough << "_tep_env = \"development\" if _tep_env.nil? || _tep_env.length == 0"
|
|
1298
|
+
passthrough << "if [#{env_lits}].include?(_tep_env)"
|
|
1299
|
+
passthrough << body
|
|
1300
|
+
passthrough << "end"
|
|
1301
|
+
end
|
|
1302
|
+
return
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1305
|
+
if name == "erb"
|
|
1306
|
+
# Track which views are referenced; the translator compiles the
|
|
1307
|
+
# corresponding .erb files into top-level Ruby methods. A bare
|
|
1308
|
+
# top-level `erb :name` is unusual but harmless.
|
|
1309
|
+
args = node.arguments&.arguments || []
|
|
1310
|
+
if args.first.is_a?(Prism::SymbolNode)
|
|
1311
|
+
# handled below in the rewrite_block textual pass; no-op here
|
|
1312
|
+
end
|
|
1313
|
+
return
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
if name == "set"
|
|
1317
|
+
args = node.arguments&.arguments || []
|
|
1318
|
+
if args.length == 2 && args[0].is_a?(Prism::SymbolNode)
|
|
1319
|
+
key = args[0].unescaped
|
|
1320
|
+
val = literal_string(args[1])
|
|
1321
|
+
case key
|
|
1322
|
+
when "public_dir", "public_folder", "public"
|
|
1323
|
+
config[:public_dir] = val if val
|
|
1324
|
+
when "views"
|
|
1325
|
+
config[:views] = val if val
|
|
1326
|
+
when "port"
|
|
1327
|
+
config[:port] = literal_int(args[1]) || config[:port]
|
|
1328
|
+
when "workers"
|
|
1329
|
+
# Default worker count; the runtime CLI's `-w N` still
|
|
1330
|
+
# overrides. Useful for apps whose handlers can block --
|
|
1331
|
+
# SSE streamers, long-poll endpoints -- to default to
|
|
1332
|
+
# something more useful than 1 and keep the homepage
|
|
1333
|
+
# responsive while one worker is held by a stream.
|
|
1334
|
+
config[:workers] = literal_int(args[1]) || config[:workers]
|
|
1335
|
+
when "bind"
|
|
1336
|
+
# silently accept; tep always binds 0.0.0.0
|
|
1337
|
+
when "scheduler"
|
|
1338
|
+
# `set :scheduler, :scheduled` opts the app into the fiber-
|
|
1339
|
+
# per-connection server (Tep::Server::Scheduled). Default
|
|
1340
|
+
# stays the prefork-blocking Tep::Server until the next major
|
|
1341
|
+
# release, when this flips and Blocking is deleted. The CLI
|
|
1342
|
+
# `-s` flag overrides the same way `-w` overrides `:workers`.
|
|
1343
|
+
sym = args[1].is_a?(Prism::SymbolNode) ? args[1].unescaped : nil
|
|
1344
|
+
if sym == "scheduled"
|
|
1345
|
+
config[:scheduler] = :scheduled
|
|
1346
|
+
elsif sym == "blocking" || sym.nil?
|
|
1347
|
+
# default; explicit blocking is a no-op
|
|
1348
|
+
else
|
|
1349
|
+
warnings << "unsupported `set :scheduler, :#{sym}` -- choose :scheduled or :blocking"
|
|
1350
|
+
end
|
|
1351
|
+
else
|
|
1352
|
+
warnings << "unsupported `set :#{key}` -- ignored"
|
|
1353
|
+
end
|
|
1354
|
+
end
|
|
1355
|
+
return
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
case name
|
|
1359
|
+
when "require", "require_relative"
|
|
1360
|
+
# tep's own library is inlined wholesale (see inlined_tep_library), so
|
|
1361
|
+
# `require_relative "tep"` / "tep/..." is a no-op. A plain `require
|
|
1362
|
+
# "gem"` (by name) is dropped -- spinel has no gem load path. But an
|
|
1363
|
+
# app-local `require_relative "vendor/foo"` IS honoured: we read the
|
|
1364
|
+
# target now and splice its source straight into the program, exactly
|
|
1365
|
+
# as we do for tep's library. spinel can't be relied on to resolve
|
|
1366
|
+
# require_relative at build time, but build-time inlining is reliable
|
|
1367
|
+
# -- and it lets a tep app vendor and use a real Ruby gem (e.g.
|
|
1368
|
+
# examples/geohash/vendor/pr_geohash.rb). Only genuinely-missing files
|
|
1369
|
+
# warn, so a typo still surfaces early instead of fifty spinel
|
|
1370
|
+
# "uninitialized constant" lines later.
|
|
1371
|
+
if name == "require_relative"
|
|
1372
|
+
args = node.arguments&.arguments || []
|
|
1373
|
+
first = args.first
|
|
1374
|
+
if first.is_a?(Prism::StringNode)
|
|
1375
|
+
path = first.unescaped
|
|
1376
|
+
base = input_path ? File.dirname(input_path) : Dir.pwd
|
|
1377
|
+
cand = File.expand_path(path.end_with?(".rb") ? path : path + ".rb", base)
|
|
1378
|
+
# tep's own library is already inlined wholesale at the top
|
|
1379
|
+
# (inlined_tep_library), so requiring it here must be a no-op --
|
|
1380
|
+
# otherwise we inline the whole library a SECOND time. Beyond the
|
|
1381
|
+
# by-name forms ("tep" / "tep/foo"), an example loads it by PATH
|
|
1382
|
+
# (`require_relative "../lib/tep"`); since the app-local inliner
|
|
1383
|
+
# became recursive (#166) that path form would re-pull every
|
|
1384
|
+
# tep/*.rb, duplicating `module Pg` + its ffi_const into a C
|
|
1385
|
+
# `redefinition` error. Resolve and compare against LIB_DIR so all
|
|
1386
|
+
# spellings that land inside tep's own lib are skipped.
|
|
1387
|
+
is_tep_lib = path == "tep" || path.start_with?("tep/") ||
|
|
1388
|
+
cand == File.join(LIB_DIR, "tep.rb") ||
|
|
1389
|
+
cand.start_with?(LIB_DIR + File::SEPARATOR)
|
|
1390
|
+
if !is_tep_lib
|
|
1391
|
+
if File.file?(cand)
|
|
1392
|
+
passthrough << "# --- inlined require_relative #{path.inspect} (#{File.basename(cand)}) ---"
|
|
1393
|
+
passthrough << inline_require_relative_tree(cand, inlined_seen, warnings)
|
|
1394
|
+
else
|
|
1395
|
+
warnings << "external `require_relative #{path.inspect}` ignored " \
|
|
1396
|
+
"(no file at #{cand}). For a published gem, declare it in " \
|
|
1397
|
+
"a Gemfile and run `spinel-compat vendor` (bundler-spinel), " \
|
|
1398
|
+
"then require_relative \"vendor/spinel/deps\"."
|
|
1399
|
+
end
|
|
1400
|
+
end
|
|
1401
|
+
end
|
|
1402
|
+
end
|
|
1403
|
+
return
|
|
1404
|
+
when "enable", "disable" then return # ignore (sessions, etc.)
|
|
1405
|
+
when "configure" then return # we run as a single env
|
|
1406
|
+
when "use"
|
|
1407
|
+
warnings << "unsupported `use` (Rack middleware) -- ignored"
|
|
1408
|
+
return
|
|
1409
|
+
when "helpers"
|
|
1410
|
+
warnings << "unsupported `helpers do ... end` -- ignored"
|
|
1411
|
+
return
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
# Unknown top-level call -- skip with a warning.
|
|
1415
|
+
warnings << "unrecognised top-level call `#{name}` -- ignored"
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
def block_source(block_node)
|
|
1419
|
+
body = block_node.body
|
|
1420
|
+
return "" if body.nil?
|
|
1421
|
+
src = body.location.slice
|
|
1422
|
+
src
|
|
1423
|
+
end
|
|
1424
|
+
|
|
1425
|
+
# ---- Tep::Proxy block-DSL lowering (#88) ----
|
|
1426
|
+
|
|
1427
|
+
# Maps a proxy block-DSL method name to the Tep::Proxy imeth the
|
|
1428
|
+
# generated subclass overrides. `before`/`after` rename to
|
|
1429
|
+
# `before_forward`/`after_forward` (the runtime hook names -- bare
|
|
1430
|
+
# before/after collide with Filter/Security/Auth under spinel's
|
|
1431
|
+
# same-name dispatch); the rest map 1:1.
|
|
1432
|
+
PROXY_HOOK_IMETH = {
|
|
1433
|
+
"before" => "before_forward",
|
|
1434
|
+
"after" => "after_forward",
|
|
1435
|
+
"on_stream_chunk" => "on_stream_chunk",
|
|
1436
|
+
"on_stream_end" => "on_stream_end",
|
|
1437
|
+
"stream_request?" => "stream_request?",
|
|
1438
|
+
# 6.4: per-request upstream picker. `pick_upstream do |req| ... end`
|
|
1439
|
+
# lowers to a subclass override; default behavior (return @upstream)
|
|
1440
|
+
# is preserved when the block isn't supplied.
|
|
1441
|
+
"pick_upstream" => "pick_upstream",
|
|
1442
|
+
# 6.5: per-request retry policy. `retry_policy do |req| ... end`
|
|
1443
|
+
# lowers to a subclass override returning a Tep::Proxy::RetryPolicy.
|
|
1444
|
+
# Default (no override) returns a no-retry policy.
|
|
1445
|
+
"retry_policy" => "retry_policy",
|
|
1446
|
+
}.freeze
|
|
1447
|
+
|
|
1448
|
+
# Route verbs a proxy var can be mounted on (`Tep.<verb> "p", api`).
|
|
1449
|
+
PROXY_MOUNT_VERBS = %w[get post put patch delete head].freeze
|
|
1450
|
+
|
|
1451
|
+
# True for a `Tep::Proxy.new(...)` / `Proxy.new(...)` call node.
|
|
1452
|
+
def proxy_new_call?(node)
|
|
1453
|
+
return false unless node.is_a?(Prism::CallNode) && node.name == :new
|
|
1454
|
+
recv = node.receiver
|
|
1455
|
+
return false if recv.nil?
|
|
1456
|
+
slice = recv.location.slice
|
|
1457
|
+
slice == "Tep::Proxy" || slice == "Proxy"
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1460
|
+
# Extract the upstream argument source from a `Tep::Proxy.new(...)`
|
|
1461
|
+
# call, normalising the doc's keyword form (`upstream: "..."`) to the
|
|
1462
|
+
# positional arg the runtime initialize(upstream) takes. Returns the
|
|
1463
|
+
# raw source slice of the value expression.
|
|
1464
|
+
def proxy_upstream_src(node, warnings)
|
|
1465
|
+
args = node.arguments&.arguments || []
|
|
1466
|
+
if args.empty?
|
|
1467
|
+
warnings << "Tep::Proxy.new needs an upstream argument; emitting empty"
|
|
1468
|
+
return '""'
|
|
1469
|
+
end
|
|
1470
|
+
first = args.first
|
|
1471
|
+
if first.is_a?(Prism::KeywordHashNode)
|
|
1472
|
+
first.elements.each do |el|
|
|
1473
|
+
next unless el.is_a?(Prism::AssocNode)
|
|
1474
|
+
k = el.key
|
|
1475
|
+
kn = k.is_a?(Prism::SymbolNode) ? k.unescaped : nil
|
|
1476
|
+
return el.value.location.slice if kn == "upstream"
|
|
1477
|
+
end
|
|
1478
|
+
warnings << "Tep::Proxy.new: no `upstream:` key found; emitting empty"
|
|
1479
|
+
return '""'
|
|
1480
|
+
end
|
|
1481
|
+
first.location.slice
|
|
1482
|
+
end
|
|
1483
|
+
|
|
1484
|
+
# Comma-joined block parameter names ("req, res, ureq"). The generated
|
|
1485
|
+
# imeth uses the user's chosen names verbatim, so their block body
|
|
1486
|
+
# references resolve. Empty string for a paramless block.
|
|
1487
|
+
def block_param_list(block_node)
|
|
1488
|
+
bp = block_node.parameters
|
|
1489
|
+
return "" unless bp.is_a?(Prism::BlockParametersNode)
|
|
1490
|
+
pp = bp.parameters
|
|
1491
|
+
return "" unless pp.is_a?(Prism::ParametersNode)
|
|
1492
|
+
pp.requireds.map { |r| r.is_a?(Prism::RequiredParameterNode) ? r.name.to_s : nil }.compact.join(", ")
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1495
|
+
def literal_string(node)
|
|
1496
|
+
case node
|
|
1497
|
+
when Prism::StringNode then node.unescaped
|
|
1498
|
+
when Prism::SymbolNode then node.unescaped
|
|
1499
|
+
else nil
|
|
1500
|
+
end
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
def literal_int(node)
|
|
1504
|
+
return node.value if node.is_a?(Prism::IntegerNode)
|
|
1505
|
+
nil
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
# ---- block body rewrites ----
|
|
1509
|
+
#
|
|
1510
|
+
# We do simple, conservative textual substitutions. Anything more
|
|
1511
|
+
# elaborate (full AST rewriting) is out of scope -- the user can
|
|
1512
|
+
# fall back to writing explicit `Tep::Handler` subclasses.
|
|
1513
|
+
def rewrite_block(src, force_string: true)
|
|
1514
|
+
s = src.dup
|
|
1515
|
+
|
|
1516
|
+
# `@name = expr` (Sinatra-style instance variables) -> store on the
|
|
1517
|
+
# per-request ivars bag. The string-coerce wrap (`(...).to_s`) keeps
|
|
1518
|
+
# the str_hash uniform-typed even when handlers stash an int / bool
|
|
1519
|
+
# in `@count` / `@flag` for a template to render. Excluded: `==`,
|
|
1520
|
+
# `=>` (which can't appear at this position anyway), `=~`. The
|
|
1521
|
+
# write rewrite runs before the bare `@name` read rewrite so the
|
|
1522
|
+
# LHS `@x` doesn't get caught by the read pass.
|
|
1523
|
+
#
|
|
1524
|
+
# Also: spinel doesn't support multi-statement single-line ivar
|
|
1525
|
+
# assignments (`@a=1; @b=2`); the `(.+)$` capture is line-greedy.
|
|
1526
|
+
# Users who need that should split across lines, which is the more
|
|
1527
|
+
# idiomatic style anyway.
|
|
1528
|
+
s = s.gsub(/^(\s*)@(\w+)\s*=(?![=~])\s*(.+)$/) do
|
|
1529
|
+
%{#{$1}req.ivars["#{$2}"] = (#{$3}).to_s}
|
|
1530
|
+
end
|
|
1531
|
+
|
|
1532
|
+
# Bare `@name` read (anywhere) -> `req.ivars["name"]`. Negative
|
|
1533
|
+
# lookbehind avoids `@@x` (class var) and the suffix of identifier-
|
|
1534
|
+
# like preceding text.
|
|
1535
|
+
s = rewrite_ivar_reads(s, sink: "req.ivars")
|
|
1536
|
+
|
|
1537
|
+
# `redirect 'url', code?` (line)
|
|
1538
|
+
s = s.gsub(/^(\s*)redirect\s+(['"][^'"]*['"])(?:\s*,\s*(\d+))?/) do
|
|
1539
|
+
indent = $1
|
|
1540
|
+
url = $2
|
|
1541
|
+
code = $3 || "302"
|
|
1542
|
+
"#{indent}res.set_status(#{code})\n#{indent}res.headers[\"Location\"] = #{url}\n#{indent}return \"\""
|
|
1543
|
+
end
|
|
1544
|
+
|
|
1545
|
+
# `halt N, 'msg'` -> set status, return string
|
|
1546
|
+
s = s.gsub(/^(\s*)halt\s+(\d+)\s*,\s*(['"][^'"]*['"])/) do
|
|
1547
|
+
"#{$1}res.set_status(#{$2})\n#{$1}return #{$3}"
|
|
1548
|
+
end
|
|
1549
|
+
s = s.gsub(/^(\s*)halt\s+(\d+)$/) do
|
|
1550
|
+
"#{$1}res.set_status(#{$2})\n#{$1}return \"\""
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
# `content_type 'foo'`
|
|
1554
|
+
s = s.gsub(/^(\s*)content_type\s+(['"][^'"]*['"])/) do
|
|
1555
|
+
"#{$1}res.headers[\"Content-Type\"] = #{$2}"
|
|
1556
|
+
end
|
|
1557
|
+
|
|
1558
|
+
# `status N`
|
|
1559
|
+
s = s.gsub(/^(\s*)status\s+(\d+)/) do
|
|
1560
|
+
"#{$1}res.set_status(#{$2})"
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
# `headers["X"] = "y"` -> `res.headers[...]` when not already qualified
|
|
1564
|
+
s = s.gsub(/(?<![\w.])headers\[/, "res.headers[")
|
|
1565
|
+
|
|
1566
|
+
# `cookies["k"]` -> `req.cookies["k"]` when not already qualified
|
|
1567
|
+
s = s.gsub(/(?<![\w.])cookies\[/, "req.cookies[")
|
|
1568
|
+
|
|
1569
|
+
# `erb :name` -> `tep_view_name(Tep.str_hash, req.ivars)`
|
|
1570
|
+
# `erb :name, locals: {a: "b", c: "d"}` -> build a String=>String
|
|
1571
|
+
# hash from the literal-only kwarg form, then call. The second
|
|
1572
|
+
# arg threads the per-request ivars bag so templates can read
|
|
1573
|
+
# `<%= @name %>` style references the handler set with `@name = v`.
|
|
1574
|
+
s = s.gsub(/(?<![\w.])erb\s+:(\w+)\s*,\s*locals:\s*\{([^}]*)\}/) do
|
|
1575
|
+
view = $1
|
|
1576
|
+
# Value runs up to the next comma or closing brace; quoted strings
|
|
1577
|
+
# are handled separately so they can contain commas or spaces.
|
|
1578
|
+
pairs = $2.scan(/(\w+):\s*("[^"]*"|'[^']*'|[^,}]+?)\s*(?=,|\z)/)
|
|
1579
|
+
setters = pairs.map { |k, v| %{__l["#{k}"] = (#{v.strip}).to_s} }.join("; ")
|
|
1580
|
+
"(__l = Tep.str_hash; #{setters}; tep_view_#{view}(__l, req.ivars))"
|
|
1581
|
+
end
|
|
1582
|
+
s = s.gsub(/(?<![\w.])erb\s+:(\w+)/, 'tep_view_\1(Tep.str_hash, req.ivars)')
|
|
1583
|
+
|
|
1584
|
+
# Same shape for `mustache :name` and `mustache :name, locals: {...}`.
|
|
1585
|
+
s = s.gsub(/(?<![\w.])mustache\s+:(\w+)\s*,\s*locals:\s*\{([^}]*)\}/) do
|
|
1586
|
+
view = $1
|
|
1587
|
+
pairs = $2.scan(/(\w+):\s*("[^"]*"|'[^']*'|[^,}]+?)\s*(?=,|\z)/)
|
|
1588
|
+
setters = pairs.map { |k, v| %{__l["#{k}"] = (#{v.strip}).to_s} }.join("; ")
|
|
1589
|
+
"(__l = Tep.str_hash; #{setters}; tep_mustache_#{view}(__l, req.ivars))"
|
|
1590
|
+
end
|
|
1591
|
+
s = s.gsub(/(?<![\w.])mustache\s+:(\w+)/, 'tep_mustache_\1(Tep.str_hash, req.ivars)')
|
|
1592
|
+
|
|
1593
|
+
# `pass` -> request a skip to the next matching route. The
|
|
1594
|
+
# framework's dispatch loop watches `req.passed`. Both the bare
|
|
1595
|
+
# form and `pass if cond` (modifier-if) are translated.
|
|
1596
|
+
s = s.gsub(/^(\s*)pass\s+if\s+(.+)$/) do
|
|
1597
|
+
"#{$1}if (#{$2})\n#{$1} req.set_passed\n#{$1} return \"\"\n#{$1}end"
|
|
1598
|
+
end
|
|
1599
|
+
s = s.gsub(/^(\s*)pass\s*$/) do
|
|
1600
|
+
"#{$1}req.set_passed\n#{$1}return \"\""
|
|
1601
|
+
end
|
|
1602
|
+
|
|
1603
|
+
# `send_file 'path'` -> `res.send_file 'path'`; reuses the static-dir
|
|
1604
|
+
# write path. The framework streams the file's bytes after the
|
|
1605
|
+
# headers go out, so the handler doesn't need to read it itself.
|
|
1606
|
+
s = s.gsub(/^(\s*)send_file\s+(['"][^'"]*['"])/) do
|
|
1607
|
+
"#{$1}res.send_file(#{$2})\n#{$1}return \"\""
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1610
|
+
# `stream X.new` -> `res.start_stream(X.new); return ""` -- ships the
|
|
1611
|
+
# subclass-style streaming DSL. The Sinatra `stream do |out| ... end`
|
|
1612
|
+
# block form isn't supported yet (closures aren't first-class in
|
|
1613
|
+
# Spinel; you'd write a `class X < Tep::Streamer; def pump(out); ...`
|
|
1614
|
+
# instead and pass `X.new` to `stream`).
|
|
1615
|
+
s = s.gsub(/^(\s*)stream\s+(.+)$/) do
|
|
1616
|
+
indent = $1
|
|
1617
|
+
expr = $2
|
|
1618
|
+
"#{indent}res.start_stream(#{expr})\n#{indent}return \"\""
|
|
1619
|
+
end
|
|
1620
|
+
|
|
1621
|
+
# `session[k] = v` and `session[k]` -> .set / .get on req.session.
|
|
1622
|
+
# Spinel doesn't dispatch user-defined []= on user classes, and
|
|
1623
|
+
# without the .set() form the dirty flag never trips.
|
|
1624
|
+
s = s.gsub(/(?<![\w.])session\[:(\w+)\]\s*=\s*([^\n]+?)$/m,
|
|
1625
|
+
'req.session.set("\1", \2)')
|
|
1626
|
+
s = s.gsub(/(?<![\w.])session\[(['"])(\w+)\1\]\s*=\s*([^\n]+?)$/m,
|
|
1627
|
+
'req.session.set("\2", \3)')
|
|
1628
|
+
s = s.gsub(/(?<![\w.])session\[:(\w+)\]/, 'req.session.get("\1")')
|
|
1629
|
+
s = s.gsub(/(?<![\w.])session\[(['"])(\w+)\1\]/, 'req.session.get("\2")')
|
|
1630
|
+
|
|
1631
|
+
# `set_cookie "name", "value"` -> `res.set_cookie("name", "value", {})`
|
|
1632
|
+
# Two-arg form only; richer kwarg forms aren't translated.
|
|
1633
|
+
s = s.gsub(/^(\s*)set_cookie\s+(['"][^'"]*['"])\s*,\s*(['"][^'"]*['"])/) do
|
|
1634
|
+
"#{$1}res.set_cookie(#{$2}, #{$3}, Tep.str_hash)"
|
|
1635
|
+
end
|
|
1636
|
+
|
|
1637
|
+
# `params['x']` / `params["x"]` and bare `params`
|
|
1638
|
+
s = s.gsub(/(?<![\w.])params(?=[\[\.]|$|\s)/, "req.params")
|
|
1639
|
+
|
|
1640
|
+
# `request.body.read` -> `req.body` (a Sinatra app's request.body
|
|
1641
|
+
# is an IO and apps commonly call `.read` on it; tep's req.body
|
|
1642
|
+
# is already a String, so `.read` is a no-op). Run BEFORE the bare
|
|
1643
|
+
# `request -> req` rewrite so we can pin the `.body.read` suffix
|
|
1644
|
+
# exactly and not have to deal with the intermediate `req.body.read`
|
|
1645
|
+
# state. The bare `request.body` form (no `.read`) is handled by
|
|
1646
|
+
# the next rewrite, which leaves it as `req.body` -- already a
|
|
1647
|
+
# String via Tep::Request#body's alias to @raw_body.
|
|
1648
|
+
s = s.gsub(/(?<![\w.])request\.body\.read\b/, "req.body")
|
|
1649
|
+
|
|
1650
|
+
# `request` -> req, `response` -> res, when used as bare identifiers
|
|
1651
|
+
s = s.gsub(/(?<![\w.])request(?=[\.\[\s,)]|$)/, "req")
|
|
1652
|
+
s = s.gsub(/(?<![\w.])response(?=[\.\[\s,)]|$)/, "res")
|
|
1653
|
+
|
|
1654
|
+
# Symbol keys -- Sinatra accepts both `params[:name]` and `params["name"]`.
|
|
1655
|
+
# Spinel doesn't poly-convert symbols to strings inside Hash[]; rewrite
|
|
1656
|
+
# to string keys.
|
|
1657
|
+
s = s.gsub(/(req\.params)\[:(\w+)\]/, '\1["\2"]')
|
|
1658
|
+
|
|
1659
|
+
# `"#{params['x']}"` interpolation works fine after the params rewrite.
|
|
1660
|
+
|
|
1661
|
+
# String-coerce the last expression of the block body. Spinel's
|
|
1662
|
+
# whole-program type inference unifies handler return types across
|
|
1663
|
+
# all subclasses; if any one subclass returns `Hash#[]` directly
|
|
1664
|
+
# (poly value), the union widens and the eventual `iv_body` read
|
|
1665
|
+
# in the worker miscompiles. Wrapping the last expression in
|
|
1666
|
+
# `"" + (...)` forces it to a String at the source level.
|
|
1667
|
+
if force_string
|
|
1668
|
+
lines = s.lines
|
|
1669
|
+
last_idx = lines.rindex { |l| !l.strip.empty? }
|
|
1670
|
+
if last_idx
|
|
1671
|
+
last = lines[last_idx]
|
|
1672
|
+
stripped = last.lstrip
|
|
1673
|
+
indent = last[0, last.length - stripped.length]
|
|
1674
|
+
# Skip if the last line is an explicit non-string control flow:
|
|
1675
|
+
# bare `end`/`else`/`when` (handled by the surrounding block),
|
|
1676
|
+
# or an explicit `return`/`raise`/`break`/`next`. Setter calls
|
|
1677
|
+
# like `res.headers[...]=...` are also skipped because the
|
|
1678
|
+
# assignment's value (the RHS) is captured but `"" + setter` is
|
|
1679
|
+
# ill-typed.
|
|
1680
|
+
unless stripped =~ /\A(end|else|elsif|when|return|raise|break|next)\b/ ||
|
|
1681
|
+
stripped =~ /\A(res|response)\.[a-zA-Z_]+\s*=|\A(res|response)\.\w+\s*\(/
|
|
1682
|
+
body_expr = stripped.sub(/\s*(#.*)?\Z/, "")
|
|
1683
|
+
comment = stripped[body_expr.length..-1] || ""
|
|
1684
|
+
lines[last_idx] = "#{indent}\"\" + (#{body_expr.rstrip})#{comment.rstrip.empty? ? "\n" : " #{comment}"}"
|
|
1685
|
+
end
|
|
1686
|
+
end
|
|
1687
|
+
s = lines.join
|
|
1688
|
+
end
|
|
1689
|
+
|
|
1690
|
+
s
|
|
1691
|
+
end
|
|
1692
|
+
|
|
1693
|
+
def indent(text, n)
|
|
1694
|
+
pad = " " * n
|
|
1695
|
+
text.lines.map { |l| l.strip.empty? ? l.rstrip : pad + l.rstrip }.join("\n")
|
|
1696
|
+
end
|
|
1697
|
+
|
|
1698
|
+
def relative_path(from_dir, target)
|
|
1699
|
+
Pathname.new(File.expand_path(target))
|
|
1700
|
+
.relative_path_from(Pathname.new(File.expand_path(from_dir))).to_s
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1703
|
+
# Compile an ERB template to a Ruby method body. The method takes
|
|
1704
|
+
# two String=>String Hash arguments: `locals` (passed by the
|
|
1705
|
+
# handler's explicit `erb :v, locals: {...}` form) and `ivars`
|
|
1706
|
+
# (the per-request bag carrying `@name = ...` Sinatra-style ivars
|
|
1707
|
+
# from handler / before-filter scope). Inside the template:
|
|
1708
|
+
#
|
|
1709
|
+
# <%= locals["k"] %> # explicit locals
|
|
1710
|
+
# <%= @name %> # rewrites to `ivars["name"]`
|
|
1711
|
+
# <% if/else/end %> # control flow, also gets ivar rewrite
|
|
1712
|
+
# <%# ... %> # comment, dropped
|
|
1713
|
+
#
|
|
1714
|
+
# Spinel can't `eval`, so all ERB rendering is build-time AOT --
|
|
1715
|
+
# this is what makes `erb :name` work on a spinel-compiled binary.
|
|
1716
|
+
def erb_to_ruby_body(template)
|
|
1717
|
+
parts = []
|
|
1718
|
+
parts << 'out = ""'
|
|
1719
|
+
i = 0
|
|
1720
|
+
literal = String.new
|
|
1721
|
+
flush = -> {
|
|
1722
|
+
if !literal.empty?
|
|
1723
|
+
parts << "out += #{literal.dump}"
|
|
1724
|
+
literal = String.new
|
|
1725
|
+
end
|
|
1726
|
+
}
|
|
1727
|
+
while i < template.length
|
|
1728
|
+
open_at = template.index("<%", i)
|
|
1729
|
+
if open_at.nil?
|
|
1730
|
+
literal << template[i..-1]
|
|
1731
|
+
break
|
|
1732
|
+
end
|
|
1733
|
+
literal << template[i...open_at]
|
|
1734
|
+
close_at = template.index("%>", open_at + 2)
|
|
1735
|
+
raise "unterminated <% in template" if close_at.nil?
|
|
1736
|
+
flush.call
|
|
1737
|
+
code = template[(open_at + 2)...close_at]
|
|
1738
|
+
if code.start_with?("=")
|
|
1739
|
+
expr = rewrite_ivar_reads(code[1..-1].strip)
|
|
1740
|
+
parts << "out += (#{expr}).to_s"
|
|
1741
|
+
elsif code.start_with?("#")
|
|
1742
|
+
# comment; ignore
|
|
1743
|
+
else
|
|
1744
|
+
parts << rewrite_ivar_reads(code.strip)
|
|
1745
|
+
end
|
|
1746
|
+
i = close_at + 2
|
|
1747
|
+
end
|
|
1748
|
+
flush.call
|
|
1749
|
+
parts << "out"
|
|
1750
|
+
parts.join("\n ")
|
|
1751
|
+
end
|
|
1752
|
+
|
|
1753
|
+
# Compile a Mustache template (documented subset) to a Ruby method
|
|
1754
|
+
# body. Same `(locals, ivars)` signature as ERB views; the call site
|
|
1755
|
+
# rewrites are parallel.
|
|
1756
|
+
#
|
|
1757
|
+
# Supported tags
|
|
1758
|
+
# --------------
|
|
1759
|
+
#
|
|
1760
|
+
# {{name}} escaped string interpolation
|
|
1761
|
+
# -> out += Tep.h(locals["name"])
|
|
1762
|
+
# {{{name}}} raw / unescaped interpolation
|
|
1763
|
+
# -> out += locals["name"]
|
|
1764
|
+
# {{& name}} raw alias (Mustache spec)
|
|
1765
|
+
# -> same as {{{name}}}
|
|
1766
|
+
# {{@name}} ivar form, escaped
|
|
1767
|
+
# -> out += Tep.h(ivars["name"])
|
|
1768
|
+
# {{{@name}}} ivar form, raw
|
|
1769
|
+
# -> out += ivars["name"]
|
|
1770
|
+
# {{! comment}} dropped at compile time
|
|
1771
|
+
#
|
|
1772
|
+
# Out of scope (deliberately)
|
|
1773
|
+
# ---------------------------
|
|
1774
|
+
#
|
|
1775
|
+
# {{#section}}{{/section}} sections (need iterable locals;
|
|
1776
|
+
# tep's view args are String=>String)
|
|
1777
|
+
# {{^section}}{{/section}} inverted sections (same reason)
|
|
1778
|
+
# {{>partial}} partials -- could be added later as
|
|
1779
|
+
# sugar for tep_mustache_<partial>(...)
|
|
1780
|
+
# {{=<% %>=}} delimiter swaps -- niche
|
|
1781
|
+
# Lambdas require Proc / closures
|
|
1782
|
+
#
|
|
1783
|
+
# When user code reaches for an unsupported tag the compiler raises
|
|
1784
|
+
# at build time so the build fails fast with a clear message,
|
|
1785
|
+
# instead of silently rendering literal `{{...}}` to the client.
|
|
1786
|
+
def mustache_to_ruby_body(template)
|
|
1787
|
+
parts = []
|
|
1788
|
+
parts << 'out = ""'
|
|
1789
|
+
i = 0
|
|
1790
|
+
literal = String.new
|
|
1791
|
+
flush = -> {
|
|
1792
|
+
if !literal.empty?
|
|
1793
|
+
parts << "out += #{literal.dump}"
|
|
1794
|
+
literal = String.new
|
|
1795
|
+
end
|
|
1796
|
+
}
|
|
1797
|
+
while i < template.length
|
|
1798
|
+
open_at = template.index("{{", i)
|
|
1799
|
+
if open_at.nil?
|
|
1800
|
+
literal << template[i..-1]
|
|
1801
|
+
break
|
|
1802
|
+
end
|
|
1803
|
+
literal << template[i...open_at]
|
|
1804
|
+
# Triple-stache `{{{name}}}` -- raw interpolation. Consume {{{ ... }}}.
|
|
1805
|
+
if template[open_at + 2] == "{"
|
|
1806
|
+
close_at = template.index("}}}", open_at + 3)
|
|
1807
|
+
raise "unterminated {{{ in mustache template" if close_at.nil?
|
|
1808
|
+
flush.call
|
|
1809
|
+
key = template[(open_at + 3)...close_at].strip
|
|
1810
|
+
parts << mustache_emit_value(key, escaped: false)
|
|
1811
|
+
i = close_at + 3
|
|
1812
|
+
next
|
|
1813
|
+
end
|
|
1814
|
+
close_at = template.index("}}", open_at + 2)
|
|
1815
|
+
raise "unterminated {{ in mustache template" if close_at.nil?
|
|
1816
|
+
flush.call
|
|
1817
|
+
inner = template[(open_at + 2)...close_at].strip
|
|
1818
|
+
if inner.start_with?("!")
|
|
1819
|
+
# comment, drop
|
|
1820
|
+
elsif inner.start_with?("#")
|
|
1821
|
+
raise "mustache section `{{##{inner[1..-1].strip}}}` unsupported -- tep's view args are String=>String, sections need iterable locals. See SINATRA_COMPAT.md > Mustache subset."
|
|
1822
|
+
elsif inner.start_with?("^")
|
|
1823
|
+
raise "mustache inverted section `{{^#{inner[1..-1].strip}}}` unsupported. See SINATRA_COMPAT.md > Mustache subset."
|
|
1824
|
+
elsif inner.start_with?("/")
|
|
1825
|
+
raise "mustache closing `{{/#{inner[1..-1].strip}}}` without a section opener (sections aren't supported)."
|
|
1826
|
+
elsif inner.start_with?(">")
|
|
1827
|
+
raise "mustache partial `{{>#{inner[1..-1].strip}}}` unsupported -- call `mustache :#{inner[1..-1].strip}` from the handler instead."
|
|
1828
|
+
elsif inner.start_with?("&")
|
|
1829
|
+
key = inner[1..-1].strip
|
|
1830
|
+
parts << mustache_emit_value(key, escaped: false)
|
|
1831
|
+
elsif inner.start_with?("=")
|
|
1832
|
+
raise "mustache delimiter swap `{{=...=}}` unsupported."
|
|
1833
|
+
else
|
|
1834
|
+
parts << mustache_emit_value(inner, escaped: true)
|
|
1835
|
+
end
|
|
1836
|
+
i = close_at + 2
|
|
1837
|
+
end
|
|
1838
|
+
flush.call
|
|
1839
|
+
parts << "out"
|
|
1840
|
+
parts.join("\n ")
|
|
1841
|
+
end
|
|
1842
|
+
|
|
1843
|
+
def mustache_emit_value(key, escaped:)
|
|
1844
|
+
# An `@name` key (with or without leading whitespace) reads from
|
|
1845
|
+
# `ivars`; bare names read from `locals`. Mirrors the ERB ivar
|
|
1846
|
+
# convention so a project can mix engines and the per-request
|
|
1847
|
+
# `req.ivars` bag flows uniformly.
|
|
1848
|
+
if key.start_with?("@")
|
|
1849
|
+
bag = "ivars"
|
|
1850
|
+
key = key[1..-1].strip
|
|
1851
|
+
else
|
|
1852
|
+
bag = "locals"
|
|
1853
|
+
end
|
|
1854
|
+
# `.to_s` to coerce the StrStrHash lookup back to String. Spinel
|
|
1855
|
+
# widens the hash value type when the param-inference pass (#542
|
|
1856
|
+
# cascade) decides the value side is poly; `.to_s` is a no-op for
|
|
1857
|
+
# an actual String and gives "" for nil.
|
|
1858
|
+
expr = %{#{bag}["#{key}"].to_s}
|
|
1859
|
+
escaped ? "out += Tep.h(#{expr})" : "out += #{expr}"
|
|
1860
|
+
end
|
|
1861
|
+
|
|
1862
|
+
# Replace `@name` references with `ivars["name"]`. Used inside ERB
|
|
1863
|
+
# template chunks (which receive `ivars` as a parameter) and as a
|
|
1864
|
+
# read-side helper for handler-body rewriting (where `@name` ->
|
|
1865
|
+
# `req.ivars["name"]`, see `rewrite_block`).
|
|
1866
|
+
#
|
|
1867
|
+
# The negative lookbehind `(?<![\w.@])` skips matches preceded by an
|
|
1868
|
+
# identifier character, a dot, or another `@` -- so `@@class_var`
|
|
1869
|
+
# isn't accidentally split into `@` + `@class_var` and a stray `@`
|
|
1870
|
+
# inside a string literal at the start of a line still gets caught
|
|
1871
|
+
# (callers mostly invoke this on small Ruby fragments where literal
|
|
1872
|
+
# strings containing `@x` are uncommon).
|
|
1873
|
+
def rewrite_ivar_reads(src, sink: 'ivars')
|
|
1874
|
+
src.gsub(/(?<![\w.@])@(\w+)/) { %{#{sink}["#{$1}"]} }
|
|
1875
|
+
end
|
|
1876
|
+
|
|
1877
|
+
# Read tep.rb plus everything it require_relatives, in order, with
|
|
1878
|
+
# the require lines stripped. Used to inline the framework into a
|
|
1879
|
+
# build-temp file so spinel doesn't have to resolve any requires.
|
|
1880
|
+
#
|
|
1881
|
+
# Also substitutes `@TEP_SPHTTP_O@` / `@TEP_SQLITE_O@` / `@TEP_PG_O@`
|
|
1882
|
+
# with the absolute paths to the built .o files; `@TEP_PG_CFLAGS@`
|
|
1883
|
+
# carries the libpq cflags+libs (pkg-config / pg_config output).
|
|
1884
|
+
# Override via TEP_SPHTTP_O / TEP_SQLITE_O / TEP_PG_O / TEP_PG_CFLAGS
|
|
1885
|
+
# env vars; defaults live under `<LIB_DIR>/tep/`.
|
|
1886
|
+
# Crypto symbols (sp_crypto_*) live in spinel's libspinel_rt.a since
|
|
1887
|
+
# matz/spinel#514, so the spinel driver auto-links them and no
|
|
1888
|
+
# separate placeholder is needed.
|
|
1889
|
+
def inlined_tep_library
|
|
1890
|
+
loaded = []
|
|
1891
|
+
visit = lambda do |abs_path|
|
|
1892
|
+
return if loaded.any? { |l| l[:path] == abs_path }
|
|
1893
|
+
text = File.read(abs_path)
|
|
1894
|
+
deps = []
|
|
1895
|
+
body = text.lines.reject do |line|
|
|
1896
|
+
m = line.match(/^\s*require_relative\s+"([^"]+)"/)
|
|
1897
|
+
next false unless m
|
|
1898
|
+
deps << File.expand_path(m[1] + ".rb", File.dirname(abs_path))
|
|
1899
|
+
true
|
|
1900
|
+
end.join
|
|
1901
|
+
deps.each { |d| visit.call(d) }
|
|
1902
|
+
loaded << { path: abs_path, body: body }
|
|
1903
|
+
end
|
|
1904
|
+
visit.call(File.join(LIB_DIR, "tep.rb"))
|
|
1905
|
+
combined = loaded.map { |l| "# --- inlined: " + relative_to_lib(l[:path]) + " ---\n" + l[:body] }.join("\n")
|
|
1906
|
+
# Resolve the @TEP_*@ placeholders from spinel-ext.json (the single
|
|
1907
|
+
# source of truth) rather than a hardcoded list. Same env-first
|
|
1908
|
+
# behavior as before -- the Makefile exports TEP_SPHTTP_O /
|
|
1909
|
+
# TEP_SQLITE_O / TEP_PG_O / TEP_PG_CFLAGS / TEP_PG_LIBS, which this
|
|
1910
|
+
# reads, defaulting `.o` paths to the entry's source-derived path.
|
|
1911
|
+
tep_ext_subs.each do |placeholder, value|
|
|
1912
|
+
combined = combined.gsub(placeholder, value)
|
|
1913
|
+
end
|
|
1914
|
+
combined
|
|
1915
|
+
end
|
|
1916
|
+
|
|
1917
|
+
# Build the {@TEP_*@ => value} substitution dict from spinel-ext.json
|
|
1918
|
+
# for tep's OWN builds. Faithful to the historical env-first behavior:
|
|
1919
|
+
# * a `.o` placeholder (entry has "source") resolves to its env
|
|
1920
|
+
# override (TEP_<NAME>, the placeholder sans @) else the prebuilt
|
|
1921
|
+
# .o derived from the entry's source (sphttp.c -> sphttp.o);
|
|
1922
|
+
# * a cflags-only placeholder (no "source", e.g. @TEP_PG_CFLAGS@)
|
|
1923
|
+
# resolves to TEP_PG_CFLAGS + TEP_PG_LIBS, the libs defaulting from
|
|
1924
|
+
# the entry's pkg_config (libpq -> -lpq).
|
|
1925
|
+
# `spinel-compat vendor` reads the same file but resolves differently
|
|
1926
|
+
# (compiles the .c, runs pkg-config at the consumer) -- that's the
|
|
1927
|
+
# point: one declaration, two build paths. See spinelgems/docs/c-ext.md.
|
|
1928
|
+
def tep_ext_subs
|
|
1929
|
+
ext_path = File.join(REPO_ROOT, "spinel-ext.json")
|
|
1930
|
+
return {} unless File.exist?(ext_path)
|
|
1931
|
+
subs = {}
|
|
1932
|
+
JSON.parse(File.read(ext_path)).each do |entry|
|
|
1933
|
+
placeholder = entry["placeholder"]
|
|
1934
|
+
env_var = placeholder.delete("@") # @TEP_SPHTTP_O@ -> TEP_SPHTTP_O
|
|
1935
|
+
if entry["source"]
|
|
1936
|
+
default_o = File.expand_path(entry["source"].sub(/\.c\z/, ".o"), REPO_ROOT)
|
|
1937
|
+
subs[placeholder] = ENV.fetch(env_var, default_o)
|
|
1938
|
+
else
|
|
1939
|
+
cflags = ENV.fetch(env_var, "")
|
|
1940
|
+
libs_var = env_var.sub(/_CFLAGS\z/, "_LIBS")
|
|
1941
|
+
default_libs = entry["pkg_config"] ? "-l" + entry["pkg_config"].sub(/\Alib/, "") : ""
|
|
1942
|
+
libs = ENV.fetch(libs_var, default_libs)
|
|
1943
|
+
subs[placeholder] = "#{cflags} #{libs}".strip
|
|
1944
|
+
end
|
|
1945
|
+
end
|
|
1946
|
+
subs
|
|
1947
|
+
end
|
|
1948
|
+
|
|
1949
|
+
# Split a Sinatra-style source on `__END__` and parse the trailing
|
|
1950
|
+
# `@@ name` blocks into a {name => template_body} hash.
|
|
1951
|
+
def split_inline_views(raw)
|
|
1952
|
+
if raw =~ /^__END__\s*\n/m
|
|
1953
|
+
head = $`
|
|
1954
|
+
tail = $'
|
|
1955
|
+
else
|
|
1956
|
+
return [raw, {}]
|
|
1957
|
+
end
|
|
1958
|
+
views = {}
|
|
1959
|
+
current = nil
|
|
1960
|
+
buf = String.new
|
|
1961
|
+
tail.each_line do |line|
|
|
1962
|
+
m = line.match(/^@@\s+(\S+)\s*$/)
|
|
1963
|
+
if m
|
|
1964
|
+
views[current] = buf if current
|
|
1965
|
+
current = m[1]
|
|
1966
|
+
buf = String.new
|
|
1967
|
+
elsif current
|
|
1968
|
+
buf << line
|
|
1969
|
+
end
|
|
1970
|
+
end
|
|
1971
|
+
views[current] = buf if current
|
|
1972
|
+
[head, views]
|
|
1973
|
+
end
|
|
1974
|
+
|
|
1975
|
+
# Expand a Sinatra-style path with optional `(...)` segments into the
|
|
1976
|
+
# Cartesian product of include/skip choices. `/say(/:greeting)` ->
|
|
1977
|
+
# ["/say", "/say/:greeting"]. Two optionals -> four paths, etc.
|
|
1978
|
+
def expand_optional_segments(path)
|
|
1979
|
+
return [path] unless path.include?("(")
|
|
1980
|
+
results = [""]
|
|
1981
|
+
i = 0
|
|
1982
|
+
while i < path.length
|
|
1983
|
+
c = path[i]
|
|
1984
|
+
if c == "("
|
|
1985
|
+
close = path.index(")", i + 1)
|
|
1986
|
+
raise "unmatched '(' in path: #{path}" unless close
|
|
1987
|
+
inside = path[(i + 1)...close]
|
|
1988
|
+
# Include first, skip second: when a request matches multiple
|
|
1989
|
+
# optional-arrangements (e.g. `/items/42` matches both
|
|
1990
|
+
# `/items/:id` and `/items/:section`), the earlier-named
|
|
1991
|
+
# capture fills first -- matches Sinatra's intuition.
|
|
1992
|
+
results = results.flat_map { |r| [r + inside, r] }
|
|
1993
|
+
i = close + 1
|
|
1994
|
+
else
|
|
1995
|
+
results = results.map { |r| r + c }
|
|
1996
|
+
i += 1
|
|
1997
|
+
end
|
|
1998
|
+
end
|
|
1999
|
+
results.uniq
|
|
2000
|
+
end
|
|
2001
|
+
|
|
2002
|
+
def sinatra_base_class?(class_node)
|
|
2003
|
+
sup = class_node.superclass
|
|
2004
|
+
return false if sup.nil?
|
|
2005
|
+
src = sup.location.slice
|
|
2006
|
+
src == "Sinatra::Base" || src == "Sinatra::Application" || src == "::Sinatra::Base"
|
|
2007
|
+
end
|
|
2008
|
+
|
|
2009
|
+
# Walk `<input_dir>/assets/` recursively and return a list of
|
|
2010
|
+
# `Tep::Assets._add` lines, one per file. Inferred mime per
|
|
2011
|
+
# extension. Files containing NUL bytes get warned + skipped --
|
|
2012
|
+
# spinel's :str type doesn't track length alongside the pointer,
|
|
2013
|
+
# so a NUL truncates the served body. For binary assets that need
|
|
2014
|
+
# to round-trip exactly (PNGs with embedded NULs, fonts) the
|
|
2015
|
+
# right path is `Tep.public_dir` at runtime, not embed.
|
|
2016
|
+
ASSET_MIME_BY_EXT = {
|
|
2017
|
+
".css" => "text/css; charset=utf-8",
|
|
2018
|
+
".js" => "application/javascript; charset=utf-8",
|
|
2019
|
+
".json" => "application/json",
|
|
2020
|
+
".html" => "text/html; charset=utf-8",
|
|
2021
|
+
".htm" => "text/html; charset=utf-8",
|
|
2022
|
+
".txt" => "text/plain; charset=utf-8",
|
|
2023
|
+
".md" => "text/markdown; charset=utf-8",
|
|
2024
|
+
".svg" => "image/svg+xml",
|
|
2025
|
+
".xml" => "application/xml",
|
|
2026
|
+
".ico" => "image/x-icon",
|
|
2027
|
+
".png" => "image/png",
|
|
2028
|
+
".jpg" => "image/jpeg",
|
|
2029
|
+
".jpeg" => "image/jpeg",
|
|
2030
|
+
".gif" => "image/gif",
|
|
2031
|
+
".webp" => "image/webp",
|
|
2032
|
+
".woff" => "font/woff",
|
|
2033
|
+
".woff2" => "font/woff2",
|
|
2034
|
+
".ttf" => "font/ttf",
|
|
2035
|
+
".otf" => "font/otf",
|
|
2036
|
+
".pdf" => "application/pdf",
|
|
2037
|
+
}.freeze
|
|
2038
|
+
|
|
2039
|
+
def embed_assets(input_path, warnings)
|
|
2040
|
+
assets_dir = File.join(File.dirname(input_path), "assets")
|
|
2041
|
+
return [] unless File.directory?(assets_dir)
|
|
2042
|
+
lines = []
|
|
2043
|
+
count = 0
|
|
2044
|
+
bytes = 0
|
|
2045
|
+
Dir.glob(File.join(assets_dir, "**", "*")).sort.each do |abs|
|
|
2046
|
+
next unless File.file?(abs)
|
|
2047
|
+
rel = abs.sub(assets_dir, "") # leading "/"
|
|
2048
|
+
body = File.binread(abs)
|
|
2049
|
+
if body.include?("\x00")
|
|
2050
|
+
warnings << "asset #{rel}: contains NUL byte; skipped (use Tep.public_dir for binary assets)"
|
|
2051
|
+
next
|
|
2052
|
+
end
|
|
2053
|
+
ext = File.extname(abs).downcase
|
|
2054
|
+
mime = ASSET_MIME_BY_EXT[ext] || "application/octet-stream"
|
|
2055
|
+
# Ruby's `String#dump` produces a literal that re-parses to the
|
|
2056
|
+
# exact same bytes (\xHH for non-printable, \uXXXX where
|
|
2057
|
+
# applicable). Spinel reads it as a const char *.
|
|
2058
|
+
lines << "Tep::Assets._add(#{rel.inspect}, #{body.dump}, #{mime.inspect})"
|
|
2059
|
+
count += 1
|
|
2060
|
+
bytes += body.bytesize
|
|
2061
|
+
end
|
|
2062
|
+
if count > 0
|
|
2063
|
+
lines.unshift("# bundled #{count} asset(s), #{bytes} bytes total")
|
|
2064
|
+
end
|
|
2065
|
+
lines
|
|
2066
|
+
end
|
|
2067
|
+
|
|
2068
|
+
def relative_to_lib(abs_path)
|
|
2069
|
+
Pathname.new(abs_path).relative_path_from(Pathname.new(LIB_DIR)).to_s rescue abs_path
|
|
2070
|
+
end
|
|
2071
|
+
|
|
2072
|
+
# ---- driver ----
|
|
2073
|
+
|
|
2074
|
+
def cmd_build(args)
|
|
2075
|
+
input = nil
|
|
2076
|
+
out_path = nil
|
|
2077
|
+
c_only = false
|
|
2078
|
+
i = 0
|
|
2079
|
+
while i < args.length
|
|
2080
|
+
a = args[i]
|
|
2081
|
+
case a
|
|
2082
|
+
when "-o" then out_path = args[i + 1]; i += 2
|
|
2083
|
+
when "-c" then c_only = true; i += 1
|
|
2084
|
+
when /^-/ then fatal "unknown option: #{a}"
|
|
2085
|
+
else
|
|
2086
|
+
input ||= a
|
|
2087
|
+
i += 1
|
|
2088
|
+
end
|
|
2089
|
+
end
|
|
2090
|
+
fatal "usage: tep build app.rb [-o out] [-c]" unless input
|
|
2091
|
+
|
|
2092
|
+
base = File.basename(input, ".rb")
|
|
2093
|
+
out_path ||= File.join(File.dirname(input), base)
|
|
2094
|
+
translated = translate(input)
|
|
2095
|
+
|
|
2096
|
+
# Spinel's require_relative resolves against the source file's
|
|
2097
|
+
# directory and chokes on absolute paths. Drop the generated file
|
|
2098
|
+
# next to the user's source so the require_relative emitted by the
|
|
2099
|
+
# translator (a path computed relative to the input dir) works.
|
|
2100
|
+
rb_path = File.join(File.dirname(input), ".#{base}.tep.rb")
|
|
2101
|
+
File.write(rb_path, translated)
|
|
2102
|
+
|
|
2103
|
+
spinel_args = [SPINEL, rb_path, "-o", out_path]
|
|
2104
|
+
spinel_args << "-c" if c_only
|
|
2105
|
+
warn "tep: building -> #{out_path}" unless ENV["TEP_QUIET"]
|
|
2106
|
+
unless system(*spinel_args)
|
|
2107
|
+
fatal "spinel failed; translated source kept at #{rb_path}"
|
|
2108
|
+
end
|
|
2109
|
+
File.unlink(rb_path) unless ENV["TEP_KEEP_TMP"]
|
|
2110
|
+
warn "tep: built #{out_path}"
|
|
2111
|
+
end
|
|
2112
|
+
|
|
2113
|
+
def cmd_run(args)
|
|
2114
|
+
build_args = []
|
|
2115
|
+
run_args = []
|
|
2116
|
+
i = 0
|
|
2117
|
+
while i < args.length
|
|
2118
|
+
a = args[i]
|
|
2119
|
+
case a
|
|
2120
|
+
when "-p", "-w" then run_args << a << args[i + 1]; i += 2
|
|
2121
|
+
else
|
|
2122
|
+
build_args << a; i += 1
|
|
2123
|
+
end
|
|
2124
|
+
end
|
|
2125
|
+
fatal "usage: tep run app.rb [-p PORT] [-w WORKERS]" if build_args.empty?
|
|
2126
|
+
out = File.join(Dir.pwd, "tep-run-bin")
|
|
2127
|
+
cmd_build(build_args + ["-o", out])
|
|
2128
|
+
exec(out, *run_args)
|
|
2129
|
+
end
|
|
2130
|
+
|
|
2131
|
+
def cmd_translate(args)
|
|
2132
|
+
input = args.first or fatal "usage: tep translate app.rb"
|
|
2133
|
+
puts translate(input)
|
|
2134
|
+
end
|
|
2135
|
+
|
|
2136
|
+
case ARGV.shift
|
|
2137
|
+
when "build" then cmd_build(ARGV)
|
|
2138
|
+
when "run" then cmd_run(ARGV)
|
|
2139
|
+
when "translate" then cmd_translate(ARGV)
|
|
2140
|
+
when nil, "-h", "--help"
|
|
2141
|
+
puts <<~USAGE
|
|
2142
|
+
tep -- Sinatra-flavoured framework that compiles to a native binary.
|
|
2143
|
+
|
|
2144
|
+
Usage:
|
|
2145
|
+
tep build app.rb [-o out] [-c] translate + spinel-compile
|
|
2146
|
+
tep run app.rb [-p PORT] [-w N] build then exec
|
|
2147
|
+
tep translate app.rb print the translated Ruby
|
|
2148
|
+
|
|
2149
|
+
Source compatibility: top-level `get '/path' do ... end`,
|
|
2150
|
+
`post`, `put`, `patch`, `delete`, `before`, `after`, `not_found`,
|
|
2151
|
+
plus `set :public_dir, ...`. Inside blocks: params, request,
|
|
2152
|
+
response, redirect, halt, content_type all rewrite cleanly.
|
|
2153
|
+
USAGE
|
|
2154
|
+
else
|
|
2155
|
+
fatal "unknown command. Try `tep --help`."
|
|
2156
|
+
end
|