Module: Cloudflare::DurableObject

Defined in:
lib/cloudflare_workers/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.



205
206
207
# File 'lib/cloudflare_workers/durable_object.rb', line 205

def handlers
  @handlers
end

Class Method Details

.build_js_response(status, headers, body) ⇒ Object



317
318
319
320
321
322
323
324
325
326
327
# File 'lib/cloudflare_workers/durable_object.rb', line 317

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)


225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/cloudflare_workers/durable_object.rb', line 225

def self.define(class_name, &block)
  raise ArgumentError, 'define requires a block' unless block
  raise ArgumentError, 'class_name must be a String' unless class_name.is_a?(String)
  @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.



254
255
256
257
258
259
260
261
262
# File 'lib/cloudflare_workers/durable_object.rb', line 254

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.



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/cloudflare_workers/durable_object.rb', line 275

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)
  ctx = DurableObjectRequestContext.new(state, js_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



340
341
342
343
344
345
346
# File 'lib/cloudflare_workers/durable_object.rb', line 340

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



348
349
350
351
352
353
354
# File 'lib/cloudflare_workers/durable_object.rb', line 348

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.



332
333
334
335
336
337
338
# File 'lib/cloudflare_workers/durable_object.rb', line 332

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.



269
270
271
# File 'lib/cloudflare_workers/durable_object.rb', line 269

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/cloudflare_workers/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.



364
365
366
367
368
369
370
371
# File 'lib/cloudflare_workers/durable_object.rb', line 364

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)


303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/cloudflare_workers/durable_object.rb', line 303

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



264
265
266
# File 'lib/cloudflare_workers/durable_object.rb', line 264

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