Module: Cloudflare::DurableObject

Defined in:
lib/homura/runtime/durable_object.rb

Overview


Ruby-side DO class registry + DSL.

The JS side (src/worker.mjs) defines a single ‘HomuraCounterDO` export. When the DO runtime instantiates it and calls its fetch, the JS class hands the call off to `globalThis.HOMURA_DO_DISPATCH(class_name, state, env, request, body_text)`. We dispatch to whichever Ruby handler block was registered for `class_name` via `Cloudflare::DurableObject.define`.


Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.handlersObject (readonly)

Returns the value of attribute handlers.



236
237
238
# File 'lib/homura/runtime/durable_object.rb', line 236

def handlers
  @handlers
end

Class Method Details

.build_js_response(status, headers, body) ⇒ Object



361
362
363
364
365
366
367
368
369
370
371
# File 'lib/homura/runtime/durable_object.rb', line 361

def self.build_js_response(status, headers, body)
  js_headers = `({})`
  headers.each do |k, v|
    ks = k.to_s
    vs = v.to_s
    `#{js_headers}[#{ks}] = #{vs}`
  end
  status_int = status.to_i
  body_str = body.to_s
  `new Response(#{body_str}, { status: #{status_int}, headers: #{js_headers} })`
end

.define(class_name, &block) ⇒ Object

Register a Ruby handler for a DO class.

Cloudflare::DurableObject.define('HomuraCounterDO') do |state, request|
  prev = (state.storage.get('count').__await__ || 0).to_i
  if request.path == '/inc'
    state.storage.put('count', prev + 1).__await__
    [200, { 'content-type' => 'application/json' }, { 'count' => prev + 1 }.to_json]
  elsif request.path == '/reset'
    state.storage.delete('count').__await__
    [200, { 'content-type' => 'application/json' }, '{"reset":true}']
  else
    [200, { 'content-type' => 'application/json' }, { 'count' => prev }.to_json]
  end
end

The block must return a Rack-style triple ‘[status, headers, body]`. `body` may be a String or an object that responds to `to_s`.

Raises:

  • (ArgumentError)


256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/homura/runtime/durable_object.rb', line 256

def self.define(class_name, &block)
  raise ArgumentError, "define requires a block" unless block
  unless class_name.is_a?(String)
    raise ArgumentError, "class_name must be a String"
  end
  @handlers ||= {}
  # Wrap via define_method so Opal's `# await: true` picks it up as
  # async (same trick Sinatra::Scheduled uses for its jobs).
  method_name =
    "__do_handler_#{class_name.gsub(/[^A-Za-z0-9_]/, "_")}".to_sym
  DurableObjectRequestContext.send(:define_method, method_name, &block)
  unbound = DurableObjectRequestContext.instance_method(method_name)
  DurableObjectRequestContext.send(:remove_method, method_name)
  @handlers[class_name] = unbound
  nil
end

.define_web_socket_handlers(class_name, on_message: nil, on_close: nil, on_error: nil) ⇒ Object

Register a WebSocket-event handler for a DO class. Accepts any combination of ‘on_message: proc { |ws, msg, state| … }`, `on_close: proc { |ws, code, reason, clean, state| … }`, `on_error: proc { |ws, err, state| … }`.

Cloudflare::DurableObject.define_web_socket_handlers('HomuraCounterDO',
  on_message: ->(ws, msg, _state) { `#{ws}.send(#{msg})` },
  on_close:   ->(ws, code, reason, clean, _state) { `#{ws}.close(#{code}, #{reason})` }
)

