PWA Kit – a starter kit to help turn your app into a Progressive Web App

PWA Kit is a starter kit designed to help Meteor developers make their apps offline capable and convert them into Progressive Web Apps (PWAs). This repository includes the essential files and instructions to get started. There are two service workers included in this kit that are made specifically for Meteor apps.

  1. sw.js - dependency free, vanilla js
  2. sw_workbox.js - uses Google Workbox. Includes an extra feature – expiration rules by content type (optional)

Pick one based on your preferences. :slight_smile: Check the config in the file for more info.

Both of them have these benefits:

  • Zero config with sensible defaults but can make changes easily
  • Optimized caching for Meteor’s bundles
  • Automatically remove old caches without you needing to think about it
  • Precache specific assets and an easy way to version them if needed with include
  • Ignore paths from being cached with exclude
  • Web push notification support (optional)
  • Support for external CDN usage (optional)
  • Dynamic import support when testing in dev (Meteor automatically handles this in prod)

If you have any ideas for improvements, let me know.

8 Likes

Actually this is something I’ve been thinking of lately. Meteor should definitely improve its defaults when it comes to PWAs. I’m thinking this should be merged into the core. The only think that’s stopping me is that Meteor already supports Cordova/Capacitor. Meteor should double down on something and go full steam instead of diffusing efforts. Nonetheless, great work. :clap:

cc @nachocodoner

Good addition to refresh SWs for Meteor users, offering a more updated option for SWs.

This is my take on Service Workers based on my experience. I always used a custom approach to handle Service Workers, like the one you’ve provided in the repo, to keep full control, well, mainly because the options weren’t so many for Meteor back then. But over time, as projects required more tweaks and advanced features, the SW file became harder to follow and maintain.

That, among other reasons, led me to delegate this part to more modern, maintained, and reliable solutions like workbox. These are used by many devs globally. The challenge is, integrating them with Meteor.js takes extra effort, while other bundlers provide this out of the box with plugins, fully maintained by a broader open community.

That’s why I started experimenting with modern bundlers alongside Meteor.js. My next focus was to offload repetitive tasks to better tools and open us up to more options.

With Workbox, in my case used via Webpack (but also available for Vite and others), I just configure the SW strategy I want. Here’s a code snippet from my app config of SWs:

const swConfig: WebpackPluginInstance = new GenerateSW({  
  swDest: 'sw.js',  
  include: ['/manifest-en.json', '/manifest-es.json'],  
  exclude: [/main\.html$/, /.*lingui_loader.*/],  
  excludeChunks: ['main'],  
  skipWaiting: true,  
  clientsClaim: true,  
  inlineWorkboxRuntime: true,  
  cleanupOutdatedCaches: true,  
  runtimeCaching: [  
    {  
      urlPattern: ({ url }) => ['/healthcheck'].includes(url.pathname),  
      handler: 'NetworkOnly',  
    },  
    {  
      urlPattern: ({ request }) => request.mode === 'navigate',  
      handler: 'NetworkFirst',  
      options: {  
        cacheName: 'pages',  
        matchOptions: { ignoreVary: true },  
      },  
    },  
    {  
      urlPattern: ({ request }) =>  
        request.destination === 'style' ||  
        request.destination === 'script' ||  
        request.destination === 'worker',  
      handler: 'StaleWhileRevalidate',  
      options: {  
        cacheName: 'assets',  
        cacheableResponse: {  
          statuses: [200],  
        },  
        matchOptions: { ignoreVary: true },  
      },  
    },  
    {  
      urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,  
      handler: 'CacheFirst',  
      options: {  
        expiration: { maxEntries: 30 },  
        cacheName: 'images',  
        matchOptions: { ignoreVary: true },  
      },  
    },  
    {  
      urlPattern: /\.md$/,  
      handler: 'CacheFirst',  
      options: {  
        expiration: { maxEntries: 15 },  
        cacheName: 'documents',  
        matchOptions: { ignoreVary: true },  
      },  
    },  
  ],  
});

