tutorial // Dec 09, 2022

How to Implement Websockets with Joystick

Learn how to use Joystick's built-in websockets functionality to quickly and easily spin up and communicate with a websocket server from Joystick components.

How to Implement Websockets with Joystick

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start:

Terminal

cd app && joystick start

After this, your app should be running and we're ready to get started.

Cleaning up the UI

For this tutorial, we're going to build a simple, two-channel chat app. The goal will be to set up our websocket server to filter connections based on the user's chosen room. Users in the "Friends" channel will only see messages sent in that channel and users in the "Family" channel will only see messages sent in that channel.

https://cheatcode-post-assets.s3.us-east-2.amazonaws.com/Rfqka8jFSNibavTi/Screen%20Shot%202022-12-08%20at%205.53.11%20PM.png
The Chat UI we're going to build.

Before we dig into implementing websockets, for fun, we're going to make a few modifications to the UI of our app. This step is entirely optional, so if you're only interested in the Websockets stuff, skip down to Wiring up a websocket server.

To get our design working, we need to make changes to two files: the /index.css file at the root of our app and the main "App" layout that our chat UI will be rendered into. For, index.css, we only need to paste in the following:

/index.css

body {
  font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
  font-size: 16px;
  background: #fff;
  overflow: hidden;
}

* {
  margin: 0;
  padding: 0;
}

*,
*:after,
*:before {
  box-sizing: border-box;
}

Three big changes to what's in this file by default. First, we want to set the <body></body> tag on our UI to be overflow: hidden. This will help us prevent showing two scrollbars in our chat (we'll scroll the messages list but we don't want to scroll the page).

Next, the * selector is saying "reset the margin and padding on all elements" and similarly, the *,*:after,*:before selector is saying set the box-sizing of all elements to use border-box (this fixes some unexpected margin, padding, and width issues in our UI).

/ui/layouts/app/index.js

import ui from "@joystick.js/ui";

const App = ui.component({
  css: `
    .app {
      display: flex;
      height: 100vh;
    }

    .app aside {
      width: 30%;
      max-width: 300px;
      background: #eee;
    }

    .app aside {
      padding: 30px;
    }

    .app aside ul li {
      position: relative;
      list-style: none;
    }

    .app aside ul li.active:before {
      content: "";
      display: block;
      width: 5px;
      background: #0099ff;
      position: absolute;
      top: 0;
      bottom: 0;
      left: -30px;
    }

    .app aside ul li.active a {
      color: #555;
    }

    .app aside ul li:not(:last-child) {
      margin-bottom: 15px;
    }

    .app aside ul li a {
      font-size: 16px;
      line-height: 16px;
      color: #888;
      text-decoration: none;
    }

    .app main {
      width: 100%;
      background: #fff;
    }
  `,
  render: ({ props, component, url }) => {
    return `
      <div class="app">
        <aside>
          <ul>
            <li class="${url.path === '/chat/friends' ? 'active' : ''}"><a href="/chat/friends">Friends</a></li>
            <li class="${url.path === '/chat/family' ? 'active' : ''}"><a href="/chat/family">Family</a></li>
          </ul>
        </aside>
        <main>
          ${component(props.page, props)}
        </main>
      </div>
    `;
  },
});

export default App;

Over in our app layout we have two big changes to make. First, down in the render() method for the component, we're adding some HTML to give structure to our layout, along with a simple navigation for flipping between chat rooms. To make denoting the current room easy, we take advantage of the url object passed to our component by Joystick to grab the current path and set an active class on the <li></li> matching the current URL.

Next, up above this, we define some css to style up this HTML, creating our side-by-side layout of our chatroom list on the left-hand side and our messages/input area on the right-hand side.

