Show All Visitor's Pointers on a Webpage

by Hexagon, 5 minutes read deno javascript html websockets pup nginx

In this step-by-step guide, you'll learn how to create a simple but cool webpage that shows mouse pointers of all its visitors. From setting up a Deno server to optional perks like keeping your app alive with Pup and serving it securely through Nginx.



Install Deno

First, to start, you need to install Deno on your computer. Follow the steps given on this page.

Create Main TypeScript File (main.ts)

Create a project folder, and new TypeScript file called main.ts. Paste your code there. This sets up your server and handles the WebSocket connections.

// Imports
import {
  Application,
  Router,
  send,
  Status,
} from "https://deno.land/x/oak@v12.5.0/mod.ts";
import { exists } from "https://deno.land/std@0.192.0/fs/mod.ts";
import { join } from "https://deno.land/std@0.192.0/path/mod.ts";

// Constants
const LISTEN_PORT = 19192;
const STATIC_DIR = "./static";
const ROOT_PATH = "/";
const BROADCAST_INTERVAL = 150; // milliseconds

// Initialize Oak Application and Router
const app = new Application();
const router = new Router();

// Define Client Interface
interface Client {
  socket: WebSocket;
  pos: { x: number; y: number; id: string };
}

// Active clients list
const clients: Client[] = [];

// Helper Functions
const removeClient = (client: Client) => {
  const index = clients.indexOf(client);
  if (index > -1) {
    clients.splice(index, 1);
  }
};

const broadcastPositions = () => {
  const positions = clients.map((client) => client.pos);
  clients.forEach((client) => {
    const filteredPositions = positions.filter((pos) =>
      pos.id !== client.pos.id
    );
    client.socket.send(JSON.stringify(filteredPositions));
  });
};

const handleWebSocketConnection = (ws: WebSocket) => {
  const client: Client = { socket: ws, pos: { x: 0, y: 0, id: "undef" } };

  ws.onopen = () => clients.push(client);
  ws.onmessage = (event) => {
    const { x, y, id } = JSON.parse(event.data);
    client.pos = {
      x: parseInt(x, 10),
      y: parseInt(y, 10),
      id: id.substring(0, 10),
    };
  };
  ws.onclose = () => removeClient(client);
  ws.onerror = () => {
    console.error("WebSocket error observed");
    removeClient(client);
  };
};

// Routing
router.get("/ws", (ctx) => {
  if (!ctx.isUpgradable) {
    ctx.throw(501);
    return;
  }
  const ws = ctx.upgrade();
  handleWebSocketConnection(ws);
});

router.get("/", ({ response }) => {
  response.redirect("/index.html");
});

// Middleware
app.use(router.routes())
  .use(router.allowedMethods())
  .use(async (ctx, next) => {
    const filePath = ctx.request.url.pathname.replace(ROOT_PATH, "");
    const localFilePath = await join(STATIC_DIR, filePath);

    if (await exists(localFilePath)) {
      await send(ctx, filePath, { root: STATIC_DIR });
    } else {
      await next();
    }
  })
  .use((ctx) => {
    ctx.response.status = Status.NotFound;
    ctx.response.body = "Not Found";
  });

// Run Server
const broadcastInterval = setInterval(broadcastPositions, BROADCAST_INTERVAL);
Deno.unrefTimer(broadcastInterval);

app.listen({ port: LISTEN_PORT });
console.log(`Server started at http://localhost:${LISTEN_PORT}`);
console.log(
  `Visit http://localhost:${LISTEN_PORT}/index.html for the main page.`,
);
console.log(`WebSocket endpoint is ws://localhost:${LISTEN_PORT}/ws`);

Add Configuration (deno.json)

Create a file named deno.json in the same directory as main.ts. Add the following content:

{
  "tasks": {
    "serve": "deno run -A --unstable main.ts"
  }
}

Create Your Webpage (static/index.html)

Make a folder named static in the same directory and add an index.html file with the code you have. This HTML file will handle the mouse pointer tracking and display.

This html assumes there is a arrow.cur at /static/img/, you will have to provide this yourself (or grab it from the GitHub repo linked below).