With this setup, the sw.js is generated automatically and served in public folder, by describing the different strategies for offline support, cache resolution and other features. You can even generate it using a modern bundler and then just copy it into Meteor.js. But with modern bundlers, I don’t need to switch context or move files, everything stays in sync and easier to manage. I still get full control, but in a cleaner and more readable way than writing all logic manually and move files around.

Of course, each method has its pros and cons. Not sure if this is useful for others, but I’d like to support having this kind of flexibility as an option for Meteor users. Hopefully, with upcoming work on modern bundler integration, I can show more of it.

PD: We could build a Meteor plugin that hooks into the bundler lifecycle to integrate Workbox, but I’ve preferred to delegate that to a modern bundler with plugin support and active maintenance, which also unlocks other benefits from bundlers more easily.

4 Likes

@harry97: CapacitorJS and Service Workers have different roles. CapacitorJS is a native runtime bridge with full support for native platforms and browsers. Service Workers enable offline support, caching, and similar features. Working on both makes sense, but it’s important to understand the reasons behind it, what each one does, and especially how they work in practice. One approach can keep us confined to a custom, less standard setup; another pushes us to go further.

Capacitor enhances PWAs. Imagine a tool that not only lets you build native hybrid apps for Android and iOS, but also gives you access to native features like the camera, preferences, push notifications, barcode scanner, payments, and so much more. All of this with a fallback for web/PWA runtime, without needing to deal with any native or browser/PWA API to make it happen, and with proper support and up-to-date maintenance. So you can smoothly use the camera or access the gallery across devices and browsers, handle push notifications, and more, all from one codebase. That’s what Capacitor offers.

Unlike Cordova, Capacitor provides an unified and updated experience. This kind of tool could bring great value to MeteorJS, integrating something modern that simplifies native features and keeps compatibility across platforms, including desktop browsers. Plus, it comes from an open-source community just as committed as we are.

With this, I am more towards integrations that help Meteor stay standard, modern, and more connected with other open communities. Encouraging Meteor devs to rely more on external tools to reduce pressure and workload on the core team and community contributors.

1 Like

+1 with workbox. We spent less time creating our caching and routing strategies and zero time on browser changes and compatibilities.

@nachocodoner Awesome, thanks for sharing!

I think it would be great if Meteor had some out-of-the-box defaults / set up for PWAs. Ideally it would be almost zero config needed and not just be limited to caching but also support things like push notifications. That’s what the linked repo hopes to help out with.

At the end of the day, I think Meteor is well positioned to help with this. It could also go further to create an unmatched DX – no fussing around with a bunch of configs. My hunch is that there is a set of sensible defaults that rarely need to be tweaked.

Perhaps with the move to Vite this will be possible via a Meteor-maintained plugin. One of the many benefits moving to Vite will bring. :slight_smile: Or perhaps there’s an even simpler way to approach this.

Assuming Meteor will one day have built-in support for PWAs, one thing in particular that would be nice is if it automatically refreshed the cache for the js and css bundles only when absolutely needed to avoid the extra network request that comes with StaleWhileRevalidate. The sw.js in the repo I linked does this. It would also be ideal if it 1) cached dynamic imports in development like it does in production and 2) automatically hashed / versioned assets in /public.

I will say one nice thing about using vanilla js is you avoid needing to learn an abstraction on top of another abstraction. It’s a bit freeing in that respect. For example, I took a look at the Vite PWA plugin which is essentially an abstraction on top of Workbox, which is another abstraction on top of just using the fundamental building blocks. With regard to caching specifically, I do see the benefits of Workbox if you have a bunch of specific rules. Though, for Meteor apps, since the bundles are hashed, the rules become simpler.

As an aside, I thought it was odd that the Vite PWA plugin seems to offer either 1) generate one with a workbox config or 2) inject a custom service worker. So if you want to use a service worker for things beyond caching, e.g. web push notifications, then it seems at least you can no longer use the workbox config. So maybe it’s best to avoid the workbox config all together and always use a custom service worker even if that custom service worker is using workbox. :sweat_smile:

