ActionCable was introduced in Rails for its 5.0 release as a way to seamlessly integrate WebSockets in Rails applications. I was curious to know more about it, and the opportunity presented itself while doing a React frontend to a Rails API. How could we interact with it with plain Javascript?
ActionCable basics
By allowing easy use of WebSockets on any Rails codebase, ActionCable adds real-time capabilities to Rails’ already impressive list of features. It will also be somewhat familiar to work with it if you’ve had any exposure to Phoenix’s channels.
There are two main concepts to grasp: Connections and Channels.
Connections represent the WebSocket link between the server and the client. Every WebSocket connection has a corresponding Connection object, which serves as parent to any Channel instance created off the connection. Connection objects are mostly concerned with authorization and identification.
Here’s an example Connection base class:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :uuid
def connect
self.uuid = SecureRandom.urlsafe_base64
end
end
end
The only method you need to implement is #connect
, which runs when every
connection is made. Here you’ll normally assign a unique identifier to the
connection. These are the steps to make this happen:
- We use
#identified_by
to tell what attribute will be the unique identifier. In our example, we have decided to call this:uuid
, but it could be anything. - This will create a class attribute
@uuid
, with a getter and setter that you can access in your code. - During connect, assign a unique identifier to this
@uuid
. You can just generate a random string like in the example.
Apart from that, it is possible to reject a connection during connect by calling
#reject_unauthorized_connection
. Finally, you can implement #disconnect
,
which will give you a chance to run cleanup code after the client goes away.
Channels build upon connections, and serve as repositories for code that handles messages sent by consumers, or that you want to broadcast to particular streams. For every message “action” that is sent by the consumer, you must have a method with a matching name in the channel class to handle that message.
Once you have made the connection, the consumer (client) will want to subscribe to specific channels and issue commands, or be notified of server-side messages to the channels it subscribes to.
If we were to use the JS library bundled with ActionCable, we would initialize the consumer with this code:
// app/assets/javascripts/cable.js
//= require action_cable
//= require_self
//= require_tree ./channels
(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer(`ws://some.host:28080`);
}).call(this);
Once initialized, the consumer can subscribe to channels with:
App.cable.subscriptions.create { channel: "SomeChannel" }
This creates a subscription. On Rails’ side we’ll have a channel class named
SomeChannel
to handle this:
class SomeChannel < ApplicationCable::Channel
def subscribed
stream_from "player_#{uuid}"
end
end
Several things to note in the example above:
- The method
subscribed
is called when the subscription to the channel is created. We’ll have to implement it. - We use
stream_from
to add the consumer to a stream. - In the example, we choose a name that includes the unique id
(
"player_#{uuid}"
). This suggests that it will be a stream exclusive to that consumer. - By the way,
uuid
is the same one we defined on the connection. It’s delegated to channels and accessible from them. - If instead we chose a more generic name (eg: “players”), then this would be common to all subscribers, and they would receive the same messages.
Going with the example above, which uses individual streams for each consumer, we would use something like the following to send a message to a single, specific consumer:
class SomeChannel < ApplicationCable::Channel
def something_something(player)
ActionCable.server.broadcast "player_#{player.uuid}", {
message: "message body",
}
end
end
Finally, from the consumer’s side, we can send messages through the channel, once subscribed:
var App = {};
App.cable = ActionCable.createConsumer(`ws://${window.location.hostname}:28080`);
App.messaging = App.cable.subscriptions.create('SomeChannel', {
received: function(data) {
$(this).trigger('received', data);
},
sendMessage: function(messageBody) {
this.perform('foobar', { body: messageBody, to: otherPlayerUuid });
}
});
In this case, whenever the consumer uses sendMessage
to send a message, the
foobar
method in the channel will be invoked:
class SomeChannel < ApplicationCable::Channel
def foobar(data)
message = data['body']
other_player_uuid = data['to']
ActionCable.server.broadcast "player_#{other_player_uuid}", {
message: message
}
end
end
For every message type there should be a corresponding method in the channel class to handle it. Now, this is all good with Rails’ default ActionCable code, but what if we want to handle the connections, subscriptions and messages with our own code?
Behind the scenes
When interfacing with ActionCable, the first thing you need to do is to open a WebSocket connection to the server.
socket = new WebSocket(url);
socket.onmessage = onMessage(socket);
socket.onclose = onClose(socket);
socket.onopen = onOpen(socket);
When you run this code and establish a connection, ActionCable will send the following payload, verbatim:
{
"type": "welcome"
}
If you inspect the WebSocket message, its data
property will be set to
"{\"type\":\"welcome\"}"
. This is because ActionCable assumes all payloads are
JSON encoded strings - you must take care to always “stringify” the data being
sent.
This shows we’re in business! After this message, you’ll want to subscribe to a channel:
const msg = {
command: 'subscribe',
identifier: JSON.stringify({
channel: 'SomeChannel',
}),
};
socket.send(JSON.stringify(msg));
If the message succeeds, ActionCable will reply with the following payload (after decoding the JSON):
{
"identifier": {
"channel": "SomeChannel"
},
"type": "confirm_subscription"
}
You are now subscribed to this channel. ActionCable will start sending heartbeat “ping” messages once every three seconds. Here’s how the payload for one of these pings looks like:
{
"type": "ping",
"message": 1510931567 // Current time in UNIX epoch format
}
Until now, the message type has been one of welcome
, subscribe
,
confirm_subscription
and ping
. But most of the time, consumer-side messages
will use the message
type. Here’s an example:
const msg = {
command: 'message',
identifier: JSON.stringify({
channel: 'SomeChannel',
}),
data: JSON.stringify({
action: 'join',
code: 'NCC1701D',
}),
};
socket.send(JSON.stringify(msg));
Again, pay close attention to how the message needs to be sent as a string.
In this example, ActionCable will try to invoke the join
method on the channel
class and pass it the contents of the data
object:
class SomeChannel < ApplicationCable::Channel
def join(data)
game = Game.find_by!(code: data["code"])
player = Player.find_by!(token: uuid)
game.add_player(player)
broadcast_current_state(game)
end
end
As we’ve seen before, the uuid
from the connection is accessible in the
channel.
And that’s all it takes to talk to ActionCable. Apart from the payload encoding
methodology, it’s pretty straightforward. Be aware that the connection can be
severed, and so special care must be taken handling this event on your socket’s
onClose
handler.
Here’s an example of a Redux middleware handling WebSocket communication. It
intercepts any action type starting with WS_
. To connect, dispatch a
WS_CONNECT
type action, and to subscribe a WS_SUBSCRIBE
one. In your app’s
state tree, you should have a connection
object with a state
key in it. This
state will transition from CLOSED
to OPENING
to SUBSCRIBING
to finally
READY
. The middleware will also dispatch any message received from the Rails
server into your Redux app, which is an interesting integration pattern between
your Rails backend and Redux frontend. Gist here
// src/actions/connection.js
import {
CONNECTION_OPENING,
CONNECTION_SUBSCRIBING,
CONNECTION_READY,
} from './constants';
export function opening() {
return {
type: CONNECTION_OPENING,
}
}
export function subscribing() {
return {
type: CONNECTION_SUBSCRIBING,
};
}
export function ready() {
return {
type: CONNECTION_READY,
};
}
// src/middlewares/websocket.js
import * as connectionActions from '../actions/connection';
let socket;
const onOpen = (ws, store, code) => evt => {
console.log("WS OPEN");
}
const onClose = (ws, store) => evt => {
console.log("WS CLOSE");
}
const onMessage = (ws, store) => evt => {
let msg = JSON.parse(evt.data);
if (msg.type === "ping") {
return;
}
console.log("FROM RAILS: ", msg);
const connectionState = store.getState().connection.state;
const gameCode = store.getState().game.code;
if (connectionState === 'OPENING') {
if (msg.type === 'welcome') {
store.dispatch(connectionActions.subscribing());
const msg = {
command: 'subscribe',
identifier: JSON.stringify({
channel: 'GameChannel',
}),
};
socket.send(JSON.stringify(msg));
} else {
console.error('WS ERRORED!');
}
} else if (connectionState === 'SUBSCRIBING') {
if (msg.type === 'confirm_subscription') {
store.dispatch(connectionActions.ready());
const msg = {
command: 'message',
identifier: JSON.stringify({
channel: 'GameChannel',
}),
data: JSON.stringify({
action: 'join',
code: gameCode,
}),
};
socket.send(JSON.stringify(msg));
} else {
console.error('WS ERRORED!');
}
} else {
store.dispatch(msg.message);
}
}
export default store => next => action => {
const match = /^WS_(.+)$/.exec(action.type);
if (!match) {
return next(action);
}
const wsAction = { ...action, type: match[1] };
if (wsAction.type === 'CONNECT') {
if (socket) {
socket.close();
}
const { code } = wsAction.payload;
socket = new WebSocket(process.env.REACT_APP_WS_URL);
socket.onmessage = onMessage(socket, store);
socket.onclose = onClose(socket, store);
socket.onopen = onOpen(socket, store, code);
store.dispatch(connectionActions.opening());
} else if (wsAction.type === 'SUBSCRIBE') {
const msg = {
command: 'subscribe',
identifier: JSON.stringify({
channel: 'GameChannel',
}),
};
socket.send(JSON.stringify(msg));
store.dispatch(connectionActions.subscribing())
} else {
const msg = {
command: 'message',
identifier: JSON.stringify({
channel: 'GameChannel',
}),
data: JSON.stringify({
...action,
action: wsAction.type.toLowerCase(),
}),
};
socket.send(JSON.stringify(msg));
next(action);
}
};
Thanks
Thanks go to Pablo for the original idea for the React app, and to him and Murtaza for providing feedback.