Check out my books on Amazon at https://www.amazon.com/John-Au-Yeung/e/B08FT5NT62
Subscribe to my email list now at http://jauyeung.net/subscribe/
WebSockets is a great technology for adding real time communication to your apps. It works by allowing apps to send events to another app, passing data along with it. This means that users can see new data on their screen without manually retrieving new data, allowing better interactivity and making the user experience easier for the user. HTTP also has a lot of overhead with sending data that not all apps need like headers, this increases the latency of the communication between apps.
Socket.io is a library that uses both WebSockets and HTTP requests to allow apps to send and receive data between each other. Sending data between apps is almost instant. It works by allow apps to emit events to other apps and the apps receiving the events can handle them the way they like. It also provides namespacing and chat rooms to segregate traffic.
One the best uses of WebSockets and Socket.io is a chat app. Chat apps requires real time communication since messages are sent and received all the time. If we use HTTP requests, we would have to make lots of requests repeatedly to do something similar. It will be very slow and taxing on computing and networking resources if we send requests all the time to get new messages.
In this article, we will build a chat app that allows you to join multiple chat rooms and send messages with different chat handles. Chat handle is the username you use for joining the chat. We will use React for front end, and Express for back end. Socket.io client will be used on the front end and Socket.io server will be used on the back end.
To start we make an empty folder for our project and then inside the folder we make a folder called backend
for our back end project. Then we go into the backend
folder and run the Express Generator to generate the initial code for the back end app. To do this, run npx express-generator
. Then in the same folder, run npm install
to install the packages. We will need to add more packages to our back end app. We need Babel to use the latest JavaScript features, including the import
syntax for importing modules, which is not yet supported by the latest versions of Node.js. We also need the CORS package to allow front end to communicate with back end. Sequelize is needed for manipulate our database, which we will use for storing chat room and chat message data. Sequelize is a popular ORM for Node.js. We also need the dotenv
package to let us retrieve our database credentials from environment variables. Postgres will be our database system of choice to store the data.
We run npm i @babel/cli @babel/core @babel/node @babel/preset-env cors dotenv pg pg-hstore sequelize sequelize-cli socket.io
to install the packages. After installing the packages, we will run npx sequelize-cli init
in the same folder to add the code needed to use Sequelize for creating models and migrations.
Now we need to configure Babel so that we can run our app with the latest JavaScript syntax. First, create a file called .babelrc
in the backend
folder and add:
{
"presets": [
"@babel/preset-env"
]
}
Next we replace the scripts
section of package.json
with:
"scripts": {
"start": "nodemon --exec npm run babel-node -- ./bin/www",
"babel-node": "babel-node"
},
Note that we also have to install nodemon
by running npm i -g nodemon
so that the app will restart whenever file changes, making it easier for us to develop the app. Now if we run npm start
, we should be able to run with the latest JavaScript features in our app.
Next we have to change config.json
created by running npx sequelize init
. Rename config.json
to config.js
and replace the existing code with:
require("dotenv").config();
const dbHost = process.env.DB_HOST;
const dbName = process.env.DB_NAME;
const dbUsername = process.env.DB_USERNAME;
const dbPassword = process.env.DB_PASSWORD;
const dbPort = process.env.DB_PORT || 5432;
module.exports = {
development: {
username: dbUsername,
password: dbPassword,
database: dbName,
host: dbHost,
port: dbPort,
dialect: "postgres",
},
test: {
username: dbUsername,
password: dbPassword,
database: "chat_app_test",
host: dbHost,
port: dbPort,
dialect: "postgres",
},
production: {
use_env_variable: "DATABASE_URL",
username: dbUsername,
password: dbPassword,
database: dbName,
host: dbHost,
port: dbPort,
dialect: "postgres",
},
};
This is allow us to read the database credentials from our .env
located in the backend
folder, which should look something like this:
DB_HOST='localhost'
DB_NAME='chat_app_development'
DB_USERNAME='postgres'
DB_PASSWORD='postgres'
Now that we have our database connection configured, we can make some models and migrations. Run npx sequelize model:generate --name ChatRoom --attributes name:string
to create the ChatRooms
table with the name column and the ChatRoom
model in our code along with the associated migration. Next we make the migration and model for storing the messages. Run npx sequelize model:generate --name ChatRoomMessages --attributes author:string,message:text,chatRoomId:integer
. Note that in both commands, we use singular word for the model name. There should also be no spaces after the comma in the column definitions.
Next we add a unique constraint to the name column of the ChatRooms table. Create a new migration by running npx sequelize-cli migration:create add-unique-constraint-for-chatroom-name
to make an empty migration. Then in there, put:
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addConstraint("ChatRooms", ["name"], {
type: "unique",
name: "unique_name",
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeConstraint("ChatRooms", "unique_name");
},
};
After all that is done, we run npx sequelize-cli db:migrate
to run the migrations.
Next in bin/www
, we add the code for sending and receiving events with Socket.io. Replace the existing code with:
#!/usr/bin/env node
/**
* Module dependencies.
*/
const app = require("../app");
const debug = require("debug")("backend:server");
const http = require("http");
const models = require("../models");
/**
* Get port from environment and store in Express.
*/
const port = normalizePort(process.env.PORT || "3000");
app.set("port", port);
/**
* Create HTTP server.
*/
const server = http.createServer(app);
const io = require("socket.io")(server);
io.on("connection", socket => {
socket.on("join", async room => {
socket.join(room);
io.emit("roomJoined", room);
});
socket.on("message", async data => {
const { chatRoomName, author, message } = data;
const chatRoom = await models.ChatRoom.findAll({
where: { name: chatRoomName },
});
const chatRoomId = chatRoom[0].id;
const chatMessage = await models.ChatMessage.create({
chatRoomId,
author,
message: message,
});
io.emit("newMessage", chatMessage);
});
});
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on("error", onError);
server.on("listening", onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== "listen") {
throw error;
}
const bind = typeof port === "string" ? "Pipe " + port : "Port " + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case "EACCES":
console.error(bind + " requires elevated privileges");
process.exit(1);
break;
case "EADDRINUSE":
console.error(bind + " is already in use");
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
const addr = server.address();
const bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port;
debug("Listening on " + bind);
}
so that the app will listen to connect from clients, and let the join rooms when the join
event is received. We process messages received with the message
event in this block of code:
socket.on("message", async data => {
const { chatRoomName, author, message } = data;
const chatRoom = await models.ChatRoom.findAll({
where: { name: chatRoomName },
});
const chatRoomId = chatRoom\[0\].id;
const chatMessage = await models.ChatMessage.create({
chatRoomId,
author,
message: message,
});
io.emit("newMessage", chatMessage);
});
and emit a newMessage
event once the message sent with the message
event is saved by getting the chat room ID and saving everything to the ChatMessages
table.
In our models, we have to create a has many relationship between the ChatRooms
and ChatMessages
table by changing our model code. In chatmessage.js
, we put:
'use strict';
module.exports = (sequelize, DataTypes) => {
const ChatMessage = sequelize.define('ChatMessage', {
chatRoomId: DataTypes.INTEGER,
author: DataTypes.STRING,
message: DataTypes.TEXT
}, {});
ChatMessage.associate = function(models) {
// associations can be defined here
ChatMessage.belongsTo(models.ChatRoom, {
foreignKey: 'chatRoomId',
targetKey: 'id'
});
};
return ChatMessage;
};
to make the ChatMessages
table belong to the ChatRooms
table.
In ChatRoom.js
, we put:
"use strict";
module.exports = (sequelize, DataTypes) => {
const ChatRoom = sequelize.define(
"ChatRoom",
{
name: DataTypes.STRING,
},
{}
);
ChatRoom.associate = function(models) {
// associations can be defined here
ChatRoom.hasMany(models.ChatMessage, {
foreignKey: "chatRoomId",
sourceKey: "id",
});
};
return ChatRoom;
};
so that we make each ChatRoom
have many ChatMessages
.
Next we need to add some routes to our back end for getting and setting chat rooms, and getting messages messages. Create a new file called chatRoom.js
in the routes
folder and add:
const express = require("express");
const models = require("../models");
const router = express.Router();
/* GET users listing. */
router.get("/chatrooms", async (req, res, next) => {
const chatRooms = await models.ChatRoom.findAll();
res.send(chatRooms);
});
router.post("/chatroom", async (req, res, next) => {
const room = req.body.room;
const chatRooms = await models.ChatRoom.findAll({
where: { name: room },
});
const chatRoom = chatRooms[0];
if (!chatRoom) {
await models.ChatRoom.create({ name: room });
}
res.send(chatRooms);
});
router.get("/chatroom/messages/:chatRoomName", async (req, res, next) => {
try {
const chatRoomName = req.params.chatRoomName;
const chatRooms = await models.ChatRoom.findAll({
where: {
name: chatRoomName,
},
});
const chatRoomId = chatRooms[0].id;
const messages = await models.ChatMessage.findAll({
where: {
chatRoomId,
},
});
res.send(messages);
} catch (error) {
res.send([]);
}
});
module.exports = router;
The /chatrooms
route get all the chat rooms from the database. The chatroom
POST route adds a new chat room if it does not yet exist by looking up any existing one by name. The /chatroom/messages/:chatRoomName
route gets the messages for a given chat room by chat room name.
Finally in app.js
, we replace the existing code with:
var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var indexRouter = require("./routes/index");
var chatRoomRouter = require("./routes/chatRoom");
var app = express();
const cors = require("cors");
// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(cors());
app.use("/", indexRouter);
app.use("/chatroom", chatRoomRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get("env") === "development" ? err : {};
// render the error page
res.status(err.status || 500);
res.render("error");
});
module.exports = app;
and add our chat room routes by adding:
app.use("/chatroom", chatRoomRouter);
Now that back end is done, we can build our front end. Go to the project’s root folder and run npx create-react-app frontend
. This create the initial code for front end with the packages installed. Next we need to install some packages ourselves. Run npm i axios bootstrap formik react-bootstrap react-router-dom socket.io-client yup
to install our Axios HTTP client, Bootstrap for styling, React Router for routing URLs to our pages, and Formik and Yup for easy form data handling and validation respectively.
After we installed our packages, we can write some code. All files we change are in the src
folder except when the path is mentioned explicitly. First, in App.js
, we change the existing code to the following:
import React from "react";
import { Router, Route, Link } from "react-router-dom";
import HomePage from "./HomePage";
import TopBar from "./TopBar";
import { createBrowserHistory as createHistory } from "history";
import "./App.css";
import ChatRoomPage from "./ChatRoomPage";
const history = createHistory();function App() { return (
<div className="App">
<Router history={history}>
<TopBar />
<Route path="/" exact component={HomePage} />
<Route path="/chatroom" exact component={ChatRoomPage} />
</Router>
</div>
);
}
export default App;
To define our routes and include the top bar in our app, which will build later. Then in App.css
, replace the existing code with:
.App {
margin: 0 auto;
}
Next create a new page called ChatRoomPage.js
and add the following:
import React from "react";
import { useEffect, useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import * as yup from "yup";
import io from "socket.io-client";
import "./ChatRoomPage.css";
import { getChatRoomMessages, getChatRooms } from "./requests";
const SOCKET_IO_URL = "http://localhost:3000";
const socket = io(SOCKET_IO_URL);
const getChatData = () => {
return JSON.parse(localStorage.getItem("chatData"));
};
const schema = yup.object({
message: yup.string().required("Message is required"),
});
function ChatRoomPage() {
const [initialized, setInitialized] = useState(false);
const [messages, setMessages] = useState([]);
const [rooms, setRooms] = useState([]);
const handleSubmit = async evt => {
const isValid = await schema.validate(evt);
if (!isValid) {
return;
}
const data = Object.assign({}, evt);
data.chatRoomName = getChatData().chatRoomName;
data.author = getChatData().handle;
data.message = evt.message;
socket.emit("message", data);
};
const connectToRoom = () => {
socket.on("connect", data => {
socket.emit("join", getChatData().chatRoomName);
});
socket.on("newMessage", data => {
getMessages();
});
setInitialized(true);
};
const getMessages = async () => {
const response = await getChatRoomMessages(getChatData().chatRoomName);
setMessages(response.data);
setInitialized(true);
};
const getRooms = async () => {
const response = await getChatRooms();
setRooms(response.data);
setInitialized(true);
};
useEffect(() => {
if (!initialized) {
getMessages();
connectToRoom();
getRooms();
}
});
return (
<div className="chat-room-page">
<h1>
Chat Room: {getChatData().chatRoomName}. Chat Handle:{" "}
{getChatData().handle}
</h1>
<div className="chat-box">
{messages.map((m, i) => {
return (
<div className="col-12" key={i}>
<div className="row">
<div className="col-2">{m.author}</div>
<div className="col">{m.message}</div>
<div className="col-3">{m.createdAt}</div>
</div>
</div>
);
})}
</div>
<Formik validationSchema={schema} onSubmit={handleSubmit}>
{({
handleSubmit,
handleChange,
handleBlur,
values,
touched,
isInvalid,
errors,
}) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group as={Col} md="12" controlId="handle">
<Form.Label>Message</Form.Label>
<Form.Control
type="text"
name="message"
placeholder="Message"
value={values.message || ""}
onChange={handleChange}
isInvalid={touched.message && errors.message}
/>
<Form.Control.Feedback type="invalid">
{errors.message}
</Form.Control.Feedback>
</Form.Group>
</Form.Row>
<Button type="submit" style={{ marginRight: "10px" }}>
Send
</Button>
</Form>
)}
</Formik>
</div>
);
}
export default ChatRoomPage;
This contains our main chat room code. The user will see the content of this page after going through the home page where they will fill out their chat handle and chat room name. First we connect to our Socket.io server by running const socket = io(SOCKET_IO_URL);
Then, we connect to the given chat room name , which we stored in local storage in the connectToRoom
function. The function will have the handler for the connect
event, which is executed after the connect
event is received. Once the event is received, the the client emits the join
event by running socket.emit(“join”, getChatData().chatRoomName);
, which sends the join
event with our chat room name. Once the join
event is received by the server. It will call the socket.join
function in its event handler. Whenever the user submits a message the handleSubmit
function is called, which emits the message
event to our Socket.io server. Once the message
is delivered to the server, it will save the message to the database and then emit the newMessage
event back to the front end. The front end will then get the latest messages using the route we defined in back end using an HTTP request.
Note that we send the chat data to the server via Socket.io instead of HTTP requests, so that all users in the chat room will get the same data right away since the newMessage
event will be broadcasted to all clients.
We create a file called ChatRoom.css
, then in the file, add:
.chat-room-page {
width: 90vw;
margin: 0 auto;
}
.chat-box {
height: calc(100vh - 300px);
overflow-y: scroll;
}
Next we create the home page, which is the first page that the users sees when the user first opens the app. It is where the user will enter their chat handle and the name of the chat room. Create a file called HomePage.js
and add:
import React from "react";
import { useEffect, useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import * as yup from "yup";
import { Redirect } from "react-router";
import "./HomePage.css";
import { joinRoom } from "./requests";
const schema = yup.object({
handle: yup.string().required("Handle is required"),
chatRoomName: yup.string().required("Chat room is required"),
});
function HomePage() {
const [redirect, setRedirect] = useState(false);
const handleSubmit = async evt => {
const isValid = await schema.validate(evt);
if (!isValid) {
return;
}
localStorage.setItem("chatData", JSON.stringify(evt));
await joinRoom(evt.chatRoomName);
setRedirect(true);
};
if (redirect) {
return <Redirect to="/chatroom" />;
}
return (
<div className="home-page">
<h1>Join Chat</h1>
<Formik
validationSchema={schema}
onSubmit={handleSubmit}
initialValues={JSON.parse(localStorage.getItem("chatData") || "{}")}
>
{({
handleSubmit,
handleChange,
handleBlur,
values,
touched,
isInvalid,
errors,
}) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group as={Col} md="12" controlId="handle">
<Form.Label>Handle</Form.Label>
<Form.Control
type="text"
name="handle"
placeholder="Handle"
value={values.handle || ""}
onChange={handleChange}
isInvalid={touched.handle && errors.handle}
/>
<Form.Control.Feedback type="invalid">
{errors.firstName}
</Form.Control.Feedback>
</Form.Group>
<Form.Group as={Col} md="12" controlId="chatRoomName">
<Form.Label>Chat Room Name</Form.Label>
<Form.Control
type="text"
name="chatRoomName"
placeholder="Chat Room Name"
value={values.chatRoomName || ""}
onChange={handleChange}
isInvalid={touched.chatRoomName && errors.chatRoomName}
/>
<Form.Control.Feedback type="invalid">
{errors.chatRoomName}
</Form.Control.Feedback>
</Form.Group>
</Form.Row>
<Button type="submit" style={{ marginRight: "10px" }}>
Join
</Button>
</Form>
)}
</Formik>
</div>
);
}
export default HomePage;
Once the user enters the data into the form, it will be checked if they are filled in and once they are, a request will be sent to back end to add the chat room if it is not there. We also save the filled in data to local storage and redirect the user to the chat room page, where they will connect to the chat room with the name that they entered.
Both forms are built with React Bootstrap’s Form
component.
Next we create a file called HomePage.css
and add:
.home-page {
width: 90vw;
margin: 0 auto;
}
to add some margins to our page.
Then we create a file called requests.js
in the src
folder to add the code for making the requests to our server for manipulating chat rooms and getting chat messages. In the file, add the following code:
const APIURL = "http://localhost:3000";
const axios = require("axios");
export const getChatRooms = () => axios.get(`${APIURL}/chatroom/chatrooms`);
export const getChatRoomMessages = chatRoomName =>
axios.get(`${APIURL}/chatroom/chatroom/messages/${chatRoomName}`);
export const joinRoom = room =>
axios.post(`${APIURL}/chatroom/chatroom`, { room });
Finally, in we create the top bar. Create a file called TopBar.js
and add:
import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import { withRouter } from "react-router-dom";
function TopBar({ location }) {
const { pathname } = location;
return (
<Navbar bg="primary" expand="lg" variant="dark">
<Navbar.Brand href="#home">Chat Room App</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="mr-auto">
<Nav.Link href="/" active={pathname == "/"}>
Join Another Chat Room
</Nav.Link>
</Nav>
</Navbar.Collapse>
</Navbar>
);
}
export default withRouter(TopBar);
We create the top bar using the Navbar
widget provided by React Bootstrap with a link to the home page. We wrap the component with the withRouter
function so that we get the location object from React Router.
Top comments (0)