Reputation: 7061
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.
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
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
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
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.
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.
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.
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
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:
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
}
})
]
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
});
});
}
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
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