Create a GitHub Tracker with Push Notifications in Svelte

    Aarnav Pai
    Share

    In this article, you’ll learn how to build a GitHub tracker that notifies users when there’s a new Issue/PR on a tracked repository by sending push notifications.

    GitHub already sends notifications through emails if you’ve opted in, but many studies have shown that push notifications reach users better than emails do. After you’ve built the GitHub tracker by following this tutorial, you’ll have learned how to:

    • add a service worker and convert the tracker to a PWA
    • subscribe to push notifications
    • use the GitHub API
    • send push events through Vercel cloud functions
    • periodically fetch new issues using EasyCron

    Prerequisites

    There are a few skills and services you’ll need to follow this article:

    What are Push Notifications?

    Let’s take a look at what these so called “push notifications” are.

    You must be familiar with regular notifications. These are little bubbles of text that appear on your screen to notify you of something. Push notifications are similar, except that they aren’t generated on-demand, but they are generated upon receiving push events. Push notifications work when an app is closed, while regular notifications require you to have the app open.

    Push notifications are supported in modern web browsers like Chrome by using something called service workers. Service workers are little pieces of JavaScript that run separately from the browser’s main thread, and as a result, can run offline if your app is installed as a PWA (progressive web application).

    Push notifications are used in chat applications to notify users when they have unread messages, in games, to notify users of game events, in news sites, to notify users of breaking articles, and for many other purposes.

    There are four steps to show push notifications in your app:

    1. request permission with window.Notification.requestPermission()
    2. convert your app to a PWA and install it
    3. subscribe to push events
    4. upon receiving a push event, send the notification

    Step 1: Creating the Tracker

    Let’s use Svelte with Vite.js instead of Rollup in this article. Vite is, as its name suggests, faster than Rollup, and also provides built-in support for environment variables. To create a new project with Svelte and Vite, run this command:

    npm init vite
    

    Select the framework to be svelte. You can use TypeScript if you want. I’ll be using regular JavaScript.

    Next, cd into the project folder and you can add TailwindCSS to your application and install all dependencies with these commands:

    npx svelte-add tailwindcss
    
    # Install packages
    yarn install # or npm install
    

    Finally, open the project in your favorite code editor and run npm run dev or yarn dev to start the application on http://localhost:3000.

    How the tracker will work

    We’ll use the GitHub API to get a list of issues and pull requests for a repository that the user has tracked. A user’s tracked repositories and their username will be stored in the MongoDB database.

    The first step would be to prompt the user for their username. Create src/lib/UsernamePrompt.svelte, which will be the component that will do so. Here’s my UI for the form, but you can design it however you want:

    <script>
      let username = "";
      async function submit() {
        // TODO
      }
    </script>
    
    <form
      on:submit|preventDefault="{submit}"
      class="mx-auto min-w-[350px] max-w-[1100px] w-[50%] border border-gray-500 rounded my-4 px-6 py-4"
    >
      <h1 class="text-center text-3xl m-4">Enter a username</h1>
      <p class="text-center text-xl m-4">Enter a username to use this tracker</p>
    
      <input
        type="text"
        class="rounded px-4 py-2 border border-gray-300 w-full outline-none"
        placeholder="Username"
        aria-label="Username"
        bind:value="{username}"
      />
    
      <button
        class="mt-4 border border-transparent bg-blue-500 text-white rounded px-4 py-2 w-full"
      >
        Submit
      </button>
    </form>
    

    Add this component in App.svelte like so:

    <script>
      import UsernamePrompt from "./lib/UsernamePrompt.svelte";
    </script>
    
    <UsernamePrompt />
    

    Next, let’s add the main tracker UI. Create file src/lib/Tracker.svelte and add the below code in it:

    <script>
      let repo = "";
      function track() {
        // TODO
      }
    
      function untrack(repo) {
        // TODO
      }
    </script>
    
    <form
      on:submit|preventDefault={track}
      class="mx-auto min-w-[350px] max-w-[1100px] w-[50%] border border-gray-500 rounded my-4 px-6 py-4"
    >
      <h1 class="text-center text-3xl m-4">GitHub tracker</h1>
    
      <input
        type="text"
        class="rounded px-4 py-2 border border-gray-300 w-full outline-none"
        placeholder="Enter the repository's URL"
        aria-label="Repository URL"
        bind:value={repo}
      />
      <button
        class="mt-2 border border-transparent bg-blue-500 text-white rounded px-4 py-2 w-full"
        >Track repository</button
      >
    
      <h2 class="mt-4 text-2xl">Tracked repositories</h2>
      <ul class="m-2 list-decimal">
        <!-- We'll use a loop to automatically add repositories here later on. -->
        <li class="py-1 flex items-center justify-between">
          <a class="text-gray-500 hover:underline" href="https://github.com/test/test"
            >https://github.com/test/test</a
          >
          <button class="text-red-500 cursor-pointer" on:click={() => untrack("")}
            >Untrack</button
          >
        </li>
      </ul>
    </form>
    

    To test out your component, temporarily swap out the UsernamePrompt component for the new Tracker component in App.svelte:

    <script>
      // import UsernamePrompt from "./lib/UsernamePrompt.svelte";
      import Tracker from "./lib/Tracker.svelte";
    </script>
    
    <!-- <UsernamePrompt /> -->
    <Tracker />
    

    Your screen should now look like this:

    The new tracker component

    Note: remember to restore App.svelte to its previous code!

    Step 2: Setting up Cloud Functions

    We need to have a back-end server to send push events to our application. This means that you need to create a new (maybe) ExpressJS project, and then deploy that separately. This will all be a headache for someone just experimenting with push notifications.

    Vercel Cloud Functions to the rescue! Cloud functions are like Express routes. They can run code and give you a response when you fetch its URL. Vercel has support for cloud functions; you just have to create files in the api folder. You’ll be using cloud functions to interact with MongoDB, since exposing secrets client-side is never a good thing.

    First, make sure you have a Cluster in MongoDB Atlas. MongoDB has a free plan (M0), so be sure to create one if you haven’t already. Now, go to the Database Access tab in the sidebar of your Atlas dashboard. Add a new Database User by clicking the green button on the right side. Enter in the user’s details (don’t forget the password), and create the user.

    Creating a new Atlas user

    To connect to the database, you’ll need the connection string. Save the new user and password somewhere and head to your Cluster’s Overview. Click the Connect button on the right side and select Connect your Application as the method of connection. You should see a connection string similar to the one below.

    Connection string

    Now that you have the connection string, you can connect to your database, but first, you need to deploy the current application to Vercel. The easiest way to do this is using GitHub.

    Create a new GitHub repository and push your code to it. Next, head to your Vercel Dashboard and click the New Project button. Import your GitHub Repository, make sure the framework is Vite, and add an environment variable called MONGODB_URL. Set its value to the connection string of the MongoDB database.

    Once your website has been deployed, you need to change your local development command from yarn dev to vercel dev. Upon running the command, if you’re asked to link to an existing project, click yes.

    Note: make sure to install the Vercel CLI with npm i -g vercel if you haven’t already.

    Like me, if you run into a problem with using vite with vercel dev, be sure to change the Development Command of your project to vite --port $PORT from vite in the Vercel Dashboard.

    This will allow us to use cloud functions with the correct environment variables locally.

    Let’s add a helper file that will allow us to access MongoDB without opening up too many connections. Create file api/_mongo.js and put the following code in it. A file in the api directory that is prefixed with a _ will not be treated as a cloud function. This allows us to add helpers and other logic in separate files:

    const { MongoClient } = require("mongodb");
    
    const mongo = new MongoClient(process.env.MONGODB_URL);
    
    // Export the connection promise
    export default mongo.connect();
    

    Exporting the connection promise instead of the main client itself will prevent us from having redundant connections, since we’re working in a serverless platform.

    Using CommonJS instead of ESModules

    Notice how I am using require instead of import? This is because, as of the time of writing, Vercel Cloud Functions doesn’t support ESModule import statements in JavaScript files. Instead, you need to use CommonJS require statements.

    There’s one problem here. If you see the package.json of our app, you’ll notice that it has a line "type": "module". This means that each JavaScript file in the project is an EsModule. This is not what we want, so to mark all files in the api directory as CommonJS files, so we can use the require statement, create api/package.json and add this line in it:

    {
      "type": "commonjs"
    }
    

    This will now allow us to use require statements in the api directory. Install the MongoDB connection driver with this command:

    # Don't forget to CD!
    cd api
    npm i mongodb # or use yarn
    

    Step 3: Adding functionality

    The tracker, as of now, doesn’t really work, so let’s fix that.

    Authentication

    For authentication, we need to store the username that the user has input in the MongoDB database.

    Create a file /api/storeusername.js. This will be a cloud function and will be mapped to http://localhost:3000/api/storeusername. Put the below code in it:

    const mongoPromise = require("../src/lib/mongo");
    // All cloud functions must export a function that takes a req and res object.
    // These objects are similar to their express counterparts.
    module.exports = async (req, res) => {
      // TODO
    };
    

    Next, get the MongoDB client like so:

    module.exports = async (req, res) =>
      // Wait for the client to connect
      const mongo = await mongoPromise;
    }
    

    Extract the username from the request’s body:

    // ...
    const { username } = req.body;
    
    // Check if the username is valid
    if (typeof username !== "string" || !username.trim()) {
      res.status(400).json({ message: "Please send the username" });
      return;
    }
    

    Next, you need to store this username in the database:

    // Get the collection
    const usersCol = mongo.db().collection("users");
    // Check if the username already exists in the database
    if (await usersCol.findOne({ _id: username })) {
      res.status(400).json({ message: "User already exists!" });
      return;
    }
    // We want the username to be the identifier of the user
    await usersCol.insertOne({ _id: username });
    
    // Everything went well :)
    res.status(200).json({ message: "Username recorded" });
    

    Finally, this is how the api/storeusername.js file should look:

    const mongoPromise = require("./_mongo");
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
    
      const { username } = req.body;
      if (typeof username !== "string" || !username.trim()) {
        res.status(400).json({ message: "Please send the username" });
        return;
      }
    
      // Get the collection
      const usersCol = mongo.db().collection("users");
    
      // Check if the username already exists in the database
      if (await usersCol.findOne({ _id: username })) {
        res.status(400).json({ message: "User already exists!" });
        return;
      }
    
      // We want the username to be the identifier of the user
      await usersCol.insertOne({ _id: username });
    
      // Everything went well :)
      res.status(200).json({ message: "Username recorded" });
    };
    

    Deploy your application to Vercel with vercel ., or by pushing to GitHub, and your serverless function should be live! You can test it using cURL with this command:

    curl -X POST -H "Content-Type: application/json" -d '{"username": "test"}' https://your-app.vercel.app/api/storeusername
    

    This should create a new document in the users collection with the _id field being the username we just gave.

    The users collection in Atlas

    Now all that’s left is to fetch this function on the front end. In src/lib/UsernamePrompt.svelte, in the submit function, first you need to send a request to the cloud function, and then put the username in localStorage, so we know that the user is authenticated. You can send requests with the fetch function:

    async function submit() {
      const res = await fetch("/api/storeusername", {
        body: JSON.stringify({ username }),
        headers: {
          "Content-Type": "application/json",
        },
        method: "POST",
      });
      const data = await res.json();
      if (!res.ok) alert(data.message);
      else {
        // Store the username in localStorage
        localStorage.setItem("username", username);
        // Reload the page
        window.location.reload();
      }
    }
    

    We’re reloading the page, because in App.svelte, when the page is loaded, we need to check if there is a username in localStorage. If there is, we can skip the UsernamePrompt screen. To do so, add this code in the script tag of App.svelte:

    <script>
      import { onMount } from "svelte";
      import UsernamePrompt from "./lib/UsernamePrompt.svelte";
      import Tracker from "./lib/Tracker.svelte";
    
      let isLoggedIn = false;
      onMount(() => {
        // If there is a username in the localStorage, set isLoggedIn to true
        isLoggedIn = !!localStorage.getItem("username");
      });
    </script>
    

    The above code will check the localStorage for a username and set isLoggedIn to true if it exists. Next, all we have to do is update the DOM. Right under the script tag of App.svelte, add this:

    {#if !isLoggedIn}
    <UsernamePrompt />
    {:else}
    <Tracker />
    {/if}
    

    Tracking and untracking repositories

    Now let’s add the functionality for the actual tracking features of the tracker. If you open Tracker.svelte, you’ll notice there are two functions — track() and untrack(). These functions should track and untrack repositories respectively, by adding them to the database.

    But before that, you need to add a few more cloud functions. One to track a repository, another to untrack, and one last to get a user’s tracked repositories.

    Let’s work on them one by one.

    Tracking Repositories

    Create file api/trackrepo.js. This will be mapped to /api/trackrepo:

    const mongoPromise = require("./_mongo");
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
      // TODO
    };
    

    When a user wants to track a repository, they’ll send a POST request to this function with the name of the repository and their username in the body. The function will add the name of the repository in the trackedRepos field of the users collection. Add some code to get these fields from the body:

    const { username, repo } = req.body;
    if (typeof username !== "string" || typeof repo !== "string") {
      res.status(400).json({ message: "Invalid body" });
      return;
    }
    

    And finally, add the code to track the repository by adding it to the database:

    // Get the users collection
    const usersCol = mongo.db().collection("users");
    let user = await usersCol.findOne({ _id: username });
    if (!user) {
      res.status(400).json({ message: "User not found" });
    }
    // Add repository to user's tracked repositories
    user.trackedRepos = !user.trackedRepos ? [repo] : [...user.trackedRepos, repo];
    // Helps avoid duplicates
    user.trackedRepos = [...new Set(user.trackedRepos)];
    // Save changes to DB
    user = await usersCol.updateOne({ _id: username }, { $set: user });
    res.status(200).json({ user });
    

    And this is how api/trackrepo.js should look:

    const mongoPromise = require("./_mongo");
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
    
      const { username, repo } = req.body;
      if (typeof username !== "string" || typeof repo !== "string") {
        res.status(400).json({ message: "Invalid body" });
        return;
      }
    
      const usersCol = mongo.db().collection("users");
      const user = await usersCol.findOne({ _id: username });
      if (!user) {
        res.status(400).json({ message: "User not found" });
      }
      user.trackedRepos = !user.trackedRepos
        ? [repo]
        : [...user.trackedRepos, repo];
      user.trackedRepos = [...new Set(user.trackedRepos)];
      await usersCol.updateOne({ _id: username }, { $set: user });
      res.status(200).json({ user });
    };
    

    Now it’s time to use this function in the tracker. Open src/lib/Tracker.svelte and change the track() function to this:

    function track() {
      // If there is no username, reload the page and end the function
      if (!localStorage.getItem("username")) return window.location.reload();
    
      fetch("/api/trackrepo", {
        body: JSON.stringify({ username: localStorage.getItem("username"), repo }),
        headers: { "Content-Type": "application/json" },
        method: "POST",
      })
        .then(async (r) => {
          // Return the data and the response itself
          return { r, data: await r.json() };
        })
        .then(({ r, data }) => {
          if (!r.ok) alert(data.message);
          else console.log("Repository tracked");
        });
      trackedRepos = [...trackedRepos, repo];
      repo = "";
    }
    

    Now, when you enter a repository in the input and click Track, it should get saved in the database.

    Untracking repositories

    Let’s add a cloud function to untrack a repository. Create file api/untrackrepo.js. This will be mapped to /api/untrackrepo:

    const mongoPromise = require("./_mongo");
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
      // TODO
    };
    

    The request body of this cloud function will be the same as that of the trackrepo function — the user’s username and the repo:

    const { username, repo } = req.body;
    if (typeof username !== "string" || typeof repo !== "string") {
      res.status(400).json({ message: "Invalid body" });
      return;
    }
    

    Next, here comes the code to delete the repository from the user’s trackedRepos:

    const usersCol = mongo.db().collection("users");
    const user = await usersCol.findOne({ _id: username });
    if (!user) {
      res.status(400).json({ message: "User not found" });
    }
    if (!Array.isArray(user.trackedRepos)) {
      user.trackedRepos = [];
    } else {
      // Remove the repo from the user's list of tracked repos.
      user.trackedRepos = user.trackedRepos.filter((r) => r !== repo);
    }
    // Save changes
    await usersCol.updateOne({ _id: username }, { $set: user });
    res.status(200).json({ user });
    

    And this is how api/untrackrepo.js should look:

    const mongoPromise = require("./_mongo");
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
    
      const { username, repo } = req.body;
      if (typeof username !== "string" || typeof repo !== "string") {
        res.status(400).json({ message: "Invalid body" });
        return;
      }
    
      const usersCol = mongo.db().collection("users");
      const user = await usersCol.findOne({ _id: username });
      if (!user) {
        res.status(400).json({ message: "User not found" });
      }
      if (!Array.isArray(user.trackedRepos)) {
        user.trackedRepos = [];
      } else {
        user.trackedRepos = user.trackedRepos.filter((r) => r !== repo);
      }
      await usersCol.updateOne({ _id: username }, { $set: user });
      res.status(200).json({ user });
    };
    

    It’s now time to utilize this cloud function on the front end. In the untrack() function of src/lib/Tracker.svelte, add this code:

    function untrack(repo) {
      // If there is no username, reload the page and end the function
      if (!localStorage.getItem("username")) return window.location.reload();
    
      fetch("/api/untrackrepo", {
        body: JSON.stringify({ username: localStorage.getItem("username"), repo }),
        headers: { "Content-Type": "application/json" },
        method: "POST",
      })
        .then(async (r) => {
          // Return the data and the response itself
          return { r, data: await r.json() };
        })
        .then(({ r, data }) => {
          if (!r.ok) alert(data.message);
          else console.log("Repository untracked");
        });
      trackedRepos = trackedRepos.filter((r) => r !== repo);
    }
    

    You’ll notice that it’s very similar to the track() function, because it’s literally the same; just the URL has been updated. You can’t really test this out just yet, because we aren’t displaying a list of the tracked repositories, so let’s fix that.

    Listing tracked repositories

    This part is pretty simple. You just need to fetch the user’s tracked repositories from the database and display it on the front end. Create a cloud function api/listrepos.js and add the following code to it:

    const mongoPromise = require("./_mongo");
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
      const username = req.query.username;
      if (typeof username !== "string" || !username.trim()) {
        res
          .status(401)
          .json({ message: "Please send `username` in the querystring." });
        return;
      }
    
      const usersCol = mongo.db().collection("users");
      const user = await usersCol.findOne({ _id: username });
      if (!user) {
        res.status(400).json({ message: "User not found" });
      }
      let repositories = [];
      if (Array.isArray(user.trackedRepos)) {
        repositories = user.trackedRepos;
      }
      res.status(200).json({ repositories });
    };
    

    Since the cloud function will be called using an HTTP GET request, you can’t put a body in it, so we’re using the query string to pass the username; and since user.trackedRepos can be null, we’re making sure to return an array. Next, it’s time to use this cloud function on the front end! Create an async function called fetchRepos in the src/lib/Tracker.svelte file. This function will be responsible for fetching the user’s tracked repositories from the database using the cloud function we just created:

    async function fetchRepos() {
      // If there is no username, reload the page and end the function
      if (!localStorage.getItem("username")) return window.location.reload();
    
      const res = await fetch(
        "/api/listrepos?username=" + localStorage.getItem("username")
      );
      const data = await res.json();
      if (!res.ok) alert(data.message);
      else return data.repositories;
    }
    

    We need to fetch this function when the component is mounted. This can be done using the onMount hook in Svelte. When the component is mounted, I want to set the returned value of the above function to a variable called trackedRepos, so we can use it in the DOM:

    import { onMount } from "svelte";
    
    let trackedRepos = [];
    onMount(async () => {
      trackedRepos = await fetchRepos();
    });
    

    Now that we have access to the user’s tracked repositories, let’s update the HTML template in Tracker.svelte to show an accurate list of tracked repositories:

    <!-- ... -->
      <ul class="m-2 list-decimal">
        {#each trackedRepos as repo}
          <li class="py-1 flex items-center justify-between">
            <a class="text-gray-500 hover:underline" href="https://github.com/{repo}"
              >https://github.com/{repo}</a
            >
            <button class="text-red-500 cursor-pointer" on:click={() => untrack(repo)}
              >Untrack</button
            >
          </li>
        {/each}
      </ul>
    <!-- ... -->
    

    We still have to reload the page to see any changes. Let’s fix that by updating the DOM every time the track or untrack buttons are clicked:

    function track() {
      // ...
      trackedRepos = [...trackedRepos, repo];
      repo = "";
    }
    
    function untrack(repo) {
      // ...
      trackedRepos = trackedRepos.filter((r) => r !== repo);
    }
    

    And here’s how Tracker.svelte should look:

    <script>
      import { onMount } from "svelte";
    
      let trackedRepos = [];
      onMount(async () => {
        trackedRepos = await fetchRepos();
      });
    
      async function fetchRepos() {
        if (!localStorage.getItem("username")) return window.location.reload();
    
        const res = await fetch("/api/listrepos?username=" + localStorage.getItem("username"));
        const data = await res.json();
        if (!res.ok) alert(data.message);
        else return data.repositories;
      }
    
      let repo = "";
      function track() {
        if (!localStorage.getItem("username")) return window.location.reload();
    
        fetch("/api/trackrepo", {
          body: JSON.stringify({ username: localStorage.getItem("username"), repo }),
          headers: { "Content-Type": "application/json" },
          method: "POST"
        })
          .then(async r => {
            // Return the data and the response itself
            return { r, data: await r.json() };
          })
          .then(({ r, data }) => {
            if (!r.ok) alert(data.message);
            else console.log("Repository tracked");
          });
        trackedRepos = [...trackedRepos, repo];
        repo = "";
      }
    
      function untrack(/** @type string*/ repo) {
        // If there is no username, reload the page and end the function
        if (!localStorage.getItem("username")) return window.location.reload();
    
        fetch("/api/untrackrepo", {
          body: JSON.stringify({ username: localStorage.getItem("username"), repo }),
          headers: { "Content-Type": "application/json" },
          method: "POST"
        })
          .then(async r => {
            // Return the data and the response itself
            return { r, data: await r.json() };
          })
          .then(({ r, data }) => {
            if (!r.ok) alert(data.message);
            else console.log("Repository untracked");
          });
        trackedRepos = trackedRepos.filter(r => r !== repo);
      }
    </script>
    
    <form
      on:submit|preventDefault={track}
      class="mx-auto min-w-[350px] max-w-[1100px] w-[50%] border border-gray-500 rounded my-4 px-6 py-4"
    >
      <h1 class="text-center text-3xl m-4">GitHub tracker</h1>
    
      <input
        type="text"
        class="rounded px-4 py-2 border border-gray-300 w-full outline-none"
        placeholder="Enter the repository's URL"
        aria-label="Repository URL"
        bind:value={repo}
      />
      <button
        class="mt-2 border border-transparent bg-blue-500 text-white rounded px-4 py-2 w-full"
        >Track repository</button
      >
    
      <h2 class="mt-4 text-2xl">Tracked repositories</h2>
      <ul class="m-2 list-decimal">
        {#each trackedRepos as repo}
          <li class="py-1 flex items-center justify-between">
            <a class="text-gray-500 hover:underline" href="https://github.com/{repo}"
              >https://github.com/{repo}</a
            >
            <button class="text-red-500 cursor-pointer" on:click={() => untrack(repo)}
              >Untrack</button
            >
          </li>
        {/each}
      </ul>
    </form>
    

    And here’s a screenshot of how the app should now appear.

    Tracked repositories are accurate now

    Step 4: Making the App Installable

    Push notifications are only supported on installed apps. Yes, you can install web applications as regular applications using supported browsers — namely, Chrome and other Chromium-based browsers.

    To make an app installable, you need to convert it into a progressive web app. This is a three-step process:

    1. add a service worker
    2. make your app work offline
    3. add a manifest.json file

    If all three steps are completed, an install button will appear on the address bar when you visit the application.

    Adding a service worker

    Service workers are JavaScript files that can run in the background, off the browser’s main thread. This allows them to do stuff like run offline, run in the background and download large files. They’re mostly used for caching requests and for listening to events, both of which we will do.

    To add a service worker, you need to add a JavaScript file that’s publicly available, like any CSS files. The name doesn’t really matter, but it’s usually named service-worker.js or sw.js. This file should be publicly served like your CSS, so put it in the public directory.

    Service workers work by listening to events. For caching files, so your app works offline, you’ll listen to the install, activate and fetch events. The install event gets called when the service worker gets installed. The activate event gets called when the service worker is running, and the fetch event gets called whenever a network request is made. Event listeners can be added using self.addEventListener(). Let’s create a public/service-worker.js file and add the following code to it:

    self.addEventListener("install", (event) => {
      console.log("Installed SW");
    });
    
    self.addEventListener("activate", (event) => {
      console.log("Activated SW");
    });
    
    self.addEventListener("fetch", (event) => {
      console.log("Fetch:", event.request);
    });
    

    All that’s left is to register this service worker. We’ll do that in the onMount function of App.svelte. Add this code at the end of the callback inside onMount:

    if ("serviceWorker" in navigator) {
      // Service worker supported
      navigator.serviceWorker.register("/service-worker.js");
    }
    

    The above code first checks for service worker support in the browser, and then registers our service worker. It has to be noted that the path in the register() function is the path relative to your domain, not to the project folder — meaning that the service worker should be accessible at http://localhost:3000/service-worker.js, which it is, since it’s in the public directory.

    Now if you reload the page and open the console, you should see the above messages.

    Making our app work offline

    To make an app work offline, you need to cache its contents using a service worker. Since our app makes requests to cloud functions, it can’t really do much when there’s no network. So instead of displaying a cached, functionless version of the app, let’s display a page that indicates that we’re offline. Create a public/offline.html file and put the following code in it:

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>You're offline</title>
      </head>
      <body>
        <h1>You're offline</h1>
        <p>This app doesn't work while offline. Please check your network</p>
      </body>
    </html>
    

    Feel free to customize this page however you want. You now need to cache this page. Caching is also a three-step process that uses the three above service worker events that we listened to. Here’s how it works:

    1. The cache is opened and desired routes are added to the cache using cache.add. This happens during install.

    2. The older cache is deleted, so only the latest is saved to the user’s computer. This utilizes less storage. This happens during activate.

    3. We intercept any network requests and check if those requests are page navigations — that is, changing routes. If the request succeeds, it’s all well and good, but if the request fails, we deliver the offline.html page to be displayed to the user. This happens during fetch.

    Let’s implement the first step. Open the service worker file and change the install event’s handler like so:

    let CACHE_NAME = "cache-" + Date.now();
    
    self.addEventListener("install", event => {
      console.log("Installed SW");
      event.waitUntil(
        caches.open(CACHE_NAME).then(cache => {
          return cache.add("/offline.html");
        });
      );
      self.skipWaiting();
    });
    

    event.waitUntil() is a function that’s similar to the await keyword. Callbacks of addEventListener can’t be asynchronous, so to implement that functionality, we should use event.waitUntil() and pass it a promise so that the promise will get awaited.

    self.skipWaiting() tells the browser that we’re done with the install process, so activate the service worker. Speaking of activate, let’s now add the code to delete any old caches:

    self.addEventListener("activate", (event) => {
      console.log("Activated SW");
      event.waitUntil(
        // Loop through the cache
        caches.keys().then((keys) => {
          // We must return a promise so it gets awaited
          return Promise.all(
            keys.map((k) => {
              // If the key doesn't match the name of the current cache, delete it
              if (k !== CACHE_NAME) return caches.delete(k);
            })
          );
        })
      );
    });
    

    And with that, the offline.html page should be cached. To double-check, open the developer tools by pressing F12 and select the Application tab. On the sidebar, there should be a Cache Storage tab. Click on it and you should notice /offline.html.

    Screenshot of the application tab

    Now all that’s left to do is to serve this file when there’s no network:

    self.addEventListener("fetch", (event) => {
      console.log("Fetch:", event.request);
    
      // We only want to send /offline.html when the user is navigating pages,
      // not when they're requesting something else like CSS files or API requests.
      if (event.request.mode !== "navigate") return;
    
      event.respondWith(
        fetch(event.request).catch(() => {
          return caches.open(CACHE_NAME).then((cache) => {
            return cache.match("offline.html");
          });
        })
      );
    });
    

    The event.respondWith() function will respond to the network fetch request with whatever Response object is passed to it. In this case, we’re fetching the request first, and if the request fails, which will most likely be because of an internet problem, we’re sending the offline.html page, which was cached by the service worker.

    Now refresh the page and turn off your Wi-Fi or Ethernet. You should now see our offline page instead of the default chrome “No network” page when you refresh. This offline page unfortunately doesn’t have the dinosaur game, but it does enable us to install the application as a PWA.

    Here’s how the service worker should look:

    let CACHE_NAME = "cache-" + Date.now();
    
    self.addEventListener("install", (event) => {
      console.log("Installed SW");
      event.waitUntil(
        caches.open(CACHE_NAME).then((cache) => {
          return cache.add("/offline.html");
        })
      );
      self.skipWaiting();
    });
    
    self.addEventListener("activate", (event) => {
      console.log("Activated SW");
      event.waitUntil(
        // Loop through the cache
        caches.keys().then((keys) => {
          // We must return a promise so it gets awaited
          return Promise.all(
            keys.map((k) => {
              // If the key doesn't match the name of the current cache, delete it
              if (k !== CACHE_NAME) return caches.delete(k);
            })
          );
        })
      );
    });
    
    self.addEventListener("fetch", (event) => {
      console.log("Fetch:", event.request);
    
      // We only want to send /offline.html when the user is navigating pages,
      // not when they're requesting something else like CSS files or API requests.
      if (event.request.mode !== "navigate") return;
    
      event.respondWith(
        fetch(event.request).catch(() => {
          return caches.open(CACHE_NAME).then((cache) => {
            return cache.match("offline.html");
          });
        })
      );
    });
    

    Adding the manifest.json file

    The manifest.json, or web manifest, contains some useful information about your application — things like the app’s name, its theme color, a description, its icons and much more. This file is usually called manifest.json and must be linked to your website using the <link> tag in the HTML, like how you link CSS files. Let’s add a manifest for our application. Feel free to use a generator for this one:

    {
      "background_color": "#ffffff",
      "description": "Notifies you of new issues and PRs in GitHub repositories",
      "display": "standalone",
      "icons": [
        {
          "src": "/icons/icon-128x128.png",
          "sizes": "128x128",
          "type": "image/png"
        },
        {
          "src": "/icons/icon-144x144.png",
          "sizes": "144x144",
          "type": "image/png"
        },
        {
          "src": "/icons/icon-152x152.png",
          "sizes": "152x152",
          "type": "image/png"
        },
        {
          "src": "/icons/icon-192x192.png",
          "sizes": "192x192",
          "type": "image/png"
        },
        {
          "src": "/icons/icon-256x256.png",
          "sizes": "256x256",
          "type": "image/png"
        },
        {
          "src": "/icons/icon-512x512.png",
          "sizes": "512x512",
          "type": "image/png"
        },
        {
          "src": "/icons/maskable_icon.png",
          "sizes": "640x640",
          "type": "image/png",
          "purpose": "any maskable"
        }
      ],
      "name": "GitHub Tracker",
      "orientation": "portrait",
      "short_name": "GH Tracker",
      "start_url": "/",
      "theme_color": "#000000"
    }
    

    You need to download a bunch of icons for the application. These icons are of different sizes and are used by different operating systems. You can download them from the source code repository or by using this link. Be sure to extract the ZIP file to public/icons.

    Next, you need to add the manifest and the icons to the index.html file. You can do so by putting the following code in it:

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <link rel="icon" href="/favicon.ico" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>GitHub Tracker</title>
        <meta
          name="description"
          content="Tracks GitHub repositories for new Issues/PRs and send you notifications"
        />
    
        <link rel="manifest" href="/manifest.json" />
        <meta name="description" content="Svelte PWA starter template" />
        <meta name="theme-color" content="#333333" />
    
        <meta name="apple-mobile-web-app-capable" content="yes" />
        <meta name="apple-mobile-web-app-status-bar-style" content="black" />
        <meta name="apple-mobile-web-app-title" content="Svelte PWA" />
        <link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
      </head>
      <body>
        <div id="app"></div>
        <script type="module" src="/src/main.js"></script>
      </body>
    </html>
    

    Open Chrome’d developer tools by pressing F12 and head to the Lighthouse tab and create a new audit. You should now get an “Installable” score on the PWA section. This means that you have successfully converted your website to a webapp, and you can now install it by clicking the button on the address bar.

    Screenshot of the installed app

    Step 5: Subscribing to Push Notifications

    Before we can send push notifications, we need to get permission from the user. You can use the Notification.requestPermission() method to do so. This method is asynchronous and returns a string that can be equal to default, denied and granted. These are returned when the user either presses the X, presses Deny or presses Allow on the notification prompt, respectively. We’ll use the onMount hook in App.svelte to call this function:

    onMount(async () => {
      isLoggedIn = !!localStorage.getItem("username");
      if ("serviceWorker" in navigator) {
        navigator.serviceWorker.register("/service-worker.js");
      }
      const status = await Notification.requestPermission();
      if (status !== "granted")
        alert(
          "Please allow notifications to make sure that the application works."
        );
    });
    

    You should now get a popup asking you to allow notifications in the app. Now that we have permission to send notifications, let’s use the service worker to subscribe to push events. This can be done using the pushManager.subscribe() function of the service worker. You can either do this in the service worker itself, or after registering the service worker in App.svelte. I’ll go with the latter, so if you want to do the same, just replace the navigator.serviceWorker.register function in onMount with the code below:

    navigator.serviceWorker.register("/service-worker.js");
    const reg = await navigator.serviceWorker.ready;
    reg.pushManager.subscribe({ userVisibleOnly: true });
    

    If you open the console, you’ll notice an error saying that the applicationServerKey is missing. Push notifications need servers to send them push messages, and these servers are authenticated with VAPID Keys. These keys identify the server and let the browser know that the push message is valid. We’ll use Vercel Cloud Functions to send push messages, so we need to set it up.

    Creating a push message server

    We’ll use the web-push npm package to help us generate keys and send push events. To install it, cd to the api folder and run the following:

    npm i web-push
    

    Remember to cd to the api folder, as otherwise the web-push package will be installed in the Svelte app.

    To send push notifications, you’ll need to generate a public and private VAPID key pair. To do so, open the Node REPL using the node command and run the following commands:

    $ node
    > const webPush = require("web-push");
    > webPush.generateVAPIDKeys()
    {
      publicKey: "XXXXXXX",
      privateKey: "XXXXXX"
    }
    

    Copy these two keys and store them as environment variables on Vercel. Be sure to call them something memorable like VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY.

    Vercel environment variables

    Now, we can start work on the cloud function. Create file api/vapidkeys.js. This file will be responsible for sending the public VAPID key to the client. You should never share the private VAPID key. In api/vapidkeys.js, first we need to initialize web-push:

    const webPush = require("web-push");
    
    webPush.setVapidDetails(
      "YOUR_VERCEL_DOMAIN",
      process.env.VAPID_PUBLIC_KEY,
      process.env.VAPID_PRIVATE_KEY
    );
    

    Be sure to replace YOUR_VERCEL_DOMAIN with your Vercel app’s domain. Next, let’s export a function to just return the public VAPID key to the requester:

    module.exports = (_, res) => {
      res.send(process.env.VAPID_PUBLIC_KEY);
    };
    

    With that done, you can now update the onMount function in App.svelte to first fetch the cloud function to get the public key, and then use the public key in the subscribe function:

    let sub;
    onMount(async () => {
      // ...
      if ("serviceWorker" in navigator) {
        // Service worker supported
        navigator.serviceWorker.register("/service-worker.js");
        const reg = await navigator.serviceWorker.ready;
        sub = await reg.pushManager.getSubscription();
        if (!sub) {
          // Fetch VAPID public key
          const res = await fetch("/api/vapidkeys");
          const data = await res.text();
          sub = await reg.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: data,
          });
        }
        console.log(sub);
      }
      // ...
    });
    

    Notice how we’re only fetching the VAPID keys if we haven’t subscribed to push notifications. If you open the console, you should see the subscription logged to the console.

    Push Notification Subscription logged in the console

    The endpoint that is provided is very important to us. This endpoint will allow us to notify this user using web-push. Let’s create a cloud function to store this endpoint in the database. Create file api/storeendpoint.js:

    const mongoPromise = require("./_mongo");
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
      const usersCol = mongo.db().collection("users");
      // TODO
    };
    

    Let’s grab the subscription and the username from the body:

    const { username, subscription } = req.body;
    if (typeof username !== "string" || typeof subscription !== "object") {
      res.status(400).json({ message: "Invalid body" });
      return;
    }
    

    And let’s add it to the database:

    await usersCol.updateOne({ _id: username }, { $set: { subscription } });
    

    And here’s how the final cloud function should look:

    const mongoPromise = require("./_mongo");
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
      const usersCol = mongo.db().collection("users");
      const { username, subscription } = req.body;
      if (typeof username !== "string" || typeof subscription !== "string") {
        res.status(400).json({ message: "Invalid body" });
        return;
      }
      await usersCol.updateOne(
        { _id: username },
        {
          $set: {
            subsciption,
          },
        }
      );
      res.status(204).end();
    };
    

    This function should be called every time we subscribe to push notifications. Let’s use a Svelte reactive block to call this cloud function every time the sub variable has a value and the isLoggedIn variable is true. Add this code just before the ending of the <script> tag in App.svelte:

    // ...
    $: if (sub && isLoggedIn) {
      // Push notifs have been subscribed to, and there's a username in localStorage
      const username = localStorage.getItem("username");
      fetch("/api/storeendpoint", {
        body: JSON.stringify({ username, subscription: sub.toJSON() }),
        headers: {
          "Content-Type": "application/json",
        },
        method: "POST",
      });
    }
    

    Refresh the page, and you should see that the current browser’s push endpoint and keys are stored in the MongoDB database in the subscription object.

    All you have to do is handle the push event in the service worker and create a cloud function to check GitHub for new issues and PRs.

    Let’s do the latter first. Create a new cloud function api/fetchgh.js. This function will be responsible for checking GitHub and sending push notifications:

    const mongoPromise = require("./_mongo");
    const webPush = require("web-push");
    
    webPush.setVapidDetails(
      "YOUR_VERCEL_DOMAIN",
      process.env.VAPID_PUBLIC_KEY,
      process.env.VAPID_PRIVATE_KEY
    );
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
      const usersCol = mongo.db().collection("users");
    };
    

    Let’s get all the users from the database, so we know what repos to fetch:

    const users = await usersCol.find().toArray();
    

    Next, create two variables to store the currently fetched repositories, and the repositories with any new issues or PRs:

    const alreadyFetchedRepos = [];
    const reposWithIssues = [];
    

    For each user, let’s check their tracked repositories for any new issues. To make sure that one repository is checked only once, we’ll add the repository to alreadyFetchedRepos, and we’ll add any repositories that have new issues to reposWithIssues. To do so, we need to loop over every user in the users array and get a list of repositories to fetch. This will be done by checking their trackedRepos for any duplicates. Once that’s done, we’ll call the fetchRepo function for every repository. fetchRepo will return a Boolean — true if there are new issues, false otherwise:

    for await (let user of users) {
      // Make sure to fetch each repo ONCE.
      const reposToFetch = user.trackedRepos.filter(
        (i) => !alreadyFetchedRepos.includes(i)
      );
      await Promise.all(
        reposToFetch.map(async (repo) => {
          const hasNewIssues = await fetchRepo(repo, user._id);
          alreadyFetchedRepos.push(repo);
          if (hasNewIssues) reposWithIssues.push(repo);
        })
      );
    }
    

    Since fetchRepo will be asynchronous, I’ve used map to return promises every time and awaited them all using Promise.all. This works because the for loop is asynchronous. If promises aren’t awaited, variables can be undefined, so be sure to await promises!

    Now for the fetchRepo function. This function will get the last time we’ve checked the GitHub API from the database. This is to only get the latest issues from GitHub. It then fetches the GitHub API for any new issues, and returns a Boolean value if there are any:

    async function fetchRepo(repo) {
      const mongo = await mongoPromise;
      const fetchedCol = mongo.db().collection("fetched");
      const lastFetchedDoc = await fetchedCol.findOne(
        {},
        { sort: { createdAt: -1 } }
      );
      const timestamp = lastFetchedDoc ? lastFetchedDoc.createdAt : null;
    
      const { data: issues } = await axios.get(
        `https://api.github.com/repos/${repo}/issues?state=open${
          timestamp ? "&since=" + timestamp : ""
        }`
      );
      if (Array.isArray(issues)) {
        await fetchedCol.insertOne({ createdAt: new Date() });
      }
      if (Array.isArray(issues) && issues.length > 0) return true;
    
      return false;
    }
    

    Once that’s done, we need to send push notifications to any user that has tracked a repository that has any new issues. This can be done using web-push. Add these lines of code to the end of the exported function:

    for await (let user of users) {
      // TODO
    }
    

    First, we need to check if any of the user’s tracked repos have new issues. This can be done with the Array.some method. Array.some() determines whether the specified callback function returns true for any element of an array, so we can easily use this to check:

    if (user.trackedRepos.some((i) => reposWithIssues.includes(i))) {
      // TODO
    }
    

    And finally, we send the notification:

    await webPush.sendNotification(user.subscription);
    

    And here’s how the cloud function should look:

    const mongoPromise = require("./_mongo");
    const webPush = require("web-push");
    const axios = require("axios");
    
    webPush.setVapidDetails(
      "https://github-tracker-arnu515.vercel.com",
      process.env.VAPID_PUBLIC_KEY,
      process.env.VAPID_PRIVATE_KEY
    );
    
    async function fetchRepo(repo) {
      const mongo = await mongoPromise;
      const fetchedCol = mongo.db().collection("fetched");
      const lastFetchedDoc = await fetchedCol.findOne(
        {},
        { sort: { createdAt: -1 } }
      );
      const timestamp = lastFetchedDoc ? lastFetchedDoc.createdAt : null;
    
      const { data: issues } = await axios.get(
        `https://api.github.com/repos/${repo}/issues?state=open${
          timestamp ? "&since=" + timestamp : ""
        }`
      );
      if (Array.isArray(issues)) {
        await fetchedCol.insertOne({ createdAt: new Date() });
      }
      if (Array.isArray(issues) && issues.length > 0) return true;
    
      return false;
    }
    
    module.exports = async (req, res) => {
      const mongo = await mongoPromise;
      const usersCol = mongo.db().collection("users");
      const users = await usersCol.find().toArray();
      const alreadyFetchedRepos = [];
      const reposWithIssues = [];
    
      for await (let user of users) {
        // Make sure to fetch each repo ONCE.
        const reposToFetch = user.trackedRepos.filter(
          (i) => !alreadyFetchedRepos.includes(i)
        );
        await Promise.all(
          reposToFetch.map(async (repo) => {
            const hasNewIssues = await fetchRepo(repo, user._id);
            alreadyFetchedRepos.push(repo);
            if (hasNewIssues) reposWithIssues.push(repo);
          })
        );
      }
    
      for await (let user of users) {
        // Send push notifications
        if (user.trackedRepos.some((i) => reposWithIssues.includes(i))) {
          await webPush.sendNotification(user.subscription, "new-issues");
        }
      }
    
      // And we're done!
      res.status(200).json({ reposWithIssues, fetched: alreadyFetchedRepos });
    };
    

    Listening to push events

    All that’s left to do is to listen to push events in the service worker. Open the service worker and add the code below:

    self.addEventListener("push", (event) => {
      console.log(event.data.text());
    });
    

    When you call the cloud function, maybe using cURL, you should see new-issue logged in the browser console. That isn’t really very helpful, so let’s make it send a notification:

    self.addEventListener("push", (event) => {
      // Double check the push event
      if (event.data.text() === "new-issue") {
        event.waitUntil(
          self.registration.showNotification("New issues", {
            body: "One or more tracked repositories have new issues or pull requests.",
          })
        );
      }
    });
    

    Delete the fetched collection from MongoDB and call the cloud function again. You should now receive a notification from the web browser.

    The notification

    Deploy the application using vercel . or by pushing to GitHub, install the app as a PWA, and run the cloud function by going to https://YOUR_VERCEL_APP/api/fetchgh and you should receive a notification, even if you haven’t opened the application!

    If you don’t receive the notification, or you get a 410 error from web push, be sure to allow the notifications forever in the prompt when you get asked.

    Notification prompt

    Step 6: Creating a CRON job

    The tracker isn’t really a tracker if we have to manually call the cloud function, right? Let’s use EasyCron to call the cloud function automatically every hour.

    Head to your EasyCron dashboard and create a new CRON job. For the URL, enter https://YOUR_VERCEL_DOMAIN/api/fetchgh, and choose an interval. I’ll go with every hour, but feel free to customize it however you like.

    Creating a new CRON job

    Conclusion

    And with that, you should be getting notifications every time there’s a new issue/PR in any of your tracked repositories. Feel free to check out the source code or the live version if you’ve gotten stuck anywhere.