Building a real-time location app with Node.js and Socket.IO

mangelosanto

Matt Angelosanto

Posted on November 17, 2022

Building a real-time location app with Node.js and Socket.IO

Written by Gbolahan Olagunju✏️

Socket.IO provides communication between web clients and Node.js servers in real time. For many use cases today, developers need to constantly update information for their applications in real time, which necessitates the use of a bi-directional communication tool that can keep data updated instantly.

In this article, we’ll look at how you can use Socket.IO with Node.js for real-time communication of data for your application for use cases like this.

Jump ahead:

REST API vs WebSockets

Traditional REST APIs are at their most useful when we want to retrieve a resource and don't need constant ongoing updates.

If you look at a crypto trade, for example, when we place a bid to buy a coin, the bid is unlikely to change very often, so we can model this behavior accurately using a traditional REST API.

However, the actual price of a coin itself is very volatile, as it responds to market trends, like any asset. For us to get the most recent price, we would need to initiate a request and it’s highly probable that it will have changed again just as our response arrives!

In such a scenario, we need to be notified as the price of the asset changes, which is where WebSockets shine.

The resources used to build traditional REST APIs are highly cacheable because they are rarely updated. WebSockets, meanwhile, don’t benefit as much from caching, as this may have negative effects on performance.

There are extensive guides that highlight the different use cases for both traditional REST APIs and WebSockets.

In this guide, we will be building a real-time, location-sharing application using Node.js and Socket.IO as our use case.

Here are the technologies we will use :

  • Node.js/Express: For our application server
  • Socket.IO: Implements WebSockets under the hood
  • Postgres: The database of choice to store user location data
  • Postgis: This extension makes it possible to work with locations in the database and provides additional functions, like calculating the distance around a location

Setting up an Express.js server

We will start by setting up an Express application.

For this, I have modified a boilerplate that we will use. Let's follow these instructions to get started:

  1. Clone this GitHub repository
  2. cd socket-location/
  3. npm install
  4. Finally, create an .env file in the root and copy the contents of the env. sample file. To get the values, we would actually need to set up a local database or use an online Postgres database platform like Elephant or Heroku. Visit the platform and create a database for free, get the URL, fill in the credentials, and start the application

Setting up WebSockets with Socket.IO

To set up Sockets.IO with our existing starter files, it’s important for us to use JavaScript Hoisting, which enables us to make the socket instance available across different files.

We will start by installing Socket.IO

npm install socket.io
Enter fullscreen mode Exit fullscreen mode

The next thing we need to do is integrate it with our express application. Open the app.js file in the root of the project directory and edit it in the following way:

const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");
const { initializeRoutes } = require("./routes");

let app = express();
const port = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app = initializeRoutes(app);
app.get("/", (req, res) => {
  res.status(200).send({
    success: true,
    message: "welcome to the beginning of greatness",
  });
});

const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: {
    origin: "*",
    methods: ["GET", "POST"],
  },
});

io.on("connection", (socket) => {
  console.log("We are live and connected");
  console.log(socket.id);
});

httpServer.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

We have successfully wired our server to work with sockets; now we need to wire our client to respond to this connection so that there can be communication back and forth.

Our client can be a web application, a mobile application, or any device that can be configured to work with WebSockets.

We will be mimicking the client behavior in this application with POSTMAN for the sake of brevity and simplicity.

To do this, do the following:

  1. Open up Postman and click on the "New" button in the top-left corner
  2. Then click the "Websocket Request" button on the pop-up
  3. There should be text that says "raw" — use the drop down and change it to "Socket.IO"
  4. Paste your connection string on the tab — in this case, localhost:3000 — and click the connect button

You should see a unique ID printed out to the console and the Postman tab should say "Connected".

Here is a video that shows where we are and a recap of what we have done so far.

Authentication and authorization

Now that we have successfully connected to our Socket.IO instance, we need to provide some form of authentication and authorization to keep unwanted users out. This is especially the case when you consider that we are allowing connections from any client, as specified by the * in our CORS option when connecting.

To do this, we will be using this middleware:

io.use((socket, next) => {
  if (socket.handshake.headers.auth) {
    const { auth } = socket.handshake.headers;
    const token = auth.split(" ")[1];
    jwt.verify(token, process.env.JWT_SECRET_KEY, async (err, decodedToken) => {
      if (err) {
        throw new Error("Authentication error, Invalid Token supplied");
      }
      const theUser = await db.User.findByPk(decodedToken.id);
      if (!theUser)
        throw new Error(
          "Invalid Email or Password, Kindly contact the admin if this is an anomaly"
        );
      socket.theUser = theUser;
      return next();
    });
  } else {
    throw new Error("Authentication error, Please provide a token");
  }
});
Enter fullscreen mode Exit fullscreen mode

