Is it possible to prevent a PWA from using caching on a specific route only?

1.4k Views Asked by At

Since I configured my React app to be a PWA, I started having issues with google authentication.

When the user clicks on sign-in with Google, this endpoint is supposed to be hit:

// @route  GET api/users/auth/google
// @desc   Authenticate user using google
// @access Public
router.get(
  "/auth/google",
  passport.authenticate("google", {
    scope: [
      "email",
      "profile",
      "https://www.googleapis.com/auth/user.birthday.read",
      "https://www.googleapis.com/auth/user.gender.read",
      "https://www.googleapis.com/auth/user.addresses.read",
    ],

  })
);

But, since the PWA uses caching, the client browser does not even communicate with the server. So that endpoint doesn't get hit.

And what happens is that it loads the cached React app, and uses react routing, and because of it when the user clicks on the Sign in with Google button, and this route gets called:

https://example.com/api/users/auth/google

Instead of communicating with the server, it uses react routing and the user gets redirected to the home page because of this:

        <Redirect to="/home" />

I had to disable the PWA in order for the sign-in to work all the time. However, this had the side-effect of disabling caching and other PWA features like installation.

The solution is to prevent the use of caching when this route is called:

https://example.com/api/users/auth/google

This is a similar question but it uses workbox which my app doesn't.

This is my serviceWorker file:

import axios from "axios";

const isLocalhost = Boolean(
  window.location.hostname === "localhost" ||
    window.location.hostname === "[::1]" ||
    window.location.hostname.match(
      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
    )
);

export function register(config) {
  if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
    if (publicUrl.origin !== window.location.origin) {
      return;
    }

    window.addEventListener("load", () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;

      if (isLocalhost) {
        checkValidServiceWorker(swUrl, config);
      } else {
        console.log(
          "Is not localhost. Just register service worker, calling registerValidSW"
        );
        registerValidSW(swUrl, config);
      }
    });
  }
}

async function subscribeToPushNotifications(serviceWorkerReg) {
  console.log("subscribeToPushNotifications is called");

  let subscription = await serviceWorkerReg.pushManager.getSubscription();

  if (subscription === null) {
    const dev_public_vapid_key =
      "BLhqVEcH5_0uDpSlwKfvNk7q5IwM5uxYf2w8qvHqdk0SrBpQMGKIZfBrlG-1XYvGxHZXHSik3pQ8IN8NeNCYRtU";
    const prod_public_vapid_key =
      "BBagXPEL91hwEird3KIG2WuxcWt0hOq1AA7QKtK1MlNqMxiBgQ_RCT8f7rCwYIkuHSVg65Xm68lIlGobXDT1yDI";

    const public_vapid_key = isLocalhost
      ? dev_public_vapid_key
      : prod_public_vapid_key;

    subscription = await serviceWorkerReg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: public_vapid_key,
    });
    axios
      .post("/api/push_notif_subscription/subscribe", subscription)
      .then((response) => {})
      .catch((error) => {
        console.log(error);
      });
  }
}

function serviceWorkerRegistrationEnhancements(config, registration) {
  registration.addEventListener("activate", function (event) {
    event.waitUntil(() => {
      if (config && config.onActivated) {
        config.onActivated(registration);
      }
    });
  });
}

function registerValidSW(swUrl, config) {
  navigator.serviceWorker.register(swUrl).then((registration) => {
    console.log("Line right before calling subscribeToPushNotifications");
    subscribeToPushNotifications(registration);

    registration.onupdatefound = () => {
      const installingWorker = registration.installing;
      if (!installingWorker) {
        return;
      }
      installingWorker.onstatechange = () => {
        if (installingWorker.state === "installed") {
          if (navigator.serviceWorker.controller) {
            if (config && config.onUpdate) {
              config.onUpdate(registration);
            }
          } else {
            if (config && config.onSuccess) {
              config.onSuccess(registration);
            }
          }
        }
      };
    };

    serviceWorkerRegistrationEnhancements(config, registration);

    registration.addEventListener("push", async function (event) {
      const message = await event.data.json();
      let { title, description, image } = message;
      await event.waitUntil(showPushNotification(title, description, image));
    });
  });
}

export function showPushNotification(title, description, image) {
  if (!("serviceWorker" in navigator)) {
    console.log("Service Worker is not supported in this browser");
    return;
  }

  navigator.serviceWorker.ready.then(function (registration) {
    registration.showNotification(title, {
      body: description,
      icon: image,
      actions: [
        {
          title: "Say hi",
          action: "Say hi",
        },
      ],
    });
  });
}

function checkValidServiceWorker(swUrl, config) {
  fetch(swUrl, {
    headers: { "Service-Worker": "script" },
  }).then((response) => {
    const contentType = response.headers.get("content-type");
    if (
      response.status === 404 ||
      (!!contentType && contentType.indexOf("javascript") === -1)
    ) {
      navigator.serviceWorker.ready.then((registration) => {
        registration.unregister().then(() => {
          window.location.reload();
        });
      });
    } else {
      console.log("Service worker found, calling registerValidSW");
      registerValidSW(swUrl, config);
    }
  });
}

export function unregister() {
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.ready.then((registration) => {
      registration.unregister();
    });
  }
}
1

There are 1 best solutions below

11
On BEST ANSWER

Service workers allow for very granular control of caching behavior based on URL.

Caching flow -- https://web-dev.imgix.net/image/admin/vtKWC9Bg9dAMzoFKTeAM.png?auto=format&w=845