1 Like

Agree there is definite value in something like Capacitor. Curious, have you or others evaluated Tauri to see how it compares? On the surface it seemed like a promising solution.

You can always use multiple service workers. In my projects, I have no need for push notifications for “visitors”. Only logged-in users receive notifications, so I only load the push sw later in the flow.

This is a working sample of push notification sw based on Firebase:

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

self.addEventListener('notificationclick', function (event) {
  event.notification.close()
  const link = event.notification?.data?.FCM_MSG?.notification?.click_action
  event.waitUntil(
    clients.matchAll({ type: 'window' })
      .then(function (clientList) {
        for (let i = 0; i < clientList.length; i++) {
          const client = clientList[i]
          if (client.url === '/' && 'focus' in client) {
            return client.focus()
          }
        }
        if (clients.openWindow) {
          return clients.openWindow(link)
        }
      })
    // clients.openWindow(`${self.location.origin}${action}`);
  )
  console.log('From click_action', { link })
})

if (firebase?.messaging?.isSupported()) {
  firebase.initializeApp({ /* these keys are public by default */
    apiKey: 'xxxx',
    authDomain: 'xxxx',
    projectId: 'xxxx',
    storageBucket: 'xxxxx',
    messagingSenderId: 'xxxxx',
    appId: 'xxxxx'
  })
  // eslint-disable-next-line no-unused-vars
  const messaging = firebase.messaging()
}

And since this looks like the right place to put it, this is a production version of a sw customized for Meteor with optimized network strategies for caching and bundle versions management.

const debug = false
const OFFLINE_HTML = '/not-connected.html'
const MANIFEST = '/manifest-pwa.json'
const PROJECT_NAME = 'Padaboom'
const VERSION = 'v1'

/**
 * Web Worker Constants
 */
const BUNDLE_CACHE = `${PROJECT_NAME}-bundleCache-${VERSION}`
const PRECACHE_CACHE = `${PROJECT_NAME}-preCache-${VERSION}`
const ASSETS_CACHE = `${PROJECT_NAME}-assetsCache-${VERSION}`
const isBundleFile = str => { return /_resource=true|meteor_runtime_config/.test(str) }
// const isJSBundleFile = str => { return /_js_|meteor_runtime_config/.test(str) }
const filesToCacheOnInstall = [
  OFFLINE_HTML,
  MANIFEST,
  '/?homescreen=1'
]
// const assetsRegex = /container_d1ACpyxi.js/

const returnOffline = () => {
  return caches.open(PRECACHE_CACHE)
    .then(cache => {
      return cache
        .match(OFFLINE_HTML)
        .then(cached => cached)
        .catch(err => console.log('there was an error on catching the cache', err))
    })
}

const cacheFirstStrategyCaching = (isBundleFile, event) => {
  event.respondWith((async () => {
    try {
      const requestToFetch = event.request.clone()
      return caches.open(isBundleFile ? BUNDLE_CACHE : ASSETS_CACHE)
        .then(cache => {
          return cache
            .match(event.request.url)
            .then(cached => {
              if (cached) {
                if (debug) { console.info('I am returning the cached file: ', cached) }
                return cached
              }
              // if use bundle provided by Meteor server: `isBundleFile ? {} : { mode: 'cors' }`
              // if use bundle from CDN get it with mode cors: `{ mode: 'cors' }`
              return fetch(requestToFetch, { method: 'GET', mode: 'cors', headers: { 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'Origin' } }) // fetch(requestToFetch), without options, if you don't use external CDNs
                .then(response => {
                  if (debug) { console.log('What do I have in this response? ', response.clone()) }
                  const clonedResponse = response.clone()
                  if (response.clone().status === 200 || response.clone().status === 304) { // Only delete the old and cache the new one if we avail of the file.(other possibilities are to get a 404 and we don't want to cache that.)
                    if (debug) { console.log('I do have a status response 200 here') }
                    return caches.open(isBundleFile ? BUNDLE_CACHE : ASSETS_CACHE)
                      .then(cache => cache.keys()
                        .then(cacheNames => {
                          if (isBundleFile) {
                            const strings = ['js_resource=true', 'meteor_runtime_config', 'css_resource=true']
                            for (const e of strings) {
                              const jsb = cacheNames.find(j => j.url.includes(e))
                              if (event.request.url.includes(e) && event.request.url !== jsb?.url) {
                                cache.delete(jsb)
                              }
                            }
                          }
                        })
                        .then(
                          () => {
                            cache.put(response.url, clonedResponse)
                            if (debug) { console.info('Reached the end, it was a new Meteor build, old files deleted, new ones in place, returning response (new files)', response.clone()) }
                            return response.clone()
                          }
                        )
                      )
                  } else {
                    if (debug) { console.info('I should reach here when a bundle changed and I need to return the new bundle after I cached it)', response.clone()) }
                    return response.clone()
                  }
                })
            })
            .catch(e => console.log(e))
        })
    } catch (error) {
      console.log('Fetch failed; returning offline page instead.', error)
    }
  })())
}

