DEV Community

loading...

SocketCluster. The most underrated framework. Part 3: A Pub/Sub example and middleware

maarteNNNN
A Belgian native living in Brazil
・4 min read

GitHub logo maarteNNNN / sc-underrated-framework-pubsub

SocketCluster. The most underrated framework. Part 3: A Pub/Sub example and middleware

Introduction

In this part we will make a simple chat example to understand how Pub/Sub works in SocketCluster. The app can be tested across multiple browser windows. We will add some simple middlewares. A chat history and censorship for bad-words.

Setup

Let's setup a blank project by running socketcluster create sc-pubsub and cd sc-pubsub. Let's install nodemon to restart the server automatically npm i -D nodemon. And for our bad-words censorship we will use a package called bad-words from NPM. npm i -s bad-words. The server can be run with npm run start:watch.

Client code setup (don't give much attention to this, just copy and paste)

We will use vanilla JavaScript in HTML like part 2 shipped with SocketCluster in public/index.html. Let's delete everything inside the style tag and replace it with:

* {
  margin: 0;
  padding: 0;
}

html {
  height: 100vh;
  width: 100vw;
}

.container {
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
}

.chat-history {
  height: 70vh;
  width: 75%;
  border: 1px solid #000;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.chat-input {
  width: 75%;
  height: 5vh;
  border-left: 1px solid #000;
  border-bottom: 1px solid #000;
  border-right: 1px solid #000;
}

input {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  border: none;
  padding: 0 1em;
}

strong,
small {
  font-size: 11px;
  color: gray;
}

.message {
  padding: 0.25rem 1rem;
}
Enter fullscreen mode Exit fullscreen mode

and delete eveything inside <div class="container"> tag and replace it with:

<div id="chat-history" class="chat-history"></div>
<div class="chat-input">
  <input placeholder="message" onkeyup="sendMessage(event)" />
</div>
Enter fullscreen mode Exit fullscreen mode

Okay. Now we have a basic chat page. Nothing too fancy. Now we can focus on getting actual logic of our chat application.

The Pub/Sub functionality

Client

Pub/Sub in SocketCluster is something that can work without writing any backend logic. We can create a channel on the client and the server makes this channel available for other clients.

(async () => {
  for await (const data of socket.subscribe('chat')) {
    console.log(data);
  }
})();
Enter fullscreen mode Exit fullscreen mode

and we should create the function that listens to the enter key on the input to send the publish the message.

const sendMessage = async (event) => {
  if (event.keyCode === 13) {
    try {
      await socket.transmitPublish('chat', {
        timestamp: Date.now(),
        message: event.target.value,
        socketId: socket.id,
      });
      event.target.value = '';
    } catch (e) {
      console.error(e);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

The transmitPublish method does not suspect a return value. If you do want a response you can look at invokePublish.

The transmitPublish sends an object with a timestamp, message and the socketId. The socket.subscribe('chat') async iterable will log any new data being pushed. Open two browser windows next to each other and open the Developer Tools in both windows. If you send a message in one window it should output it in both consoles.

We will display the messages in the #chat-history div by creating a function that creates an element, changes the text, adds a class and appends the element.

const createMessage = ({ socketId, timestamp, message }) => {
  const chatHistoryElement = document.getElementById('chat-history');
  const messageElement = document.createElement('div');
  messageElement.className = 'message';
  messageElement.innerHTML = `<strong>${socketId}</strong> <small>${timestamp}:</small> ${message}`;

  chatHistoryElement.appendChild(messageElement);

  // Always scroll to the bottom
  chatHistoryElement.scrollTop = chatHistoryElement.scrollHeight
};
Enter fullscreen mode Exit fullscreen mode

change the previous console.log(data) inside the socket.subscribe('chat') to createMessage(data).

Now if we send messages it should display them in the HTML instead of the Developer Tools. Pretty neat, huh? Upon to this point we still didn't do any server-side code.

Server-side

There's only one problem with our app. Every new window does not have any older messages. This is where the server comes in. We will create a middleware that pushes every message to an array, for simplicity sake. Another thing the middleware will pick-up is bad-words. We can filter them and replace the characters with a *.

const Filter = require('bad-words');
const filter = new Filter();

...

const history = []

agServer.setMiddleware(
  agServer.MIDDLEWARE_INBOUND,
  async (middlewareStream) => {
    for await (const action of middlewareStream) {
      if (action.type === action.PUBLISH_IN) {
        try {
          // Censor the message
          action.data.message = filter.clean(action.data.message);
        } catch (e) {
          console.error(e.message);
        }
        // Push to the array for history
        history.push(action.data);
      }
      // Allow the action
      action.allow();
    }
  },
);

...
Enter fullscreen mode Exit fullscreen mode

We set an inbound middleware, we pass it an async iterable stream. On every action of the stream we check if the action.type equals the constant provided by SC action.PUBLISH_IN. If the conditional is true we filter the message and allow the action. Alternatively we could action.block() the action if we don't want it to go through. More on middleware here

To implement the history it's pretty simple, we just create a constant const history = [] and push every action.data to it. Like shown in the above code.

To initially get the history we transmit the data upon a socket connection (e.g. a new browser window).

(async () => {
  for await (let { socket } of agServer.listener('connection')) {
    await socket.transmit('history', history);
  }
})();
Enter fullscreen mode Exit fullscreen mode

And create a receiver on the client which uses a loop to create the messages.

(async () => {
  for await (let data of socket.receiver('history')) {
    for (let i = 0; i < data.length; i++) {
      const m = data[i];
      createMessage(m);
    }
  }
})();
Enter fullscreen mode Exit fullscreen mode

I will try to add an article every two weeks.

Discussion (2)

Collapse
bias profile image
Tobias Nickel

I am not sure if the loops are so much better than registering a listener function. Anyway, If someone prefer an other codestyle it would be easy to implement a single helper method, that takes an Iterable and a callback-function.

However I really thank you showing this project. In has a very interesting architecture. I already installed it and made a first test. because I wanted to know if web-clients can access cross domain. And it worked instandly.

If SocketCluster can hold up to all its promisses, I will have a lot of fun with it.

Thanks for this article.

Collapse
maartennnn profile image
maarteNNNN Author

Thanks for your reply and insight!

I have covered the benefit of the async iterables briefly in part 1. It's basically queuing actions. This is more advantageous than callbacks that do not care about concurrent logic. An example I gave is that database operations will execute according to the queue. Event listeners actions run concurrently, and therefore could potentially generate errors. Upon talking to the creator of SocketCluster, Jon Dubois, he said that in previous versions of the framework - which did not utilize the async iterable, that it was the most common problem.

As for syntax the (async => { ... })() is indeed not a very beautiful syntax. It can be easily forgotten to execute it by adding the (), overall it needs a ; to be able to format it with Prettier for example. However there are some proposals to ECMAScript to resolve these issues. One of them is the async do proposal. Another is top-level await.