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,801 @@
|
|
|
1
|
+
# backtick_javascript: true
|
|
2
|
+
# await: true
|
|
3
|
+
# Cloudflare Workers runtime adapter for Opal.
|
|
4
|
+
#
|
|
5
|
+
# This file is the primary place in the Workers runtime stack that knows the
|
|
6
|
+
# underlying transport is Cloudflare Workers. Its job is to make CF Workers
|
|
7
|
+
# look like any other Rack server (Puma, Unicorn, Falcon, WEBrick, ...).
|
|
8
|
+
# User Ruby code is therefore a plain Rack application and would run
|
|
9
|
+
# unchanged on any Rack-compatible host.
|
|
10
|
+
#
|
|
11
|
+
# Three responsibilities, modelled after existing Ruby conventions:
|
|
12
|
+
#
|
|
13
|
+
# 1. CloudflareWorkersIO — replaces nodejs.rb's $stdout / $stderr (which
|
|
14
|
+
# try to write to a closed Socket on Workers) with shims that route
|
|
15
|
+
# Ruby `puts` / `print` to V8's globalThis.console.log/error.
|
|
16
|
+
#
|
|
17
|
+
# 2. Rack::Handler::CloudflareWorkers — a standard Rack handler. Same
|
|
18
|
+
# shape as Rack::Handler::Puma, Rack::Handler::WEBrick, etc. User
|
|
19
|
+
# code uses the conventional top-level `run app` from a config.ru-
|
|
20
|
+
# style entry point and never sees a Cloudflare-specific symbol.
|
|
21
|
+
#
|
|
22
|
+
# 3. Cloudflare::D1Database / KVNamespace / R2Bucket — tiny Ruby wrappers
|
|
23
|
+
# around the JS bindings. They expose the binding methods as regular
|
|
24
|
+
# Ruby method calls returning native JS Promises, which the user
|
|
25
|
+
# routes can `.__await__` inside a `# await: true` block.
|
|
26
|
+
#
|
|
27
|
+
# Note: Opal Strings are immutable (they map to JS Strings), so this file
|
|
28
|
+
# uses reassignment (`@buffer = @buffer + str`) instead of `<<` mutation.
|
|
29
|
+
|
|
30
|
+
require 'stringio'
|
|
31
|
+
require 'await'
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# 1. stdout / stderr → console.log / console.error
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
class CloudflareWorkersIO
|
|
38
|
+
def initialize(channel)
|
|
39
|
+
@channel = channel # 'log' or 'error'
|
|
40
|
+
@buffer = ''
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write(*args)
|
|
44
|
+
written = 0
|
|
45
|
+
args.each do |arg|
|
|
46
|
+
str = arg.to_s
|
|
47
|
+
@buffer = @buffer + str
|
|
48
|
+
written += str.length
|
|
49
|
+
end
|
|
50
|
+
flush_lines
|
|
51
|
+
written
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def puts(*args)
|
|
55
|
+
if args.empty?
|
|
56
|
+
emit('')
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
args.each do |arg|
|
|
60
|
+
if arg.is_a?(Array)
|
|
61
|
+
puts(*arg)
|
|
62
|
+
next
|
|
63
|
+
end
|
|
64
|
+
line = arg.to_s
|
|
65
|
+
@buffer = @buffer + (line.end_with?("\n") ? line : line + "\n")
|
|
66
|
+
end
|
|
67
|
+
flush_lines
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def print(*args)
|
|
72
|
+
args.each { |a| @buffer = @buffer + a.to_s }
|
|
73
|
+
flush_lines
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def flush
|
|
78
|
+
return self if @buffer.empty?
|
|
79
|
+
emit(@buffer)
|
|
80
|
+
@buffer = ''
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def sync; true; end
|
|
85
|
+
def sync=(_); end
|
|
86
|
+
def tty?; false; end
|
|
87
|
+
def isatty; false; end
|
|
88
|
+
def closed?; false; end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def flush_lines
|
|
93
|
+
while (idx = @buffer.index("\n"))
|
|
94
|
+
line = @buffer[0...idx]
|
|
95
|
+
@buffer = @buffer[(idx + 1)..-1] || ''
|
|
96
|
+
emit(line)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def emit(line)
|
|
101
|
+
channel = @channel
|
|
102
|
+
text = line
|
|
103
|
+
`globalThis.console[#{channel}](#{text})`
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
$stdout = CloudflareWorkersIO.new('log')
|
|
108
|
+
$stderr = CloudflareWorkersIO.new('error')
|
|
109
|
+
Object.const_set(:STDOUT, $stdout) unless Object.const_defined?(:STDOUT) && STDOUT.is_a?(CloudflareWorkersIO)
|
|
110
|
+
Object.const_set(:STDERR, $stderr) unless Object.const_defined?(:STDERR) && STDERR.is_a?(CloudflareWorkersIO)
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# 2. Rack::Handler::CloudflareWorkers
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
#
|
|
116
|
+
# Conforms to the Rack handler convention: a module with a `run` class
|
|
117
|
+
# method that takes a Rack app, registers it, and arranges for incoming
|
|
118
|
+
# requests to be dispatched through it. See Rack::Handler::Puma,
|
|
119
|
+
# Rack::Handler::WEBrick, etc. for prior art.
|
|
120
|
+
|
|
121
|
+
module Rack
|
|
122
|
+
module Handler
|
|
123
|
+
module CloudflareWorkers
|
|
124
|
+
EMPTY_STRING_IO = StringIO.new('').freeze
|
|
125
|
+
|
|
126
|
+
def self.run(app, **_options)
|
|
127
|
+
@app = app
|
|
128
|
+
install_dispatcher
|
|
129
|
+
app
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.app
|
|
133
|
+
@app
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Entry point invoked from the Module Worker (src/worker.mjs) for
|
|
137
|
+
# every fetch event. `js_req` is a Cloudflare Workers Request,
|
|
138
|
+
# `js_env` is the bindings object (D1, KV, R2, secrets...),
|
|
139
|
+
# `js_ctx` is the ExecutionContext, `body_text` is the pre-resolved
|
|
140
|
+
# request body (the worker.mjs front awaits req.text() before
|
|
141
|
+
# handing control to Ruby because Opal runs synchronously).
|
|
142
|
+
def self.call(js_req, js_env, js_ctx, body_text = '')
|
|
143
|
+
raise '`run app` was never called from user code' if @app.nil?
|
|
144
|
+
|
|
145
|
+
env = build_rack_env(js_req, js_env, js_ctx, body_text)
|
|
146
|
+
status, headers, body = @app.call(env)
|
|
147
|
+
build_js_response(status, headers, body)
|
|
148
|
+
ensure
|
|
149
|
+
body.close if body.respond_to?(:close) && body
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
class << self
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def install_dispatcher
|
|
156
|
+
handler = self
|
|
157
|
+
`
|
|
158
|
+
globalThis.__HOMURA_RACK_DISPATCH__ = async function(req, env, ctx, body_text) {
|
|
159
|
+
return await #{handler}.$call(req, env, ctx, body_text == null ? "" : body_text);
|
|
160
|
+
};
|
|
161
|
+
(function () {
|
|
162
|
+
var g = globalThis;
|
|
163
|
+
g.__OPAL_WORKERS__ = g.__OPAL_WORKERS__ || {};
|
|
164
|
+
g.__OPAL_WORKERS__.rack = g.__HOMURA_RACK_DISPATCH__;
|
|
165
|
+
})();
|
|
166
|
+
`
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Build a Rack-compliant env Hash from a Cloudflare Workers Request.
|
|
170
|
+
# See https://github.com/rack/rack/blob/main/SPEC.rdoc for the contract.
|
|
171
|
+
def build_rack_env(js_req, js_env, js_ctx, body_text = '')
|
|
172
|
+
method = `#{js_req}.method`
|
|
173
|
+
url_obj = `new URL(#{js_req}.url)`
|
|
174
|
+
path = `#{url_obj}.pathname`
|
|
175
|
+
# Phase 16 docs: Sinatra + Opal builds responses with String#<< and raises on PATH_INFO `/docs/`.
|
|
176
|
+
path = '/docs' if path == '/docs/'
|
|
177
|
+
raw_qs = `#{url_obj}.search` # includes leading '?' or empty string
|
|
178
|
+
qs = raw_qs && raw_qs.length > 0 ? raw_qs[1..-1] : ''
|
|
179
|
+
scheme = `#{url_obj}.protocol`.sub(/:\z/, '')
|
|
180
|
+
host = `#{url_obj}.hostname`
|
|
181
|
+
port = `#{url_obj}.port`
|
|
182
|
+
port = (scheme == 'https' ? '443' : '80') if port.nil? || port.empty?
|
|
183
|
+
|
|
184
|
+
env = {
|
|
185
|
+
'REQUEST_METHOD' => method,
|
|
186
|
+
'SCRIPT_NAME' => '',
|
|
187
|
+
'PATH_INFO' => path,
|
|
188
|
+
'QUERY_STRING' => qs,
|
|
189
|
+
'SERVER_NAME' => host,
|
|
190
|
+
'SERVER_PORT' => port,
|
|
191
|
+
'SERVER_PROTOCOL' => 'HTTP/1.1',
|
|
192
|
+
'HTTPS' => scheme == 'https' ? 'on' : 'off',
|
|
193
|
+
'rack.url_scheme' => scheme,
|
|
194
|
+
'rack.input' => body_text.nil? || body_text.empty? ? EMPTY_STRING_IO : StringIO.new(body_text),
|
|
195
|
+
'rack.errors' => $stderr,
|
|
196
|
+
'rack.multithread' => false,
|
|
197
|
+
'rack.multiprocess' => false,
|
|
198
|
+
'rack.run_once' => false,
|
|
199
|
+
'rack.hijack?' => false,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
copy_headers_into_env(js_req, env)
|
|
203
|
+
|
|
204
|
+
# Cloudflare-specific extras under their own namespace, per the
|
|
205
|
+
# Rack convention that env keys other than the standard ones
|
|
206
|
+
# SHOULD use a `<library>.<key>` form.
|
|
207
|
+
env['cloudflare.env'] = js_env
|
|
208
|
+
env['cloudflare.ctx'] = js_ctx
|
|
209
|
+
|
|
210
|
+
# Expose D1 / KV / R2 bindings as plain Ruby wrapper objects.
|
|
211
|
+
# The user Sinatra routes reach them via
|
|
212
|
+
# `env['cloudflare.DB']` / `.KV` / `.BUCKET`, call normal-looking
|
|
213
|
+
# Ruby methods on them, and `.__await__` the resulting JS Promise.
|
|
214
|
+
js_db = `#{js_env} && #{js_env}.DB`
|
|
215
|
+
js_kv = `#{js_env} && #{js_env}.KV`
|
|
216
|
+
js_r2 = `#{js_env} && #{js_env}.BUCKET`
|
|
217
|
+
js_ai = `#{js_env} && #{js_env}.AI`
|
|
218
|
+
env['cloudflare.DB'] = Cloudflare::D1Database.new(js_db) if `#{js_db} != null`
|
|
219
|
+
env['cloudflare.KV'] = Cloudflare::KVNamespace.new(js_kv) if `#{js_kv} != null`
|
|
220
|
+
env['cloudflare.BUCKET'] = Cloudflare::R2Bucket.new(js_r2) if `#{js_r2} != null`
|
|
221
|
+
# Phase 10: env.AI is a Workers AI binding object. Routes call
|
|
222
|
+
# Cloudflare::AI.run(model, inputs, binding: env['cloudflare.AI'])
|
|
223
|
+
# to invoke a model. We expose the raw JS object (not a wrapper)
|
|
224
|
+
# because the wrapper is stateless — every call passes both the
|
|
225
|
+
# model id and the binding explicitly.
|
|
226
|
+
env['cloudflare.AI'] = js_ai if `#{js_ai} != null`
|
|
227
|
+
|
|
228
|
+
# Phase 11B: Durable Objects / Queues.
|
|
229
|
+
# env.COUNTER is a DurableObjectNamespace binding; wrap it into
|
|
230
|
+
# Cloudflare::DurableObjectNamespace so routes can call
|
|
231
|
+
# `do_counter.get_by_name("global").fetch('/inc').__await__`
|
|
232
|
+
# without a backtick. env.JOBS_QUEUE is a Queue producer binding.
|
|
233
|
+
js_do_counter = `#{js_env} && #{js_env}.COUNTER`
|
|
234
|
+
if `#{js_do_counter} != null`
|
|
235
|
+
env['cloudflare.DO_COUNTER'] = Cloudflare::DurableObjectNamespace.new(js_do_counter)
|
|
236
|
+
end
|
|
237
|
+
js_queue = `#{js_env} && #{js_env}.JOBS_QUEUE`
|
|
238
|
+
env['cloudflare.QUEUE_JOBS'] = Cloudflare::Queue.new(js_queue, 'JOBS_QUEUE') if `#{js_queue} != null`
|
|
239
|
+
js_dlq = `#{js_env} && #{js_env}.JOBS_DLQ`
|
|
240
|
+
env['cloudflare.QUEUE_JOBS_DLQ'] = Cloudflare::Queue.new(js_dlq, 'JOBS_DLQ') if `#{js_dlq} != null`
|
|
241
|
+
|
|
242
|
+
# Phase 17 — SEND_EMAIL。worker_module.fetch は先に globalThis.__OPAL_WORKERS__.sendEmailBinding を設定する。
|
|
243
|
+
# 本番では js_env.SEND_EMAIL が直に入る。Miniflare で欠ける場合のみ global を試す(2 段に分けて Opal の埋め込みを単純化)。
|
|
244
|
+
js_send_email = `#{js_env}.SEND_EMAIL`
|
|
245
|
+
if `#{js_send_email} == null || #{js_send_email} === undefined`
|
|
246
|
+
js_send_email = `(typeof globalThis !== 'undefined' && globalThis.__OPAL_WORKERS__ && globalThis.__OPAL_WORKERS__.sendEmailBinding) || null`
|
|
247
|
+
end
|
|
248
|
+
env['cloudflare.SEND_EMAIL'] = Cloudflare::Email.new(js_send_email) if `#{js_send_email} != null`
|
|
249
|
+
|
|
250
|
+
env
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Copy CF Workers Request headers into Rack HTTP_* keys, with the
|
|
254
|
+
# spec-mandated CONTENT_TYPE / CONTENT_LENGTH special cases.
|
|
255
|
+
def copy_headers_into_env(js_req, env)
|
|
256
|
+
ruby_env = env
|
|
257
|
+
`
|
|
258
|
+
#{js_req}.headers.forEach(function(value, key) {
|
|
259
|
+
var lower = key.toLowerCase();
|
|
260
|
+
var rack_key;
|
|
261
|
+
if (lower === "content-type") {
|
|
262
|
+
rack_key = "CONTENT_TYPE";
|
|
263
|
+
} else if (lower === "content-length") {
|
|
264
|
+
rack_key = "CONTENT_LENGTH";
|
|
265
|
+
} else {
|
|
266
|
+
rack_key = "HTTP_" + key.toUpperCase().replace(/-/g, "_");
|
|
267
|
+
}
|
|
268
|
+
#{ruby_env}.$store(rack_key, value);
|
|
269
|
+
})
|
|
270
|
+
`
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Convert a Rack response triple [status, headers, body] into a
|
|
274
|
+
# Cloudflare Workers Response. `body` must respond to #each yielding
|
|
275
|
+
# strings, per the Rack body contract. Arrays satisfy this.
|
|
276
|
+
#
|
|
277
|
+
# Chunks can also be JS Promises (returned by D1 / KV / R2 binding
|
|
278
|
+
# wrappers). When any chunk is a thenable, we return a JS Promise
|
|
279
|
+
# that awaits all of them, concatenates their results into a body
|
|
280
|
+
# string, and resolves to a fresh Response. worker.mjs awaits the
|
|
281
|
+
# value we return, so both sync and async paths look the same on
|
|
282
|
+
# the outside.
|
|
283
|
+
def build_js_response(status, headers, body)
|
|
284
|
+
# Raw JS Response fast-path (Phase 11B): a route that needs
|
|
285
|
+
# to hand the Workers runtime a Response object that was
|
|
286
|
+
# constructed OUTSIDE our pipeline (e.g. a 101 upgrade
|
|
287
|
+
# Response carrying a `.webSocket` property from a DO
|
|
288
|
+
# stub.fetch) wraps it in Cloudflare::RawResponse. We pass
|
|
289
|
+
# the JS object through unchanged — any reconstruction
|
|
290
|
+
# would strip runtime-only properties the client depends on.
|
|
291
|
+
raw = nil
|
|
292
|
+
if body.is_a?(::Cloudflare::RawResponse)
|
|
293
|
+
raw = body
|
|
294
|
+
elsif body.respond_to?(:first) && body.first.is_a?(::Cloudflare::RawResponse)
|
|
295
|
+
raw = body.first
|
|
296
|
+
end
|
|
297
|
+
if raw
|
|
298
|
+
js_resp = raw.js_response
|
|
299
|
+
return js_resp
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Binary body fast-path: pass the JS ReadableStream directly
|
|
303
|
+
# to Response without touching Opal's String encoding.
|
|
304
|
+
if body.is_a?(::Cloudflare::BinaryBody) || (body.respond_to?(:first) && body.first.is_a?(::Cloudflare::BinaryBody))
|
|
305
|
+
bin = body.is_a?(::Cloudflare::BinaryBody) ? body : body.first
|
|
306
|
+
js_stream = bin.stream
|
|
307
|
+
ct = bin.content_type
|
|
308
|
+
cc = bin.cache_control
|
|
309
|
+
js_headers = `({})`
|
|
310
|
+
headers.each { |k, v| ks = k.to_s; vs = v.to_s; `#{js_headers}[#{ks}] = #{vs}` }
|
|
311
|
+
`#{js_headers}['content-type'] = #{ct}` if ct
|
|
312
|
+
`#{js_headers}['cache-control'] = #{cc}` if cc
|
|
313
|
+
return `new Response(#{js_stream}, { status: #{status.to_i}, headers: #{js_headers} })`
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Phase 10 — Workers AI streaming: a Cloudflare::AI::Stream wraps
|
|
317
|
+
# a JS ReadableStream<Uint8Array> emitting SSE-formatted bytes
|
|
318
|
+
# ("data: {json}\n\n"). Pass it straight through so the client
|
|
319
|
+
# receives the chunks as they arrive.
|
|
320
|
+
stream_obj = nil
|
|
321
|
+
if body.respond_to?(:sse_stream?) && body.sse_stream?
|
|
322
|
+
stream_obj = body
|
|
323
|
+
elsif body.respond_to?(:first) && body.first.respond_to?(:sse_stream?) && body.first.sse_stream?
|
|
324
|
+
stream_obj = body.first
|
|
325
|
+
end
|
|
326
|
+
if stream_obj
|
|
327
|
+
js_stream = stream_obj.js_stream
|
|
328
|
+
js_headers = `({})`
|
|
329
|
+
# Order: Rack-response headers → stream-provided headers
|
|
330
|
+
# (SSE defaults + caller extras) → route-set hard wins. The
|
|
331
|
+
# old code hardcoded text/event-stream here, which silently
|
|
332
|
+
# dropped any `headers:` hash the caller passed to `sse`.
|
|
333
|
+
# Merge the stream's own response_headers if it exposes them
|
|
334
|
+
# (duck-type; non-SSE stream wrappers may not).
|
|
335
|
+
headers.each { |k, v| ks = k.to_s; vs = v.to_s; `#{js_headers}[#{ks}] = #{vs}` }
|
|
336
|
+
if stream_obj.respond_to?(:response_headers)
|
|
337
|
+
stream_obj.response_headers.each { |k, v| ks = k.to_s; vs = v.to_s; `#{js_headers}[#{ks}] = #{vs}` }
|
|
338
|
+
else
|
|
339
|
+
# Legacy Cloudflare::AI::Stream (Phase 10.3) doesn't expose
|
|
340
|
+
# response_headers; keep the hardcoded SSE defaults for
|
|
341
|
+
# backwards compatibility in that case.
|
|
342
|
+
`#{js_headers}['content-type'] = 'text/event-stream; charset=utf-8'`
|
|
343
|
+
`#{js_headers}['cache-control'] = 'no-cache, no-transform'`
|
|
344
|
+
`#{js_headers}['x-accel-buffering'] = 'no'`
|
|
345
|
+
end
|
|
346
|
+
return `new Response(#{js_stream}, { status: #{status.to_i}, headers: #{js_headers} })`
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
chunks = []
|
|
350
|
+
if body.respond_to?(:each)
|
|
351
|
+
body.each { |chunk| chunks << chunk }
|
|
352
|
+
else
|
|
353
|
+
chunks << body
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
js_headers = `({})`
|
|
357
|
+
headers.each do |k, v|
|
|
358
|
+
ks = k.to_s
|
|
359
|
+
vs = v.to_s
|
|
360
|
+
`#{js_headers}[#{ks}] = #{vs}`
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
status_int = status.to_i
|
|
364
|
+
|
|
365
|
+
js_chunks = `[]`
|
|
366
|
+
has_promise = false
|
|
367
|
+
chunks.each do |c|
|
|
368
|
+
`#{js_chunks}.push(#{c})`
|
|
369
|
+
has_promise = true if `#{c} != null && typeof #{c}.then === 'function'`
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
if has_promise
|
|
373
|
+
# Phase 10 patch: route may return [status, body] from a
|
|
374
|
+
# post-await branch so it can express a non-200 status the
|
|
375
|
+
# async-promise path of Sinatra::Base#invoke would otherwise
|
|
376
|
+
# snapshot away. Single-line x-string per the file convention.
|
|
377
|
+
#
|
|
378
|
+
# Phase 11B addendum: if any resolved chunk is a
|
|
379
|
+
# `Cloudflare::RawResponse` (`.$$is_raw_response` on the
|
|
380
|
+
# Opal side OR has `js_response` + `raw_response?` duck
|
|
381
|
+
# markers), return its underlying JS Response verbatim —
|
|
382
|
+
# used for 101 WebSocket upgrades where the Workers
|
|
383
|
+
# runtime's own Response carries runtime-only properties
|
|
384
|
+
# (`.webSocket`) that a reconstructed Response would lose.
|
|
385
|
+
`Promise.all(#{js_chunks}).then(function(resolved) { for (var i = 0; i < resolved.length; i++) { var r = resolved[i]; if (r != null && typeof r === 'object' && typeof r['$raw_response?'] === 'function' && typeof r['$js_response'] === 'function') { try { if (r['$raw_response?']()) { return r['$js_response'](); } } catch (_) {} } } for (var i = 0; i < resolved.length; i++) { var r = resolved[i]; if (r != null && r.stream != null && r.content_type != null) { var bh = {}; bh['content-type'] = r.content_type; if (r.cache_control) bh['cache-control'] = r.cache_control; return new Response(r.stream, { status: #{status_int}, headers: bh }); } } if (resolved.length === 1 && resolved[0] != null && Array.isArray(resolved[0]) && resolved[0].length === 2 && typeof resolved[0][0] === 'number') { var ov = resolved[0]; var ovs = ov[0]|0; var ovb = ov[1] == null ? '' : (typeof ov[1] === 'string' ? ov[1] : (ov[1].$$is_string ? ov[1].toString() : String(ov[1]))); return new Response(ovb, { status: ovs, headers: #{js_headers} }); } var parts = []; for (var i = 0; i < resolved.length; i++) { var r = resolved[i]; if (r == null) { parts.push(''); continue; } if (typeof r === 'string') { parts.push(r); continue; } if (r != null && r.$$is_string) { parts.push(r.toString()); continue; } try { parts.push(JSON.stringify(r)); } catch (e) { parts.push(String(r)); } } return new Response(parts.join(''), { status: #{status_int}, headers: #{js_headers} }); })`
|
|
386
|
+
else
|
|
387
|
+
body_str = ''
|
|
388
|
+
chunks.each { |c| body_str = body_str + c.to_s }
|
|
389
|
+
`new Response(#{body_str}, { status: #{status_int}, headers: #{js_headers} })`
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# 3. Top-level `run` so user code looks like config.ru
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
#
|
|
401
|
+
# Rack::Builder normally provides `run` inside a config.ru file. Since
|
|
402
|
+
# our user .rb files are not literal config.ru, we install `run` on
|
|
403
|
+
# Kernel as a private method so it is callable from any top-level Ruby
|
|
404
|
+
# scope without leaking through method_missing or polluting Object's
|
|
405
|
+
# public surface.
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# 3. Top-level `run` so user code looks like config.ru
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
module Kernel
|
|
411
|
+
private
|
|
412
|
+
|
|
413
|
+
def run(app, **options)
|
|
414
|
+
Rack::Handler::CloudflareWorkers.run(app, **options)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# 4. Cloudflare bindings — D1 / KV / R2 wrappers
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
#
|
|
422
|
+
# These wrappers give Sinatra routes a plain Ruby API over the underlying
|
|
423
|
+
# Cloudflare JS objects. Each mutation method (D1 `.all`/`.first`/`.run`,
|
|
424
|
+
# KV `.get`/`.put`/`.delete`, R2 `.get`/`.put`) returns a raw JS Promise
|
|
425
|
+
# that user code is expected to `.__await__` inside a `# await: true`
|
|
426
|
+
# route block.
|
|
427
|
+
#
|
|
428
|
+
# The wrappers convert incoming Ruby values to JS and outgoing JS values
|
|
429
|
+
# to Ruby where it matters (D1 row hashes, KV strings, R2 object bodies),
|
|
430
|
+
# so user code never has to reach for a backtick.
|
|
431
|
+
|
|
432
|
+
module Cloudflare
|
|
433
|
+
# Base error class for Cloudflare binding failures. Wraps the JS
|
|
434
|
+
# error message so Ruby rescue can handle it meaningfully.
|
|
435
|
+
class BindingError < StandardError
|
|
436
|
+
attr_reader :binding_type, :operation
|
|
437
|
+
|
|
438
|
+
def initialize(message, binding_type: nil, operation: nil)
|
|
439
|
+
@binding_type = binding_type
|
|
440
|
+
@operation = operation
|
|
441
|
+
super("[Cloudflare::#{binding_type}] #{operation}: #{message}")
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
class D1Error < BindingError; end
|
|
446
|
+
class KVError < BindingError; end
|
|
447
|
+
class R2Error < BindingError; end
|
|
448
|
+
|
|
449
|
+
# Check whether the argument is a native JS Promise / thenable.
|
|
450
|
+
# Ruby's `Object#then` (alias of `yield_self`) is a universal method
|
|
451
|
+
# since Ruby 2.6, so `obj.respond_to?(:then)` is always true and is
|
|
452
|
+
# useless as a Promise detector. We must check for a JS function
|
|
453
|
+
# at `.then` instead.
|
|
454
|
+
def self.js_promise?(obj)
|
|
455
|
+
`(#{obj} != null && typeof #{obj}.then === 'function' && typeof #{obj}.catch === 'function')`
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# JS Array -> Ruby Array of Ruby Hashes (for D1 result.results).
|
|
459
|
+
def self.js_rows_to_ruby(js_rows)
|
|
460
|
+
out = []
|
|
461
|
+
return out if `#{js_rows} == null`
|
|
462
|
+
len = `#{js_rows}.length`
|
|
463
|
+
i = 0
|
|
464
|
+
while i < len
|
|
465
|
+
js_row = `#{js_rows}[#{i}]`
|
|
466
|
+
out << js_object_to_hash(js_row)
|
|
467
|
+
i += 1
|
|
468
|
+
end
|
|
469
|
+
out
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Shallow copy of a JS object's own enumerable string keys into a Hash.
|
|
473
|
+
def self.js_object_to_hash(js_obj)
|
|
474
|
+
h = {}
|
|
475
|
+
return h if `#{js_obj} == null`
|
|
476
|
+
keys = `Object.keys(#{js_obj})`
|
|
477
|
+
len = `#{keys}.length`
|
|
478
|
+
i = 0
|
|
479
|
+
while i < len
|
|
480
|
+
k = `#{keys}[#{i}]`
|
|
481
|
+
v = `#{js_obj}[#{k}]`
|
|
482
|
+
h[k] = v
|
|
483
|
+
i += 1
|
|
484
|
+
end
|
|
485
|
+
h
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# RawResponse wraps an already-constructed JS `Response` so routes
|
|
489
|
+
# can return it through Sinatra and have `build_js_response` pass
|
|
490
|
+
# it through to the Workers runtime untouched. Needed when the
|
|
491
|
+
# Response carries runtime-only properties that would disappear if
|
|
492
|
+
# reconstructed — e.g. a 101 upgrade Response with `.webSocket`.
|
|
493
|
+
# Unlike `BinaryBody`, no new Response is constructed; the stored
|
|
494
|
+
# JS object is returned as-is.
|
|
495
|
+
class RawResponse
|
|
496
|
+
attr_reader :js_response
|
|
497
|
+
|
|
498
|
+
def initialize(js_response)
|
|
499
|
+
@js_response = js_response
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Rack body contract — yield nothing. The bytes never flow
|
|
503
|
+
# through Ruby; the JS Response goes straight to the runtime.
|
|
504
|
+
def each; end
|
|
505
|
+
|
|
506
|
+
def close; end
|
|
507
|
+
|
|
508
|
+
def raw_response?
|
|
509
|
+
true
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# BinaryBody wraps a JS ReadableStream (from R2, fetch, etc.) so it can
|
|
514
|
+
# flow through the Rack/Sinatra body pipeline without being converted to
|
|
515
|
+
# an Opal String (which would mangle the bytes). `build_js_response`
|
|
516
|
+
# detects BinaryBody and passes the stream directly to `new Response`.
|
|
517
|
+
class BinaryBody
|
|
518
|
+
attr_reader :stream, :content_type, :cache_control
|
|
519
|
+
|
|
520
|
+
def initialize(stream, content_type = 'application/octet-stream', cache_control = nil)
|
|
521
|
+
@stream = stream
|
|
522
|
+
@content_type = content_type
|
|
523
|
+
@cache_control = cache_control
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Rack body contract — yield nothing so Sinatra's content-length
|
|
527
|
+
# calculation skips this body. The real bytes go through JS.
|
|
528
|
+
def each; end
|
|
529
|
+
|
|
530
|
+
def close; end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# NOTE: the single-line backtick `...` form is used below instead of the
|
|
534
|
+
# multi-line `%x{ ... }` or multi-line backtick form. Opal's compiler
|
|
535
|
+
# treats a *multi-line* x-string as a raw statement and refuses to use
|
|
536
|
+
# it as an expression — which would silently return `undefined` from a
|
|
537
|
+
# wrapper method. Keeping each x-string on one line makes Opal emit it
|
|
538
|
+
# as a true expression that the surrounding Ruby code can return.
|
|
539
|
+
|
|
540
|
+
# D1Database wraps a Cloudflare D1 JS binding. The public API is
|
|
541
|
+
# modelled on CRuby's `sqlite3-ruby` gem (`SQLite3::Database`) so
|
|
542
|
+
# that the calling code reads identically:
|
|
543
|
+
#
|
|
544
|
+
# # sqlite3-ruby on CRuby:
|
|
545
|
+
# rows = db.execute("SELECT * FROM users WHERE id = ?", [1])
|
|
546
|
+
#
|
|
547
|
+
# # homura on Opal (+ async):
|
|
548
|
+
# rows = db.execute("SELECT * FROM users WHERE id = ?", [1]).__await__
|
|
549
|
+
#
|
|
550
|
+
# Every query method returns a JS Promise. Use `.__await__` inside a
|
|
551
|
+
# `# await: true` route block to unwrap it synchronously (Opal
|
|
552
|
+
# compiles `.__await__` to a native JS `await`).
|
|
553
|
+
#
|
|
554
|
+
# Results are always Hashes — `results_as_hash` is effectively
|
|
555
|
+
# hardcoded to `true`. This matches the common `db.results_as_hash =
|
|
556
|
+
# true` convention in the sqlite3-ruby world and gives downstream ORM
|
|
557
|
+
# code a ready-made Hash-per-row interface to build on.
|
|
558
|
+
class D1Database
|
|
559
|
+
def initialize(js)
|
|
560
|
+
@js = js
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# ---- sqlite3-ruby compatible high-level API ----------------------
|
|
564
|
+
|
|
565
|
+
# Execute a SQL statement with optional bind parameters and return
|
|
566
|
+
# all result rows as an Array of Hashes.
|
|
567
|
+
#
|
|
568
|
+
# db.execute("SELECT * FROM users") → Array<Hash>
|
|
569
|
+
# db.execute("SELECT * FROM users WHERE id = ?", [1]) → Array<Hash>
|
|
570
|
+
# db.execute("INSERT INTO users (name) VALUES (?)", ["alice"]) → Array<Hash> (empty for writes)
|
|
571
|
+
def execute(sql, bind_params = [])
|
|
572
|
+
stmt = prepare(sql)
|
|
573
|
+
stmt = stmt.bind(*bind_params) unless bind_params.empty?
|
|
574
|
+
stmt.all
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Execute and return only the first row (or nil).
|
|
578
|
+
#
|
|
579
|
+
# db.get_first_row("SELECT * FROM users WHERE id = ?", [1]) → Hash or nil
|
|
580
|
+
def get_first_row(sql, bind_params = [])
|
|
581
|
+
stmt = prepare(sql)
|
|
582
|
+
stmt = stmt.bind(*bind_params) unless bind_params.empty?
|
|
583
|
+
stmt.first
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Execute a write statement (INSERT / UPDATE / DELETE) and return
|
|
587
|
+
# a metadata Hash with `changes`, `last_row_id`, `duration`, etc.
|
|
588
|
+
#
|
|
589
|
+
# meta = db.execute_insert("INSERT INTO users (name) VALUES (?)", ["alice"])
|
|
590
|
+
# meta['last_row_id'] # → 7
|
|
591
|
+
def execute_insert(sql, bind_params = [])
|
|
592
|
+
stmt = prepare(sql)
|
|
593
|
+
stmt = stmt.bind(*bind_params) unless bind_params.empty?
|
|
594
|
+
stmt.run
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Execute one or more raw SQL statements separated by semicolons.
|
|
598
|
+
# Useful for schema migrations. Returns the D1 exec result.
|
|
599
|
+
def execute_batch(sql)
|
|
600
|
+
exec(sql)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# ---- low-level D1 API (prepare/bind/all/first/run) ---------------
|
|
604
|
+
|
|
605
|
+
def prepare(sql)
|
|
606
|
+
js = @js
|
|
607
|
+
D1Statement.new(`#{js}.prepare(#{sql})`)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def exec(sql)
|
|
611
|
+
js = @js
|
|
612
|
+
`(#{js}.exec ? #{js}.exec(#{sql}) : #{js}.prepare(#{sql}).run())`
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
class D1Statement
|
|
617
|
+
def initialize(js)
|
|
618
|
+
@js = js
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def bind(*args)
|
|
622
|
+
js_args = `[]`
|
|
623
|
+
args.each { |a| `#{js_args}.push(#{a})` }
|
|
624
|
+
js = @js
|
|
625
|
+
D1Statement.new(`#{js}.bind.apply(#{js}, #{js_args})`)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Returns a JS Promise that resolves to a Ruby Array of Ruby Hashes.
|
|
629
|
+
def all
|
|
630
|
+
js_stmt = @js
|
|
631
|
+
cf = Cloudflare
|
|
632
|
+
err_cls = Cloudflare::D1Error
|
|
633
|
+
`#{js_stmt}.all().then(function(res) { return #{cf}.$js_rows_to_ruby(res.results); }).catch(function(e) { #{Kernel}.$raise(#{err_cls}.$new(e.message || String(e), Opal.hash({binding_type: 'D1', operation: 'all'}))); })`
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# Returns a JS Promise that resolves to a single Ruby Hash (or nil).
|
|
637
|
+
def first
|
|
638
|
+
js_stmt = @js
|
|
639
|
+
cf = Cloudflare
|
|
640
|
+
err_cls = Cloudflare::D1Error
|
|
641
|
+
`#{js_stmt}.first().then(function(res) { return res == null ? nil : #{cf}.$js_object_to_hash(res); }).catch(function(e) { #{Kernel}.$raise(#{err_cls}.$new(e.message || String(e), Opal.hash({binding_type: 'D1', operation: 'first'}))); })`
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Returns a JS Promise that resolves to a Ruby Hash with the D1 meta.
|
|
645
|
+
def run
|
|
646
|
+
js_stmt = @js
|
|
647
|
+
cf = Cloudflare
|
|
648
|
+
err_cls = Cloudflare::D1Error
|
|
649
|
+
`#{js_stmt}.run().then(function(res) { return #{cf}.$js_object_to_hash(res); }).catch(function(e) { #{Kernel}.$raise(#{err_cls}.$new(e.message || String(e), Opal.hash({binding_type: 'D1', operation: 'run'}))); })`
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
class KVNamespace
|
|
654
|
+
def initialize(js)
|
|
655
|
+
@js = js
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# KV#get returns a JS Promise resolving to a String or nil.
|
|
659
|
+
def get(key)
|
|
660
|
+
js_kv = @js
|
|
661
|
+
err_cls = Cloudflare::KVError
|
|
662
|
+
`#{js_kv}.get(#{key}, "text").then(function(v) { return v == null ? nil : v; }).catch(function(e) { #{Kernel}.$raise(#{err_cls}.$new(e.message || String(e), Opal.hash({binding_type: 'KV', operation: 'get'}))); })`
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Put a value. `expiration_ttl:` (seconds) maps to the Workers KV
|
|
666
|
+
# `expirationTtl` option so callers can set TTLs without reaching
|
|
667
|
+
# for backticks. Returns a JS Promise.
|
|
668
|
+
def put(key, value, expiration_ttl: nil)
|
|
669
|
+
js_kv = @js
|
|
670
|
+
err_cls = Cloudflare::KVError
|
|
671
|
+
ttl = expiration_ttl
|
|
672
|
+
if ttl.nil?
|
|
673
|
+
`#{js_kv}.put(#{key}, #{value}).catch(function(e) { #{Kernel}.$raise(#{err_cls}.$new(e.message || String(e), Opal.hash({binding_type: 'KV', operation: 'put'}))); })`
|
|
674
|
+
else
|
|
675
|
+
ttl_int = ttl.to_i
|
|
676
|
+
`#{js_kv}.put(#{key}, #{value}, { expirationTtl: #{ttl_int} }).catch(function(e) { #{Kernel}.$raise(#{err_cls}.$new(e.message || String(e), Opal.hash({binding_type: 'KV', operation: 'put'}))); })`
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# Delete a key. Returns a JS Promise.
|
|
681
|
+
def delete(key)
|
|
682
|
+
js_kv = @js
|
|
683
|
+
err_cls = Cloudflare::KVError
|
|
684
|
+
`#{js_kv}.delete(#{key}).catch(function(e) { #{Kernel}.$raise(#{err_cls}.$new(e.message || String(e), Opal.hash({binding_type: 'KV', operation: 'delete'}))); })`
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
class R2Bucket
|
|
689
|
+
def initialize(js)
|
|
690
|
+
@js = js
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# R2 get. Returns a JS Promise resolving to a Ruby Hash (or nil).
|
|
694
|
+
def get(key)
|
|
695
|
+
js_bucket = @js
|
|
696
|
+
fallback_key = key
|
|
697
|
+
`#{js_bucket}.get(#{key}).then(async function(obj) { if (obj == null) return nil; var text = await obj.text(); var rb = new Map(); rb.set('body', text); rb.set('etag', obj.etag || ''); rb.set('size', obj.size || 0); rb.set('key', obj.key || #{fallback_key}); return rb; })`
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# R2 get_binary. Returns a JS Promise that resolves to a
|
|
701
|
+
# Cloudflare::BinaryBody (wrapping the R2 object's ReadableStream)
|
|
702
|
+
# or nil. Use this for serving images and other binary content
|
|
703
|
+
# through Sinatra routes without byte-mangling.
|
|
704
|
+
#
|
|
705
|
+
# get '/images/:key' do
|
|
706
|
+
# obj = bucket.get_binary(key).__await__
|
|
707
|
+
# halt 404 if obj.nil?
|
|
708
|
+
# obj # BinaryBody flows through build_js_response as a stream
|
|
709
|
+
# end
|
|
710
|
+
# Returns a JS Promise resolving to a Cloudflare::BinaryBody or nil.
|
|
711
|
+
# BinaryBody wraps the R2 ReadableStream so build_js_response can
|
|
712
|
+
# pass it straight to `new Response(stream)` without mangling bytes.
|
|
713
|
+
def get_binary(key)
|
|
714
|
+
js_bucket = @js
|
|
715
|
+
bb = Cloudflare::BinaryBody
|
|
716
|
+
`#{js_bucket}.get(#{key}).then(function(obj) { if (obj == null) return nil; var ct = (obj.httpMetadata && obj.httpMetadata.contentType) || 'application/octet-stream'; return #{bb}.$new(obj.body, ct, 'public, max-age=86400'); })`
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# Put a value. `body` may be a String. Returns a JS Promise.
|
|
720
|
+
def put(key, body, content_type = 'application/octet-stream')
|
|
721
|
+
js_bucket = @js
|
|
722
|
+
`#{js_bucket}.put(#{key}, #{body}, { httpMetadata: { contentType: #{content_type} } })`
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Delete a key. Returns a JS Promise.
|
|
726
|
+
def delete(key)
|
|
727
|
+
js_bucket = @js
|
|
728
|
+
`#{js_bucket}.delete(#{key})`
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# List objects under a prefix. Returns a JS Promise that resolves
|
|
732
|
+
# to a Ruby Array of Hashes, one per object. Each Hash carries the
|
|
733
|
+
# common R2 metadata fields so callers can render a gallery view
|
|
734
|
+
# (key / size / uploaded / httpMetadata['contentType']).
|
|
735
|
+
#
|
|
736
|
+
# bucket.list(prefix: 'phase11a/uploads/', limit: 50).__await__
|
|
737
|
+
# => [{ 'key' => 'phase11a/uploads/abc-cat.png',
|
|
738
|
+
# 'size' => 31337, 'uploaded' => '2026-04-17T...',
|
|
739
|
+
# 'content_type' => 'image/png' }, ...]
|
|
740
|
+
#
|
|
741
|
+
# NOTE: R2's `list()` returns bare objects by default — `httpMetadata`
|
|
742
|
+
# is ONLY populated when `include: ['httpMetadata']` is passed in
|
|
743
|
+
# the options. Without it, every row would come back with a
|
|
744
|
+
# fallback `application/octet-stream` content-type even for real
|
|
745
|
+
# PNG uploads. Always requesting httpMetadata is the right default
|
|
746
|
+
# for a gallery UI; callers that need the bytes-only fast path can
|
|
747
|
+
# fall back to listing raw keys + `get()` on-demand.
|
|
748
|
+
def list(prefix: nil, limit: 100, cursor: nil, include: %w[httpMetadata])
|
|
749
|
+
js_bucket = @js
|
|
750
|
+
opts = `({})`
|
|
751
|
+
`#{opts}.prefix = #{prefix}` if prefix
|
|
752
|
+
`#{opts}.limit = #{limit.to_i}` if limit
|
|
753
|
+
`#{opts}.cursor = #{cursor}` if cursor
|
|
754
|
+
if include && !include.empty?
|
|
755
|
+
js_include = `[]`
|
|
756
|
+
include.each { |v| vs = v.to_s; `#{js_include}.push(#{vs})` }
|
|
757
|
+
`#{opts}.include = #{js_include}`
|
|
758
|
+
end
|
|
759
|
+
`#{js_bucket}.list(#{opts}).then(function(res) { var rows = []; var arr = res && res.objects ? res.objects : []; for (var i = 0; i < arr.length; i++) { var o = arr[i]; var ct = (o.httpMetadata && o.httpMetadata.contentType) || 'application/octet-stream'; var h = new Map(); h.set('key', o.key); h.set('size', o.size|0); h.set('uploaded', o.uploaded ? o.uploaded.toISOString() : null); h.set('content_type', ct); rows.push(h); } return rows; })`
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# Phase 6 — HTTP client foundation. Loaded as part of the Cloudflare
|
|
765
|
+
# Workers adapter so user code can simply `require 'sinatra/base'`
|
|
766
|
+
# and use Net::HTTP / Cloudflare::HTTP.fetch without an extra require.
|
|
767
|
+
require 'cloudflare_workers/http'
|
|
768
|
+
|
|
769
|
+
# Phase 9 — Scheduled (Cron Triggers) dispatcher. Installs the JS
|
|
770
|
+
# `globalThis.__HOMURA_SCHEDULED_DISPATCH__` hook that
|
|
771
|
+
# `src/worker.mjs#scheduled` forwards every cron firing through.
|
|
772
|
+
# Must be loaded after the Cloudflare::* binding wrappers above
|
|
773
|
+
# because it constructs D1Database/KVNamespace/R2Bucket instances
|
|
774
|
+
# inside the dispatcher's per-job env.
|
|
775
|
+
require 'cloudflare_workers/scheduled'
|
|
776
|
+
|
|
777
|
+
# Phase 10 — Workers AI binding wrapper. Loaded here so any Sinatra
|
|
778
|
+
# route can call Cloudflare::AI.run(...) without an extra require.
|
|
779
|
+
require 'cloudflare_workers/ai'
|
|
780
|
+
|
|
781
|
+
# Phase 11A — HTTP foundations.
|
|
782
|
+
#
|
|
783
|
+
# `multipart` installs a Rack::Request#POST override so Sinatra routes
|
|
784
|
+
# can `params['file']` an uploaded file part without any ceremony.
|
|
785
|
+
# `stream` adds `Cloudflare::SSEStream` + `Sinatra::Streaming#sse`
|
|
786
|
+
# so a route can `sse do |out| ... end` and flush chunks
|
|
787
|
+
# through a Workers ReadableStream.
|
|
788
|
+
require 'cloudflare_workers/multipart'
|
|
789
|
+
require 'cloudflare_workers/stream'
|
|
790
|
+
|
|
791
|
+
# Phase 11B — Cloudflare native bindings (Durable Objects / Cache /
|
|
792
|
+
# Queues). Each file registers its own globalThis dispatcher hook
|
|
793
|
+
# where applicable (DO / Queue consumer). Loaded here so user code
|
|
794
|
+
# just needs `require 'sinatra/base'` — no extra `require` per
|
|
795
|
+
# binding.
|
|
796
|
+
require 'cloudflare_workers/cache'
|
|
797
|
+
require 'cloudflare_workers/queue'
|
|
798
|
+
require 'cloudflare_workers/email'
|
|
799
|
+
require 'cloudflare_workers/durable_object'
|
|
800
|
+
|
|
801
|
+
require 'cloudflare_workers/async_registry'
|