The callbacks are invoked from ‘webSocketMessage` / `webSocketClose` / `webSocketError` dispatches on the JS DO class (wired by the exported HomuraCounterDO in src/worker.mjs). Return value is ignored — the runtime doesn’t expect a body.



288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/homura/runtime/durable_object.rb', line 288

def self.define_web_socket_handlers(
  class_name,
  on_message: nil,
  on_close: nil,
  on_error: nil
)
  @ws_handlers ||= {}
  @ws_handlers[class_name] = {
    on_message: on_message,
    on_close: on_close,
    on_error: on_error
  }.compact
  nil
end

.dispatch_js(class_name, js_state, js_env, js_request, body_text) ⇒ Object

Dispatcher called from the JS DO class. Returns a JS Promise that resolves to a JS Response.



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/homura/runtime/durable_object.rb', line 314

def self.dispatch_js(class_name, js_state, js_env, js_request, body_text)
  handler = handler_for(class_name)
  if handler.nil?
    body = {
      "error" => "no Ruby handler for DurableObject class #{class_name}"
    }.to_json
    return(
      build_js_response(500, { "content-type" => "application/json" }, body)
    )
  end

  state = DurableObjectState.new(js_state)
  request = DurableObjectRequest.new(js_request, body_text)
  env = Cloudflare::Bindings.build_env(js_env)
  ctx = DurableObjectRequestContext.new(state, env, request)

  result = handler.bind(ctx).call(state, request)

  # If the block itself was async (used __await__ internally), its
  # return value is a Promise — await it so the caller sees the
  # resolved triple.
  if `(#{result} != null && typeof #{result}.then === 'function')`
    result = result.__await__
  end

  status, headers, body = normalise_response(result)
  build_js_response(status, headers, body)
end

.dispatch_ws_close(class_name, js_ws, code, reason, was_clean, js_state, js_env) ⇒ Object



390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/homura/runtime/durable_object.rb', line 390

def self.dispatch_ws_close(
  class_name,
  js_ws,
  code,
  reason,
  was_clean,
  js_state,
  js_env
)
  h = web_socket_handlers_for(class_name)
  return nil if h.nil? || h[:on_close].nil?
  state = DurableObjectState.new(js_state)
  h[:on_close].call(js_ws, code, reason, was_clean, state)
  nil
end

.dispatch_ws_error(class_name, js_ws, js_error, js_state, js_env) ⇒ Object



406
407
408
409
410
411
412
# File 'lib/homura/runtime/durable_object.rb', line 406

def self.dispatch_ws_error(class_name, js_ws, js_error, js_state, js_env)
  h = web_socket_handlers_for(class_name)
  return nil if h.nil? || h[:on_error].nil?
  state = DurableObjectState.new(js_state)
  h[:on_error].call(js_ws, js_error, state)
  nil
end

.dispatch_ws_message(class_name, js_ws, js_message, js_state, js_env) ⇒ Object

WebSocket dispatchers — called from the JS DO class’s ‘webSocketMessage` / `webSocketClose` / `webSocketError` methods. Each returns a JS Promise that resolves to undefined.



376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/homura/runtime/durable_object.rb', line 376

def self.dispatch_ws_message(
  class_name,
  js_ws,
  js_message,
  js_state,
  js_env
)
  h = web_socket_handlers_for(class_name)
  return nil if h.nil? || h[:on_message].nil?
  state = DurableObjectState.new(js_state)
  h[:on_message].call(js_ws, js_message, state)
  nil
end

.handler_for(class_name) ⇒ Object

Lookup handler by class name.



308
309
310
# File 'lib/homura/runtime/durable_object.rb', line 308

def self.handler_for(class_name)
  (@handlers || {})[class_name.to_s]
end

.install_dispatcherObject

Install the JS dispatcher hook. Idempotent.

Kept as a single-line backtick x-string — Opal’s compiler refuses multi-line backticks as expressions (same constraint documented in ‘lib/homura/runtime/scheduled.rb#install_dispatcher`). Installs FOUR hooks: fetch dispatcher + 3 websocket event dispatchers. Each wraps Ruby exceptions in a console.error so a bad handler doesn’t crash the DO.



422
423
424
425
426
427
428
429
# File 'lib/homura/runtime/durable_object.rb', line 422

def self.install_dispatcher
  mod = self
  `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' } }); } };`
  `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) {} } };`
  `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) {} } };`
  `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 (_) {} } };`
  `(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__;})();`
end

.normalise_response(result) ⇒ Object

Accept common return shapes from the user block:

- Array triple [status, headers, body]
- Hash   {status:, headers:, body:}
- String (200 OK, text/plain)


347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/homura/runtime/durable_object.rb', line 347

def self.normalise_response(result)
  if result.is_a?(Array) && result.length == 3
    [result[0].to_i, result[1] || {}, result[2].to_s]
  elsif result.is_a?(Hash)
    [
      (result["status"] || result[:status] || 200).to_i,
      result["headers"] || result[:headers] || {},
      (result["body"] || result[:body] || "").to_s
    ]
  else
    [200, { "content-type" => "text/plain; charset=utf-8" }, result.to_s]
  end
end

.web_socket_handlers_for(class_name) ⇒ Object



303
304
305
# File 'lib/homura/runtime/durable_object.rb', line 303

def self.web_socket_handlers_for(class_name)
  (@ws_handlers || {})[class_name.to_s]
end