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:
The client performs HTTP requests to perform client-to-server actions (open a connection, send a message, close the connection)
The server process them as the corresponding WebSocket events as usual and let the client keep the
ApplicationCable::Connection
state between requestsTo deliver real-time updates, a custom subscription adapter is used that broadcast messages via the BroadcastChannel. For that, we use an external JS interface.
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) }),
);
}
}
}