A small 'chat' as PoC

Recently Google announced the support for WebSockets in Cloud Run. We were quite excited about it so we decided to create a small PoC to see if everything is working correctly.

The goal of the POC was to create a small chat (nothing fancy) and see how that goes. We ended up creating a chat with Stomp and one with Socket.IO. Recently we integrated a live chat with PubNub, which comes with a montly cost. So now we have an opportunity to "compare" the effort it takes.

A working example can be found on GitLab

Stomp in combination with a Message Broker

STOMP, Simple (or Streaming) Text Orientated Messaging Protocol, is a simple text-based protocol used for transmitting data across applications and is derived on top of WebSockets. It is a much simpler and less complicated protocol than AMQP, it is more similar to HTTP.

It provides an interoperable wire format that allows STOMP clients to talk with any message broker supporting the protocol. There are many message brokers that support STOMP, like Apache ActiveMQ, RabbitMQ, etc... We've chosen for RabbitMQ. When running RabbitMQ (via docker), make sure that the STOMP WEB plugin is enabled.

STOMP does not deal with queues and topics — it uses a SEND semantic with a destination string. RabbitMQ maps the message to topics, queues, or exchanges (other brokers might map onto something else that it understands internally). Consumers then SUBSCRIBE to those destinations.

Getting started

The PoC uses Next.js in combination with Stomp.js that will be used to communicate with the RabbitMQ message broker.

Connect to RabbitMQ and subscribe on a topic

// next.js page
...

const setupStompClient = () => {
client = new Client({
brokerURL: 'ws://localhost:15674/ws',
connectHeaders: {
login: 'guest',
passcode: 'guest',
},
debug: function (str) {
console.log(str);
},
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});

client.onConnect = function (frame) {
client.subscribe(
'/topic/chat',
(message) => {
if(message.body) {
setMessages( arr => [...arr, message.body]);
}
}
);
};

client.activate();
};

Publish messages

const onSendMessage = () => {
client.publish({ destination: "/topic/chat", body: message });
};

Socket.IO with Redis

Socket.IO is a JavaScript library for real-time web applications. It enables real-time, bi-directional communication between web clients and servers. It has two parts: a client-side library that runs in the browser, and a server-side library for Node.js. In this example a custom Next.js server was set up together with Socket.IO. As mentioned before, one of our goals was to see if the new Websocket capabilities from Cloud Run are working as expected. With this example we're actually making use of those new capabilities.

Also, we run the Socket.IO server with the Redis adapter, but this is optional. This gives us the benefit of emitting events from outside the context of our Socket.IO processes. On top of that, by running Socket.IO with the Redis Adapter you can run multiple Socket.IO instances in different processes or servers that can all broadcast and emit events to and from each other.

Customer next.js server with Socket.IO

const express = require("express")();
const server = require("http").Server(express);

const io = require("socket.io")(server);
const redis = require("redis");
const redisAdapter = require("socket.io-redis");
const pubClient = redis.createClient(6379, "localhost", {
auth_pass: "sOmE_sEcUrE_pAsS",
});
const subClient = pubClient.duplicate();

const next = require("next");

const port = parseInt(process.env.PORT, 10) || 3000;
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev, dir: process.env.NEXT_DIR });
const handle = app.getRequestHandler();

io.adapter(redisAdapter({ pubClient, subClient }));

io.on("connect", (socket) => {
socket.on("new-message", (data) => {
io.emit("publish-message", { message: data.message });
});
});

app.prepare().then(() => {
// Everything else is handled by Next
express.get("*", (req, res) => {
return handle(req, res);
});

server.listen(port, (err) => {
if (err) throw err;
console.log(`> Ready on http://localhost:${port}`);
});
});

Client: connect with Socket.IO server

import io from 'socket.io-client';

...

const setupSocketIO = () => {
socket = io();

socket.on('publish-message', data => {
setMessages( arr => [...arr, data.message]);
});
};

Client: emit messages

const onSendMessage = () => {
socket.emit("new-message", { message });
};

Cloud function

As mentioned above, by using the Redis adapter we can emit events from outside the context of our Socket.IO processes, for example a cloud function like below.

const { Emitter } = require("@socket.io/redis-emitter");
const { createClient } = require("redis");

const redisClient = createClient(6379, "localhost", {
auth_pass: "sOmE_sEcUrE_pAsS",
});
const io = new Emitter(redisClient);

exports.notify = async (req, res) => {
const { message } = req.query;

if (message) {
io.emit("publish-message", { message });

res.send(`Message '${message}' sent!`);
} else {
res.send("No message given");
}
};

Summary

Ok... we didn't create a full working chat, but when you look at the little code we need to create a 'small chat' it's quite easy... Several solutions are possible to implement the use of Websockets, which one to choose probably depend on the needs of the project. Socket.IO could have a slight advantage since you don't need an external service and could be more cost-efficient.

Ooooh, and WebSockets with Cloud Run does work ;).