That does it for this part (we'll add some more styling in a bit when we work on the chat UI). Next up, we're going to jump up to the server and get our websocket server wired along with a way to send messages back and forth.

Wiring up a websocket server

This part is easier than it may sound. To rig up a websocket server, all we need to do is add a websockets definition to the existing node.app() call in /index.server.js.

/index.server.js

import node from "@joystick.js/node";
import api from "./api";

node.app({
  api,
  websockets: {
    chat: {
      onOpen: (connection = {}) => {
        console.log('A new chatter has joined!');
      },
      onMessage: (message = {}, connection = {}) => {
        console.log(message);
      },
      onClose: (code = 0, reason = '', connection = {}) => {
        console.log('A chatter has left!');
      },
    },
  },
  routes: {
    "/": (req, res) => {
      res.redirect('/chat/friends');
    },
    "/chat/:channelId": (req = {}, res = {}) => {
      res.render('ui/pages/chat/index.js', {
        layout: 'ui/layouts/app/index.js',
      });
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

First, we want to call attention to a change we've made to the routes object here. We've added an additional route /chat/:channelId which will render a new Joystick page component representing the chat for a channel. Here, we've added that route and render the page we'll create later at ui/pages/chat/index.js. To the options object of res.render() here, we set the layout path to ui/layouts/app/index.js which will utilize the layout component we set up earlier.

In addition to this, we've also modified the / index route. Instead of calling res.render() here, we've just added a res.redirect() to /channel/friends. As you might expect, if someone visits the root route (e.g., at http://localhost:2600/), they'll just be redirected to the friends chat room.

Up above our routes object and beneath the api option we're setting to the import from up top, we add a new object websockets. On that object, we set one property for each websocket server we want to define. Each "server" will just piggyback on our main HTTP server (the Express.js server that Joystick automatically spins up when we call node.app()), creating a special path to handle our websocket connections at /api/_websockets/<websocketName>.

Here, our <websocketName> is chat (i.e., if we changed this to pizza, our websocket URL would be /api/_websockets/pizza). To that property, we assign an object with a few methods on it:

  • onOpen which is a function that will be called when any websocket connection is established between our server and a client.
  • onMessage which is a function that will be called when any connected client sends a message.
  • onClose which is a function that will be called when any previously connected client disconnects.

For our chat, this is all we need to do to get our websocket server up and running (neat, right?). Next, let's see how we'll actually "push" or send websocket messages for our app. To make this interesting, we're going to use websockets as a UX touch for our app. We'll persist chat messages in our database like any other app, but as new messages come in, push them to any connected clients so they show up in the chat UI immediately.

/api/index.js

import joystick, { websockets } from '@joystick.js/node';

export default {
  getters: {
    chat: {
      get: (input = {}, context = {}) => {
        return context.mongodb.collection('chat').find({
          channelId: input?.channelId,
        }).toArray();
      },
    },
  },
  setters: {
    sendChat: {
      set: async (input = {}, context = {}) => {
        const message = {
          _id: joystick.id(),
          channelId: input?.channelId,
          message: input?.message,
          createdAt: new Date().toISOString(),
        };

        await context.mongodb.collection('chat').insertOne(message);

        websockets('chat').send(message, input?.channelId);
      },
    },
  },
};

Here, we're doing two things: adding a "getter" which will allow us to load our existing list of chat messages when our app loads up and a "setter" which will allow us to send new chat messages.

Starting with our getter, chat, here, we define a getter endpoint in Joystick that takes in an input object that anticipates a channelId property (as we foreshadowed earlier, this will either be friends or family) and a context object. Both of these are automatically passed to us by Joystick automatically.

As we'll see in a bit, when we call our getter, Joystick will pass the request off to this get function and return whatever that function returns to the initial request in our UI. Here, from the context object we access the mongodb instance that Joystick has automatically connected to our app on startup. The value stored at context.mongodb is nothing more than the native MongoDB Node.js driver (Joystick doesn't modify this in any way, it just hands it over for convenience).

From context.mongodb, we call to the .collection() method, passing the name of the collection we want to get data from—in this case, chat. On that, we chain a call to .find() to pass our query to the chat collection. On that query, we say that we want to find all messages matching the channelId we've passed in to our getter via its input value. After that, we convert the result to an array with .toArray() and return it from our getter (we expect MongoDB to return us an array of objects representing messages if there are any).

Next, the interesting part. Down in the setters part of our API, we're defining a setter called sendChat that we'll call from our UI whenever we want to send a new message. Identical to our getter—just with a slight name change—we define a set function that's responsible for creating or changing some data in our app. Here, we begin by structuring a chat message we anticipate from the client as an object, setting a random ID on it using the built-in joystick.id() method.

With this, we set the channelId and message we expect to receive from the client along with a createdAt timestamp so we know when the message was sent. After this is structured, we use the context.mongodb driver, again calling to the .collection() method passing chat, however this time, we call to .insertOne() passing the message object we just constructed.

This gets the message persisted in our database, but what we really care about is just below this. In order to relay the new message to any connected websocket clients, we import the websockets function from the @joystick.js/node package up top (this is a named export in Joystick so we import it using the {} curly braces notation).

To relay the message, we call the websockets() function passing the name of the server we want to relay the message to (this matches the name we used back in index.server.js). On to this, we chain a call to .send() which receives two arguments: first, the message we want to relay (Joystick expects this to be a plain JavaScript object that it treats as JSON) and, optionally, a specific id that signifies which connections we should relay the message to. Here, even though we have a single server, we can specify a channel to relay the message to on that server (i.e., one server can have many channels, each specified by a unique ID). So it's clear, we also have the option to not pass a unique ID meaning all connections to the chat websocket server will receive all messages.

For our needs, we want to use the channelId the message was sent in. This ensures that only clients connected to our chat websocket server receive messages for the channel they intend (we'll see how this is specified on the client in a bit).

That's it! Now, down on the client, we're ready to connect to our websocket server and receive new messages.

Wiring up a websocket client

To kick things off on the client, we're going to get a lot of the nitpicky stuff out of the way. Below, we're starting by building out the HTML and CSS for our component and adding a default state value, setting chatMessages to an empty array:

/ui/pages/chat/index.js

import ui, { set } from '@joystick.js/ui';

const Chat = ui.component({
  state: {
    chatMessages: [],
  },
  css: `
    .chat {
      display: flex;
      flex-direction: column;
    }

    .messages {
      height: calc(100vh - 88px);
      overflow-y: scroll;
      padding: 30px;
    }

    .messages ol li {
      list-style: none;
    }

    .messages ol li:not(:last-child) {
      margin-bottom: 30px;
    }

    .messages ol li h5 {
      font-size: 15px;
      color: #888;
      font-weight: 500;
    }

    .messages ol li p {
      font-size: 15px;
      line-height: 21px;
      color: #333;
      margin-top: 5px;
    }

    footer {
      display: flex;
      margin-top: auto;
      padding: 20px;
      border-top: 1px solid #eee;
    }

    footer input {
      width: 100%;
      padding: 15px;
      border: 1px solid #ddd;
      border-radius: 3px;
    }

    footer button {
      margin-left: 20px;
      white-space: nowrap;
      padding: 15px 20px;
      border: none;
      background: #eee;
      color: #333;
    }
  `,
  render: ({ each, state }) => {
    return `
      <div class="chat">
        <div class="messages">
          <ol>
            ${each(state?.chatMessages, (chatMessage = {}) => {
              return `
                <li>
                  <h5>${chatMessage?.createdAt}</h5>
                  <p>${chatMessage?.message}</p>
                </li>
              `;
            })}
          </ol>
        </div>
        <footer>
          <input type="text" name="message" placeholder="Type your message here..." />
          <button class="send-message">Send Message</button>
        </footer>
      </div>
    `;
  },
});

export default Chat;

This gives us the basic setup for our chat UI. Here, we're rendering out a list of messages, using Joystick's each() render method to say "for each message in the state.chatMessages array, render an <li></li> with the createdAt timestamp for the message and the message." Beneath this, we've added an <input /> and a button for sending new messages.

Up top, we've added some CSS to make our chat feel more like a traditional chat UI and like we hinted above, setting a default value for state.chatMessages (important as without this, our each() loop would fail as it would be trying to loop over an undefined value).

/ui/pages/chat/index.js

import ui, { set } from '@joystick.js/ui';

const Chat = ui.component({
  state: { ... },
  data: async (api = {}, req = {}) => {
    return {
      chat: await api.get('chat', {
        input: {
          channelId: req?.params?.channelId,
        }
      }),
    };
  },
  lifecycle: {
    onMount: (component = {}) => {
      component.setState({
        chatMessages: component?.data?.chat,
      }, () => {
        const messages = component.DOMNode.querySelector('.messages');
        messages.scrollTo(0, messages.scrollHeight);
      });
    },
  },
  css: `...`,
  render: ({ each, state }) => {
    return `
      <div class="chat">
        <div class="messages">
          <ol>
            ${each(state?.chatMessages, (chatMessage = {}) => {
              return `
                <li>
                  <h5>${chatMessage?.createdAt}</h5>
                  <p>${chatMessage?.message}</p>
                </li>
              `;
            })}
          </ol>
        </div>
        <footer>
          <input type="text" name="message" placeholder="Type your message here..." />
          <button class="send-message">Send Message</button>
        </footer>
      </div>
    `;
  },
});

export default Chat;

Next, we're adding two additional properties to our ui.component() definition: data and lifecycle.

For data, we want to fetch the initial or existing list of chat messages returned from the chat getter we wired up earlier. To do it, we utilize the data function on our component which is passed an object api that contains two methods: get() and set(). Here, we utilize api.get() to say "call the chat getter, passing the current channelId in our route params as the input.channelId value." This may seem odd.

As the second argument to the function we're assigning to data, we receive another object req which contains information about the current HTTP request being made to the server. Behind the scenes, Joystick routes this information to here in our component so that we can fetch data during the SSR (server side rendering) process. As a benefit, we get information about the current params passed in the URL which we "piggyback" on here to fetch our data.

Next down in the lifecycle object, we add the onMount function which is automatically called by Joystick when the component's HTML is mounted in the DOM (in other words, rendered on screen). Inside of onMount we take the component instance that Joystick passes to us and on it, call the .setState() method. Here, we set the chatMessages state value that we initialized earlier to component.data.chat. Here, component.data contains the result of what we return from our data function up above.

So, here, component.data.chat represents the value returned by our call to await api.get('chat', ...). While we certainly could just render data.chat directly down in our render() method, because we're going to add in Websockets next, we need a way to not only store the data fetched at page load, but also a way to "append" new messages that we receive over websockets.

Here, state.chatMessages is the "middle ground" that allows us to accomplish this. We set the existing data on page load and then each subsequent message we get in real-time gets "pushed" into the state.chatMessages array, triggering a re-render of our component, showing each new message without a page refresh.

Before we jump to websockets, one more note: to .setState(), as a second argument we're passing a callback function. Inside, as a nice UX touch, we use the component.DOMNode property from our component instance which points to the rendered DOM node representing the HTML returned from our render() in the browser (literally, the element in the browser's memory).

From this, we use the native JavaScript .querySelector() method to locate the div with the class messages in our HTML and then, call the scrollTo() method on that element. Here, we force the scroll of that element to its scrollHeight, or, bottom. We do this because all new messags appear at the bottom of the list, not the top.

/ui/pages/chat/index.js

import ui, { set } from '@joystick.js/ui';

const Chat = ui.component({
  state: { ... },
  data: async (api = {}, req = {}, input = {}) => { ... },
  websockets: (component = {}) => {
    return {
      chat: {
        options: {
          logging: true,
          autoReconnect: true,
        },
        query: {
          id: component?.url?.params?.channelId,
        },
        events: {
          onOpen: (connection = {}) => {
            console.log('Websockets opened for chat!');
          },
          onMessage: (message = {}, connection = {}) => {
            component.setState({
              chatMessages: [
                ...component.state.chatMessages,
                message,
              ],
            }, () => {
              const messages = component.DOMNode.querySelector('.messages');
              messages.scrollTo(0, messages.scrollHeight);
            });
            console.log({ message, connection });
          },
          onClose: (code = 0, reason = '', connection = {}) => {
            console.log('Websockets closed for chat!');
          },
        },
      },
    };
  },
  lifecycle: { ... },
  css: `...`,
  render: ({ each, state }) => {
    return `
      <div class="chat">
        <div class="messages">
          <ol>
            ${each(state?.chatMessages, (chatMessage = {}) => {
              return `
                <li>
                  <h5>${chatMessage?.createdAt}</h5>
                  <p>${chatMessage?.message}</p>
                </li>
              `;
            })}
          </ol>
        </div>
        <footer>
          <input type="text" name="message" placeholder="Type your message here..." />
          <button class="send-message">Send Message</button>
        </footer>
      </div>
    `;
  },
});

export default Chat;

Now for the fun part. Here, we've added a new property to our ui.component() options, websockets which is set to a function that returns an object with the websocket connections we wish to establish. We use a function here because this allows Joystick to pass us the component instance so we can reference any data passed to our component when connecting our websockets.

Inside, notice that on the object we return we've used the exact same name as we did on our server: chat. To it, we pass an object with three different properties:

  • options which contains configuration specific to the websocket client Joystick will establish behind the scenes.
  • query which contains query parameters that Joystick will embed in the websocket client's URL (this is how you pass connection-specific data to a websocket server without sending messages).
  • events, identical to the server, functions to call when our connection to the server opens, when we receive a message from the server, or our connection to the server closes.

We want to focus on two parts here: query and the events.onMessage function. For query, remember that we want to limit the messages we receive from our websocket server to those intended for the current channel we're viewing. To do this, Joystick gives us the query object and a special id property that can be assigned to a unique ID, establishing a "channel" (we put that in quotes because we're not creating anything new, just filtering data behind the scenes).

Here, we set that unique id to be the channelId from our route params (identical to what we did up in our data function—the only difference is that here we reference component.url.params instead of req.params; same values, just stored different places).

Where this starts to come together is down in the onMessage event handler. Here, as a JavaScript object, we receive the message sent to us from the server. Remember that we expect that message to be the same message we stored in the database via our setter. In case you don't remember, message here is identical to this message object we defined and stored in the database in our setter:

const message = {
  _id: joystick.id(),
  channelId: input?.channelId,
  message: input?.message,
  createdAt: new Date().toISOString(),
};

Here in our onMessage handler, then, we want to take that message and treat it like one of the messages we received from our database on page load. Remember, we put those messages on state in the chatMessages array. So, to add this new message that we received in real-time, we just need to append it to the end of our existing chatMessages array. Above, we do that by setting chatMessages back on state passing [...component.state.chatMessages, message] where ...component.state.chatMessages is saying "copy the contents of the existing chatMessages array on state over to this new array and then place the new message on the end of that array."

That's it! Now, whenever we receive a new message from the server, we'll update chatMessages on state, trigger a re-render, and show the new message in the UI. Again, just like we saw on our initial copy of messages from component.data.chat over to state.chatMessages, here too, we find the .messages element in the DOM and scroll to the bottom.

That just about does it but we have one more step: wiring up sending.

/ui/pages/chat/index.js

import ui, { set } from '@joystick.js/ui';

const Chat = ui.component({
  state: { ... },
  data: async (api = {}, req = {}) => { ... },
  websockets: (component = {}) => { ... },
  lifecycle: { ... },
  events: {
    'click .send-message': (event, component) => {
      const message = component.DOMNode.querySelector('[name="message"]');
      set('sendChat', {
        input: {
          channelId: component.url.params.channelId,
          message: message?.value,
        },
      }).then(() => {
        message.value = '';
      }).catch(({ errors }) => {
        const error = errors && errors[0]?.message;
        window.alert(error);
      });
    },
  },
  css: `...`,
  render: ({ each, state }) => {
    return `
      <div class="chat">
        <div class="messages">
          <ol>
            ${each(state?.chatMessages, (chatMessage = {}) => {
              return `
                <li>
                  <h5>${chatMessage?.createdAt}</h5>
                  <p>${chatMessage?.message}</p>
                </li>
              `;
            })}
          </ol>
        </div>
        <footer>
          <input type="text" name="message" placeholder="Type your message here..." />
          <button class="send-message">Send Message</button>
        </footer>
      </div>
    `;
  },
});

export default Chat;

To send a message, all we need to do is listen for a click event on the button we've rendered in our HTML with the send-message class. To do it, we add one final property to our ui.component() options object: events.

On this object, we define the events we want to listen for, scoped relative to the HTML in our component. Here, we define an event listener by defining a property on the events object in the form of a string that combines the <Event Type> with the <Event Target>. Here, we're saying "listen for a click event on any element with the .send-message class (the . here stands for class).

When a click is detected on .send-message, we call the function assigned to that property click .send-message which receives the raw, native DOM event that was triggered and the component instance.

Inside of that function, we start by utilizing the component.DOMNode value once more, this time querying for our message input using the [name="message"] selector. With this, next, we import the set() method from the @joystick.js/ui package up above and call it, passing sendChat (the name of the setter we defined earlier) as the first argument, and as the second, an options object where we define the input we want to pass.

In this case, remember that we expect a channelId to assign the message to and the message itself. Here, once again, we reference channelId via the component.url.params object and for the message, we take the message element we found via querySelector() and pass its .value property.

Done! For a nice touch, because we expect set() to return a JavaScript Promise, we chain on a call to .then() to say "when the set() completes successfully, do this." "This," here, is just setting the message input's value back to nothing (clear the typed message out).

Also, just in case, we chain on a call to .catch() and take the object we expect to be passed by the set() function in the event on an error and destructure it, "plucking off" the errors property—an array of one or more errors received from the server—and get the first error's message property. We toss that error to window.alert() to show it in a popup.

That's it. We now have a real-time chat system using websockets up and running.

Wrapping up

In this tutorial, we learned how to use Websockets with Joystick. We learned how to set up a websocket server, define API endpoints for getting and setting chat messages, and how to relay new messages in real-time to clients by sending websocket messages from the server. We also learned how to wire up a websocket client on a Joystick component, learning how to handle inbound messages and combining them with static data on state for a great UX.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode