Homepage

Using WebSockets with platformOS

Last edit: May 21, 2024

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

  1. 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.

  1. Can the websocket service connect to anything else (a notification service to receive events)?

Yes, if that service handles websocket connections.

  1. Timeouts

There is 5 seconds timeout for handling subscribed.liquid and receive.liquid partials.

  1. How do you define a message?

Message is a JSON object.

  1. 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.

Questions?

We are always happy to help with any questions you may have.

contact us