Using WebSockets with platformOS
In this guide we will show you how to integrate WebSockets into your application based on the chat functionality we implemented for the pOS Marketplace Template.
WebSocket is a protocol for real-time communication between a client and a server. It allows to build an interactive UI where data can be updated from the server without the need of doing http requests. Each client can subscribe to multiple channels and rooms. Messages sent to a certain room are then broadcasted to all subscribers of that room. The pOS implementation of Websockets uses ActionCable.
Requirements
To understand this topic, you should be familiar with:
Steps
Step 1: Setup
Make sure you have added the @rails/actioncable package "@rails/actioncable": "^6.0.3-2"
to your package.json
file. This package handles communication between the server and the client.
Step 2: Create a connection
Consumers require an instance of the connection on their side. This can be established using the code below.
// modules/chat/src/js/consumer.js
import { createConsumer } from "@rails/actioncable"
export default createConsumer('/websocket')
Step 3: Subscribe to a channel
A consumer becomes a subscriber by creating a subscription to a given channel:
// modules/chat/src/js/chat.js
import consumer from "./consumer";
const chat = function(){
// cache 'this' value not to be overwritten later
const module = this;
module.createSubscription = () => {
module.channel = consumer.subscriptions.create(
{
channel: 'conversate', //channel name
room_id: module.conversationId, //room ID
}
)
}
}
Step 4: Handle callbacks
There are 4 callbacks handled by the subscription that allow you to react on certain events. You can add handlers for them when you create the subscription object:
// modules/chat/src/js/chat.js
import consumer from "./consumer";
const chat = function(){
// cache 'this' value not to be overwritten later
const module = this;
module.createSubscription = () => {
module.channel = consumer.subscriptions.create(
{
channel: 'conversate',
room_id: module.conversationId
},
{
received: function(data){
// function responsible for handling new message
module.showMessage(data);
},
connected: function(){
// on connect we want to enable the message input
module.settings.messageInput.disabled = false;
module.settings.messageInput.focus();
},
rejected: function(){
// when connection has been rejected we want to notify the user about the error
module.blocked();
},
disconnected: function(){
// when consumer has been disconnected we want to notify the user about the error
module.blocked();
}
}
);
};
}
Step 5: Secure room subscription
To make sure that only authorized users can access certain rooms (for example, rooms for conversations where they are one of the pariticpants) you can use a partial that will be called when the user tries to subscribe to a certain room.
This file should be stored in views/partials/channels/:channel_name/subscribed.liquid
. In this example, we use code from the pOS Marketplace Template and the channel is called conversate
. This partial needs to return a true
string if the current user is authorized to subscribe to the room.
{%- comment -%}
modules/chat/public/views/partials/channels/conversate/subscribed.liquid
{%- endcomment -%}
{% liquid
function current_profile = 'lib/current_organization_profile', user_id: context.current_user.id
assign room_id = context.params.room_id
function conversation = 'modules/chat/lib/queries/conversations/find_by_participant', id: room_id, participant_id: current_profile.id
if conversation
echo 'true'
else
echo 'false'
endif
%}
In the pOS Marketplace Template, each user has a profile and the profile is linked with a conversation. If the conversation is found then we return true
to confirm that the user can subscribe to this room.
Step 6: Send messages
To send a message, you need to call the send
method on the subscription object you have created. This method takes Object as an argument.
// modules/chat/src/js/chat.js
module.sendMessage = (message) => {
let messageData = {
message: encodeHtml(message),
author_id: module.settings.currentUserId,
sender_name: module.settings.messageInput.getAttribute('data-from-name'),
created_at: new Date(),
create: true
};
module.channel.send(messageData);
};
Step 7: Set server callback on received message
You may want to trigger some code when the server receives a message from a client. To do so, you need to create a partial that will be called when the message is received. This file should be stored in views/partials/channels/:channel_name/receive.liquid
. In this example, we use code from the pOS Marketplace Template and the channel is called conversate
.
context.params
contains all fields that have been sent in the previous step.
{%- comment -%}
modules/chat/public/views/partials/channels/conversate/receive.liquid
{%- endcomment -%}
{% liquid
function current_profile = 'lib/current_organization_profile', user_id: context.current_user.id
assign room_id = context.params.room_id
function conversation = 'modules/chat/lib/queries/conversations/find_by_participant', id: room_id, participant_id: current_profile.id
if conversation
assign message_safe = context.params.message | raw_escape_string
assign object = '{}' | parse_json
hash_assign object['conversation_id'] = conversation.id
hash_assign object['autor_id'] = current_profile.id
hash_assign object['message'] = message_safe
function message = 'modules/chat/lib/commands/messages/create', object: object
if message.valid != true
log message, 'ERROR receive message'
echo "false"
else
function res = 'modules/chat/lib/commands/conversations/mark_unread', conversation: conversation, current_profile: current_profile
endif
else
echo "false"
endif
%}
In this example, we are retrieving the conversation object and creating a message object to persist data in the database.
To avoid broadcasting (for example if the user is not authorized to add a message in a conversation or when an validation error is triggered) ensure the partial returns "false" (whitespace characters do not matter).
Employing Cross-Site Request Forgery token validation
If you've enabled websockets_require_csrf_token
(default) in your app/config.yml, the CSRF token must be passed as below:
const token = csrfToken();
createConsumer(`/websocket?authenticity_token=${token}`)
The csrfToken
function can retrieve the token
from the meta tag for example:
const csrfToken = () => {
const meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) { throw new Error('Unable to find CSRF token meta'); }
const token = meta.getAttribute('content');
if (!token) { throw new Error('Unable to get CSRF token value'); }
return token;
}
GraphQL mutation to broadcast to a channel and a room
You can broadcast a message from the backend, to a specific channel and room, using the mutation channel_send_message
.
The mutation takes the channel_name
, the room_id
, and the JSON payload
as mandatory arguments to send.
For example:
mutation broadcast_message {
channel_send_message(
channel_name: "conversate"
room_id: "12"
payload: {
message: "Some message",
author_id: 2,
sender_name: "Some Sender",
created_at: "2030-07-01 10:30:00",
create: true
}
)
}
Other examples
In the pOS Marketplace Template, we have also implemented a notifications functionality that uses WebSockets to inform user about unread messages. You can find it in the file modules/chat/src/js/notifications.js.
Q&A
- Socket timeouts — how does it handle reconnect? Is that the job of the client or can the server reconnect in a permanent listen mode?
The JS client @rails/actioncable is responsible for reconnecting.
- Can the websocket service connect to anything else (a notification service to receive events)?
Yes, if that service handles websocket connections.
- Timeouts
There is 5 seconds timeout for handling subscribed.liquid
and receive.liquid
partials.
- How do you define a message?
Message is a JSON object.
- Parallel processing, assuming just one connection, but how does the server keep up if there are streams of events one after another,
does it split off thread / processes in the background to cover this or queue this somehow?
Each client has their connection and each connection is handled by a thread on the server side.
If there are multiple incoming messages from a single connection then they are queued.