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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/README.md +37 -0
- data/bin/cloudflare-workers-build +255 -0
- data/docs/ARCHITECTURE.md +59 -0
- data/exe/auto-await +102 -0
- data/exe/compile-assets +142 -0
- data/exe/compile-erb +262 -0
- data/lib/cloudflare_workers/ai.rb +197 -0
- data/lib/cloudflare_workers/async_registry.rb +198 -0
- data/lib/cloudflare_workers/auto_await/analyzer.rb +264 -0
- data/lib/cloudflare_workers/auto_await/transformer.rb +19 -0
- data/lib/cloudflare_workers/cache.rb +234 -0
- data/lib/cloudflare_workers/durable_object.rb +590 -0
- data/lib/cloudflare_workers/email.rb +180 -0
- data/lib/cloudflare_workers/http.rb +164 -0
- data/lib/cloudflare_workers/multipart.rb +332 -0
- data/lib/cloudflare_workers/queue.rb +407 -0
- data/lib/cloudflare_workers/scheduled.rb +185 -0
- data/lib/cloudflare_workers/stream.rb +317 -0
- data/lib/cloudflare_workers/version.rb +5 -0
- data/lib/cloudflare_workers.rb +801 -0
- data/lib/opal_patches.rb +653 -0
- data/runtime/patch-opal-evals.mjs +66 -0
- data/runtime/setup-node-crypto.mjs +20 -0
- data/runtime/worker.mjs +9 -0
- data/runtime/worker_module.mjs +384 -0
- data/runtime/wrangler.toml.example +40 -0
- data/templates/wrangler.toml.example +8 -0
- metadata +104 -0
|
@@ -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
|