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,590 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# backtick_javascript: true
|
|
3
|
+
# await: true
|
|
4
|
+
#
|
|
5
|
+
# Phase 11B — Durable Objects binding wrapper.
|
|
6
|
+
#
|
|
7
|
+
# Cloudflare Durable Objects (DO) give a Worker a single-writer, strongly
|
|
8
|
+
# consistent storage actor reachable by name or unique id. The runtime
|
|
9
|
+
# instantiates ONE instance per DO id per colo and routes every `stub.fetch`
|
|
10
|
+
# to the same instance, so the user can build counters, rate limiters,
|
|
11
|
+
# session servers, and collaborative state machines without any external
|
|
12
|
+
# store.
|
|
13
|
+
#
|
|
14
|
+
# Three responsibilities here, analogous to `cloudflare_workers.rb`'s
|
|
15
|
+
# D1 / KV / R2 section:
|
|
16
|
+
#
|
|
17
|
+
# 1. `Cloudflare::DurableObjectNamespace` — wraps the binding JS object
|
|
18
|
+
# (`env.COUNTER`). Exposes `.id_from_name(str)` / `.new_unique_id` /
|
|
19
|
+
# `.get(id)` / `.get_by_name(str)` so routes never need backticks.
|
|
20
|
+
#
|
|
21
|
+
# 2. `Cloudflare::DurableObjectStub` — wraps the stub returned by
|
|
22
|
+
# `namespace.get(id)`. Its `#fetch(url_or_request, init = nil)` posts
|
|
23
|
+
# to the DO and returns a `Cloudflare::HTTPResponse` (reusing the
|
|
24
|
+
# Phase 6 shape) after awaiting body text.
|
|
25
|
+
#
|
|
26
|
+
# 3. Ruby-side DO handlers — the DO class logic itself.
|
|
27
|
+
# A Sinatra-like DSL (`Cloudflare::DurableObject.define 'Counter' do ... end`)
|
|
28
|
+
# registers a Ruby block that is invoked from the JS DO class
|
|
29
|
+
# (defined in `src/worker.mjs`) once per incoming request. Each
|
|
30
|
+
# block runs inside a `DurableObjectRequestContext` that exposes
|
|
31
|
+
# `state.storage` (get/put/delete/list), `env` (bindings), and
|
|
32
|
+
# `request` (the HTTP request).
|
|
33
|
+
#
|
|
34
|
+
# The JS side (src/worker.mjs) exports a generic `HomuraCounterDO`
|
|
35
|
+
# class whose constructor + fetch forward to Ruby through
|
|
36
|
+
# `globalThis.__HOMURA_DO_DISPATCH__(class_name, state, env, req_init)`.
|
|
37
|
+
# That hook is installed by this file on load, mirroring the scheduled
|
|
38
|
+
# dispatcher pattern from Phase 9.
|
|
39
|
+
#
|
|
40
|
+
# WebSockets are deferred to the next phase — this wrapper focuses on
|
|
41
|
+
# HTTP fetch-style interaction with DO instances, which is enough for
|
|
42
|
+
# counters / session state / rate limiters.
|
|
43
|
+
|
|
44
|
+
require 'json'
|
|
45
|
+
|
|
46
|
+
module Cloudflare
|
|
47
|
+
class DurableObjectError < StandardError
|
|
48
|
+
attr_reader :operation, :do_class
|
|
49
|
+
def initialize(message, operation: nil, do_class: nil)
|
|
50
|
+
@operation = operation
|
|
51
|
+
@do_class = do_class
|
|
52
|
+
super("[Cloudflare::DurableObject] class=#{do_class || '?'} op=#{operation || 'fetch'}: #{message}")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Wraps a JS DurableObjectId (opaque token returned by
|
|
57
|
+
# `namespace.idFromName(...)` / `namespace.newUniqueId()`). Carries
|
|
58
|
+
# the raw JS id so we can round-trip it into `namespace.get(id)`.
|
|
59
|
+
class DurableObjectId
|
|
60
|
+
attr_reader :js_id
|
|
61
|
+
|
|
62
|
+
def initialize(js_id)
|
|
63
|
+
@js_id = js_id
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Hex string representation (stable across invocations for a named id).
|
|
67
|
+
def to_s
|
|
68
|
+
js = @js_id
|
|
69
|
+
`(#{js} && typeof #{js}.toString === 'function' ? #{js}.toString() : String(#{js} || ''))`
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Wraps a DurableObjectNamespace binding (env.COUNTER). The binding
|
|
74
|
+
# object itself is opaque; we only need three methods from it —
|
|
75
|
+
# idFromName, newUniqueId, get — but we also expose a high-level
|
|
76
|
+
# `get_by_name` helper because "get a stub by name" is the most
|
|
77
|
+
# common call site and the unwrapped form requires two steps.
|
|
78
|
+
class DurableObjectNamespace
|
|
79
|
+
def initialize(js)
|
|
80
|
+
@js = js
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Hash-derived DurableObjectId. Two calls with the same `name` in
|
|
84
|
+
# the same namespace return equal ids, so `get_by_name("foo")` is
|
|
85
|
+
# the idiomatic "single-writer per name" pattern.
|
|
86
|
+
def id_from_name(name)
|
|
87
|
+
js_ns = @js
|
|
88
|
+
DurableObjectId.new(`#{js_ns}.idFromName(#{name.to_s})`)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Random unique DurableObjectId. Used when the caller wants an
|
|
92
|
+
# ephemeral / request-scoped actor (e.g. one DO per user session).
|
|
93
|
+
def new_unique_id
|
|
94
|
+
js_ns = @js
|
|
95
|
+
DurableObjectId.new(`#{js_ns}.newUniqueId()`)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Parse a hex id string back into a DurableObjectId. Matches the
|
|
99
|
+
# Workers `namespace.idFromString(hex)` method.
|
|
100
|
+
def id_from_string(hex)
|
|
101
|
+
js_ns = @js
|
|
102
|
+
DurableObjectId.new(`#{js_ns}.idFromString(#{hex.to_s})`)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Get a stub for an id (or a DurableObjectId wrapper).
|
|
106
|
+
def get(id)
|
|
107
|
+
js_ns = @js
|
|
108
|
+
js_id = id.is_a?(DurableObjectId) ? id.js_id : id
|
|
109
|
+
DurableObjectStub.new(`#{js_ns}.get(#{js_id})`)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Convenience: derive id-from-name and return the stub in one call.
|
|
113
|
+
#
|
|
114
|
+
# stub = env_ns.get_by_name('global-counter')
|
|
115
|
+
# resp = stub.fetch('/inc').__await__
|
|
116
|
+
def get_by_name(name)
|
|
117
|
+
get(id_from_name(name))
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Wraps a stub (the object returned by `namespace.get(id)`). Its only
|
|
122
|
+
# callable method is `fetch(request)`, which submits an HTTP request
|
|
123
|
+
# to the DO instance and returns a `Response`. We return the same
|
|
124
|
+
# `Cloudflare::HTTPResponse` shape as Phase 6 so routes don't need to
|
|
125
|
+
# learn a second API.
|
|
126
|
+
class DurableObjectStub
|
|
127
|
+
attr_reader :js
|
|
128
|
+
|
|
129
|
+
def initialize(js)
|
|
130
|
+
@js = js
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Same as `fetch` but returns the raw JS Response (a JS Promise
|
|
134
|
+
# resolving to it) instead of wrapping it in a
|
|
135
|
+
# `Cloudflare::HTTPResponse`. Needed for WebSocket-upgrade
|
|
136
|
+
# responses — the 101 Response carries its WebSocket in a
|
|
137
|
+
# `.webSocket` property that disappears if we reconstruct the
|
|
138
|
+
# Response via the HTTPResponse wrapper.
|
|
139
|
+
def fetch_raw(url_or_request, method: 'GET', headers: nil, body: nil)
|
|
140
|
+
hdrs = headers || {}
|
|
141
|
+
method_str = method.to_s.upcase
|
|
142
|
+
js_headers = Cloudflare::HTTP.ruby_headers_to_js(hdrs)
|
|
143
|
+
js_body = body.nil? ? nil : body.to_s
|
|
144
|
+
url_str = url_or_request.to_s
|
|
145
|
+
js_stub = @js
|
|
146
|
+
err_klass = Cloudflare::DurableObjectError
|
|
147
|
+
`(async function(stub, url_str, method_str, js_headers, js_body, Kernel, err_klass) { var init = { method: method_str, headers: js_headers }; if (js_body !== null && js_body !== undefined && js_body !== Opal.nil) { init.body = js_body; } try { return await stub.fetch(url_str, init); } catch (e) { Kernel.$raise(err_klass.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'stub.fetch_raw' }))); } })(#{js_stub}, #{url_str}, #{method_str}, #{js_headers}, #{js_body}, #{Kernel}, #{err_klass})`
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Call the DO with a plain URL (String) and optional init-Hash. The
|
|
151
|
+
# DO's fetch handler sees the URL as-is (no routing layer strips
|
|
152
|
+
# the prefix), so user code can use any pathname it wants as its
|
|
153
|
+
# internal DO command channel. Returns a JS Promise the caller
|
|
154
|
+
# `__await__`s to get a `Cloudflare::HTTPResponse`.
|
|
155
|
+
def fetch(url_or_request, method: 'GET', headers: nil, body: nil)
|
|
156
|
+
js_stub = @js
|
|
157
|
+
hdrs = headers || {}
|
|
158
|
+
method_str = method.to_s.upcase
|
|
159
|
+
js_headers = Cloudflare::HTTP.ruby_headers_to_js(hdrs)
|
|
160
|
+
js_body = body.nil? ? nil : body.to_s
|
|
161
|
+
url_str = url_or_request.to_s
|
|
162
|
+
err_klass = Cloudflare::DurableObjectError
|
|
163
|
+
response_klass = Cloudflare::HTTPResponse
|
|
164
|
+
do_class_label = 'DurableObjectStub'
|
|
165
|
+
|
|
166
|
+
# Single-line IIFE — see `lib/cloudflare_workers/cache.rb#put`
|
|
167
|
+
# for why Opal can silently drop a multi-line x-string Promise.
|
|
168
|
+
js_promise = `(async function(stub, url_str, method_str, js_headers, js_body, Kernel, err_klass, do_class_label) { var init = { method: method_str, headers: js_headers }; if (js_body !== null && js_body !== undefined && js_body !== Opal.nil) { init.body = js_body; } var resp; try { resp = await stub.fetch(url_str, init); } catch (e) { Kernel.$raise(err_klass.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'stub.fetch', do_class: do_class_label }))); } var text = ''; try { text = await resp.text(); } catch (_) { text = ''; } var hk = []; var hv = []; if (resp.headers && typeof resp.headers.forEach === 'function') { resp.headers.forEach(function(v, k) { hk.push(String(k).toLowerCase()); hv.push(String(v)); }); } return { status: resp.status|0, text: text, hkeys: hk, hvals: hv }; })(#{js_stub}, #{url_str}, #{method_str}, #{js_headers}, #{js_body}, #{Kernel}, #{err_klass}, #{do_class_label})`
|
|
169
|
+
|
|
170
|
+
js_result = js_promise.__await__
|
|
171
|
+
hkeys = `#{js_result}.hkeys`
|
|
172
|
+
hvals = `#{js_result}.hvals`
|
|
173
|
+
h = {}
|
|
174
|
+
i = 0
|
|
175
|
+
len = `#{hkeys}.length`
|
|
176
|
+
while i < len
|
|
177
|
+
h[`#{hkeys}[#{i}]`] = `#{hvals}[#{i}]`
|
|
178
|
+
i += 1
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
response_klass.new(
|
|
182
|
+
status: `#{js_result}.status`,
|
|
183
|
+
headers: h,
|
|
184
|
+
body: `#{js_result}.text`,
|
|
185
|
+
url: url_str
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# -----------------------------------------------------------------
|
|
191
|
+
# Ruby-side DO class registry + DSL.
|
|
192
|
+
#
|
|
193
|
+
# The JS side (src/worker.mjs) defines a single `HomuraCounterDO`
|
|
194
|
+
# export. When the DO runtime instantiates it and calls its fetch,
|
|
195
|
+
# the JS class hands the call off to
|
|
196
|
+
# `globalThis.__HOMURA_DO_DISPATCH__(class_name, state, env, request, body_text)`.
|
|
197
|
+
# We dispatch to whichever Ruby handler block was registered for
|
|
198
|
+
# `class_name` via `Cloudflare::DurableObject.define`.
|
|
199
|
+
# -----------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
module DurableObject
|
|
202
|
+
@handlers = {}
|
|
203
|
+
|
|
204
|
+
class << self
|
|
205
|
+
attr_reader :handlers
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Register a Ruby handler for a DO class.
|
|
209
|
+
#
|
|
210
|
+
# Cloudflare::DurableObject.define('HomuraCounterDO') do |state, request|
|
|
211
|
+
# prev = (state.storage.get('count').__await__ || 0).to_i
|
|
212
|
+
# if request.path == '/inc'
|
|
213
|
+
# state.storage.put('count', prev + 1).__await__
|
|
214
|
+
# [200, { 'content-type' => 'application/json' }, { 'count' => prev + 1 }.to_json]
|
|
215
|
+
# elsif request.path == '/reset'
|
|
216
|
+
# state.storage.delete('count').__await__
|
|
217
|
+
# [200, { 'content-type' => 'application/json' }, '{"reset":true}']
|
|
218
|
+
# else
|
|
219
|
+
# [200, { 'content-type' => 'application/json' }, { 'count' => prev }.to_json]
|
|
220
|
+
# end
|
|
221
|
+
# end
|
|
222
|
+
#
|
|
223
|
+
# The block must return a Rack-style triple `[status, headers, body]`.
|
|
224
|
+
# `body` may be a String or an object that responds to `to_s`.
|
|
225
|
+
def self.define(class_name, &block)
|
|
226
|
+
raise ArgumentError, 'define requires a block' unless block
|
|
227
|
+
raise ArgumentError, 'class_name must be a String' unless class_name.is_a?(String)
|
|
228
|
+
@handlers ||= {}
|
|
229
|
+
# Wrap via define_method so Opal's `# await: true` picks it up as
|
|
230
|
+
# async (same trick Sinatra::Scheduled uses for its jobs).
|
|
231
|
+
method_name = "__do_handler_#{class_name.gsub(/[^A-Za-z0-9_]/, '_')}".to_sym
|
|
232
|
+
DurableObjectRequestContext.send(:define_method, method_name, &block)
|
|
233
|
+
unbound = DurableObjectRequestContext.instance_method(method_name)
|
|
234
|
+
DurableObjectRequestContext.send(:remove_method, method_name)
|
|
235
|
+
@handlers[class_name] = unbound
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Register a WebSocket-event handler for a DO class. Accepts any
|
|
240
|
+
# combination of `on_message: proc { |ws, msg, state| ... }`,
|
|
241
|
+
# `on_close: proc { |ws, code, reason, clean, state| ... }`,
|
|
242
|
+
# `on_error: proc { |ws, err, state| ... }`.
|
|
243
|
+
#
|
|
244
|
+
# Cloudflare::DurableObject.define_web_socket_handlers('HomuraCounterDO',
|
|
245
|
+
# on_message: ->(ws, msg, _state) { `#{ws}.send(#{msg})` },
|
|
246
|
+
# on_close: ->(ws, code, reason, clean, _state) { `#{ws}.close(#{code}, #{reason})` }
|
|
247
|
+
# )
|
|
248
|
+
#
|
|
249
|
+
# The callbacks are invoked from `webSocketMessage` /
|
|
250
|
+
# `webSocketClose` / `webSocketError` dispatches on the JS DO
|
|
251
|
+
# class (wired by the exported HomuraCounterDO in
|
|
252
|
+
# src/worker.mjs). Return value is ignored — the runtime doesn't
|
|
253
|
+
# expect a body.
|
|
254
|
+
def self.define_web_socket_handlers(class_name, on_message: nil, on_close: nil, on_error: nil)
|
|
255
|
+
@ws_handlers ||= {}
|
|
256
|
+
@ws_handlers[class_name] = {
|
|
257
|
+
on_message: on_message,
|
|
258
|
+
on_close: on_close,
|
|
259
|
+
on_error: on_error
|
|
260
|
+
}.compact
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def self.web_socket_handlers_for(class_name)
|
|
265
|
+
(@ws_handlers || {})[class_name.to_s]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Lookup handler by class name.
|
|
269
|
+
def self.handler_for(class_name)
|
|
270
|
+
(@handlers || {})[class_name.to_s]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Dispatcher called from the JS DO class. Returns a JS Promise that
|
|
274
|
+
# resolves to a JS Response.
|
|
275
|
+
def self.dispatch_js(class_name, js_state, js_env, js_request, body_text)
|
|
276
|
+
handler = handler_for(class_name)
|
|
277
|
+
if handler.nil?
|
|
278
|
+
body = { 'error' => "no Ruby handler for DurableObject class #{class_name}" }.to_json
|
|
279
|
+
return build_js_response(500, { 'content-type' => 'application/json' }, body)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
state = DurableObjectState.new(js_state)
|
|
283
|
+
request = DurableObjectRequest.new(js_request, body_text)
|
|
284
|
+
ctx = DurableObjectRequestContext.new(state, js_env, request)
|
|
285
|
+
|
|
286
|
+
result = handler.bind(ctx).call(state, request)
|
|
287
|
+
|
|
288
|
+
# If the block itself was async (used __await__ internally), its
|
|
289
|
+
# return value is a Promise — await it so the caller sees the
|
|
290
|
+
# resolved triple.
|
|
291
|
+
if `(#{result} != null && typeof #{result}.then === 'function')`
|
|
292
|
+
result = result.__await__
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
status, headers, body = normalise_response(result)
|
|
296
|
+
build_js_response(status, headers, body)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Accept common return shapes from the user block:
|
|
300
|
+
# - Array triple [status, headers, body]
|
|
301
|
+
# - Hash {status:, headers:, body:}
|
|
302
|
+
# - String (200 OK, text/plain)
|
|
303
|
+
def self.normalise_response(result)
|
|
304
|
+
if result.is_a?(Array) && result.length == 3
|
|
305
|
+
[result[0].to_i, result[1] || {}, result[2].to_s]
|
|
306
|
+
elsif result.is_a?(Hash)
|
|
307
|
+
[
|
|
308
|
+
(result['status'] || result[:status] || 200).to_i,
|
|
309
|
+
result['headers'] || result[:headers] || {},
|
|
310
|
+
(result['body'] || result[:body] || '').to_s
|
|
311
|
+
]
|
|
312
|
+
else
|
|
313
|
+
[200, { 'content-type' => 'text/plain; charset=utf-8' }, result.to_s]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def self.build_js_response(status, headers, body)
|
|
318
|
+
js_headers = `({})`
|
|
319
|
+
headers.each do |k, v|
|
|
320
|
+
ks = k.to_s
|
|
321
|
+
vs = v.to_s
|
|
322
|
+
`#{js_headers}[#{ks}] = #{vs}`
|
|
323
|
+
end
|
|
324
|
+
status_int = status.to_i
|
|
325
|
+
body_str = body.to_s
|
|
326
|
+
`new Response(#{body_str}, { status: #{status_int}, headers: #{js_headers} })`
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# WebSocket dispatchers — called from the JS DO class's
|
|
330
|
+
# `webSocketMessage` / `webSocketClose` / `webSocketError`
|
|
331
|
+
# methods. Each returns a JS Promise that resolves to undefined.
|
|
332
|
+
def self.dispatch_ws_message(class_name, js_ws, js_message, js_state, js_env)
|
|
333
|
+
h = web_socket_handlers_for(class_name)
|
|
334
|
+
return nil if h.nil? || h[:on_message].nil?
|
|
335
|
+
state = DurableObjectState.new(js_state)
|
|
336
|
+
h[:on_message].call(js_ws, js_message, state)
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def self.dispatch_ws_close(class_name, js_ws, code, reason, was_clean, js_state, js_env)
|
|
341
|
+
h = web_socket_handlers_for(class_name)
|
|
342
|
+
return nil if h.nil? || h[:on_close].nil?
|
|
343
|
+
state = DurableObjectState.new(js_state)
|
|
344
|
+
h[:on_close].call(js_ws, code, reason, was_clean, state)
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def self.dispatch_ws_error(class_name, js_ws, js_error, js_state, js_env)
|
|
349
|
+
h = web_socket_handlers_for(class_name)
|
|
350
|
+
return nil if h.nil? || h[:on_error].nil?
|
|
351
|
+
state = DurableObjectState.new(js_state)
|
|
352
|
+
h[:on_error].call(js_ws, js_error, state)
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Install the JS dispatcher hook. Idempotent.
|
|
357
|
+
#
|
|
358
|
+
# Kept as a single-line backtick x-string — Opal's compiler refuses
|
|
359
|
+
# multi-line backticks as expressions (same constraint documented
|
|
360
|
+
# in `lib/cloudflare_workers/scheduled.rb#install_dispatcher`).
|
|
361
|
+
# Installs FOUR hooks: fetch dispatcher + 3 websocket event
|
|
362
|
+
# dispatchers. Each wraps Ruby exceptions in a console.error so a
|
|
363
|
+
# bad handler doesn't crash the DO.
|
|
364
|
+
def self.install_dispatcher
|
|
365
|
+
mod = self
|
|
366
|
+
`globalThis.__HOMURA_DO_DISPATCH__ = async function(class_name, state, env, request, body_text) { try { return await #{mod}.$dispatch_js(class_name, state, env, request, body_text == null ? '' : body_text); } catch (err) { try { globalThis.console.error('[Cloudflare::DurableObject] dispatch failed:', err && err.stack || err); } catch (e) {} return new Response(JSON.stringify({ error: String(err && err.message || err) }), { status: 500, headers: { 'content-type': 'application/json' } }); } };`
|
|
367
|
+
`globalThis.__HOMURA_DO_WS_MESSAGE__ = async function(class_name, ws, message, state, env) { try { await #{mod}.$dispatch_ws_message(class_name, ws, message, state, env); } catch (err) { try { globalThis.console.error('[Cloudflare::DurableObject] ws.message dispatch failed:', err && err.stack || err); } catch (e) {} } };`
|
|
368
|
+
`globalThis.__HOMURA_DO_WS_CLOSE__ = async function(class_name, ws, code, reason, wasClean, state, env) { try { await #{mod}.$dispatch_ws_close(class_name, ws, code, reason, wasClean, state, env); } catch (err) { try { globalThis.console.error('[Cloudflare::DurableObject] ws.close dispatch failed:', err && err.stack || err); } catch (e) {} } };`
|
|
369
|
+
`globalThis.__HOMURA_DO_WS_ERROR__ = async function(class_name, ws, err, state, env) { try { await #{mod}.$dispatch_ws_error(class_name, ws, err, state, env); } catch (e2) { try { globalThis.console.error('[Cloudflare::DurableObject] ws.error dispatch failed:', e2 && e2.stack || e2); } catch (_) {} } };`
|
|
370
|
+
`(function(){var g=globalThis;g.__OPAL_WORKERS__=g.__OPAL_WORKERS__||{};var d=g.__OPAL_WORKERS__.durableObject=g.__OPAL_WORKERS__.durableObject||{};d.dispatch=g.__HOMURA_DO_DISPATCH__;d.wsMessage=g.__HOMURA_DO_WS_MESSAGE__;d.wsClose=g.__HOMURA_DO_WS_CLOSE__;d.wsError=g.__HOMURA_DO_WS_ERROR__;})();`
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# -----------------------------------------------------------------
|
|
375
|
+
# DurableObjectState — wraps the `state` object passed to the DO's
|
|
376
|
+
# fetch(). Only exposes `.storage` because that is the one piece of
|
|
377
|
+
# state DO code touches >99% of the time. Future additions
|
|
378
|
+
# (blockConcurrencyWhile, waitUntil, etc.) can come here.
|
|
379
|
+
# -----------------------------------------------------------------
|
|
380
|
+
class DurableObjectState
|
|
381
|
+
attr_reader :js_state, :storage
|
|
382
|
+
|
|
383
|
+
def initialize(js_state)
|
|
384
|
+
@js_state = js_state
|
|
385
|
+
@storage = DurableObjectStorage.new(`#{js_state} && #{js_state}.storage`)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Unique id of this DO instance as a hex String.
|
|
389
|
+
def id
|
|
390
|
+
js_state = @js_state
|
|
391
|
+
`(#{js_state} && #{js_state}.id && typeof #{js_state}.id.toString === 'function' ? #{js_state}.id.toString() : '')`
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Wrap a Promise in state.blockConcurrencyWhile(...) so that no
|
|
395
|
+
# other fetch to this DO can run until the promise resolves. Rare
|
|
396
|
+
# but critical for consistent read-modify-write against storage.
|
|
397
|
+
def block_concurrency_while(promise)
|
|
398
|
+
js_state = @js_state
|
|
399
|
+
`(#{js_state} && #{js_state}.blockConcurrencyWhile ? #{js_state}.blockConcurrencyWhile(async function(){ return await #{promise}; }) : #{promise})`
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Accept an incoming WebSocket for the Hibernation API. The DO
|
|
403
|
+
# instance transparently survives `webSocketMessage` /
|
|
404
|
+
# `webSocketClose` callbacks even if the isolate goes idle in
|
|
405
|
+
# between — the runtime wakes the DO, invokes the callback, and
|
|
406
|
+
# lets it hibernate again. Without `acceptWebSocket`, the DO must
|
|
407
|
+
# stay alive for the lifetime of the socket (billed per-invocation
|
|
408
|
+
# second).
|
|
409
|
+
#
|
|
410
|
+
# `tags` is an optional Array of string tags attached to the
|
|
411
|
+
# socket so callers can later filter `get_web_sockets(tag: ...)`.
|
|
412
|
+
def accept_web_socket(js_ws, tags: nil)
|
|
413
|
+
js_state = @js_state
|
|
414
|
+
if tags && !tags.empty?
|
|
415
|
+
js_tags = `([])`
|
|
416
|
+
tags.each { |t| ts = t.to_s; `#{js_tags}.push(#{ts})` }
|
|
417
|
+
`#{js_state}.acceptWebSocket(#{js_ws}, #{js_tags})`
|
|
418
|
+
else
|
|
419
|
+
`#{js_state}.acceptWebSocket(#{js_ws})`
|
|
420
|
+
end
|
|
421
|
+
nil
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# List every WebSocket the runtime has attached to this DO via
|
|
425
|
+
# `acceptWebSocket`. Optional `tag:` filter forwards to
|
|
426
|
+
# `getWebSockets(tag)`.
|
|
427
|
+
def web_sockets(tag: nil)
|
|
428
|
+
js_state = @js_state
|
|
429
|
+
js_arr = if tag
|
|
430
|
+
ts = tag.to_s
|
|
431
|
+
`(#{js_state}.getWebSockets ? #{js_state}.getWebSockets(#{ts}) : [])`
|
|
432
|
+
else
|
|
433
|
+
`(#{js_state}.getWebSockets ? #{js_state}.getWebSockets() : [])`
|
|
434
|
+
end
|
|
435
|
+
out = []
|
|
436
|
+
len = `#{js_arr}.length`
|
|
437
|
+
i = 0
|
|
438
|
+
while i < len
|
|
439
|
+
out << `#{js_arr}[#{i}]`
|
|
440
|
+
i += 1
|
|
441
|
+
end
|
|
442
|
+
out
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Thin Ruby wrapper around state.storage.
|
|
447
|
+
# Values are serialised to JSON on `put` and parsed on `get`, so user
|
|
448
|
+
# code can pass/retrieve plain Ruby Hashes, Arrays, numbers, strings,
|
|
449
|
+
# booleans without reaching for a backtick.
|
|
450
|
+
class DurableObjectStorage
|
|
451
|
+
def initialize(js_storage)
|
|
452
|
+
@js = js_storage
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Returns a JS Promise resolving to the stored Ruby value, or nil.
|
|
456
|
+
def get(key)
|
|
457
|
+
js = @js
|
|
458
|
+
err_klass = Cloudflare::DurableObjectError
|
|
459
|
+
`#{js}.get(#{key.to_s}).then(function(v) { if (v == null) return nil; if (typeof v === 'string') { try { return JSON.parse(v); } catch (e) { return v; } } return v; }).catch(function(e) { #{Kernel}.$raise(#{err_klass}.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'storage.get' }))); })`
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Store any JSON-serialisable Ruby value. Always serialises via
|
|
463
|
+
# `to_json` to avoid the JS-side "{$$is_hash:true}" Opal leakage.
|
|
464
|
+
# Returns a JS Promise.
|
|
465
|
+
def put(key, value)
|
|
466
|
+
js = @js
|
|
467
|
+
err_klass = Cloudflare::DurableObjectError
|
|
468
|
+
js_value = value.nil? ? 'null' : value.to_json
|
|
469
|
+
`#{js}.put(#{key.to_s}, #{js_value}).catch(function(e) { #{Kernel}.$raise(#{err_klass}.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'storage.put' }))); })`
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Remove a key. Returns a JS Promise resolving to a boolean (true
|
|
473
|
+
# if a key was deleted). We coerce to Ruby true/false.
|
|
474
|
+
def delete(key)
|
|
475
|
+
js = @js
|
|
476
|
+
err_klass = Cloudflare::DurableObjectError
|
|
477
|
+
`#{js}.delete(#{key.to_s}).then(function(v) { return v ? true : false; }).catch(function(e) { #{Kernel}.$raise(#{err_klass}.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'storage.delete' }))); })`
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Clear every key. Returns a JS Promise.
|
|
481
|
+
def delete_all
|
|
482
|
+
js = @js
|
|
483
|
+
err_klass = Cloudflare::DurableObjectError
|
|
484
|
+
`#{js}.deleteAll().catch(function(e) { #{Kernel}.$raise(#{err_klass}.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'storage.deleteAll' }))); })`
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# List keys. Returns a JS Promise resolving to a Ruby `Hash`
|
|
488
|
+
# of `{ key => parsed-value }` (values are JSON-parsed when they
|
|
489
|
+
# round-tripped through `put`; opaque strings are returned as-is).
|
|
490
|
+
# Options `prefix:`, `limit:`, `reverse:`, `start:`, `end_key:`
|
|
491
|
+
# forward to the underlying Workers `storage.list({...})` call.
|
|
492
|
+
#
|
|
493
|
+
# The earlier iteration returned a JS `Map`; that was documented as
|
|
494
|
+
# a Ruby Hash but forced callers to reach for JS iteration, which
|
|
495
|
+
# Copilot review (#9) flagged. The JS side still builds the
|
|
496
|
+
# intermediate Map (the Workers runtime also gives us a Map) and we
|
|
497
|
+
# copy it into a Ruby Hash before resolving so downstream code can
|
|
498
|
+
# use `each` / `[]` / `keys` without backticks.
|
|
499
|
+
def list(prefix: nil, limit: nil, reverse: nil, start: nil, end_key: nil)
|
|
500
|
+
js = @js
|
|
501
|
+
err_klass = Cloudflare::DurableObjectError
|
|
502
|
+
js_opts = `({})`
|
|
503
|
+
`#{js_opts}.prefix = #{prefix.to_s}` unless prefix.nil?
|
|
504
|
+
`#{js_opts}.limit = #{limit.to_i}` unless limit.nil?
|
|
505
|
+
`#{js_opts}.reverse = #{!!reverse}` unless reverse.nil?
|
|
506
|
+
`#{js_opts}.start = #{start.to_s}` unless start.nil?
|
|
507
|
+
`#{js_opts}.end = #{end_key.to_s}` unless end_key.nil?
|
|
508
|
+
js_promise = `#{js}.list(#{js_opts}).catch(function(e) { #{Kernel}.$raise(#{err_klass}.$new(e && e.message ? e.message : String(e), Opal.hash({ operation: 'storage.list' }))); })`
|
|
509
|
+
js_result = js_promise.__await__
|
|
510
|
+
out = {}
|
|
511
|
+
return out if `#{js_result} == null`
|
|
512
|
+
`(#{js_result}.forEach && typeof #{js_result}.forEach === 'function') && #{js_result}.forEach(function(v, k) { var pv = v; if (typeof pv === 'string') { try { pv = JSON.parse(pv); } catch (_) {} } #{out}.$store(String(k), pv); })`
|
|
513
|
+
out
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# The incoming `request` argument passed to DO handlers. `body_text`
|
|
518
|
+
# is pre-awaited by the JS dispatcher because Ruby runs synchronously
|
|
519
|
+
# under Opal (same pattern as `Rack::Handler::CloudflareWorkers.call`).
|
|
520
|
+
class DurableObjectRequest
|
|
521
|
+
attr_reader :js_request, :body
|
|
522
|
+
|
|
523
|
+
def initialize(js_request, body_text = '')
|
|
524
|
+
@js_request = js_request
|
|
525
|
+
@body = body_text.to_s
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def method
|
|
529
|
+
js = @js_request
|
|
530
|
+
`(#{js} ? String(#{js}.method || 'GET') : 'GET')`
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def url
|
|
534
|
+
js = @js_request
|
|
535
|
+
`(#{js} ? String(#{js}.url || '') : '')`
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def path
|
|
539
|
+
u = url
|
|
540
|
+
return '' if u.nil? || u.empty?
|
|
541
|
+
begin
|
|
542
|
+
# Extract pathname via URL() so relative paths aren't mangled.
|
|
543
|
+
u_str = u
|
|
544
|
+
`new URL(#{u_str}).pathname`
|
|
545
|
+
rescue StandardError
|
|
546
|
+
# Fallback regex — strip scheme+host, keep everything up to ?/#.
|
|
547
|
+
m = u.match(%r{\Ahttps?://[^/]+([^?#]*)})
|
|
548
|
+
m ? m[1] : u
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Shallow header Hash with lowercased keys.
|
|
553
|
+
def headers
|
|
554
|
+
return @headers if @headers
|
|
555
|
+
h = {}
|
|
556
|
+
js = @js_request
|
|
557
|
+
`(#{js} && #{js}.headers && typeof #{js}.headers.forEach === 'function') && #{js}.headers.forEach(function(v, k) { #{h}.$store(String(k).toLowerCase(), String(v)); })`
|
|
558
|
+
@headers = h
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def [](name)
|
|
562
|
+
headers[name.to_s.downcase]
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def json
|
|
566
|
+
JSON.parse(body)
|
|
567
|
+
rescue JSON::ParserError
|
|
568
|
+
{}
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# `self` inside a DurableObject.define block. Exposes state / env /
|
|
573
|
+
# request so user code reads like a regular Sinatra handler.
|
|
574
|
+
class DurableObjectRequestContext
|
|
575
|
+
attr_reader :state, :env, :request
|
|
576
|
+
|
|
577
|
+
def initialize(state, env, request)
|
|
578
|
+
@state = state
|
|
579
|
+
@env = env
|
|
580
|
+
@request = request
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def storage; @state.storage; end
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Auto-install the JS-side dispatcher on load, mirroring the scheduled
|
|
588
|
+
# dispatcher. Safe even if the worker never uses DO — the hook is only
|
|
589
|
+
# invoked when a DO class is instantiated by the Workers runtime.
|
|
590
|
+
Cloudflare::DurableObject.install_dispatcher
|