Skip to content

Instantly share code, notes, and snippets.

@claus
Last active October 15, 2019 13:47
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save claus/511786ddbbf26755d48358ef31c6ba14 to your computer and use it in GitHub Desktop.
Save claus/511786ddbbf26755d48358ef31c6ba14 to your computer and use it in GitHub Desktop.
Simple Webpack plugin to create Workbox based service workers for Next.js 9.1+ projects.

NextWorkboxPlugin

This is a simple Webpack plugin for Next.js to create Google Workbox based service workers.

I recently had to create a Next.js PWA with support for offline Google Analytics. The app uses a public folder (introduced in Next.js 9.1) so i thought it must be relatively easy to find a Webpack plugin that creates a service worker there. There is next-offline but it doesn't seem to support the public folder yet (doesn't pick up files for precaching and doesn't save the service worker there), the Workbox tools don't play well with the Next.js pipeline, and i haven't found anything else. So i wrote my own plugin.

It is really simple and somewhat hacky, and may fail for your use case. It neither supports dynamic routes nor API routes at this point. It may or may not work when deployed on Now. It's not very well tested. But maybe it's a good starting point for your own solution.

The nice thing about this is that you won't need a custom server because the service worker is served via the public folder (which is mapped to your app's root). This wasn't possible prior to Next.js 9.1.

Usage

Add the plugin to your next.config.js:

const NextWorkboxPlugin = require('./NextWorkboxPlugin');

module.exports = {
    webpack: (config, options) => {
        config.plugins.push(new NextWorkboxPlugin(options));
        return config;
    },
);

Now when you build your project, Webpack creates the service worker in ./public/sw.js.

Register the service worker in your project's _app.js:

componentDidMount() {
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('/sw.js');
    }
}
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const readdir = require('recursive-readdir');
const SW_NAME = 'sw.js';
const fileBlacklist = [
'sw.js',
'build-manifest.json',
'react-loadable-manifest.json',
];
class NextWorkboxPlugin {
constructor(options) {
this.options = options;
}
getPages() {
try {
const { dir, config, buildId } = this.options;
const dist = path.resolve(dir, config.distDir);
const buildManifestFile = path.resolve(dist, 'build-manifest.json');
const buildManifest = fs.readFileSync(buildManifestFile, {
encoding: 'utf8',
});
const buildManifestJson = JSON.parse(buildManifest);
const internals = ['/_app', '/_document', '/_error', '/index'];
return Object.keys(buildManifestJson.pages)
.filter(page => !internals.includes(page))
.map(page => {
return {
url: page,
revision: buildId,
};
});
} catch (err) {
console.error('Error getting pages');
console.error(err);
return [];
}
}
getPublicFiles() {
const publicDir = path.resolve(this.options.dir, 'public');
return readdir(publicDir, fileBlacklist)
.catch(err => {
console.error('Error reading public folder');
console.error(err);
return [];
})
.then(files =>
Promise.all(
files.map(
file =>
new Promise((resolve, reject) => {
const hash = crypto.createHash('sha1');
const rs = fs.createReadStream(file);
rs.on('error', reject);
rs.on('data', chunk => hash.update(chunk));
rs.on('end', () =>
resolve({
url: file
.replace(publicDir, '')
.replace(path.sep, '/'),
revision: hash.digest('hex'),
})
);
})
)
)
);
}
getCompilationAssets(compilation) {
return Object.keys(compilation.assets)
.filter(file => !fileBlacklist.includes(file))
.map(file => file.replace(path.sep, '/'))
.map(file => `/_next/${file}`);
}
writeServiceWorker(sw) {
const file = path.resolve(this.options.dir, 'public', SW_NAME);
fs.writeFileSync(file, sw);
}
apply(compiler) {
const { isServer, dev, buildId } = this.options;
if (isServer) {
return;
}
compiler.hooks.afterEmit.tapPromise('NextWorkboxPlugin', compilation => {
if (dev) {
const sw = `console.log('Service worker disabled in dev')`;
this.writeServiceWorker(sw);
return Promise.resolve();
}
const urlToString = url =>
typeof url === 'string'
? `'${url}'`
: `{ url: '${url.url}', revision: '${url.revision}' }`;
const flatten = arr =>
arr.reduce((acc, val) => acc.concat(val), []);
const sort = urls =>
urls.sort((url1, url2) => {
url1 = typeof url1 === 'string' ? url1 : url1.url;
url2 = typeof url2 === 'string' ? url2 : url2.url;
return url1.localeCompare(url2);
});
const createServiceWorker = urls => {
const sw =
'/* global importScripts, workbox */\n' +
"importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js');\n\n" +
'workbox.setConfig({\n' +
' debug: true,\n' +
'});\n\n' +
'workbox.precaching.precacheAndRoute([\n' +
urls.map(url => ' ' + urlToString(url)).join(',\n') +
'\n' +
']);\n\n' +
'workbox.googleAnalytics.initialize();\n';
this.writeServiceWorker(sw);
};
return Promise.all([
this.getPages(),
this.getPublicFiles(),
this.getCompilationAssets(compilation),
])
.then(flatten)
.then(sort)
.then(createServiceWorker)
.then(() => {
console.log(
'\x1b[32m%s\x1b[0m',
`\nService worker created at ./public/sw.js (buildId: "${buildId}").\n`
);
})
.catch(err => {
console.error(err);
});
});
}
}
module.exports = NextWorkboxPlugin;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment