/inject-manifest-plugin

Injects a Workbox PWA manifest into a Service Worker.

Primary LanguageTypeScript

inject-manifest-plugin

inject-manifest-plugin

Injects a Workbox PWA manifest into a Service Worker. Alternative for the InjectManifest plugin in the workbox-webpack-plugin.

  • Supports webpack and Rspack
  • Compatible with Google Workbox
  • Check out the demo or the papua PWA template
    • Get started by running npm init now papua ./my-pwa pwa
  • Replaces self.INJECT_MANIFEST_PLUGIN variable in a service-worker.[jt]s file with a { url: string; revision: string (hash) } array.

service-worker File

To get started add a service-worker.js or service-worker.ts file to the root of the project and install the necessary dependencies with npm install url-join workbox-core workbox-expiration workbox-precaching workbox-routing workbox-strategies.

import join from 'url-join'
import { clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate } from 'workbox-strategies'

clientsClaim() // Allows updating open service workers.

// Add types for the plugin and workbox.
declare global {
  interface Window {
    INJECT_MANIFEST_PLUGIN: { url: string; revision: string }[]
    skipWaiting: Function
  }
}

// Add all assets generated during build to the browser cache.
precacheAndRoute(self.INJECT_MANIFEST_PLUGIN)

const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
registerRoute(
  // Return false to exempt requests from being fulfilled by index.html.
  ({ request, url }) => {
    // If this isn't a navigation, skip.
    if (request.mode !== 'navigate') {
      return false
    } // If this is a URL that starts with /_, skip.

    if (url.pathname.startsWith('/_')) {
      return false
    } // If this looks like a URL for a resource, because it contains // a file extension, skip.

    if (url.pathname.match(fileExtensionRegexp)) {
      return false
    } // Return true to signal that we want to use the handler.

    return true
  },
  createHandlerBoundToURL(join(process.env.PUBLIC_URL as string, '/index.html'))
)

// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
  // Add in any other file extensions or routing criteria as needed.
  ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst.
  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [
      // Ensure that once this runtime cache reaches a maximum size the
      // least-recently used images are removed.
      new ExpirationPlugin({ maxEntries: 50 }),
    ],
  })
)

// Update cached assets after reload without the need for the user to close all open tabs.
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting()
  }
})

Service Worker Registration

The service-worker.js asset created during the build needs to be dynamically loaded and registered from any regular JavaScript entry.

import join from 'url-join'

const store = {
  ready: false,
  update: false,
  error: false,
  offline: false
}

const isLocalhost = Boolean(
  window.location.hostname === 'localhost' ||
    // [::1] is the IPv6 localhost address.
    window.location.hostname === '[::1]' ||
    // 127.0.0.0/8 are considered localhost for IPv4.
    window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
)

function registerValidSW(swUrl: string) {
  navigator.serviceWorker
    .register(swUrl)
    .then((registration) => {
      store.ready = true
      registration.onupdatefound = () => {
        const installingWorker = registration.installing
        if (installingWorker == null) {
          return
        }
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // Force contents to update on reload.
              if (registration && registration.waiting) {
                registration.waiting.postMessage({ type: 'SKIP_WAITING' })
              }
              // Timeout to ensure message passed.
              setTimeout(() => { store.update = true }, 100)
            }
          }
        }
      }
    })
    .catch(() => { error = true })
}

function checkValidServiceWorker(swUrl: string) {
  fetch(swUrl, {
    headers: { 'Service-Worker': 'script' },
  })
    .then((response) => {
      const contentType = response.headers.get('content-type')
      if (
        response.status === 404 ||
        (contentType != null && contentType.indexOf('javascript') === -1)
      ) {
        navigator.serviceWorker.ready.then((registration) => {
          registration.unregister().then(() => {
            window.location.reload()
          })
        })
      } else {
        registerValidSW(swUrl)
      }
    })
    .catch(() => { store.offline = true })
}

export function register() {
  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) {
      store.error = true
      return
    }

    window.addEventListener('load', () => {
      const swUrl = join(process.env.PUBLIC_URL, '/service-worker.js')

      if (isLocalhost) {
        checkValidServiceWorker(swUrl)
        navigator.serviceWorker.ready.then(() => Todo.setReady())
      } else {
        registerValidSW(swUrl)
      }
    })
  }
}

// Useful if you had a worker registered in the past on this url.
export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready
      .then((registration) => {
        registration.unregister()
      })
      .catch((error) => { store.error = error.message })
  }
}

Call the register() method from anywhere:

import { register } from './registration'

createRoot(document.body).render(<p>my App!</p>)

register()

Plugin Usage in webpack.config.mjs / rspack.config.mjs

When used in a project with webpack-cli or @rspack/cli make sure that the package.json contains "type": "module" or the configuration file ends with .mjs. Both the plugin as well as the registration require the process.env.PUBLIC_URL variable to be set.

import { InjectManifestPlugin } from 'inject-manifest-plugin'

export default {
  plugins: [new InjectManifestPlugin()],
}

Configuration

new InjectManifestPlugin({
  file: 'my-worker.js', // Default: service-worker.js
  injectionPoint: 'global.replace-this', // Default: self.INJECT_MANIFEST_PLUGIN
  exclude: ['extension/*'], // Default: []
  removeHash: true, // Default: false
  chunkFilename: 'worker': // Default: service-worker
})

file should point to a Service Worker file in the project and will automatically be added as an entry. The Service Worker entry chunk will be called service-worker. The injectionPoint can be any text that will be replaced in the Service Worker code with the generated manifest. Using the exclude array it's possible to keep some assets from appearing in the manifest to avoid caching in the Service Worker. The array can include globs and items are matched against the generated assets using minimatch. With the removeHash option it's possible to ensure the name of the generated Service Worker asset matches the input file. A Service Worker cannot change it's name once registered, therefore it's important that no hash is added. Since, this option uses a workaround, it's generally recommended to avoid hashing any JavaScript assets as the Service Worker will usually ensure well enough that all assets are up-to-date. The chunkFilename names the injected Service Worker chunk.

Programmatic Usage

import webpack from 'webpack'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import { InjectManifestPlugin } from 'inject-manifest-plugin'

const configuration: webpack.Configuration = {
  plugins: [
    new InjectManifestPlugin(),
    new HtmlWebpackPlugin()
  ],
}

// When using programmatic API, otherwise place configuration in webpack.config.js.
webpack(configuration, (error, stats) => { ... })
import { Configuration, rspack } from '@rspack/core'
import { InjectManifestPlugin } from 'inject-manifest-plugin'

const configuration: Configuration = {
  builtins: {
    html: [{}], // Empty object creates a default html template (index.html).
  },
  plugins: [new InjectManifestPlugin()],
}

// When using programmatic API, otherwise place configuration in rspack.config.js.
rspack(configuration, (error, stats) => { ... })