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