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