homura-runtime 0.1.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.
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parser/current'
4
+ require 'parser/source/tree_rewriter'
5
+
6
+ module CloudflareWorkers
7
+ module AutoAwait
8
+ class Analyzer
9
+ def initialize(registry, debug: false)
10
+ @registry = registry
11
+ @debug = debug
12
+ @await_nodes = []
13
+ @env = {}
14
+ @method_returns = {}
15
+ end
16
+
17
+ def process(source, filename = '(auto-await)')
18
+ buffer = Parser::Source::Buffer.new(filename)
19
+ buffer.source = source
20
+ parser = Parser::CurrentRuby.new
21
+ ast = parser.parse(buffer)
22
+ @await_nodes = []
23
+ @env = {}
24
+ process_node(ast)
25
+ [buffer, @await_nodes]
26
+ end
27
+
28
+ private
29
+
30
+ def process_node(node)
31
+ return unless node.is_a?(Parser::AST::Node)
32
+
33
+ if node.type == :def
34
+ process_def(node)
35
+ return
36
+ end
37
+ if node.type == :block
38
+ process_block(node)
39
+ return
40
+ end
41
+
42
+ # Bottom-up traversal: process children first so helper-factory
43
+ # sends (e.g. bare +db+) populate @env before their parent
44
+ # send (e.g. +db.execute(...)+) is checked by should_await?.
45
+ node.children.each { |child| process_node(child) if child.is_a?(Parser::AST::Node) }
46
+ case node.type
47
+ when :lvasgn
48
+ process_lvasgn(node)
49
+ when :ivasgn
50
+ process_ivasgn(node)
51
+ when :send
52
+ process_send(node)
53
+ end
54
+ end
55
+
56
+ def process_lvasgn(node)
57
+ name, value = *node
58
+ cls = infer_class(value)
59
+ if cls
60
+ @env[name] = cls
61
+ else
62
+ @env.delete(name)
63
+ end
64
+ end
65
+
66
+ def process_ivasgn(node)
67
+ _name, value = *node
68
+ cls = infer_class(value)
69
+ # instance variable tracking is best-effort; skip storing for now
70
+ end
71
+
72
+ def process_def(node)
73
+ method_name = node.children[0]
74
+ saved_env = @env
75
+ @env = @registry.helper_factories.dup
76
+
77
+ node.children[1..-1].each { |child| process_node(child) if child.is_a?(Parser::AST::Node) }
78
+
79
+ body = node.children[2]
80
+ return_cls = infer_class(body)
81
+ @method_returns[method_name] = return_cls if return_cls
82
+
83
+ @env = saved_env
84
+ end
85
+
86
+ def process_block(node)
87
+ call_node, args_node, body = *node
88
+ saved_env = @env
89
+ @env = @env.dup
90
+
91
+ block_param_bindings(call_node, args_node).each do |name, cls|
92
+ @env[name] = cls
93
+ end
94
+
95
+ process_node(call_node) if call_node.is_a?(Parser::AST::Node)
96
+ process_node(body) if body.is_a?(Parser::AST::Node)
97
+ ensure
98
+ @env = saved_env
99
+ end
100
+
101
+ def process_send(node)
102
+ receiver, method_name = *node
103
+ if receiver.nil? && (factory_cls = @registry.helper_factories[method_name])
104
+ @env[method_name] = factory_cls
105
+ end
106
+ if should_await?(node)
107
+ @await_nodes << node
108
+ debug "await target: #{node.loc.expression.source}"
109
+ end
110
+ end
111
+
112
+ def should_await?(node)
113
+ return false unless node.type == :send
114
+ receiver, method_name = *node
115
+ if receiver
116
+ recv_cls = infer_class(receiver)
117
+ return true if recv_cls && @registry.async?(recv_cls, method_name)
118
+ end
119
+ # Receiver-less calls (implicit self) — check helpers.
120
+ helpers = @registry.async_helpers[method_name]
121
+ return true if helpers && !helpers.empty?
122
+ false
123
+ end
124
+
125
+ def infer_class(node)
126
+ return nil unless node.is_a?(Parser::AST::Node)
127
+ case node.type
128
+ when :send
129
+ receiver, method_name = *node
130
+ if receiver.nil?
131
+ return @env[method_name] if @env.key?(method_name)
132
+ return @method_returns[method_name] if @method_returns.key?(method_name)
133
+ end
134
+ infer_send_class(node)
135
+ when :index
136
+ infer_index_class(node)
137
+ when :lvar
138
+ @env[node.children[0]]
139
+ when :ivar
140
+ nil
141
+ when :const
142
+ const_path(node)
143
+ else
144
+ nil
145
+ end
146
+ end
147
+
148
+ def infer_send_class(node)
149
+ receiver, method_name = *node
150
+ if method_name == :new && receiver&.type == :const
151
+ return const_path(receiver)
152
+ end
153
+ if receiver
154
+ if method_name == :[]
155
+ key_node = node.children[2]
156
+ if key_node&.type == :str
157
+ key = key_node.children[0]
158
+ mapped = @registry.async_accessors[[env_name(receiver), key.to_sym]]
159
+ return mapped if mapped
160
+ end
161
+ end
162
+ accessor_cls = infer_env_accessor(receiver, method_name)
163
+ return accessor_cls if accessor_cls
164
+ recv_cls = infer_class(receiver)
165
+ if recv_cls
166
+ factory = @registry.factory?(recv_cls, method_name)
167
+ return recv_cls if factory
168
+ ret = @registry.taint_return_class(recv_cls, method_name)
169
+ return ret if ret
170
+ end
171
+ else
172
+ return @method_returns[method_name] if @method_returns.key?(method_name)
173
+ end
174
+ nil
175
+ end
176
+
177
+ def block_param_bindings(call_node, args_node)
178
+ return {} unless durable_object_define_call?(call_node)
179
+ return {} unless args_node&.type == :args
180
+
181
+ arg_names = args_node.children.filter_map do |arg|
182
+ next unless arg&.type == :arg
183
+ arg.children[0]
184
+ end
185
+ return {} if arg_names.empty?
186
+
187
+ bindings = { arg_names[0] => 'Cloudflare::DurableObjectState' }
188
+ bindings[arg_names[1]] = 'Cloudflare::DurableObjectRequest' if arg_names.length > 1
189
+ bindings
190
+ end
191
+
192
+ def durable_object_define_call?(call_node)
193
+ return false unless call_node&.type == :send
194
+
195
+ receiver, method_name = *call_node
196
+ method_name == :define && const_path(receiver) == 'Cloudflare::DurableObject'
197
+ end
198
+
199
+ def infer_index_class(node)
200
+ recv_cls = infer_class(node.children[0])
201
+ return nil unless recv_cls
202
+ @registry.taint_return_class(recv_cls, :[]) || recv_cls
203
+ end
204
+
205
+ def infer_env_accessor(receiver_node, method_name)
206
+ if env_node?(receiver_node)
207
+ return nil unless method_name.to_s =~ /^[A-Z]/
208
+ lvar = env_name(receiver_node)
209
+ mapped = @registry.async_accessors[[lvar, method_name.to_sym]]
210
+ return mapped if mapped
211
+ end
212
+ if receiver_node.type == :send
213
+ recv, meth = *receiver_node
214
+ if meth == :[] && env_node?(recv)
215
+ key_node = receiver_node.children[2]
216
+ if key_node&.type == :str
217
+ key = key_node.children[0]
218
+ mapped = @registry.async_accessors[[env_name(recv), key.to_sym]]
219
+ return mapped if mapped
220
+ end
221
+ lvar = env_name(recv)
222
+ mapped = @registry.async_accessors[[lvar, method_name.to_sym]]
223
+ return mapped if mapped
224
+ elsif env_node?(recv)
225
+ return nil unless method_name.to_s =~ /^[A-Z]/
226
+ lvar = env_name(recv)
227
+ mapped = @registry.async_accessors[[lvar, method_name.to_sym]]
228
+ return mapped if mapped
229
+ end
230
+ parent_cls = infer_env_accessor(recv, meth) if recv&.type == :send
231
+ return parent_cls if parent_cls
232
+ elsif receiver_node.type == :lvar
233
+ lvar = receiver_node.children[0]
234
+ mapped = @registry.async_accessors[[lvar, method_name.to_sym]]
235
+ return mapped if mapped
236
+ end
237
+ nil
238
+ end
239
+
240
+ def env_node?(node)
241
+ return false unless node&.type == :send
242
+ node.children[0].nil? && node.children[1] == :env
243
+ end
244
+
245
+ def env_name(node)
246
+ :env
247
+ end
248
+
249
+ def const_path(node)
250
+ parts = []
251
+ n = node
252
+ while n&.type == :const
253
+ parts.unshift(n.children[1])
254
+ n = n.children[0]
255
+ end
256
+ parts.join('::')
257
+ end
258
+
259
+ def debug(msg)
260
+ puts "[auto-await] #{msg}" if @debug
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parser/source/tree_rewriter'
4
+
5
+ module CloudflareWorkers
6
+ module AutoAwait
7
+ class Transformer
8
+ def self.transform(source, await_nodes, buffer)
9
+ rewriter = Parser::Source::TreeRewriter.new(buffer)
10
+ await_nodes.each do |node|
11
+ range = node.loc.expression
12
+ next unless range
13
+ rewriter.replace(range, "#{range.source}.__await__")
14
+ end
15
+ rewriter.process
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+ # await: true
4
+ #
5
+ # Phase 11B — Cloudflare Cache API wrapper.
6
+ #
7
+ # The Cache API (`caches.default`, `caches.open(name)`) is an edge
8
+ # cache local to the colo that serves a request. Unlike KV, there is
9
+ # no binding; the `caches` global is always present on Workers.
10
+ #
11
+ # Semantics are HTTP-level: the cache key is a Request (method, URL,
12
+ # headers — Vary-aware), and the value is a full Response with
13
+ # headers, status, and body. `cache.match(req)` returns a Response or
14
+ # nil; `cache.put(req, resp)` stores it. This wrapper exposes the
15
+ # subset that matters for Ruby routes:
16
+ #
17
+ # cache = Cloudflare::Cache.default
18
+ # resp = cache.match(request.url).__await__
19
+ # if resp
20
+ # # serve from cache
21
+ # else
22
+ # # compute…
23
+ # cache.put(request.url, body, headers: { 'cache-control' => 'public, max-age=60' }).__await__
24
+ # end
25
+ #
26
+ # The Sinatra helper `cache_get(url_or_request) { block }` (exposed
27
+ # through user-side `helpers do ... end`) wraps the match/compute/put
28
+ # dance so routes can write:
29
+ #
30
+ # get '/demo/cache/heavy' do
31
+ # cache_get(request.url, ttl: 60, content_type: 'application/json') do
32
+ # compute_expensive_value.to_json
33
+ # end
34
+ # end
35
+ #
36
+ # Both the helper and the low-level API await under the hood, so
37
+ # routes only need a single `.__await__` at the call site if they use
38
+ # the raw wrapper directly.
39
+ #
40
+ # LIMITATION — on Cloudflare Workers, `cache.put` only honours the
41
+ # key URL's scheme/host/path, and the URL must have an HTTP(S)
42
+ # scheme. In `wrangler dev`'s local mode the cache is a noop unless
43
+ # `--local` is combined with miniflare's cache (>= wrangler 3.x
44
+ # defaults). We document this in the README and the helper logs a
45
+ # single console.warn on the first noop put so misconfiguration is
46
+ # visible rather than silent.
47
+
48
+ module Cloudflare
49
+ class CacheError < StandardError
50
+ attr_reader :operation
51
+ def initialize(message, operation: nil)
52
+ @operation = operation
53
+ super("[Cloudflare::Cache] op=#{operation || 'match'}: #{message}")
54
+ end
55
+ end
56
+
57
+ # Wrapper around a JS Cache object (caches.default or a named cache).
58
+ class Cache
59
+ attr_reader :js_cache, :name
60
+
61
+ # caches.default — the shared cache that every Worker gets for free.
62
+ # Returns a fresh wrapper each call; the underlying JS object is a
63
+ # singleton per isolate.
64
+ def self.default
65
+ Cache.new(`(typeof caches !== 'undefined' && caches ? caches.default : null)`, 'default')
66
+ end
67
+
68
+ # caches.open(name) — named cache partitions. Returns a JS Promise
69
+ # resolving to a wrapped Cache. Following Workers conventions, the
70
+ # wrapper itself holds the resolved JS Cache so subsequent calls
71
+ # don't re-open the handle.
72
+ def self.open(name)
73
+ name_str = name.to_s
74
+ js_promise = `(typeof caches !== 'undefined' && caches && caches.open ? caches.open(#{name_str}) : Promise.resolve(null))`
75
+ js_cache = js_promise.__await__
76
+ Cache.new(js_cache, name_str)
77
+ end
78
+
79
+ def initialize(js_cache, name = 'default')
80
+ @js_cache = js_cache
81
+ @name = name.to_s
82
+ end
83
+
84
+ # True when the underlying JS cache is present. In unusual runtimes
85
+ # (tests / non-Workers hosts) `caches` may be undefined. We check
86
+ # Ruby-nil first because Opal's `nil` marshals to an object (not
87
+ # JS null) so a bare `#{js} != null` would be truthy — this is the
88
+ # same pitfall `Cloudflare::AI.run` documents for `env.AI`.
89
+ def available?
90
+ js = @js_cache
91
+ # Opal marshals Ruby `nil` to a runtime sentinel (`Opal.nil`),
92
+ # not JS null / undefined. Compare against the sentinel
93
+ # explicitly so a Cache built with Ruby `nil` reports itself as
94
+ # unavailable (which the non-Workers tests rely on).
95
+ !!`(#{js} !== null && #{js} !== undefined && #{js} !== Opal.nil)`
96
+ end
97
+
98
+ # Look up a Request (or URL String) in the cache. Returns a JS
99
+ # Promise resolving to a Cloudflare::HTTPResponse (populated with
100
+ # body text + headers) or nil.
101
+ def match(request_or_url)
102
+ js = @js_cache
103
+ response_klass = Cloudflare::HTTPResponse
104
+ err_klass = Cloudflare::CacheError
105
+ req = request_to_js(request_or_url)
106
+ # Copilot review PR #9 (fourth pass): `request_or_url.to_s` is
107
+ # correct for a String URL but produces "#<Cloudflare::HTTPResponse:...>"
108
+ # for the HTTPResponse wrapper and "[object Request]" for a raw
109
+ # JS Request. Derive the URL from the same shapes supported by
110
+ # `request_to_js` so the `url` field on the returned
111
+ # HTTPResponse is always a real URL.
112
+ url_str = if request_or_url.is_a?(String)
113
+ request_or_url
114
+ elsif defined?(Cloudflare::HTTPResponse) && request_or_url.is_a?(Cloudflare::HTTPResponse)
115
+ request_or_url.url.to_s
116
+ elsif `(#{request_or_url} != null && typeof #{request_or_url} === 'object' && typeof #{request_or_url}.url === 'string')`
117
+ `String(#{request_or_url}.url)`
118
+ else
119
+ request_or_url.to_s
120
+ end
121
+
122
+ # Single-line backtick IIFE — see `put` for the Opal multi-line
123
+ # x-string quirk that silently drops the returned Promise.
124
+ js_promise = `(async function(js, req, Kernel, err_klass) { if (js == null || js === Opal.nil) return null; var cached; try { cached = await js.match(req); } catch (e) { Kernel.$raise(err_klass.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'match' }))); } if (cached == null) return null; var text = ''; try { text = await cached.text(); } catch (_) { text = ''; } var hk = []; var hv = []; if (cached.headers && typeof cached.headers.forEach === 'function') { cached.headers.forEach(function(v, k) { hk.push(String(k).toLowerCase()); hv.push(String(v)); }); } return { status: cached.status|0, text: text, hkeys: hk, hvals: hv }; })(#{js}, #{req}, #{Kernel}, #{err_klass})`
125
+ js_result = js_promise.__await__
126
+ return nil if `#{js_result} == null`
127
+
128
+ hkeys = `#{js_result}.hkeys`
129
+ hvals = `#{js_result}.hvals`
130
+ h = {}
131
+ i = 0
132
+ len = `#{hkeys}.length`
133
+ while i < len
134
+ h[`#{hkeys}[#{i}]`] = `#{hvals}[#{i}]`
135
+ i += 1
136
+ end
137
+ response_klass.new(
138
+ status: `#{js_result}.status`,
139
+ headers: h,
140
+ body: `#{js_result}.text`,
141
+ url: url_str
142
+ )
143
+ end
144
+
145
+ # Store a Response for the given Request/URL.
146
+ #
147
+ # cache.put(request.url, body_str,
148
+ # status: 200,
149
+ # headers: { 'content-type' => 'application/json',
150
+ # 'cache-control' => 'public, max-age=60' }).__await__
151
+ #
152
+ # Returns a JS Promise that resolves to nil. Workers refuses to
153
+ # store responses without a cacheable status / cache-control; we
154
+ # surface that as a CacheError rather than silently succeeding.
155
+ def put(request_or_url, body, status: 200, headers: {})
156
+ js = @js_cache
157
+ err_klass = Cloudflare::CacheError
158
+ req = request_to_js(request_or_url)
159
+ hdrs = ruby_headers_to_js(headers)
160
+ body_str = body.to_s
161
+ status_int = status.to_i
162
+
163
+ # Single-line backtick IIFE — multi-line form is parsed by Opal
164
+ # as a statement (not an expression), so the returned Promise
165
+ # gets dropped and the caller's `__await__` receives `undefined`
166
+ # instead of waiting for `cache.put` to resolve. That was the
167
+ # silent bug: the inner `await` ran, but the outer await had
168
+ # already proceeded. See lib/cloudflare_workers/scheduled.rb for
169
+ # the same Opal multi-line x-string constraint.
170
+ # Warn ONCE per isolate on a nil cache. Non-Workers runtimes
171
+ # hit `Cache.new(nil, ...)` intentionally (tests, safe fall-back
172
+ # for routes that can run without caching) and repeated warn
173
+ # output would drown signal in noise — Copilot review PR #9.
174
+ `(async function(js, req, body_str, status_int, hdrs, Kernel, err_klass) { if (js == null || js === Opal.nil) { try { if (!globalThis.__HOMURA_CACHE_NOOP_WARNED__) { globalThis.__HOMURA_CACHE_NOOP_WARNED__ = true; globalThis.console.warn('[Cloudflare::Cache] caches.default unavailable; skipping put (this is expected in non-Workers runtimes). Further warnings suppressed.'); } } catch (_) {} return null; } try { var resp = new Response(String(body_str), { status: status_int, headers: hdrs }); await js.put(req, resp); } catch (e) { try { globalThis.console.error('[Cloudflare::Cache] put threw:', e && e.stack || e); } catch (_) {} Kernel.$raise(err_klass.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'put' }))); } return null; })(#{js}, #{req}, #{body_str}, #{status_int}, #{hdrs}, #{Kernel}, #{err_klass})`
175
+ end
176
+
177
+ # Remove a Request/URL from the cache. Returns a JS Promise
178
+ # resolving to a boolean — true if an entry was removed.
179
+ def delete(request_or_url)
180
+ js = @js_cache
181
+ err_klass = Cloudflare::CacheError
182
+ req = request_to_js(request_or_url)
183
+ # Single-line IIFE — see `put` for the Opal multi-line quirk.
184
+ `(async function(js, req, Kernel, err_klass) { if (js == null || js === Opal.nil) return false; try { var deleted = await js.delete(req); return deleted ? true : false; } catch (e) { Kernel.$raise(err_klass.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'delete' }))); } return false; })(#{js}, #{req}, #{Kernel}, #{err_klass})`
185
+ end
186
+
187
+ private
188
+
189
+ # Normalise supported inputs to a JS `Request` suitable as a
190
+ # cache key:
191
+ #
192
+ # - `String` URL → `new Request(url)`
193
+ # - `Cloudflare::HTTPResponse` → `new Request(resp.url)`
194
+ # (the Phase 6 wrapper exposes a `.url` attr; we reuse its URL
195
+ # as the cache key so `cache.put(response, ...)` reads natural.)
196
+ # - raw JS `Request` / `Response` / any JS object carrying a
197
+ # string `.url` property → passed through unchanged.
198
+ #
199
+ # Anything else (integers, Hashes, etc.) is rejected with
200
+ # `ArgumentError` so callers don't silently build an invalid
201
+ # `Request('12345')`.
202
+ # (Copilot review PR #9: previous docstring advertised HTTPResponse
203
+ # support but the impl only covered JS Request-like objects — both
204
+ # the docs AND the code now line up.)
205
+ def request_to_js(request_or_url)
206
+ if request_or_url.is_a?(String)
207
+ return `new Request(#{request_or_url})`
208
+ end
209
+ if defined?(Cloudflare::HTTPResponse) && request_or_url.is_a?(Cloudflare::HTTPResponse)
210
+ url_str = request_or_url.url.to_s
211
+ return `new Request(#{url_str})`
212
+ end
213
+ if `(#{request_or_url} != null && typeof #{request_or_url} === 'object' && typeof #{request_or_url}.url === 'string')`
214
+ return request_or_url
215
+ end
216
+ raise ArgumentError, "Cloudflare::Cache request must be a String URL, Cloudflare::HTTPResponse, or JS Request (got #{request_or_url.class})"
217
+ end
218
+
219
+ def ruby_headers_to_js(hash)
220
+ js_obj = `({})`
221
+ (hash || {}).each do |k, v|
222
+ ks = k.to_s
223
+ vs = v.to_s
224
+ `#{js_obj}[#{ks}] = #{vs}`
225
+ end
226
+ js_obj
227
+ end
228
+ end
229
+
230
+ # Module-level alias so calling code reads naturally:
231
+ # resp = Cloudflare::Cache.default.match(url).__await__
232
+ # mirrors Phase 3's `Cloudflare::D1Database` / `Cloudflare::KVNamespace`
233
+ # "the wrapper IS the API" pattern.
234
+ end