First, we are checking if a token is provided in the request header. We are also checking if it is a valid token and that the user is present in our database before allowing them to connect — this ensures only authenticated users have access.

Sharing location between users

To share locations between users, we will be using socket connections. We will also need to prepare our database to accept geometry objects, and to do this we will install the PostGIS extension on our database. On the client, we will be using the JavaScript Geolocation API.

First, we will install the PostGIS extension on our database.

Run this command at the root of the project:

 npx sequelize-cli migration:generate --name install-postgis
Enter fullscreen mode Exit fullscreen mode

Then, open the migration file generated and paste the following:

"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.sequelize.query("CREATE EXTENSION postgis;");
  },
  down: (queryInterface, Sequelize) => {
    return queryInterface.sequelize.query("DROP EXTENSION postgis CASCADE;");
  },
};
Enter fullscreen mode Exit fullscreen mode

The above code snippet will install the postgis extension on our database instance when we run migration.

npm run migrate
Enter fullscreen mode Exit fullscreen mode

(Note: As of writing, I couldn’t successfully run migration to install PostGIS extension on the Postgres instance provided by ElephantSQL as I had permission issues, but I was able to successfully run the same on a Heroku instance.)

Next, we need to prepare our database to store user location information, and to do this, we will create another migration file:

npx sequelize-cli model:generate --name Geolocation --attributes socketID:string,location:string
Enter fullscreen mode Exit fullscreen mode

Delete the contents and paste the following:

"use strict";

/** @type {import('sequelize-cli').Migration} */

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable("Geolocations", {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER,
        references: {
          model: "Users",
          key: "id",
          as: "id",
        },
      },
      socketID: {
        type: Sequelize.STRING,
        unique: true,
      },
      location: {
        type: Sequelize.GEOMETRY,
      },
      online: {
        type: Sequelize.BOOLEAN,
      },
      trackerID: {
        type: Sequelize.INTEGER,
        references: {
          model: "Users",
          key: "id",
          as: "id",
        },
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    });
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable("Geolocations");
  },
};
Enter fullscreen mode Exit fullscreen mode

…and then we run migrations again:

npm run migrate
Enter fullscreen mode Exit fullscreen mode

We also want to create a relationship between the two models, so in models/user.js, add the following code. Here we are creating a one-to-one relationship between the user model and the Geolocation model.

...
static associate(models) {
      // define association here
      this.hasOne(models.Geolocation, { foreignKey: "id" });
    }
...
Enter fullscreen mode Exit fullscreen mode

Then, in models/geolocation.js, add:

.... 
static associate(models) {
      // define association here
      this.belongsTo(models.User, { foreignKey: "id" });
    }
....
Enter fullscreen mode Exit fullscreen mode

Next, we need to build the actual route that facilitates the connection. To do this, go into routes and create /track/track.js. Then, add the following:

const { Router } = require("express");
const db = require("../../models");
const { handleJwt } = require("../../utils/handleJwt");
const trackerRouter = Router();
trackerRouter.post(
  "/book-ride",
  handleJwt.verifyToken,
  async (req, res, next) => {
    // search for user that is offline
    // assign the booker id to the
    const {
      user,
      body: { location },
    } = req;
    //returns the first user that meets the criteria
    const user2 = await db.User.findOne({
      where: { role: "driver" },
    });
    db.Geolocation.update(
      {
        trackerID: user2.id,
        online: true,
      },
      { where: { id: user.id }, returning: true }
    );
    db.Geolocation.update(
      {
        trackerID: user.id,
        location: {
          type: "Point",
          coordinates: [location.longitude, location.latitude],
        },
        online: true,
      },
      { where: { id: user2.id }, returning: true }
    );
    if (!user2)
      return res.status(404).send({
        success: false,
        message,
      });
    return res.status(200).send({
      success: true,
      message: "You have successfully been assigned a driver",
    });
  }
);
module.exports = { route: trackerRouter, name: "track" };
Enter fullscreen mode Exit fullscreen mode

In addition, we also want to make sure that we every time we sign up a user, we also create a geolocation object for them.

Add this just before you send a response when creating a user in the routes/user/user.js file:

....
await db.Geolocation.create({ id: user.id });
const token = handleJwt.signToken(user.dataValues);
.....
Enter fullscreen mode Exit fullscreen mode

if you now open POSTMAN up and send the request, you should get the response that you have successfully been assigned a driver.

Emitting and receiving events

We will be sending and receiving objects that represent the geographical position of a user, which we will do on the web via the Geolocation API.