By default, your PWA (Progressive Web App) is caching all the assets, including your API calls, due to which you are having issues with Google authentication. You will have to exclude a specific route from being cached, specifically /api/users/auth/google.

From your serviceWorker.js file, I noticed that you are not handling fetch events, which are typically used to manage caching and offline support.
See for example this "simple-service-worker/sw.js".

You would need to implement that part first, so you can control what gets cached.

In the registerValidSW function, after the onupdatefound event listener, add a fetch event listener where you can specify the logic for caching:

registration.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  
  // Do not handle non-GET requests.
  if (event.request.method !== 'GET') {
    return;
  }

  // Ignore /api/users/auth/google route.
  if (url.pathname.startsWith('/api/users/auth/google')) {
    return;
  }

  // Here, you can add your logic to handle other requests and caching.

  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        if (cachedResponse) {
          return cachedResponse;
        }

        return caches.open(CACHE_NAME)
          .then(cache => {
            return fetch(event.request).then(response => {
              return cache.put(event.request, response.clone()).then(() => {
                return response;
              });
            });
          });
      })
  );
});

Here, you first check if the method is GET. If it is not, skip it.
Then, check if the request is for the /api/users/auth/google route. If it is, also skip it.

For other routes, you perform a fetch from the cache, and if it is not present in the cache, you fetch it from the network, cache it, and then serve it to the client.
(Do replace CACHE_NAME with the actual cache name that you intend to use).

That code should ideally be inside a service worker file (e.g., service-worker.js) that is separate from the main JavaScript bundle that your web application uses.
I see that your serviceWorker.js file (which appears to be a part of your main JavaScript bundle, given it imports other modules like axios) tries to register a separate service worker from the service-worker.js file. If the service worker file you have provided is indeed part of your main JavaScript bundle, you should ideally move this fetch event handling code to the separate service-worker.js file that is being registered as a service worker. That separate service worker file should not use modern ES6 imports and should be written in a way that it can be served as a standalone script file. You would also need to handle caching the appropriate files during the install and activate events of the service worker.

Again, the service-worker/simple-service-worker/sw.js is a good example.


how do I force the browser to call the server when site_name/api/users/auth/google is entered in the browser instead of using the cached version of the site?

Service workers are designed to provide control over the network requests made by a web page, allowing you to intercept those requests and provide custom responses, such as serving cached content when offline.
They do not generally have control over navigation requests made by entering a URL in the browser's address bar, as those requests are made at a higher level than the page itself.

When you are dealing with a request to an external URL that should always hit the server (such as an API endpoint), you can specifically tell the service worker to bypass the cache and make a network request. Here is how you could set up your fetch event listener to do that for the URL path you have mentioned:

self.addEventListener('fetch', event => {
  // Check if the request is for the specific API endpoint
  if (event.request.url.endsWith('/api/users/auth/google')) {
    // Respond to this request with a network fetch, bypassing the cache
    event.respondWith(fetch(event.request));
    return;
  }

  // Handle other requests here, possibly using caches
});

That would set up a fetch event listener inside the service worker that checks if the requested URL ends with the specific path you have mentioned. If it does, it responds to the request with a network fetch, bypassing any cached content. Other requests can be handled differently, possibly using cached content as appropriate.

Note: Make sure that the URL path is exactly matched with what you are requesting; adjust the condition accordingly.

That will make sure that any requests to that specific URL will always hit the network, rather than using cached content. But it will not have any effect on navigation requests made by typing a URL directly into the address bar, as those are not controlled by the service worker's fetch event listener.

If you want to make sure that the entire page is always fetched from the network, rather than being served from the cache, you may need to adjust the overall caching strategy used by your service worker, possibly using the NetworkOnly strategy provided by some service worker libraries like Workbox.


But, thing is the way authentication works is that when user clicks sign in with Google it loads the auth URL in the browser (as if I'm typing it directly into the address bar) by doing this window.location.href = "http://example.com/api/users/auth/google"; so the service worker won't be able to intercept it.

True: Redirecting the entire page using window.location.href would indeed cause a full-page navigation request that is not controlled by the service worker.

In such scenarios, you might want to employ a different strategy for handling these authentication requests.

For instance, instead of directly changing window.location.href, you can use fetch or AJAX to hit the endpoint. That way, you will be making a request that can be intercepted by a service worker. However, this might be tricky with OAuth redirection flows.

Or, when setting window.location.href, append a unique cache-busting parameter to the URL. That would make each request unique, and thus it will not be served from the cache.

window.location.href = `http://example.com/api/users/auth/google?timestamp=${new Date().getTime()}`;

Make sure your server sets the appropriate Cache-Control headers for the URL, indicating that the request should not be cached. That header will advise the service worker not to cache this request.

Cache-Control: no-store

If you are using a library like Workbox to control your caching behavior, you can explicitly tell it to never cache certain URLs. Though this might not work if you are using full-page navigation as in your case.


TomG adds in the comments

After searching, I found the solution (workbox sw) to add the routes to be ignored in navigateFallbackDenylist: [/^\/backoffice/]

See vite-pwa/vite-plugin-pwa issue 218

How to exclude a backend route ?

You can try adding that route to the navigateFallbackDenylist on workbox plugin option (I test it on vue-router examples with the hi route, the route is not being intercepted by the sw):

// vite.config.ts
VitePWA({
  // other options
  workbox: {
    navigateFallbackDenylist: [/^\/backoffice/]
  }
}),

Previous option will generate this entry on your sw (you must deal with offline support for excluded routes):

  workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
    denylist: [/^\/backoffice/]
  }));