/**
 * Web Workers Specific Listener: install
 */
self.addEventListener('install', e => {
  self.skipWaiting()
  e.waitUntil(
    caches.open(PRECACHE_CACHE)
      .then(cache => cache.addAll(filesToCacheOnInstall))
  )
})

/**
 * Web Workers Specific Listener: activate. If I updated the version of the Service Worker, everything will be recached.
 */
self.addEventListener('activate', e => {
  e.waitUntil(
    caches.keys().then(cacheNames =>
      // eslint-disable-next-line array-callback-return
      cacheNames.map(cacheName => {
        if ((cacheName.includes('bundleCache') && cacheName !== BUNDLE_CACHE) ||
          (cacheName.includes('preCache') && cacheName !== PRECACHE_CACHE) ||
          (cacheName.includes('assetsCache') && cacheName !== ASSETS_CACHE)) {
          return caches.delete(cacheName)
        }
      }))
  )
})

/**
 * Web Worker Specific Listener: fetch
 */
self.addEventListener('fetch', event => {
  self.clients.claim()

  // Bundle files JS and CSS management. If new names are detected while calling the bundle files from the cache, the olds files
  // are deleted and the new ones cached.
  if (isBundleFile(event.request.url)) {
    if (debug) { console.log('My event request url for BUNDLE file: ', event.request.url) }
    cacheFirstStrategyCaching(true, event)
  } else {
    // I only need to return this once if I need this exact path (but I only use it for offline PWA Lighthouse test)
    if (/\?homescreen/.test(event.request.url)) {
      event.respondWith(fetch(event.request.clone())
        // .then(response => response)
        .catch(error => {
          if (debug) { console.log('Failed on homescreen fetch: ', error) }
          return returnOffline()
        })
      )
    }

    // manifest-pwa.json is on a CacheFirst strategy. Fallback goes to network but that ideally should never happen .
    if (/manifest-pwa/.test(event.request.url)) {
      event.respondWith(async function () {
        const cachedResponse = await caches.open(PRECACHE_CACHE)
          .then(cache => {
            return cache
              .match(event.request.url)
              .then(cached => cached)
          })
        if (cachedResponse) { return cachedResponse }
        return fetch(event.request)
      }())
    }

    // cache all other assets.
    // const isAsset = assetsRegex.test(event.request.url)

    // DO NOT CACHE ASSETS, will be following the cache policy from the CDN
    /*
      if (isAsset) {
        cacheFirstStrategyCaching(false, event)
        return false
      }
  */
    // This is a NetworkFirst (or Network Only) strategy and is intended to let every traffic pass through unless handled by the above IF's
    // except the pre-cached files which I would prefer to have from the cache. Without these files, when offline, I cannot
    // route to where I need. This was implemented with React Router. If I don't ignore robots, sitemap etc the browser will
    // route to these locations instead of returning the files (those Routes do not exist e.g. www.website.com/robots.txt
    // but there is a public file there which is what I want.)
    if (event.request.mode === 'navigate' && !/robots|sitemap|\?homescreen|manifest-pwa|not-connected.html/.test(event.request.url) /* && !isAsset */) {
      const requestToFetch = event.request.clone()
      event.respondWith(
        fetch(requestToFetch)
          .then(response => {
            return caches.open(PRECACHE_CACHE)
              .then(cache => {
                return cache
                  .match(event.request.url)
                  .then(cached => {
                    return cached || response
                  })
              })
          })
          .catch(error => {
            if (debug) { console.log('I am probably offline. You can\'t see me because I run very fast in debug screen ', error) }
            return returnOffline() // no cache and no live return the offline page (Pure HTML)
          })
      )
    }
  }
})

