Danielo515
Danielo515

Reputation: 7061

Proper way to setup firebase-messaging service worker with VITE

I have a project built with SolidJS, typescript and Vite. I already have a service worker working using vite PWA plugin, using the generate service worker strategy. Now I want to add notifications using firebase cloud messaging (FCM) and their documentation instructs you to create a very simple file that is meant to be bundled as a service worker. This presents a challenge because Vite is not really meant to be used with several entry-points, which is somewhat required to properly include this file.

I tried several approaches, and I am not happy with any of them. All feel hacky and seem to be suboptimal. Here are the things I tried, ordered from more to less success.

Adding another instance of vite PWA plugin using the injectManifest strategy.

This is the current strategy I'm using because it has the best balance between convenience and being a working solution. There is no single line in the vite-pwa-plugin documentation that says that using several instances of the plugin is possible or encouraged, but there is not anything against it either. So what I did was to instantiate the plugin two times, one for the "legit/real" service worker that my app uses, and another with the inject manifest strategy to bundle the required firebase-messaging-sw.js service worker:

Vite config:

export default defineConfig({
plugins: [
// Abuse the plugin API to bundle the messaging service worker
    VitePWA({
      strategies: 'injectManifest',
      srcDir: 'src',
      filename: 'firebase-messaging-sw.ts',
      workbox: {
        globPatterns: [],
        globIgnores: ['*'],
      },
    }),
// Real legit service worker for my app
    VitePWA({
      registerType: 'autoUpdate',
      devOptions: { enabled: true },
      // minimum PWA
      includeAssets: ['favicon.ico', 'robots.txt', '*.svg', '*.{png,ico}'],
      workbox: { 
      // bla bla bla

Service worker file firebase-messaging-sw.js:

// Import and configure the Firebase SDK
import { initializeApp } from 'firebase/app';
import { getMessaging } from 'firebase/messaging/sw';
import firebaseConfig from './firebase-config.json';
// Line below makes typescript happy by importing the definitions required for ServiceWorkerGlobalScope
import { precacheAndRoute as _ } from 'workbox-precaching';

declare let self: ServiceWorkerGlobalScope;

const firebaseApp = initializeApp(firebaseConfig);
getMessaging(firebaseApp);
console.info('Firebase messaging service worker is set up');
// If we don't include a point to inject the manifest the plugin will fail.
// Using just a variable will not work because it is tree-shaked, we need to make it part of a side effect to prevent it from being removed
console.log(self.__WB_MANIFEST);

As you can see, this approach seems to be abusing the plugin API and has some nasty side-effects that fulfills no purpose other than preventing the bundling from failing. However, it works, doesn't require a separate build file or configuration and it's everything within a single vite config. Nasty, but convenient

Creating my own plugin to bundle the service worker separately

I tried creating a local plugin to handle the imports of the firebase-messaging-sw.js file and emitting it as a separate chunk. However, when the file is registered as a service worker I get errors because the file is bundled as if it was a chunk that is part of the application, therefore it relies on bundle features (like import) that are not available on the service worker environment. The plugin code looks something like this:

import { Plugin } from 'vite';
const print = (obj) => console.dir(obj, { depth: 8 });
export function renameFirebaseSw(): Plugin {
  const virtualId = 'virtual:firebase-messaging-sw';
  const resolvedVirtualModuleId = '\0' + virtualId;
  return {
    name: 'vite:rename-firebase-sw',
    // enforce: 'post',
    resolveId(id) {
      if (id === virtualId) return resolvedVirtualModuleId;
    },
    buildStart() {
      print('start');
    },
    load(id) {
      if (id === resolvedVirtualModuleId) {
        const ref1 = this.emitFile({
          type: 'chunk',
          fileName: 'firebase-messaging-sw.js',
          id: './src/firebase-messaging-sw.ts',
        });
        console.log({ ref1 });
        return `export default import.meta.ROLLUP_FILE_URL_${ref1}`;
      }
    },
  };
}

Then, you import the virtual module from anywhere in your app, and the plugin will intercept it and emit a separate chunk with the messaging service worker file. As I mentioned before, this does not bundle the service worker code properly and fails, not to mention that it does not work on development, only when building for production

Having a separate vite config

Lastly, you can have a separate vite config just for the purpose of bundling the service worker file. This approach seems to be the cleanest because you are just bundling the service worker code like it were a separate app (which it kinda is). The problem is that you need to make sure that the output name is the appropriate one (not having a hash, and that the app that imports/registers it uses the same name), to make sure you always run the main app build step and the service worker build step and to also run them in parallel in dev mode. To be honest, not something I want to maintain.

Is there any clean and convenient way to include the required service worker for cloud messaging that does not have any of the compromises mentioned? I did a lot of research and investigation and I didn't find anything but workarounds.

Upvotes: 14

Views: 8856

Answers (3)

Peyi Oyelo
Peyi Oyelo

Reputation: 130

Okay. So I spent about a week understanding Vite and Service workers in order to understand how to use firebase with the default Vite PWA service worker. You don't need to create a separate service worker in order to add Firebase. You can use the "Inject Manifest" strategy.

With the Inject manifest strategy, Vite will take your custom service worker template and add the preloaded assets (typically called the precache manifest) into your custom service worker template.

Knowing this, you can configure your vite.config.ts file to make use of this strategy. You would still need to handle the caching of assets and setting up workbox. Let's delve into it.

  1. Configure your Vite PWA to use the inject manifest strategy (see here for how to do this). Make sure to specify the srcDir and the filename as instructed in the documentation.

  2. You're going to be creating a service worker using a .ts extension along with workbox dependencies. In order for your ts service worker to be successfully compiled into it's js equivalent in the public directory, it's important that you specify the format for rollup to use. This format is the 'iife' format. Instructions on how to do this can be found here. If you don't do this, the workbox dependencies in your .ts file will cause your service worker to have errors and prevent it from running in the browser.

  3. At this point all that's left is to create the custom.ts file. Make sure that the path that you're using matches the path that you supplied to the Vite PWA srcDir and filename. I created my service worker with the path src/sw.ts, this meant my Vite.config.ts looked like this

     VitePWA({
      strategies: 'injectManifest',
      injectManifest: {
        rollupFormat: 'iife',
      },
      registerType: 'autoUpdate',
      srcDir: 'src',
      filename: 'sw.ts',

      devOptions: {
        enabled: true,
        type: "module"
      },
    })

I removed my manifest for brevity. Now that we've established the sw.ts file, we will now need to create it. You can find the splintered code needed to create your .ts file here. Unfortunately, the way these files are splintered are not very beginner friendly. I have been able to re-produce and study the code while adding comments to explain what each part of the code does. I have also outlined how to go about the firebase integration in the comments.

Here's the code!

//Import Cache Names and ClientClaim module for providing the cache name and taking control of all pages immediately
import { cacheNames, clientsClaim } from 'workbox-core'

//Import routing modules for registering routes and setting default and catch handlers
import { registerRoute, setCatchHandler, setDefaultHandler } from 'workbox-routing'

//Import caching modules for caching strategies
import {
    NetworkFirst, //Cache the network response first and return it if it succeeds, otherwise return the cached response
    NetworkOnly, //Fetch the resource from the network and don't cache it
    Strategy, //Base class for caching strategies
    StrategyHandler //Base class for caching strategy handlers
} from 'workbox-strategies'

//Import module for caching precached assets
import type { ManifestEntry } from 'workbox-build'

//Firebase
// declare let firebase: any;
// importScripts('https://www.gstatic.com/firebasejs/9.6.8/firebase-app-compat.js');
import { initializeApp } from 'firebase/app';
// importScripts('https://www.gstatic.com/firebasejs/9.6.8/firebase-messaging-compat.js');
import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw';

//Extend the ServiceWorkerGlobalScope to include the __WB_MANIFEST property
interface MyServiceWorkerGlobalScope extends ServiceWorkerGlobalScope {
    __WB_MANIFEST: any;
}

// Give TypeScript the correct global.
declare let self: MyServiceWorkerGlobalScope

// Declare type for ExtendableEvent to use in install and activate events
declare type ExtendableEvent = any

const data = {
    race: false, //Fetch first, if it fails, return a previously cached response
    debug: false, //Don't log debug messages for intercepted requests and responses
    credentials: 'same-origin', //Only request resources from the same origin
    networkTimeoutSeconds: 0, //Timout in seconds for network requests; 0 means no timeout
    fallback: 'index.html' //Fallback to index.html if the request fails
}

// Access the pre-defined cache names for use in this app
const cacheName = cacheNames.runtime

//Configure the strategy for handling all requests based on the data object
const buildStrategy = (): Strategy => {

    //If race condition is set to true, begin a race condition between fetching from Network and Cache
    if (data.race) {
        class CacheNetworkRace extends Strategy {

            //Handle method for the race condition exists on the Strategy Calass
            _handle(request: Request, handler: StrategyHandler): Promise<Response | undefined> {
                const fetchAndCachePutDone: Promise<Response> = handler.fetchAndCachePut(request)
                const cacheMatchDone: Promise<Response | undefined> = handler.cacheMatch(request)

                //Return Promise with race conditions where the first to resolve wins
                return new Promise((resolve, reject) => {
                    fetchAndCachePutDone.then(resolve).catch((e) => {
                        if (data.debug)
                            console.log(`Cannot fetch resource: ${request.url}`, e)
                    })
                    cacheMatchDone.then(response => response && resolve(response))

                    // Reject if both network and cache error or find no response.
                    Promise.allSettled([fetchAndCachePutDone, cacheMatchDone]).then((results) => {
                        const [fetchAndCachePutResult, cacheMatchResult] = results
                        //fetchAndCachePutResult will be rejected if the network request fails and cacheMatchResult will be 
                        //undefined if the cache is empty since the cachematch promise has no way to be rejected
                        if (fetchAndCachePutResult.status === 'rejected' && cacheMatchResult.status !== 'fulfilled')
                            reject(fetchAndCachePutResult.reason)
                    })
                })

            }
        }
        return new CacheNetworkRace()
    }
    else {
        if (data.networkTimeoutSeconds > 0)
            return new NetworkFirst({ cacheName, networkTimeoutSeconds: data.networkTimeoutSeconds })
        else
            return new NetworkFirst({ cacheName })
    }
}

//Retrieve the manifest. First define asynchronus function to retrieve the manifest
// This is also required for the injection of the manifest into the service worker by workbox
// So despite it being outdate, Your application will not build without it
const manifest = self.__WB_MANIFEST as Array<ManifestEntry>

//Array for resources that have been cached by the service worker
const cacheEntries: RequestInfo[] = []

//Run through the manifest and cache all resources
const manifestURLs = manifest.map(
    (entry) => {
        //Create a new url using the service worker location and the manifest entry url
        const url = new URL(entry.url, self.location.href)

        cacheEntries.push(new Request(url.href, {
            credentials: data.credentials as any
        }))

        return url.href
    }
)

// Cache resources when the service worker is first installed
self.addEventListener('install', (event: ExtendableEvent) => {
    // The browser will wait until the promise is resolved
    event.waitUntil(
        // Open the cache and cache all the resources in it. This may include resources
        // that are not in the manifest
        caches.open(cacheName).then((cache) => {
            return cache.addAll(cacheEntries)
        })
    )
})

// Upon activating the service worker, clear outdated caches by removing caches associated with 
// URL resources that are not in the manifest URL array
self.addEventListener('activate', (event: ExtendableEvent) => {
    // - clean up outdated runtime cache
    event.waitUntil(
        caches.open(cacheName).then((cache) => {

            // clean up those who are not listed in manifestURLs since the manifestURLs are the only 
            // resources that are unlikely to be outdated
            cache.keys().then((keys) => {

                keys.forEach((request) => {

                    data.debug && console.log(`Checking cache entry to be removed: ${request.url}`)

                    //If the manifest does not include the request url, delete it from the cache
                    if (!manifestURLs.includes(request.url)) {

                        cache.delete(request).then((deleted) => {
                            if (data.debug) {
                                if (deleted)
                                    console.log(`Precached data removed: ${request.url || request}`)
                                else
                                    console.log(`No precache found: ${request.url || request}`)
                            }
                        })

                    }
                })
            })
        })
    )
})

//Register all URLs that are found in the manifest and use the buildStrategy function to cache them
registerRoute(
    ({ url }) => manifestURLs.includes(url.href),
    buildStrategy()
)

// Inform the service worker to send all routes that are not recognized to the network to be fetched
setDefaultHandler(new NetworkOnly())

// This method is called when the service worker is unable to fetch a resource from the network
setCatchHandler(
    ({ event }: any): Promise<Response> => {
        switch (event.request.destination) {
            case 'document':
                return caches.match(data.fallback).then((r) => {
                    return r ? Promise.resolve(r) : Promise.resolve(Response.error())
                })
            default:
                return Promise.resolve(Response.error())
        }
    }
)

// this is necessary, since the new service worker will keep on skipWaiting state
// and then, caches will not be cleared since it is not activated
self.skipWaiting()
clientsClaim()

//Firebase config. You can init here by pasting the details. Don't worry it's not a security risk
//as the config is used to connect to the firebase project for listening and not to access the project's admin console
const firebaseConfig = {
    apiKey: //Enter your API key here,
    authDomain: //Enter your auth domain,
    projectId: //Enter your project ID,
    storageBucket: //Enter your storage Bucket,
    messagingSenderId: //Enter the messaging sender Id,
    appId: //Enter your app id,
}

//Initialize Firebase and get the messaging module
const firebaseApp = initializeApp(firebaseConfig)
const messaging = getMessaging(firebaseApp)


// //Handle Background Firebase Messages that come in while the app is closed
onBackgroundMessage(messaging, (payload: any) => {
    console.log('Received background message ', payload)
})

And with that you should have the service worker being able to listen to your firebase app instance! If I've left anything feel free to leave a comment. If you don't understand anything, feel free to comment as well. Try to read the vite documentation. It may be a bit disjointed, but most of what you need is there.

All the best!!

Upvotes: 8

janechii
janechii

Reputation: 1193

Thanks for the detailed write up, I almost gave up on VitePWA before I read your question. There is another method that, to me, feels just right. Maybe the libraries has evolved since you've posted. The key here is explicitly telling Firebase to use existing (custom) Service Worker and ditching the firebase-messaging-sw.js convention:

VitePA Config

For VitePWA options of note, I set injectRegister to null because I will be manually registering my own custom service worker. I've also enabled dev mode and type of module to allow es6 imports. In production, VitePWA will convert this to classic for maximum compatibility.

  // vite.config.js
  plugins: [
    VitePWA({
      strategies: 'injectManifest',
      injectRegister: null,
      registerType: 'autoUpdate',
      devOptions: {
        enabled: true,
        type: 'module',
        navigateFallback: 'index.html'
      },
      workbox: {
        sourcemap: true
      }
    })
  ]

Register Service Worker and pass to Firebase

The registration part is documented in VitePWA Development page. Another thing to note here is the serviceWorkerRegistration option of Firebase's getToken. That is where you can tell Firebase to use an existing custom Service Worker.

    import { initializeApp } from "firebase/app";
    import { getMessaging, getToken, onMessage } from "firebase/messaging";
    
    const firebaseApp = initializeApp(<your-firebase-config>);
    const messaging = getMessaging(firebaseApp);

    if ("serviceWorker" in navigator) {
      navigator.serviceWorker
        .register(    
          import.meta.env.MODE === 'production' ? '/sw.js' : '/dev-sw.js?dev-sw',
          { type: import.meta.env.MODE === 'production' ? 'classic' : 'module' }
        )
        .then((registration) => {
          getToken(messaging, {
              vapidKey: '<your-vapidkey>',
              serviceWorkerRegistration : registration 
          })
            .then((currentToken) => {
              // do something
            });
         });
    }
 

Service Worker

Do stuff with your Service Worker with some Firebase bit in there too.

// public/sw.js

import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching';
import { clientsClaim } from 'workbox-core';
import { NavigationRoute, registerRoute } from 'workbox-routing';

import { initializeApp } from "firebase/app";
import { getMessaging } from "firebase/messaging/sw";

// self.__WB_MANIFEST is default injection point
precacheAndRoute(self.__WB_MANIFEST);

// clean old assets
cleanupOutdatedCaches();

let allowlist;
if (import.meta.env.DEV) {
  allowlist = [/^\/$/];
}

// to allow work offline
registerRoute(new NavigationRoute(
  createHandlerBoundToURL('index.html'),
  { allowlist },
));

const firebaseApp = initializeApp(<your-firebase-config>);

const messaging = getMessaging(firebaseApp);

self.skipWaiting();
clientsClaim();

You can run Vite the way you normally do in dev mode and your custom service worker should be working with Firebase Cloud Messaging.

I hope this helps someone. I'm at the beginning stages myself and will update if I come up with something better or run into more issues. But so far it's working great!

Upvotes: 10

dashb
dashb

Reputation: 157

I wanted two service workers on my project, one being the firebase-messaging-sw.js, it didn't play nice with VitePWA and I wanted to write them in typescript so I ended up making a plugin like you did to transpile them and put them into the dist/src dir and so they work with HMR and in development. Still not an amazing solution but it worked for me. https://github.com/x8BitRain/vite-plugin-ts-sw-hmr

As for the imports part, I ended up just using the following to import the necessary scripts to make it work instead of bundling local dependancies.

importScripts('https://www.gstatic.com/firebasejs/9.5.0/firebase-app-compat.js')
importScripts(
  'https://www.gstatic.com/firebasejs/9.5.0/firebase-messaging-compat.js'
)

Upvotes: 0

Related Questions