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