2 Likes

For a nice DX in Meteor we should probably have Meteor packages for some of these PWA features: https://whatpwacando.today/
I have recently implemented this Web Authentication API - Web APIs | MDN and it’s been not so easy, especially because it can only be tested with SSL and valid public URLs. This also needs integration with the Accounts package or the Users collection.

Those 2 are not mutually exclusive. You can do custom handling while using workbox

Hmm, it seems like they are Getting Started | Workbox | Vite PWA

The choice becomes:

  1. generateSW — configure workbox inside the plugin config (similar to Nacho’s example)

  2. injectManifest — create your own service worker with more features beyond caching, e.g. push notifications.

You can use workbox inside your custom service worker if that’s what you mean. But what I meant is if I want push notifications and workbox for caching I have to use option 2. I can’t use option 1 and then add just the extra bits. At least as far as I can tell. If there is a way, let me know.

I misunderstood your post. My response was about service workers in general and not about Vite nor any plugins.

I made some big updates to not only the original service worker included in the repo but I also added a workbox-based service worker for those that prefer using it. Both options share a similar, simple config. I’ve updated the original post to reflect the new updates. Thanks to all who shared their thoughts and existing set ups.

Hope it’s helpful for those that don’t already have this stuff set up :slight_smile:

Check out the repo for more info.

2 Likes

Using a custom solution for more than just caching makes a lot of sense. I haven’t had the opportunity to dive into other features than caching yet. Typically, I rely on services to handle various notifications, but integrating the browser PWA with SWs is a clear use case. Workbox also provides ways to build on a baseline (injectManifest) or use a preconfigured approach (generateSW).

Once we have more options in the bundler, we can explore these ideas further and consider setting defaults in Meteor. Your efforts already show a lot of care. If you’re open to it, adding a PR in the community packages section of the docs would be really helpful for others.

2 Likes

I haven’t explored Tauri much. I know it’s mainly tailored towards desktop apps, which fits the idea of expanding Meteor’s multiplatform capabilities. I’m aware of some experimental native Android/iOS support, but I’m unsure of its current state and have little experience with integrating it into Meteor.

As a Meteor developer, CapacitorJS felt like a more natural path for me. Coming from a Cordova background, it offered retrocompatibility with Cordova plugins while improving maintenance and unifying Web/PWA and native deployments. The transition was smooth, and I’ve noticed the community creating tools to port apps to Electron or even Tauri for desktop. I’d love to get a better understanding of these tools for desktop anyway.

Have you tried Tauri? I’d appreciate any feedback you have.

1 Like

Haven’t tried it yet. Was hoping you or someone else had. :slight_smile:

I did notice the Tauri folks were in the Meteor discord a while back so I thought maybe something interesting was brewing there.

1 Like

We met with the Tauri team a few months ago, and they wanted to create official integrations and tutorials for using Meteor with Tauri. Their recent v2 release makes Tauri compatible with desktop (MacOS, Linux, Windows) and mobile (iOS, Android) platforms.

They also demonstrated Taurify, showing how a Meteor app could be integrated with Tauri in just a few minutes, and expressed interest in connecting this tool to Galaxy. However, due to their tight schedule, they postponed these tutorials and integration. I will contact them again to follow up.

They’ve shared this great intro video with us for those who don’t know Tauri yet.

2 Likes