Skip to content

The JavaScript bridge

run_script handles Ruby→JS communication for simple cases. For JS→Ruby you need a bridge — a mechanism for JavaScript to send messages back to your Ruby code. This lesson covers three approaches, progressively more capable, each demonstrated in a separate working app.

bridge.png

Demo 1 — Navigation interception

The simplest approach requires no setup at all. JavaScript sets window.location to a custom URL scheme. Ruby intercepts the navigation in evt_webview_navigating, cancels it, and handles the message.

JavaScript:

1
2
3
4
5
6
7
function send(action, payload) {
  var data = encodeURIComponent(JSON.stringify(payload));
  window.location = 'bridge://' + action + '?data=' + data;
}

// Usage:
send('alert', { message: 'Hello from JS!' });

Ruby:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@webview.evt_webview_navigating(@webview.id) do |event|
  url = event.url
  next unless url.start_with?('bridge://')

  event.veto   # cancel the navigation

  uri    = URI.parse(url)
  action = uri.host
  params = URI.decode_www_form(uri.query || '').to_h
  data   = JSON.parse(URI.decode_www_form_component(params['data'] || '{}'))

  handle_message(action, data)
end

This works on all platforms with no WebKit-specific setup. The limitations are that it is one direction only (JS→Ruby) and URL length restricts payload size. It is a good fit for simple notification-style messages.

See bridge_demo_1.rb for a working demonstration.

Download bridge_demo_1.rb

Demo 2 — Script message handler

WebKit’s native message passing mechanism. JavaScript posts structured JSON to a named handler; Ruby receives it in evt_webview_script_message_received. Ruby can respond by calling run_script to invoke a JavaScript function directly.

The critical timing rule: add_script_message_handler must be called inside evt_webview_loaded, not during frame initialisation. Calling it earlier results in the handler silently not being registered.

1
2
3
4
5
6
7
8
9
@webview.evt_webview_loaded(@webview.id) do
  next if @webview.current_url == 'about:blank'
  @webview.add_script_message_handler('bridge')
end

@webview.evt_webview_script_message_received(@webview.id) do |event|
  data = JSON.parse(event.get_string)
  handle_message(data)
end

JavaScript — sending to Ruby:

1
2
3
window.webkit.messageHandlers.bridge.postMessage(
  JSON.stringify({ action: 'buttonClicked', value: 42 })
);

Ruby — responding to JS:

1
@webview.run_script("receiveFromRuby('#{text}')")

This approach has no payload size limit and is cleaner than URL interception. Ruby→JS is still fire-and-forget via run_script — there is no way to get a return value back from JS. That requires the full JsBridge pattern.

See bridge_demo_2.rb for a working demonstration.

Download bridge_demo_2.rb

Demo 3 — JsBridge

The JsBridge class wraps the message handler into a clean bidirectional RPC system. Ruby can call JS methods and receive responses. JS can emit events to Ruby. Both sides are fully symmetrical.

1
2
3
require_relative 'js_bridge'

@bridge = JsBridge.new(@webview)

Wire it up inside evt_webview_loaded:

1
2
3
4
5
6
7
8
9
@webview.evt_webview_loaded(@webview.id) do
  next if @webview.current_url == 'about:blank'
  @webview.add_script_message_handler('bridge')
  @bridge.run_script("RubyBridge.emit('ready', {})")
end

@webview.evt_webview_script_message_received(@webview.id) do |event|
  @bridge.dispatch(event.get_string)
end

The ready pattern

Notice that Ruby triggers the ready event via run_script after registering the handler — it does not rely on JS emitting ready at the end of the page script.

This is critical. By the time evt_webview_loaded fires, the page’s <script> block has already executed. If JS calls RubyBridge.emit('ready', {}) at the bottom of the script, the message handler is not yet registered and the message is silently dropped. Ruby never sees it and nothing works.

The correct pattern is always:

1
2
# Inside evt_webview_loaded, after add_script_message_handler:
@bridge.run_script("RubyBridge.emit('ready', {})")

And start all Ruby-side work inside @bridge.on('ready'):

1
2
3
4
@bridge.on('ready') do
  # Safe to call JS methods now
  @bridge.call('initialise', { data: @initial_data })
end

Ruby calling JS with a response

1
2
3
4
5
6
7
@bridge.call('reverseText', { text: 'wxRuby3' }) do |result, error|
  if error
    puts "Error: #{error}"
  else
    puts "Reversed: #{result['reversed']}"
  end
end

The callback receives the return value of the JS function. This is true RPC — Ruby gets a value back from JavaScript.

JS registering methods Ruby can call

1
2
3
4
RubyBridge.register('reverseText', function(payload) {
  var reversed = payload.text.split('').reverse().join('');
  return { reversed: reversed, length: payload.text.length };
});

JS emitting events to Ruby

1
RubyBridge.emit('userAction', { button: 'save' });
1
2
3
@bridge.on('userAction') do |payload|
  puts "User clicked: #{payload['button']}"
end

See bridge_demo_3.rb, js_bridge.rb and js_bridge_client.js for the complete implementation.

Choosing an approach

Situation Approach
Simple one-way JS→Ruby notifications Navigation interception
JS→Ruby with Ruby responding via run_script Script message handler
Full bidirectional RPC with responses JsBridge
Real app with multiple methods and events JsBridge

Navigation interception is useful for quick experiments. For anything beyond a few message types, use JsBridge — it scales cleanly and the Ruby side stays readable.


Previous: WebView basics | Next: Live data with Chart.js