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,407 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+ # await: true
4
+ #
5
+ # Phase 11B — Cloudflare Queues binding wrapper.
6
+ #
7
+ # Cloudflare Queues let a Worker send messages to a persistent queue
8
+ # and another Worker (or the same one) consume them in batches.
9
+ #
10
+ # Producer side:
11
+ # env.JOBS_QUEUE.send(body, options)
12
+ # env.JOBS_QUEUE.sendBatch(messages, options)
13
+ #
14
+ # Consumer side (worker.mjs):
15
+ # export default { async queue(batch, env, ctx) { ... } }
16
+ # // batch.messages[i].body — the payload
17
+ # // batch.messages[i].ack() — per-message ack
18
+ # // batch.messages[i].retry() — retry with backoff
19
+ # // batch.ackAll() / batch.retryAll()
20
+ #
21
+ # This file provides:
22
+ #
23
+ # 1. `Cloudflare::Queue` — a Ruby wrapper around the producer binding
24
+ # (`env.JOBS_QUEUE`). `#send(body, delay_seconds:, content_type:)`
25
+ # and `#send_batch(messages)` return JS Promises.
26
+ #
27
+ # 2. `Cloudflare::QueueBatch` / `Cloudflare::QueueMessage` — Ruby
28
+ # views over the JS batch passed to the `queue()` export. Each
29
+ # message exposes `.body` (parsed if JSON), `.id`, `.timestamp`,
30
+ # `.ack` / `.retry`.
31
+ #
32
+ # 3. A dispatcher hook (`globalThis.__HOMURA_QUEUE_DISPATCH__`)
33
+ # that `src/worker.mjs#queue` calls once per incoming batch. It
34
+ # walks `App.consume_queue_handlers` (registered via the
35
+ # `consume_queue 'queue-name' do |batch| ... end` DSL in
36
+ # lib/sinatra/queue.rb) and invokes whichever matches the batch's
37
+ # `queue` name.
38
+
39
+ require 'json'
40
+
41
+ module Cloudflare
42
+ class QueueError < StandardError
43
+ attr_reader :operation, :queue
44
+ def initialize(message, operation: nil, queue: nil)
45
+ @operation = operation
46
+ @queue = queue
47
+ super("[Cloudflare::Queue] queue=#{queue || '?'} op=#{operation || 'send'}: #{message}")
48
+ end
49
+ end
50
+
51
+ # Producer wrapper. The binding's JS API:
52
+ # env.JOBS_QUEUE.send(body, options?) — one message
53
+ # env.JOBS_QUEUE.sendBatch(messages, options?) — multiple
54
+ # Each returns a JS Promise resolving to undefined.
55
+ class Queue
56
+ attr_reader :js, :name
57
+
58
+ def initialize(js, name = nil)
59
+ @js = js
60
+ @name = (name || 'queue').to_s
61
+ end
62
+
63
+ def available?
64
+ js = @js
65
+ # Opal's Ruby nil is a runtime sentinel (Opal.nil), not JS null.
66
+ # See `lib/cloudflare_workers/cache.rb#available?` for the same
67
+ # pattern and rationale.
68
+ !!`(#{js} !== null && #{js} !== undefined && #{js} !== Opal.nil)`
69
+ end
70
+
71
+ # Send one message. `body` may be any JSON-serialisable Ruby value.
72
+ # Strings / numbers / booleans pass through; Hashes / Arrays are
73
+ # sent as plain JS objects (Workers Queues natively encodes them
74
+ # via structured clone).
75
+ #
76
+ # delay_seconds: 60 # schedule for ~1 minute from now
77
+ # content_type: "json" (default) | "text" | "bytes"
78
+ def send(body, delay_seconds: nil, content_type: nil)
79
+ js = @js
80
+ qname = @name
81
+ err_klass = Cloudflare::QueueError
82
+ raise QueueError.new('queue binding not bound', operation: 'send', queue: qname) unless available?
83
+
84
+ js_body = ruby_to_js(body)
85
+ js_opts = `({})`
86
+ `#{js_opts}.delaySeconds = #{delay_seconds.to_i}` if delay_seconds
87
+ `#{js_opts}.contentType = #{content_type.to_s}` if content_type
88
+
89
+ # Single-line IIFE — see `lib/cloudflare_workers/cache.rb#put`
90
+ # for the Opal multi-line x-string quirk. Passing arguments in
91
+ # explicitly (rather than interpolating inside the template)
92
+ # keeps the Promise a first-class expression.
93
+ `(async function(js, body, opts, qname, Kernel, err_klass) { try { await js.send(body, opts); } catch (e) { Kernel.$raise(err_klass.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'send', queue: qname }))); } return null; })(#{js}, #{js_body}, #{js_opts}, #{qname}, #{Kernel}, #{err_klass})`
94
+ end
95
+
96
+ # Send an Array of {body:, delay_seconds?, content_type?} Hashes
97
+ # or plain bodies as a single batch.
98
+ def send_batch(messages, delay_seconds: nil)
99
+ js = @js
100
+ qname = @name
101
+ err_klass = Cloudflare::QueueError
102
+ raise QueueError.new('queue binding not bound', operation: 'send_batch', queue: qname) unless available?
103
+
104
+ js_msgs = `([])`
105
+ messages.each do |m|
106
+ if m.is_a?(Hash)
107
+ body = m['body'] || m[:body]
108
+ ct = m['content_type'] || m[:content_type]
109
+ ds = m['delay_seconds'] || m[:delay_seconds]
110
+ js_body = ruby_to_js(body)
111
+ js_msg = `({ body: #{js_body} })`
112
+ `#{js_msg}.contentType = #{ct.to_s}` if ct
113
+ `#{js_msg}.delaySeconds = #{ds.to_i}` if ds
114
+ `#{js_msgs}.push(#{js_msg})`
115
+ else
116
+ js_body = ruby_to_js(m)
117
+ `#{js_msgs}.push({ body: #{js_body} })`
118
+ end
119
+ end
120
+ js_opts = `({})`
121
+ `#{js_opts}.delaySeconds = #{delay_seconds.to_i}` if delay_seconds
122
+
123
+ # Single-line IIFE — see `send` above for rationale.
124
+ `(async function(js, msgs, opts, qname, Kernel, err_klass) { try { await js.sendBatch(msgs, opts); } catch (e) { Kernel.$raise(err_klass.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'send_batch', queue: qname }))); } return null; })(#{js}, #{js_msgs}, #{js_opts}, #{qname}, #{Kernel}, #{err_klass})`
125
+ end
126
+
127
+ private
128
+
129
+ # Same Ruby→JS conversion that Cloudflare::AI.ruby_to_js uses, but
130
+ # local to avoid cross-wrapper coupling.
131
+ def ruby_to_js(val)
132
+ return val if val.is_a?(String) || val.is_a?(Numeric) || val == true || val == false || val.nil?
133
+ if val.is_a?(Symbol)
134
+ return val.to_s
135
+ end
136
+ if val.is_a?(Hash)
137
+ obj = `({})`
138
+ val.each do |k, v|
139
+ ks = k.to_s
140
+ jv = ruby_to_js(v)
141
+ `#{obj}[#{ks}] = #{jv}`
142
+ end
143
+ return obj
144
+ end
145
+ if val.is_a?(Array)
146
+ arr = `([])`
147
+ val.each { |v| jv = ruby_to_js(v); `#{arr}.push(#{jv})` }
148
+ return arr
149
+ end
150
+ val
151
+ end
152
+ end
153
+
154
+ # Represents one message inside a queue batch. Wraps the JS message
155
+ # object so user code can call `.body`, `.id`, `.ack`, `.retry`.
156
+ class QueueMessage
157
+ attr_reader :js
158
+
159
+ def initialize(js)
160
+ @js = js
161
+ end
162
+
163
+ def id
164
+ js = @js
165
+ `(#{js} && #{js}.id ? String(#{js}.id) : '')`
166
+ end
167
+
168
+ def timestamp
169
+ js = @js
170
+ ms = `(#{js} && #{js}.timestamp && typeof #{js}.timestamp.getTime === 'function' ? #{js}.timestamp.getTime() : null)`
171
+ return nil if `#{ms} == null`
172
+ Time.at(ms.to_f / 1000.0)
173
+ end
174
+
175
+ # Parsed body. Workers Queues gives us the structured clone of
176
+ # whatever the producer sent, so this is usually already a Ruby
177
+ # Hash / Array after crossing the JS<->Ruby boundary. Raw
178
+ # strings pass through untouched — we deliberately do NOT attempt
179
+ # to `JSON.parse` an opaque String here because Workers Queues
180
+ # already structure-clones producer payloads, so a String body
181
+ # means the producer really meant a String. Callers that did
182
+ # encode a JSON string themselves can `JSON.parse(msg.body)` at
183
+ # the call site.
184
+ #
185
+ # (Copilot review PR #9 flagged the earlier "JSON-looking strings
186
+ # are parsed" comment — fixed by removing that claim.)
187
+ def body
188
+ return @body if defined?(@body)
189
+ js = @js
190
+ raw = `(#{js} && typeof #{js}.body !== 'undefined' ? #{js}.body : null)`
191
+ @body = js_to_ruby(raw)
192
+ end
193
+
194
+ # Raw body as it came from JS — useful when the consumer wants to
195
+ # see the unparsed value (e.g. a JSON string stored as-is).
196
+ def raw_body
197
+ js = @js
198
+ `(#{js} && typeof #{js}.body !== 'undefined' ? #{js}.body : null)`
199
+ end
200
+
201
+ # Queues API: mark this message as successfully handled.
202
+ def ack
203
+ js = @js
204
+ `(#{js} && typeof #{js}.ack === 'function' ? #{js}.ack() : null)`
205
+ nil
206
+ end
207
+
208
+ # Queues API: request a retry. `delay_seconds:` caps the retry
209
+ # backoff at a specific value.
210
+ def retry(delay_seconds: nil)
211
+ js = @js
212
+ if delay_seconds
213
+ ds = delay_seconds.to_i
214
+ `(#{js} && typeof #{js}.retry === 'function' ? #{js}.retry({ delaySeconds: #{ds} }) : null)`
215
+ else
216
+ `(#{js} && typeof #{js}.retry === 'function' ? #{js}.retry() : null)`
217
+ end
218
+ nil
219
+ end
220
+
221
+ private
222
+
223
+ def js_to_ruby(v)
224
+ return nil if `#{v} == null`
225
+ return v if `typeof #{v} === 'string' || typeof #{v} === 'number' || typeof #{v} === 'boolean'`
226
+ if `Array.isArray(#{v})`
227
+ out = []
228
+ len = `#{v}.length`
229
+ i = 0
230
+ while i < len
231
+ out << js_to_ruby(`#{v}[#{i}]`)
232
+ i += 1
233
+ end
234
+ return out
235
+ end
236
+ if `typeof #{v} === 'object'`
237
+ h = {}
238
+ keys = `Object.keys(#{v})`
239
+ len = `#{keys}.length`
240
+ i = 0
241
+ while i < len
242
+ k = `#{keys}[#{i}]`
243
+ h[k] = js_to_ruby(`#{v}[#{k}]`)
244
+ i += 1
245
+ end
246
+ return h
247
+ end
248
+ v
249
+ end
250
+ end
251
+
252
+ # Wraps the MessageBatch passed to `queue(batch, env, ctx)` in
253
+ # worker.mjs. Exposes `queue` name, an Array of QueueMessage, and
254
+ # batch-level ack/retry helpers.
255
+ class QueueBatch
256
+ attr_reader :js
257
+
258
+ def initialize(js)
259
+ @js = js
260
+ end
261
+
262
+ def queue
263
+ js = @js
264
+ `(#{js} && #{js}.queue ? String(#{js}.queue) : '')`
265
+ end
266
+
267
+ def messages
268
+ return @messages if @messages
269
+ js = @js
270
+ arr = `(#{js} && Array.isArray(#{js}.messages) ? #{js}.messages : [])`
271
+ out = []
272
+ len = `#{arr}.length`
273
+ i = 0
274
+ while i < len
275
+ out << QueueMessage.new(`#{arr}[#{i}]`)
276
+ i += 1
277
+ end
278
+ @messages = out
279
+ end
280
+
281
+ def size
282
+ messages.length
283
+ end
284
+
285
+ def ack_all
286
+ js = @js
287
+ `(#{js} && typeof #{js}.ackAll === 'function' ? #{js}.ackAll() : null)`
288
+ nil
289
+ end
290
+
291
+ def retry_all(delay_seconds: nil)
292
+ js = @js
293
+ if delay_seconds
294
+ ds = delay_seconds.to_i
295
+ `(#{js} && typeof #{js}.retryAll === 'function' ? #{js}.retryAll({ delaySeconds: #{ds} }) : null)`
296
+ else
297
+ `(#{js} && typeof #{js}.retryAll === 'function' ? #{js}.retryAll() : null)`
298
+ end
299
+ nil
300
+ end
301
+ end
302
+
303
+ # Consumer dispatcher. The Sinatra DSL (`consume_queue`) registers
304
+ # handlers here via `Cloudflare::QueueConsumer.register(queue_name, unbound_method)`.
305
+ # `src/worker.mjs#queue` calls `globalThis.__HOMURA_QUEUE_DISPATCH__`
306
+ # which forwards into `dispatch_js` below.
307
+ module QueueConsumer
308
+ @handlers = {}
309
+
310
+ class << self
311
+ attr_reader :handlers
312
+ end
313
+
314
+ def self.register(queue_name, unbound_method)
315
+ @handlers ||= {}
316
+ @handlers[queue_name.to_s] = unbound_method
317
+ end
318
+
319
+ def self.handler_for(queue_name)
320
+ (@handlers || {})[queue_name.to_s]
321
+ end
322
+
323
+ def self.handlers_by_queue
324
+ (@handlers || {}).dup
325
+ end
326
+
327
+ # Dispatcher called from the JS hook. `js_batch` is the MessageBatch,
328
+ # `js_env`, `js_ctx` are the Workers env and ExecutionContext.
329
+ # Returns a Hash summary ({ queue:, handled:, size: }) for diagnostics.
330
+ def self.dispatch_js(js_batch, js_env, js_ctx)
331
+ batch = QueueBatch.new(js_batch)
332
+ queue_name = batch.queue
333
+ handler = handler_for(queue_name)
334
+ if handler.nil?
335
+ warn "[Cloudflare::QueueConsumer] no handler registered for queue #{queue_name.inspect}; messages will time out and retry"
336
+ return { 'queue' => queue_name, 'handled' => false, 'size' => batch.size, 'reason' => 'no_handler' }
337
+ end
338
+
339
+ ctx = QueueContext.new(batch, js_env, js_ctx)
340
+ result = handler.bind(ctx).call(batch)
341
+ if `(#{result} != null && typeof #{result}.then === 'function')`
342
+ result = result.__await__
343
+ end
344
+ { 'queue' => queue_name, 'handled' => true, 'size' => batch.size, 'result' => result.is_a?(Hash) ? result : nil }
345
+ end
346
+
347
+ # Single-line backtick (see scheduled.rb for the Opal multi-line
348
+ # constraint). Logs+swallows thrown errors so one bad handler
349
+ # doesn't crash the Workers queue consumer.
350
+ def self.install_dispatcher
351
+ mod = self
352
+ `globalThis.__HOMURA_QUEUE_DISPATCH__ = async function(js_batch, js_env, js_ctx) { try { return await #{mod}.$dispatch_js(js_batch, js_env, js_ctx); } catch (err) { try { globalThis.console.error('[Cloudflare::QueueConsumer] dispatch failed:', err && err.stack || err); } catch (e) {} return { queue: (js_batch && js_batch.queue) || '', handled: false, size: (js_batch && Array.isArray(js_batch.messages) ? js_batch.messages.length : 0), error: String(err && err.message || err) }; } };(function(){var g=globalThis;g.__OPAL_WORKERS__=g.__OPAL_WORKERS__||{};g.__OPAL_WORKERS__.queue=g.__HOMURA_QUEUE_DISPATCH__;})();`
353
+ end
354
+ end
355
+
356
+ # `self` inside a `consume_queue do |batch| ... end` block. Exposes
357
+ # env / ctx helpers alongside the batch so the block can reach the
358
+ # same D1 / KV / R2 bindings an HTTP route would. This keeps the
359
+ # consumer side consistent with Phase 9's ScheduledContext.
360
+ class QueueContext
361
+ attr_reader :batch, :env, :js_env, :js_ctx
362
+
363
+ def initialize(batch, js_env, js_ctx)
364
+ @batch = batch
365
+ @js_env = js_env
366
+ @js_ctx = js_ctx
367
+ @env = build_env(js_env)
368
+ end
369
+
370
+ def db; env['cloudflare.DB']; end
371
+ def kv; env['cloudflare.KV']; end
372
+ def bucket; env['cloudflare.BUCKET']; end
373
+
374
+ # Hand a long-running promise to ctx.waitUntil. Mirrors the same
375
+ # helper in Sinatra::Scheduled's ScheduledContext.
376
+ def wait_until(promise)
377
+ return promise if @js_ctx.nil?
378
+ js_ctx = @js_ctx
379
+ `#{js_ctx}.waitUntil(#{promise})`
380
+ promise
381
+ end
382
+
383
+ private
384
+
385
+ def build_env(js_env)
386
+ env = {
387
+ 'cloudflare.queue' => true,
388
+ 'cloudflare.env' => js_env,
389
+ 'cloudflare.ctx' => @js_ctx
390
+ }
391
+ # js_env is a raw JS object when called from the Workers runtime,
392
+ # so `.nil?` would explode with NoMethodError. Use a JS-level
393
+ # null/undefined/Opal.nil check instead — same pattern
394
+ # `Cloudflare::Cache#available?` uses.
395
+ return env if `(#{js_env} == null || #{js_env} === undefined || #{js_env} === Opal.nil)`
396
+ js_db = `#{js_env} && #{js_env}.DB`
397
+ js_kv = `#{js_env} && #{js_env}.KV`
398
+ js_r2 = `#{js_env} && #{js_env}.BUCKET`
399
+ env['cloudflare.DB'] = Cloudflare::D1Database.new(js_db) if `#{js_db} != null`
400
+ env['cloudflare.KV'] = Cloudflare::KVNamespace.new(js_kv) if `#{js_kv} != null`
401
+ env['cloudflare.BUCKET'] = Cloudflare::R2Bucket.new(js_r2) if `#{js_r2} != null`
402
+ env
403
+ end
404
+ end
405
+ end
406
+
407
+ Cloudflare::QueueConsumer.install_dispatcher
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+ # await: true
4
+ #
5
+ # Phase 9 — Cloudflare Workers Cron Trigger dispatcher.
6
+ #
7
+ # Cloudflare Workers fire `scheduled(event, env, ctx)` for each entry
8
+ # in `wrangler.toml`'s `[triggers] crons = [...]` array. The runtime
9
+ # guarantees `event.cron` is one of the cron expressions declared in
10
+ # wrangler.toml; `event.scheduledTime` is the firing time as a JS
11
+ # epoch-millis number.
12
+ #
13
+ # This file:
14
+ # 1. Defines `Cloudflare::ScheduledEvent` — a Ruby wrapper around
15
+ # the JS ScheduledEvent so user blocks see plain Ruby values
16
+ # (`event.cron` -> String, `event.scheduled_time` -> Time).
17
+ #
18
+ # 2. Installs `globalThis.__HOMURA_SCHEDULED_DISPATCH__` — the JS
19
+ # hook called from `src/worker.mjs#scheduled`. The hook receives
20
+ # the JS event/env/ctx, builds a Ruby ScheduledEvent, and calls
21
+ # `Sinatra::Scheduled::ClassMethods#dispatch_scheduled` on the
22
+ # Rack handler's app.
23
+ #
24
+ # 3. Exposes `Cloudflare::Scheduled.app=` so non-Sinatra Rack apps
25
+ # can hook the dispatcher too. By default, `Rack::Handler::
26
+ # CloudflareWorkers.app` (set by `run app` in user code) is used.
27
+ #
28
+ # Test entry point: `Cloudflare::Scheduled.dispatch(cron, scheduled_time, js_env, js_ctx)`
29
+ # — used by `test/scheduled_smoke.rb` so the same code path that the
30
+ # Workers runtime takes is exercised under Node, without needing the
31
+ # JS dispatcher.
32
+
33
+ require 'time'
34
+
35
+ module Cloudflare
36
+ # Wrapper around the JS ScheduledEvent. The Workers runtime gives us:
37
+ #
38
+ # event.cron — String, e.g. '*/5 * * * *'
39
+ # event.scheduledTime — Number (millis-since-epoch)
40
+ # event.type — String, always 'scheduled'
41
+ # event.waitUntil(p) — same shape as ctx.waitUntil(p)
42
+ #
43
+ # We surface these as Ruby idioms so user code never needs backticks.
44
+ class ScheduledEvent
45
+ attr_reader :cron, :scheduled_time, :type, :raw
46
+
47
+ def initialize(cron:, scheduled_time:, type: 'scheduled', raw: nil)
48
+ @cron = cron.to_s.freeze
49
+ @scheduled_time = scheduled_time
50
+ @type = type.to_s.freeze
51
+ @raw = raw
52
+ end
53
+
54
+ # Build a ScheduledEvent from the native JS event. `js_event` may
55
+ # be nil during smoke tests; in that case the caller passes the
56
+ # cron / scheduled_time via the keyword form above.
57
+ #
58
+ # JS undefined / null guards are written in JS land instead of
59
+ # delegating to `nil?` because Opal doesn't always coerce a bare
60
+ # JS undefined into Ruby's nil — calling `.nil?` on it raises
61
+ # TypeError.
62
+ def self.from_js(js_event)
63
+ return new(cron: '', scheduled_time: Time.now) if `#{js_event} == null`
64
+
65
+ cron = `(#{js_event}.cron == null ? '' : String(#{js_event}.cron))`
66
+ type = `(#{js_event}.type == null ? 'scheduled' : String(#{js_event}.type))`
67
+ has_sched = `(#{js_event}.scheduledTime != null)`
68
+ sched_t = if has_sched
69
+ sched_ms = `Number(#{js_event}.scheduledTime)`
70
+ Time.at(sched_ms.to_f / 1000.0)
71
+ else
72
+ Time.now
73
+ end
74
+
75
+ new(cron: cron, scheduled_time: sched_t, type: type, raw: js_event)
76
+ end
77
+
78
+ def to_h
79
+ {
80
+ 'cron' => cron,
81
+ 'scheduled_time' => scheduled_time.to_i,
82
+ 'type' => type
83
+ }
84
+ end
85
+ end
86
+
87
+ # Dispatcher singleton. The Rack handler installs the JS hook on
88
+ # boot; user code never calls anything in this module directly.
89
+ module Scheduled
90
+ @app = nil
91
+
92
+ class << self
93
+ # Override the dispatch target. By default the dispatcher uses
94
+ # `Rack::Handler::CloudflareWorkers.app`, which is whatever the
95
+ # user passed to top-level `run app`. Tests use this to plug
96
+ # a fake Sinatra subclass without booting the full handler.
97
+ attr_accessor :app
98
+ end
99
+
100
+ # Install the JS-side dispatcher on globalThis. Idempotent — safe
101
+ # to call multiple times (last writer wins).
102
+ #
103
+ # NOTE: kept as a single-line backtick x-string. Opal's parser
104
+ # treats multi-line backtick strings as raw statements that don't
105
+ # return a value AND it tries to lex-as-Ruby everything inside,
106
+ # so JS comments containing the Ruby backtick delimiter (`...`)
107
+ # crash the build. Single-line form sidesteps both pitfalls.
108
+ def self.install_dispatcher
109
+ mod = self
110
+ `globalThis.__HOMURA_SCHEDULED_DISPATCH__ = async function(js_event, js_env, js_ctx) { try { return await #{mod}.$dispatch_js(js_event, js_env, js_ctx); } catch (err) { try { globalThis.console.error('[Cloudflare::Scheduled] dispatch failed:', err && err.stack || err); } catch (e) {} return { error: String(err && err.message || err) }; } };(function(){var g=globalThis;g.__OPAL_WORKERS__=g.__OPAL_WORKERS__||{};g.__OPAL_WORKERS__.scheduled=g.__HOMURA_SCHEDULED_DISPATCH__;})();`
111
+ end
112
+
113
+ # Called from the JS hook. Resolves the Ruby app, builds a
114
+ # ScheduledEvent, runs `dispatch_scheduled` on the app class, and
115
+ # returns the result Hash for diagnostics. We `__await__` the
116
+ # inner dispatch so the returned object is the resolved Hash, not
117
+ # a Promise — the JS hook then `await`s our return promise once.
118
+ def self.dispatch_js(js_event, js_env, js_ctx)
119
+ event = ScheduledEvent.from_js(js_event)
120
+ target = resolve_app
121
+ if target.nil?
122
+ warn '[Cloudflare::Scheduled] no app registered; ignoring scheduled event'
123
+ return { 'fired' => 0, 'total' => 0, 'results' => [], 'error' => 'no_app' }
124
+ end
125
+ target.dispatch_scheduled(event, js_env, js_ctx).__await__
126
+ end
127
+
128
+ # Test-friendly direct entry point. Lets `test/scheduled_smoke.rb`
129
+ # exercise the same dispatch logic without going through the JS
130
+ # hook. `cron` / `scheduled_time` are plain Ruby values. Returns
131
+ # the awaited result Hash (callers can still `__await__` the
132
+ # outer Promise — this method is async since it uses `__await__`).
133
+ def self.dispatch(cron, scheduled_time = Time.now, js_env = nil, js_ctx = nil)
134
+ event = ScheduledEvent.new(cron: cron, scheduled_time: scheduled_time)
135
+ target = resolve_app
136
+ raise 'no app registered for Cloudflare::Scheduled' if target.nil?
137
+ target.dispatch_scheduled(event, js_env, js_ctx).__await__
138
+ end
139
+
140
+ # Await a JS Promise from inside `# await: true` code without
141
+ # exposing the `__await__` keyword to callers. Re-throws Promise
142
+ # rejections as synchronous Ruby exceptions so callers can rescue.
143
+ # Sinatra::Scheduled uses this to bridge per-job exceptions out of
144
+ # the async block boundary.
145
+ #
146
+ # Implemented as `__await__` directly so Opal compiles this method
147
+ # itself as async — callers in `# await: true` files that do
148
+ # `await_promise(p)` get the resolved value back the same way they
149
+ # would from a bare `p.__await__`. Rejections re-throw as Ruby
150
+ # exceptions thanks to ES8 `await`'s built-in throw semantics.
151
+ def self.await_promise(promise)
152
+ promise.__await__
153
+ end
154
+
155
+ # Resolve which app class should receive the dispatch. Priority:
156
+ # 1. `Cloudflare::Scheduled.app = SomeApp` (explicit override)
157
+ # 2. `Rack::Handler::CloudflareWorkers.app` (set by `run app`)
158
+ # Returns the class itself (not an instance), because
159
+ # `dispatch_scheduled` is a class method on Sinatra apps.
160
+ def self.resolve_app
161
+ candidate = @app
162
+ if candidate.nil? && defined?(::Rack::Handler::CloudflareWorkers)
163
+ candidate = ::Rack::Handler::CloudflareWorkers.app
164
+ end
165
+ return nil if candidate.nil?
166
+ # Sinatra app classes respond to `dispatch_scheduled` (added by
167
+ # Sinatra::Scheduled). Plain Rack apps would be instances and
168
+ # lack the class method — we return them as-is and let the
169
+ # caller's `dispatch_scheduled` raise NoMethodError so the
170
+ # mistake is visible.
171
+ if candidate.respond_to?(:dispatch_scheduled)
172
+ candidate
173
+ elsif candidate.respond_to?(:class) && candidate.class.respond_to?(:dispatch_scheduled)
174
+ candidate.class
175
+ else
176
+ candidate
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ # Auto-install the JS dispatcher hook the moment this file is loaded.
183
+ # Sinatra extensions are evaluated by the user code that comes after,
184
+ # so the hook must be live before the first scheduled event arrives.
185
+ Cloudflare::Scheduled.install_dispatcher