<!DOCTYPE html>
<html>
<head>
  <title>Hello Pointers!</title>
  <meta charset="utf8">
  <script>
    function wsUrl(s) {
      const l = window.location;
      const dir = l.pathname.substring(0, l.pathname.lastIndexOf("/"));

      return ((l.protocol === "https:") ? "wss://" : "ws://") + l.host + dir + s;
    }
  </script>
  <script>
    document.addEventListener("DOMContentLoaded", function(event) {
      const ws = new WebSocket(wsUrl("/ws"));

      const pointerId = Math.random().toString(36).substr(2, 9);  // unique identifier for this pointer

      ws.onopen = function(event) {
        console.log("Connection established");
      };

      ws.onclose = function(event) {
        console.log("Connection closed");
      };

      ws.onerror = function(event) {
        console.error("WebSocket error observed:", event);
      };

      let mousePos = { x: 0, y: 0, id: pointerId };
      let lastPos = { x: 0, y: 0, id: pointerId };

      document.onmousemove = function(e) {
        mousePos.x = (e.clientX / window.innerWidth) * 100;
        mousePos.y = (e.clientY / window.innerHeight) * 100;
      }

      setInterval(function() {
        if(ws.readyState === ws.OPEN) {
          if (!lastPos || lastPos.x !== mousePos.x || lastPos.y !== mousePos.y) {
            lastPos.x = mousePos.x
            lastPos.y = mousePos.y
            ws.send(JSON.stringify(mousePos));
          }
        }
      }, 150); // Send mouse position every second

      const cursors = {};

      ws.onmessage = function(event) {
        const positions = JSON.parse(event.data);
        positions.forEach((pos) => {
          if (pos.id === pointerId) return;  // skip own pointer

          let cursor = cursors[pos.id];
          if (!cursor) {
            cursor = document.createElement('img');
            cursor.src = 'cur/arrow.cur';
            cursor.style.position = 'absolute';
            document.body.appendChild(cursor);
            cursors[pos.id] = cursor;
          }
          cursor.style.left = pos.x + '%';
          cursor.style.top = pos.y + '%';
        });

        // Slightly optimized cleanup, this should 
        // probably be made less frequent in a separate setInterval
        const positionIds = new Set(positions.map(pos => pos.id));

        Object.keys(cursors).forEach((id) => {
            if (!positionIds.has(id)) {
                document.body.removeChild(cursors[id]);
                delete cursors[id];
            }
        });
      }
    });
  </script>
</head>
<body>
  <h1>Hello Pointers!</h1>
</body>
</html>

Add This to an Existing Site

If you want multiple pointers on your existing site, you can insert this JavaScript code anywhere on the page. Just make sure to expose the service publicly, like shown below, and replace your.domain.

document.addEventListener("DOMContentLoaded", function (event) {
  const ws = new WebSocket(wsUrl("wss://your.domain/pointer/ws"));

  const pointerId = Math.random().toString(36).substr(2, 9); // unique identifier for this pointer

  ws.onopen = function (event) {
    console.log("Connection established");
  };

  ws.onclose = function (event) {
    console.log("Connection closed");
  };

  ws.onerror = function (event) {
    console.error("WebSocket error observed:", event);
  };

  let mousePos = { x: 0, y: 0, id: pointerId };
  let lastPos = { x: 0, y: 0, id: pointerId };

  document.onmousemove = function (e) {
    mousePos.x = (e.clientX / window.innerWidth) * 100;
    mousePos.y = (e.clientY / window.innerHeight) * 100;
  };

  setInterval(function () {
    if (ws.readyState === ws.OPEN) {
      if (!lastPos || lastPos.x !== mousePos.x || lastPos.y !== mousePos.y) {
        lastPos.x = mousePos.x;
        lastPos.y = mousePos.y;
        ws.send(JSON.stringify(mousePos));
      }
    }
  }, 150); // Send mouse position every second

  const cursors = {};

  ws.onmessage = function (event) {
    const positions = JSON.parse(event.data);
    positions.forEach((pos) => {
      if (pos.id === pointerId) return; // skip own pointer

      let cursor = cursors[pos.id];
      if (!cursor) {
        cursor = document.createElement("img");
        cursor.src = "cur/arrow.cur";
        cursor.style.position = "absolute";
        document.body.appendChild(cursor);
        cursors[pos.id] = cursor;
      }
      cursor.style.left = pos.x + "%";
      cursor.style.top = pos.y + "%";
    });

    // Slightly optimized cleanup, this should
    // probably be made less frequent in a separate setInterval
    const positionIds = new Set(positions.map((pos) => pos.id));

    Object.keys(cursors).forEach((id) => {
      if (!positionIds.has(id)) {
        document.body.removeChild(cursors[id]);
        delete cursors[id];
      }
    });
  };
});

Run the Server

Open your terminal and navigate to your project folder. Run the server with:

deno run -A --unstable main.ts

Your server should start running, and you can visit http://localhost:19192/ to see it in action.

The full source code for this tutorial is available at GitHub, check it out on github.com/Hexagon/deno-pointer-tutorial.

(Optional) Keep It Running With Pup

Want your server to stay up when you close the terminal? Use Pup, which is a process manager for Deno. Follow the installation steps and usage guide at pup.56k.guru.

Create a pup.json file with the specified content and run the following commands to keep your server alive:

pup install --name pointer-server-service

To check the status (provided you use systemd I am):

systemctl --user status pointer-server-service

And to verify the process:

pup status

(Bonus) Serve Through Nginx

Level up by serving your app behind an Nginx reverse proxy. This adds extra layers of security and features.

First, if you doesn't already have it installed, install Nginx. If you're using Ubuntu, you can do so with:

sudo apt update sudo apt install nginx

Next, edit your Nginx config file, usually `/etc/nginx/sites-available/default``. Add the following location block in your server block:

location /pointer/ {
  proxy_pass http://127.0.0.1:19192/;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_redirect off;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

This will serve the webpage and socker server through http(s)://your.domain/pointer/.

Don't forget to reload Nginx after you edit the config:

sudo nginx -s reload

And that's it! You now have a live webpage that tracks and shows all the mouse pointers of its visitors. Plus, you can keep it running 24/7 and serve it securely.

Happy coding!


Deno vs. Bun vs. Node.js: A Feature Comparison Getting Started with Deno: A Secure Runtime for JavaScript and TypeScript