Action Cable on Wasm

Let's think about how we can implement a real-time communication functionality for the app running locally, without any real-time server involved. What the use of live updates in such an isolated environment? Apart from the case of running multiple copies of the application (e.g., browser tabs), there is a more significant scenario of performing some work in the background. In other words, signaling the client (JavaScript) about the operations completed outside of the request-response loop is still important.

Luckly, we can leverage Action Cable for that even under such unusual circumstances.

NOTE: All approaches described in this chapter have two requirements: 1) next-gen Action Cable architecture (see actioncable-next) to support non-WebSocket clients; 2) usage of AnyCable JS SDK instead of the Rails one because of the ability to implement custom transports with the former one.

Polling

The simplest alternative to WebSockets is good old HTTP, or polling. We can emulate a real-time communication by keeping the state on the server (e.g., via cache) and implementing a client that polls the server for updates periodically.

There are two types of polling: short-polling and long-polling. Long polling uses blocking requests (waiting for new messages or the timeout whichever occurs the first). Since we process requests sequentially via a single Wasm module, this is not an option for us (well, having a dedicated Wasm "process" could be, but let's skip it). Short polling should work well in our scenario. We only need to implement a mechanism to cache the messages backlog for clients and the corresponding transport adapter (for both Action Cable server and AnyCable client).

BroadcastChannel and HTTP

A more interesting approach for browser applications could be to use the BroadcastChannel API in conjunction with an HTTP API.

The idea is as follows:

Here is the code for the subscription adapter:

class BroadcastChannelSubscriptionAdapter < ActionCable::SubscriptionAdapter::Base
  def broadcast(channel, payload)
    begin
      JS.global["actionCableBroadcaster"].broadcast(channel, payload)
    rescue => e
      Rails.logger.error "Failed to broadcast to #{channel}: #{e.message}"
    end
  end
end

It assumes that somewhere in the JavaScript global scope, the actionCableBroadcaster object is present:

class ActionCableBroadcaster {
  constructor(channel) {
    this.bc = new BroadcastChannel(channel);
  }

  broadcast(stream, data) {
    console.log("[rails-web] Broadcasting to channel:", stream, data);
    this.bc.postMessage({ stream, data });
  }
}

globalThis.actionCableBroadcaster = new ActionCableBroadcaster("action_cable");

The HTTP API can be implemented at the server side as follows:

module ActionCable
  # An HTTP version of command executor for Action Cable
  class CommandsController < ApplicationController
    skip_before_action :verify_authenticity_token

    def open
      connection.handle_open

      respond_with_socket_state
    end

    def message
      connection.handle_incoming(command_params)

      respond_with_socket_state(identifier:)
    end

    def close
      connection.handle_close
      head :ok
    end

    private

    def respond_with_socket_state(**other)
      # ...
    end

    class ServerInterface < SimpleDelegator
      attr_accessor :pubsub
    end

    class Socket
      #== Action Cable socket interface ==
      attr_reader :logger, :protocol, :request
      attr_reader :transmissions, :streams, :stopped_streams, :coder, :server

      delegate :env, to: :request
      delegate :worker_pool, :logger, :perform_work, to: :server

      def initialize(request, server, coder: ActiveSupport::JSON)
        @request = request
        @coder = coder
        @server = server
        @transmissions = []
        @streams = []
        @stopped_streams = []
      end

      def transmit(data)
        transmissions << coder.encode(data)
      end

      #== Action Cable pubsub interface ==
      def subscribe(channel, handler, on_success = nil)
        streams << channel
        on_success&.call
      end

      def unsubscribe(channel, handler)
        stopped_streams << channel
      end

      def close
      end
    end

    def socket
      @socket ||= begin
        Socket.new(request, server)
      end
    end

    def connection
      @connection ||= begin
        srv = ServerInterface.new(socket)
        srv.pubsub = socket
        server.config.connection_class.call.new(srv, socket)
      end
    end

    def server = ActionCable.server
  end
end

The code above is just an example and it doesn't take into account the connection state (identifiers). Though it's good enough for Hotwire applications.

The custom JavaScript transport implementation can be derived from the long polling adapter for AnyCable with the introduction of BroadcastChannel to handle live updates:

class BroadcastChannelTransport {
  constructor(url, opts = {}) {
    this.channel = opts.channel || "action_cable";
    this.bc = new BroadcastChannel(this.channel);
    this.bc.onmessage = this.processBroadcast.bind(this);
  }

  processBroadcast(event) {
    let {stream, data} = event.data;

    if (data.data) {
      const identifier = this.streamToIdentifier[stream];

      this.emitter.emit(
        "data",
        JSON.stringify({ identifier, message: JSON.parse(data.data) }),
      );
    }
  }
}