This is mainly an annotation of the Rails ActionCable documentation, with additional information for using it with React and publishing to Heroku.
Most of the boilerplate files can be generated with e.g. rails g channel Game
to create a channel named GameChannel.
The generator will create a subscription file in e.g. app/assets/javascripts/subscriptions/game.coffee
that contains the code to create a new subscription. You can safely delete this file, because this code will be moved into a React component instead.
Section 3.1.1 discusses a signed cookie to authorize the connection. This can be accomplished by using a Warden initialization hook. Keep in mind that the initializer in the article should be placed in config/initializers/warden_hooks.rb
instead of app/config/initializers/warden_hooks.rb
. If your Rails server is currently running, you'll need to restart it for the hook to take effect.
After running rails g channel
, there should be a new Channel in the app/channels
folder. My final version looks like
class GameChannel < ApplicationCable::Channel
def subscribed
# stream_from "some_channel"
stream_from "game_#{params[:game_id]}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def receive(data)
turn_and_dice = data["turn_and_dice"]
die1 = rand(1..6)
die2 = rand(1..6)
# bits 1-3 are die1, 4-6 die2, 7 whether white's turn
turn_and_dice |= (die1 | (die2 << 3))
data["turn_and_dice"] = turn_and_dice
ActionCable.server.broadcast("game_#{params[:game_id]}", data)
end
end
This class defines the functionality for a channel. There can be multiple instances of this channel at the same time, e.g. for multiple simultaneous games or chats. The stream_from
function will specify an individual instance of this channel to stream from. Here, the subscription (described below) passes a game_id
param to specify which instance of the game channel it wants to stream. For instance, a game with ID 5 would, after the string interpolation, resolves to stream_from "game_5"
. If you only have one instance of the channel, you can ignore the parameterization of stream_from
and just specify a static string, e.g. stream_from "game"
, at which point all connected clients would stream from the instance called "game".
App.gameChannel = App.cable.subscriptions.create(
{
channel: "GameChannel",
game_id: this.props.gameId
},
{
connected: () => console.log("GameChannel connected"),
disconnected: () => console.log("GameChannel disconnected"),
received: data => {
console.log(data)
}
}
);
The above code establishes a connection (subscription) with a backend Channel, defined above, and saves it to the variable App.gameChannel
. It is usually best placed in the componentDidMount()
function.
If there is only one instance of a channel, the first argument of create()
can simply be a string, e.g. App.gameChannel = App.cable.subscriptions.create("GameChannel", ...)
If there can be multiple parameterized channel instances, you can pass an object as the first parameter, with a key channel
specifying the channel to subscribe to, and any additional parameters you want available on the backend via params[]
. Here, we pass the ID of the current game, so the backend subscription can stream_from
the desired stream, e.g. game_5
.
The second parameter to create
is an object whose values are functions. The function assigned to the key received
will be run on data received from a backend call to ActionCable.server.broadcast
, such as the one above.
To send a message to ActionCable (e.g. to be broadcast to all connected clients), you can run the send()
method on the variable you saved the new subscription to, e.g.
App.gameChannel.send({
message: "Hello from an ActionCable client!"
})
ActionCable calls JSON.stringify
on the argument of send()
, so it must be a Javascript Object.
There also appears to be a delay between when the subscription is created and when ActionCable will start handling .send()
calls. Presumably sent messages will only be handled after the connection has been established, which takes some time after subscription.create()
has been called. Importantly, if a message is sent while the connection is still being established, there will be no error messages or other feedback, so one might erroneously conclude that their connection or subscription has not been setup correctly or their .send()
call is faulty, when in fact all one needs to do is wait for the connection to be established.
Calls to .send()
on the client will direct to the receive(data)
function on the backend, which can handle the data however the developer wishes. In the Channel definition above, a new pair of dice are assigned to the data, and then the data object is broadcast to all clients connected to the stream indicated in the first argument of .broadcast()
.
As mentioned in the documentation, .broadcast()
sends the data to the client that sent the data as well, so the sending client will run the received:
function (defined during subscription.create
) on the data that it sent out, once it receives it from the backend broadcast()
call, just like all the other connected clients. The above code just calls console.log on this data, but one can imagine a state update such as
received: data => {
this.setState( {messages: [...this.state.messages, data]} )
}
if the data was a chat message that one wanted to append to an array of messages.
Heroku requires Redis to be setup, which can be done by following the documentation.
The config/cable.yml
file will also need to be updated with the new Redis URL for Heroku. This url can be found by running heroku config | grep REDIS
.