Post

Server-sent events, Geolocation Web API, and TogetherGPS - a group location sharing site

A long time ago, I saw my friends used the location sharing feature of Meta’s Messenger, it then crossed my mind that it will be cool though if something like it can be used even without a user account and not as a dedicated app to be installed. Eventually I started building TogetherGPS. TogetherGPS is a group location sharing site. You can easily share your location to your group by joining a group map. Think of it like joining a Zoom call where you only need an ID and passcode. It only has basic features. There is no path tracing, no keeping of someone’s location history. No integrated traffic info and route suggestions. You can add markers/places/pins though. Also, if you closed your browser and forgot to “leave” the group map, your location is hidden and you are automatically removed after 3 minutes of inactivity. You can watch the demo video at the bottom of this page.

Currently, the backend has 2 components: an Express web app, and a background worker for removing inactive persons, while the frontend is built with Svelte. The near real-time location sharing is done thru Server-sent events (SSE). A challenge with SSE is that you cannot add request headers, which means no native way of sending an Authorization header. To overcome such limitation, this extended-eventsource package is really helpful. During location-sharing, the coordinates of a person is obtained by using the Geolocation Web API. Positions of all the persons and markers in a group map are stored in Redis, which are broadcasted to all active persons.

Here’s how the positions are broadcasted (streamController) and captured (locationController) in the backend:

const streamController = async (req: Request, res: Response) => {
  const groupSpaceId = req.query.group_space_id;
  const personId = req.query.person_id;
  await updateInactiveWatchlist(groupSpaceId, personId, false);

  const headers = {
    "Content-Type": "text/event-stream",
    Connection: "keep-alive",
    "Cache-Control": "no-cache",
  };
  res.writeHead(200, headers);

  logger.info(`Client ${personId} connected to ${groupSpaceId}`);

  const subscriber = listenToGroupSpace(groupSpaceId);
  subscriber.on("message", async (channel, message) => {
    const locations = await getCurrentLocations(groupSpaceId);

    if (
      channel === groupSpaceId &&
      locations.size !== 0 &&
      locations.has(personId) &&
      locations.get(personId).is_active === true
    ) {
      // broadcast all of the persons current positions in a specific group map to all in the said group map
      const locationsArray = [...locations.values()];
      const newMsgPayload = streamDataFormatter(
        "userlocationupdate",
        locationsArray
      );
      res.write(newMsgPayload);
    } else if (channel === `places-${groupSpaceId}`) {
      // broadcast the positions of the markers/places in the group map
      const places = await getPlaces(groupSpaceId);
      const placesArray = Array.isArray(places) ? [...places] : [];
      const newMsgPayload = streamDataFormatter("placesupdate", placesArray);
      res.write(newMsgPayload);
    }
  });

  // when SSE closes, set the person as inactive
  res.on("close", async () => {
    await captureLocation(
      groupSpaceId,
      personId,
      0,
      0,
      "Inactive",
      0,
      "default",
      false
    );
    await updateInactiveWatchlist(groupSpaceId, personId, true);
    logger.info(`Client closed: ${personId}`);
    await subscriber.unsubscribe();
    res.end();
  });
};

const locationController = async (req: Request, res: Response) => {
  if (req.method === "POST") {
    // when person is sharing their location
    const data = req.body;
    await captureLocation(
      req.user.groupSpaceId,
      req.user.personId,
      data.latitude,
      data.longitude,
      data.given_name,
      data.speed,
      data.avatar
    );
    res.json({ success: true });
  } else if (req.method === "DELETE") {
    // stop location sharing
    await disconnectUserLocation(req.user.groupSpaceId, req.user.personId);
    logger.info(`Client disconnected: ${req.user.personId}`);
    res.json({ disconnected: true });
  }
};

The Geolocation’s watchPosition() method is used to get the device coordinates each time the position of the device changes. The shareLocation() function then hits the API endpoint that is handled by locationController in the backend. I don’t have empirical data on how much stress it does to the server but it’s something that should be improved. To avoid crashing the server that may be caused by the high number of API calls, TogetherGPS limits the number of persons in a group map to 7.

watchId = navigator.geolocation.watchPosition(
  async (position) => {
    $userLocation = {
      person_id: localStorage.getItem("current_person_id"),
      location: {
        latitude: position.coords.latitude,
        longitude: position.coords.longitude,
        speed: position.coords.speed,
        accuracy: position.coords.accuracy,
      },
    };

    try {
      await shareLocation(
        position.coords.latitude,
        position.coords.longitude,
        position.coords.speed
      );
    } catch (error) {
      console.error("Unexpected sharing location error:", error);
    }
  },
  (error) => {
    console.error("Geolocation error:", error);
  },
  {
    enableHighAccuracy: true,
    timeout: 5000,
    maximumAge: 3000,
  }
);

Well those are the important parts of the codebase. One of my plans for TogetherGPS is for others to be able to self-host the website. Code are all written in Typescript (I’m no good in Typescript). I’ll eventually make it open source. Now, here’s a demo video of TogetherGPS. Note that I am doing this demo using a web browser in my desktop. A mobile phone with GPS turned ON will give more accurate positions: