Yet Another Tutorial. I focus on going quickly to the relevant paths to achieve running a rails SSR app with a standalone ActionCable process, with (P)React rendering with two use case of web sockets. The authentication part and reliability discussions are eluded here.
The ActionCable process can be embedded in the Rails process or packaged as a standalone process. Both configs are shown below. Since you may use background jobs to delegate the broadcast, this would need another process too.
Why (P)React? Because any broadcasted data via a web socket can be used to mutate a state, and therefor trigger a rendering, just with a useEffect
, no soup. If you use Preact, you save 30k and have a lightweight easy going JS frontend; just alias react
and react-dom
to preact/compat
in Webpack (and yarn add preact preact-compat
), nothing else :)
Instead of a traditional chat app, I want to build an app that manages realtime inventories and broadcasts the total page hits. It has a button that on-click increments a counter and broadcasts it; this can easily simulate the action of a customer filling his basket and monitoring the stock. We also increment a total page hit counter server-side and broadcast it: this simulates the total connected customers. The code below can be easily adapted to achieve this.
A demo app is visible on Heroku. Open several browsers to check.
Config
We will setup the backend and the frontend for a standalone cable process. The frontend requires the installation of the npm package @rails/actioncable, and the backend to enable the middleware action_cable/engine. Rails guide standalone and npm @rails/actioncable
For the frontend
- run
npm i @rails/actioncable
- tell to the client where to connect for the standalone mode, so target the cable process:
#/config/environnemnts/development.rb
config.action_cable.url = "ws://localhost:28080/cable"
...
#app/views/layouts/application.html.erb
<%= action_cable_meta_tag %>
or
// /app/javacsript/channels/consumer.js
createConsumer('ws://${window.location.hostname}:28080/cable')
In production, you may need to adjust with your (sub)domain and wss
.
🔵 If you wish to run ActionCable packaged with Rails, target the current port of the Rails process:
#/config/environnemnts/development.rb
config.action_cable.url = "ws://localhost:3000/cable"
It will be parsed by the client Javascript to get the URL.
For the backend (local config)
- subscribe the Redis adapter ("cable.yml")
development:
adapter: redis
url: <%= ENV["REDIS_URL"] %>
channel_prefix: cable_prod
- enable the middleware,
require "action_cable/engine"
in "/config/application.rb" - you may want to restrict the origin. In development, it's
localhost
and in production you (sub)domain.
#/config/application.rb
require "action_cable/engine"
[...]
#config/environments/dev.rb
Rails.application.configure do
[...]
origins = ['http://localhost:3000','http://localhost:9000']
config.action_cable.allowed_request_origins = origins
end
Add:
#/cable/config.ru
require_relative "../config/environment"
Rails.application.eager_load!
run ActionCable.server
Now you can launch the standalone cable process with:
bundle exec puma -p 28080 cable/config.ru
Example app
The app is a standard SSR app with React embedded (ie installed via rails webpacker:install:react
). We count and broadcast the number of page loads and the number of clicks on a button. It has a unique component, named "Button".
We run rails g channel counter
and rails g channel hits
. This will generate the following pre-filled files:
/app/channels
|_ /application_cable
|_ counter_channel.rb
|_ hits_channel.rb
/javascript/channels
|_ consumer.js
|_ index.js
|_ counter_channel.js
|_ hits_channel.js
In our routes:
#app/config/routes.rb
mount ActionCable.server => '/cable'
The "click counter" channel
counter_channel.js <=> counter_channel .rb
stream_from "counter_channel" <=> { channel: "CounterChannel" }
handleClick => increment counter =>
counterChannel.sending(data) =>
sending(){ this.perform ('receiving', data) } =>
receiving.rb => ActionCable.server.broadcast(data) =>
counterChannel.received(data) => mutate state
=> render
The handleClick()
of "Button" calls a custom function counterChannel.sending()
and pass data to it. This function is defined in the web socket wrapper "counter_channel.js". The custom function sending()
calls with this.perform()
a server-side custom callback method CounterChanel::receiving.rb
. Any transferred data must be in the form "{key:value}". The server saves to the database and broadcasts back the data to any connected client.
The web socket wrapper has a built-in listener received()
that we overwrite in the "Button.js" component within a useEffect
hook that will render. It will mutate the state if any data is present. If there is no data, for example on page refresh, then we fetch
from the database and mutate the state accordingly. This is needed on page refresh, since the connection is lost and renewed. A simple switch is implemented to realise this.
You basically transformed a POST request to Postgres into a web socket call that saves to the database.
❗ However, this relies on the continuity of the communication; if it is important to save the client state, then the traditional POST request of the value of the counter to an SQL database and broadcast back through the socket is safer in this case. This means instead of calling counterChannel.sending()
from the component, you would perform a POST
from it and create an endpoint with a controller and put the code below into it.
Server-side, we have (we have a model Counter
):
#app/controllers/counter_channel.rb
class CounterChannel < ApplicationCable::Channel
def subscribed
stream_from "counter_channel" # pubsub
end
def receiving(msg)
# the value of the counter is incremented client-side
counter = Counter.create!(nb: msg['countPG'] )
data = {}
data['countPG'] = msg['countPG']
puts data
ActionCable.server.broadcast('counter_channel', data.as_json) if counter.valid?
end
def unsubscribed
stop_all_streams
end
end
and client-side, we have:
import consumer from "./consumer";
const counterChannel = consumer.subscriptions.create(
{ channel: "CounterChannel" },
{
connected() { },
disconnected() {},
received: function (data) {
// overwritten in "Button.jsx" called when there's incoming data on the web socket for this channel
},
sending(data) {
this.perform("receiving", data);
},
}
);
export default counterChannel;
The "data" is an object sent from the component "Button".
The "hit page counter" channel
hit_channel.js <=> hit_channel .rb
stream_from "hit_channel" <=> { channel: "HitChannel" }
send() <=> receive
connected() {this.perform('trigger_hits')} =>
trigger_hits.rb => ActionCable.server.broadcast
=> hitChannel.received => mutate state
=> render
On page load, the client connects to the web socket defined.
A channel has a predefined
connected()
function. The predefined callback method isHitChannel::subscribed.rb
. In particular,stream_for "hit_channel"
In our case, we decided as an exercise to implement a custom method server-side. We call with this.perform()
a custom server-side callback method called HitChannel::trigger_hits.rb
. It increments the database (a Redis store) and broadcasts the data back to any connected client. Then, client-side, the web socket wrapper has a listener received()
that we overwrite in the "Button.js" component". It will update the state within a useEffect
.
If we want safety, we would directly retrieve, increment and broadcast the counter from the home page controller.
We could also have use the built-in
send
. For example,connected(){ this.send({msg: "new connection") }
. It's server-side alter ego callback is the predefined methodreceive
. The actions would have been put in this method.
Server-side, we have:
#app/channels/hit_channel.rb
class HitChannel < ApplicationCable::Channel
def subscribed
stream_from "hit_channel"
# the code in "trigger_hits" could be here without using this custom method
end
def trigger_hits
REDIS.incr('hits_count')
data = {}
data['hits_count'] = REDIS.get('hits_count').to_i
puts data
ActionCable.server.broadcast('hit_channel', data.as_json)
end
def receive(data)
# responds to `hitChannel.send()`
puts data
end
def unsubscribed
stop_all_streams
end
end
and client-side:
import consumer from "./consumer";
const hitChannel = consumer.subscriptions.create(
{ channel: "HitChannel" },
{
connected() {
//<- predefined callback HitChannel::subscribed.rb
console.log("Client connected");
//<- custom callback "HitChannel::trigger_hits.rb" called
this.perform("trigger_hits");
//<- predefined callback HitChannel::receive.rb"
this.send({ msg: "new connection" });
},
disconnected() {},
received: function (data) {
// overwritten in Button.jsx
},
}
);
export default hitChannel;
The "button"
The 'Button.jsx" component imported in "/packs/application.js"
...
import Button from "./components/Button.jsx";
document.addEventListener("DOMContentLoaded", () => {
ReactDOM.render(
<Button />, document.getElementById("root")
);
});
import React, { useState, useEffect } from "react";
import fetchCounters from "../utils/fetchCounters.js";
import counterChannel from "../../channels/counter_channel.js";
import hitChannel from "../../channels/hit_channel.js";
const Button = () => {
const [counters, setCounters] = useState({});
const [hitCounts, setHitCounts] = useState();
useEffect(() => {
async function initCounter() {
try {
hitChannel.received = (data) => {
if (data) return setHitCounts(data.hits_count);
};
counterChannel.received = (data) => {
if (data.countPG) {
return setCounters({ countPG: data.countPG });
}
};
const { countPG } = await fetchCounters("/getCounters");
setCounters({ countPG: Number(countPG) });
} catch (err) {
console.warn(err);
throw new Error(err);
}
}
initCounter();
}, []);
const handleClick = async (e) => {
e.preventDefault();
try {
let { countPG } = counters;
(!countPG) ? countPG=0 : countPG += 1;
counterChannel.sending({countPG});
} catch (err) {
console.log(err);
throw new Error(err);
}
};
return (
<>
<div className="flexed">
<button className="button" onClick={handleClick}>
Click me!
</button>
{counters && (
<div>
<h1>Click counter: {counters.countPG}</h1>
<br />
<h1>Total page hits: {hitCounts}</h1>
</div>
)}
</div>
</>
);
};
export default Button;
#fetchCounters.js
export default async (path) => {
return fetch(path, { cache: "no-store" }).then((res) => res.json());
};
#postCounters.js
import { csrfToken } from "@rails/ujs";
export default async (path, object) => {
return fetch(path, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken(),
},
body: JSON.stringify(object),
}).then((res) => res.json());
};
Run this
On localhost. You can use for example overmind to run the Procfile. Firstly start Nginx and Postgres, then overmind start
#Procfile
assets: ./bin/webpack-dev-server
web: bundle exec rails server
redis-server: redis-server redis/redis.conf
worker: bundle exec sidekiq -C config/sidekiq.yml
cable: bundle exec puma -p 28080 cable/config.ru
There you will see that the Rails process does not log anything about "ActionCable", only the cable
process replies.
On Docker, you use the Rails image to run two containers, one with Rails and one for a cable process, launched with the above command.
If the Rails service is called app
and your cable process is called cable
and you would like to reverse-proxy it with Nginx, then the config file to pass along the web sockets would be like:
upstream puma {
server app:3000;
}
server {
listen 9000;
root /usr/share/nginx/html;
try_files $uri @puma;
access_log off;
gzip_static on;
sendfile on;
expires max;
add_header Cache-Control public;
add_header Last-Modified "";
add_header Etag "";
location @puma {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_pass_header Set-Cookie;
proxy_redirect off;
proxy_pass http://puma;
}
location /cable {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://cable:28080;
}
error_page 500 502 503 504 /500.html;
location = /500.html {
root /usr/share/nginx/html;
}
}
Happy coding!
Top comments (0)