It is quite simple to work with and it is very accurate for the vast majority of modern consumer devices like smartphones and laptops, as they have GPS built in.

When we log out the object that captures this information, we have:

position: {
          coords: {
                       accuracy: 7299.612787273156
                       altitude: null
                       altitudeAccuracy: null
                       heading: null
                       latitude: 6.5568768
                       longitude: 3.3488896
                       speed: null
           },
        timestamp: 1665474267691
}
Enter fullscreen mode Exit fullscreen mode

If you visit w3schools with your mobile phone and walk around while looking at the longitude and latitude information, you will discover that your location is constantly changing to reflect where you are in real time.

This is because your new location details are being fired as you are moving! There is other information captured in the position object, which includes speed as well as altitude.

Now, let's create socket handlers that will actually emit and listen to these events between the user and drivers.

In our app.js, edit the following line to look like this:

const { onConnection } = require("./socket");

....
io.on("connection", onConnection(io));
....
Enter fullscreen mode Exit fullscreen mode

Then, we will create a socket directory in the root of our application and add the following files:

driversocket.js

const { updateDbWithNewLocation } = require("./helpers");
const db = require("../models");
const hoistedIODriver = (io, socket) => {
  return async function driverLocation(payload) {
    console.log(`driver-move event has been received with ${payload} 🐥🥶`);
    const isOnline = await db.Geolocation.findByPk(payload.id);
    if (isOnline.dataValues.online) {
      const recipient = await updateDbWithNewLocation(payload, isOnline);
      if (recipient.trackerID) {
        const deliverTo = await db.Geolocation.findOne({
          where: { trackerID: recipient.trackerID },
        });
        const { socketID } = deliverTo.dataValues;
        io.to(socketID).emit("driver:move", {
          location: recipient.location,
        });
      }
    }
  };
};
module.exports = { hoistedIODriver };
Enter fullscreen mode Exit fullscreen mode

The above code handles updating the database with the driver location and broadcasting it to the listening user.

usersocket.js

const { updateDbWithNewLocation } = require("./helpers");
const db = require("../models");
const hoistedIOUser = (io, socket) => {
  return async function driverLocation(payload) {
    console.log(
      `user-move event has been received with ${JSON.stringify(payload)} 🍅🍋`
    );
    const isOnline = await db.Geolocation.findByPk(payload.id);
    if (isOnline.dataValues.online) {
      const recipient = await updateDbWithNewLocation(payload, isOnline);
      if (recipient.trackerID) {
        const deliverTo = await db.Geolocation.findOne({
          where: { trackerID: recipient.trackerID },
        });
        const { socketID } = deliverTo.dataValues;
        io.to(socketID).emit("user:move", {
          location: recipient.location,
        });
      }
    }
  };
};
module.exports = { hoistedIOUser };
Enter fullscreen mode Exit fullscreen mode

The above code handles updating the database with the user location and broadcasting it to the listening driver.

index.js

const { hoistedIODriver } = require("./driversocket");
const { hoistedIOUser } = require("./usersocket");
const configureSockets = (io, socket) => {
  return {
    driverLocation: hoistedIODriver(io),
    userLocation: hoistedIOUser(io),
  };
};
const onConnection = (io) => (socket) => {
  const { userLocation, driverLocation } = configureSockets(io, socket);
  socket.on("user-move", userLocation);
  socket.on("driver-move", driverLocation);
};
module.exports = { onConnection };
Enter fullscreen mode Exit fullscreen mode

… and finally:

helpers.js

const db = require("../models");
const updateDbWithNewLocation = async (payload, oldGeoLocationInfo) => {
  const { id, socketID } = payload;
  const [, [newLocation]] = await db.Geolocation.update(
    {
      online: oldGeoLocationInfo.online,
      socketID,
      trackerID: oldGeoLocationInfo.trackerID,
      location: {
        type: "Point",
        coordinates: [payload.coords.longitude, payload.coords.latitude],
      },
    },
    { where: { id }, returning: true }
  );
  return newLocation;
};
module.exports = { updateDbWithNewLocation };
Enter fullscreen mode Exit fullscreen mode

To get the working code sample, kindly visit the GitHub repository and clone and navigate between the branches.

Alright, let’s take this for a spin and see this in action!

Conclusion

Thanks for following along with this tutorial on using Node.js and Socket.IO to build a real-time location app.

We were able to demonstrate how with basic but common tools we can begin to implement the functions of a location tracking app with WebSockets.


200’s only ✔️ Monitor failed and slow network requests in production

Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket.

LogRocket Network Request Monitoring

LogRocket is like a DVR for web apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on November 17, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related