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,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'