Talking to ActionCable without Rails

Bruno Antunes

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.