feat(v2): Plugin for Offline/PWA support (#2205)

* implement PWA plugin

* added pwa support for docusaurus website

* moved sw registration to client module

* moved compile function to webpack util

* build sw using webpack and render pwa popup

* implement @theme/PwaReloadPopup

* update website sw to use modules

* updated pwa readme

* fix header lint errors

* apply code formatting

* cache files only for mobile, saveData, or installed pwa

* added comments about clearing registrations

* fixed prettier error

* updated pwa README

* fix README JS

* move /blog => /blog/index.html logic to else branch

* add `alwaysPrecache` option

* updated docusaurus-plugin-pwa version

* added pwa to using-plugins.md

* review fixes

* re-disable restricted-globals to use self in service worker

* useless doc

* Update packages/docusaurus-plugin-pwa/README.md

Co-authored-by: Reece Dunham <me@rdil.rocks>

* Update packages/docusaurus-plugin-pwa/README.md

* update a bit pwa doc + minor refactors

* minor refactors + add workbox debug mode

* env PWA_ prefix

* typo

* minor refactor

* fix file output

* add serve:v2:ssl yarn command

* minor pwa fixes

* typo

* add dynamic import comment in SW

* comment

* let the PWA plugin implement its reload popup on his own

* pwa: add Joi options validation

* pwa plugin should have its own webpack/babel custom setup

* PWA:
- debug logs
- better SW params system
- offline mode activation strategies
- docs

* add pwa install gif

* pwa: popup -> reloadPopup + minor refactors

* fix process.env reading + better debug log

* minor fixes

* minor changes

* minor changes

Co-authored-by: slorber <lorber.sebastien@gmail.com>
Co-authored-by: Sébastien Lorber <slorber@users.noreply.github.com>
Co-authored-by: Reece Dunham <me@rdil.rocks>
This commit is contained in:
Jeremy Asuncion 2020-07-08 03:32:41 -07:00 committed by GitHub
parent 46f794b2ba
commit 9b3da59886
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1508 additions and 45 deletions

View file

@ -0,0 +1,173 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const LogPlugin = require('@docusaurus/core/lib/webpack/plugins/LogPlugin');
const {compile} = require('@docusaurus/core/lib/webpack/utils');
const path = require('path');
const webpack = require('webpack');
const {injectManifest} = require('workbox-build');
const {PluginOptionSchema} = require('./pluginOptionSchema');
const Terser = require('terser-webpack-plugin');
const isProd = process.env.NODE_ENV === 'production';
function getSWBabelLoader() {
return {
loader: 'babel-loader',
options: {
babelrc: false,
configFile: false,
presets: [
[
require.resolve('@babel/preset-env'),
{
useBuiltIns: 'usage',
corejs: '2',
// See https://twitter.com/jeffposnick/status/1280223070876315649
targets: 'chrome >= 56',
},
],
],
plugins: [
require.resolve('@babel/plugin-proposal-object-rest-spread'),
require.resolve('@babel/plugin-proposal-optional-chaining'),
require.resolve('@babel/plugin-proposal-nullish-coalescing-operator'),
],
},
};
}
function plugin(context, options) {
const {outDir, baseUrl} = context;
const {
debug,
offlineModeActivationStrategies,
injectManifestConfig,
reloadPopup,
pwaHead,
swCustom,
swRegister,
} = options;
return {
name: 'docusaurus-plugin-pwa',
getThemePath() {
return path.resolve(__dirname, './theme');
},
getClientModules() {
return isProd ? [swRegister] : [];
},
configureWebpack(config) {
if (!isProd) {
return {};
}
return {
plugins: [
new webpack.EnvironmentPlugin({
PWA_DEBUG: debug,
PWA_SERVICE_WORKER_URL: path.resolve(
`${config.output.publicPath || '/'}`,
'sw.js',
),
PWA_OFFLINE_MODE_ACTIVATION_STRATEGIES: offlineModeActivationStrategies,
PWA_RELOAD_POPUP: reloadPopup,
}),
],
};
},
injectHtmlTags() {
const headTags = [];
if (isProd && pwaHead) {
pwaHead.forEach(({tagName, ...attributes}) =>
headTags.push({
tagName,
attributes,
}),
);
}
return {headTags};
},
async postBuild(props) {
if (!isProd) {
return;
}
const swSourceFileTest = /\.m?js$/;
const swWebpackConfig = {
entry: path.resolve(__dirname, 'sw.js'),
output: {
path: outDir,
filename: 'sw.js',
publicPath: baseUrl,
},
target: 'webworker',
mode: debug ? 'development' : 'production',
devtool: debug ? 'source-map' : false,
optimization: {
splitChunks: false,
minimize: !debug,
// see https://developers.google.com/web/tools/workbox/guides/using-bundlers#webpack
minimizer: [
!debug &&
new Terser({
test: swSourceFileTest,
}),
].filter(Boolean),
},
plugins: [
new webpack.EnvironmentPlugin({
PWA_SW_CUSTOM: swCustom,
}),
new LogPlugin({
name: 'Service Worker',
color: 'red',
}),
],
module: {
rules: [
{
test: swSourceFileTest,
exclude: /(node_modules)/,
use: getSWBabelLoader(),
},
],
},
};
await compile([swWebpackConfig]);
const swDest = path.resolve(props.outDir, 'sw.js');
await injectManifest({
...injectManifestConfig,
globPatterns: [
'**/*.{js,json,css,html}',
'**/*.{png,jpg,jpeg,gif,svg,ico}',
'**/*.{woff,woff2,eot,ttf,otf}',
...(injectManifest.globPatterns || []),
],
// those attributes are not overrideable
swDest,
swSrc: swDest,
globDirectory: props.outDir,
});
},
};
}
module.exports = plugin;
plugin.validateOptions = function validateOptions({validate, options}) {
return validate(PluginOptionSchema, options);
};

View file

@ -0,0 +1,43 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const Joi = require('@hapi/joi');
const path = require('path');
const DEFAULT_OPTIONS = {
debug: false,
offlineModeActivationStrategies: ['appInstalled', 'queryString'],
injectManifestConfig: {},
pwaHead: [],
swCustom: undefined,
swRegister: path.join(__dirname, 'registerSw.js'),
reloadPopup: '@theme/PwaReloadPopup',
};
exports.PluginOptionSchema = Joi.object({
debug: Joi.bool().default(DEFAULT_OPTIONS.debug),
offlineModeActivationStrategies: Joi.array()
.items(
Joi.string()
.valid('appInstalled', 'queryString', 'mobile', 'saveData', 'always')
.required(),
)
.default(DEFAULT_OPTIONS.offlineModeActivationStrategies),
injectManifestConfig: Joi.object().default(
DEFAULT_OPTIONS.injectManifestConfig,
),
pwaHead: Joi.array()
.items(Joi.object({tagName: Joi.string().required()}).unknown().required())
.default(DEFAULT_OPTIONS.pwaHead),
swCustom: Joi.string(),
swRegister: Joi.alternatives()
.try(Joi.string(), Joi.bool().valid(false))
.default(DEFAULT_OPTIONS.swRegister),
reloadPopup: Joi.alternatives()
.try(Joi.string(), Joi.bool().valid(false))
.default(DEFAULT_OPTIONS.reloadPopup),
});

View file

@ -0,0 +1,211 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
// First: read the env variables (provided by Webpack)
/* eslint-disable prefer-destructuring */
const PWA_SERVICE_WORKER_URL = process.env.PWA_SERVICE_WORKER_URL;
const PWA_RELOAD_POPUP = process.env.PWA_RELOAD_POPUP;
const PWA_OFFLINE_MODE_ACTIVATION_STRATEGIES =
process.env.PWA_OFFLINE_MODE_ACTIVATION_STRATEGIES;
const PWA_DEBUG = process.env.PWA_DEBUG;
/* eslint-enable prefer-destructuring */
const debug = PWA_DEBUG; // shortcut
async function clearRegistrations() {
const registrations = await navigator.serviceWorker.getRegistrations();
if (debug && registrations.length > 0) {
console.log(
`[Docusaurus-PWA][registerSW]: unregister service workers`,
registrations,
);
}
registrations.forEach((registration) => {
registration.registration.unregister();
});
window.location.reload();
}
const MAX_MOBILE_WIDTH = 940;
const APP_INSTALLED_KEY = 'docusaurus.pwa.appInstalled';
const OfflineModeActivationStrategiesImplementations = {
always: () => true,
mobile: () => window.innerWidth <= MAX_MOBILE_WIDTH,
saveData: () => !!(navigator.connection && navigator.connection.saveData),
appInstalled: () => !!localStorage.getItem(APP_INSTALLED_KEY),
queryString: () =>
new URLSearchParams(window.location.search).get('offlineMode') === 'true',
};
function isOfflineModeEnabled() {
const activeStrategies = PWA_OFFLINE_MODE_ACTIVATION_STRATEGIES.filter(
(strategyName) => {
const strategyImpl =
OfflineModeActivationStrategiesImplementations[strategyName];
return strategyImpl();
},
);
const enabled = activeStrategies.length > 0;
if (debug) {
const logObject = {
activeStrategies,
availableStrategies: PWA_OFFLINE_MODE_ACTIVATION_STRATEGIES,
};
if (enabled) {
console.log(
'[Docusaurus-PWA][registerSW]: offline mode enabled, because of activation strategies',
logObject,
);
} else {
console.log(
'[Docusaurus-PWA][registerSW]: offline mode disabled, because none of the offlineModeActivationStrategies could be used',
logObject,
);
}
}
return enabled;
}
function createServiceWorkerUrl(params) {
const paramsQueryString = JSON.stringify(params);
const url = `${PWA_SERVICE_WORKER_URL}?params=${encodeURIComponent(
paramsQueryString,
)}`;
if (debug) {
console.log(`[Docusaurus-PWA][registerSW]: service worker url`, {
url,
params,
});
}
return url;
}
(async () => {
if (typeof window === 'undefined') {
return;
}
if (debug) {
console.log('[Docusaurus-PWA][registerSW]: debug mode enabled');
}
if ('serviceWorker' in navigator) {
const {Workbox} = await import('workbox-window');
const offlineMode = isOfflineModeEnabled();
const url = createServiceWorkerUrl({offlineMode, debug});
const wb = new Workbox(url);
const registration = await wb.register();
const sendSkipWaiting = () => wb.messageSW({type: 'SKIP_WAITING'});
const handleServiceWorkerWaiting = async () => {
if (debug) {
console.log('[Docusaurus-PWA][registerSW]: handleServiceWorkerWaiting');
}
// Immediately load new service worker when files aren't cached
if (!offlineMode) {
sendSkipWaiting();
} else if (PWA_RELOAD_POPUP) {
const renderReloadPopup = (await import('./renderReloadPopup')).default;
renderReloadPopup({
onReload() {
wb.addEventListener('controlling', () => {
window.location.reload();
});
sendSkipWaiting();
},
});
}
};
if (debug) {
if (registration.active) {
console.log(
'[Docusaurus-PWA][registerSW]: registration.active',
registration,
);
}
if (registration.installing) {
console.log(
'[Docusaurus-PWA][registerSW]: registration.installing',
registration,
);
}
if (registration.waiting) {
console.log(
'[Docusaurus-PWA][registerSW]: registration.waiting',
registration,
);
}
}
// Update service worker if the next one is already in the waiting state.
// This happens when the user doesn't click on `reload` in the popup.
if (registration.waiting) {
handleServiceWorkerWaiting();
}
// Update the current service worker when the next one has finished
// installing and transitions to waiting state.
wb.addEventListener('waiting', (event) => {
if (debug) {
console.log('[Docusaurus-PWA][registerSW]: event waiting', event);
}
handleServiceWorkerWaiting();
});
// Update current service worker if the next one finishes installing and
// moves to waiting state in another tab.
wb.addEventListener('externalwaiting', (event) => {
if (debug) {
console.log(
'[Docusaurus-PWA][registerSW]: event externalwaiting',
event,
);
}
handleServiceWorkerWaiting();
});
window.addEventListener('appinstalled', (event) => {
if (debug) {
console.log('[Docusaurus-PWA][registerSW]: event appinstalled', event);
}
localStorage.setItem(APP_INSTALLED_KEY, true);
// After the app is installed, we register a service worker with the path
// `/sw?enabled`. Since the previous service worker was `/sw`, it'll be
// treated as a new one. The previous registration will need to be
// cleared, otherwise the reload popup will show.
clearRegistrations();
});
window.addEventListener('beforeinstallprompt', (event) => {
if (debug) {
console.log('[Docusaurus-PWA][registerSW]: event appinstalled', event);
}
// TODO instead of default browser install UI, show custom docusaurus prompt?
// event.preventDefault();
if (localStorage.getItem(APP_INSTALLED_KEY)) {
localStorage.removeItem(APP_INSTALLED_KEY);
// After uninstalling the app, if the user doesn't clear all data, then
// the previous service worker will continue serving cached files. We
// need to clear registrations and reload, otherwise the popup will show.
clearRegistrations();
}
});
} else if (debug) {
console.log(
'[Docusaurus-PWA][registerSW]: browser does not support service workers',
);
}
})();

View file

@ -0,0 +1,26 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import {render} from 'react-dom';
const POPUP_CONTAINER_ID = 'pwa-popup-container';
const getContainer = () => document.getElementById(POPUP_CONTAINER_ID);
const createContainer = () => {
const container = document.createElement('div');
container.id = POPUP_CONTAINER_ID;
document.body.appendChild(container);
return container;
};
export default async function renderReloadPopup(props) {
const container = getContainer() || createContainer();
const {default: ReloadPopup} = await import(process.env.PWA_RELOAD_POPUP);
render(<ReloadPopup {...props} />, container);
}

View file

@ -0,0 +1,120 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/* eslint-disable no-restricted-globals */
import {PrecacheController} from 'workbox-precaching';
function parseSwParams() {
const params = JSON.parse(
new URLSearchParams(self.location.search).get('params'),
);
if (params.debug) {
console.log('[Docusaurus-PWA][SW]: Service Worker params:', params);
}
return params;
}
// doc advise against dynamic imports in SW
// https://developers.google.com/web/tools/workbox/guides/using-bundlers#code_splitting_and_dynamic_imports
// https://twitter.com/sebastienlorber/status/1280155204575518720
// but I think it's working fine as it's inlined by webpack, need to double check?
async function runSWCustomCode(params) {
if (process.env.PWA_SW_CUSTOM) {
const customSW = await import(process.env.PWA_SW_CUSTOM);
if (typeof customSW.default === 'function') {
customSW.default(params);
} else if (params.debug) {
console.warn(
'[Docusaurus-PWA][SW]: swCustom should have a default export function',
);
}
}
}
/**
* Gets different possible variations for a request URL. Similar to
* https://git.io/JvixK
*
* @param {String} url
*/
function getPossibleURLs(url) {
const possibleURLs = [];
const urlObject = new URL(url, self.location.href);
if (urlObject.origin !== self.location.origin) {
return possibleURLs;
}
// Ignore search params and hash
urlObject.search = '';
urlObject.hash = '';
// /blog.html
possibleURLs.push(urlObject.href);
// /blog/ => /blog/index.html
if (urlObject.pathname.endsWith('/')) {
possibleURLs.push(`${urlObject.href}index.html`);
} else {
// /blog => /blog/index.html
possibleURLs.push(`${urlObject.href}/index.html`);
}
return possibleURLs;
}
(async () => {
const params = parseSwParams();
const precacheManifest = self.__WB_MANIFEST;
const controller = new PrecacheController();
if (params.offlineMode) {
controller.addToCacheList(precacheManifest);
}
await runSWCustomCode(params);
self.addEventListener('install', (event) => {
event.waitUntil(controller.install());
});
self.addEventListener('activate', (event) => {
event.waitUntil(controller.activate());
});
self.addEventListener('fetch', async (event) => {
if (params.offlineMode) {
const requestURL = event.request.url;
const possibleURLs = getPossibleURLs(requestURL);
for (let i = 0; i < possibleURLs.length; i += 1) {
const possibleURL = possibleURLs[i];
const cacheKey = controller.getCacheKeyForURL(possibleURL);
if (cacheKey) {
if (params.debug) {
console.log('[Docusaurus-PWA][SW]: serving cached asset', {
requestURL,
possibleURL,
possibleURLs,
cacheKey,
});
}
event.respondWith(caches.match(cacheKey));
break;
}
}
}
});
self.addEventListener('message', async (event) => {
const type = event.data && event.data.type;
if (type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
})();

View file

@ -0,0 +1,42 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useState} from 'react';
import clsx from 'clsx';
import styles from './styles.module.css';
export default function PwaReloadPopup({onReload}) {
const [isVisible, setIsVisible] = useState(true);
return (
isVisible && (
<div className={clsx('alert', 'alert--secondary', styles.popup)}>
<p>New version available</p>
<div className={styles.buttonContainer}>
<button
className="button button--link"
type="button"
onClick={() => {
setIsVisible(false);
onReload();
}}>
Refresh
</button>
<button
aria-label="Close"
className="close"
type="button"
onClick={() => setIsVisible(false)}>
<span aria-hidden="true">×</span>
</button>
</div>
</div>
)
);
}

View file

@ -0,0 +1,39 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.popup {
position: fixed;
bottom: 16px;
right: 16px;
width: 344px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 999;
}
.popup p {
margin: 0;
}
@media screen and (max-width: 500px) {
.popup {
width: 100%;
bottom: 0;
right: 0;
}
}
.buttonContainer {
display: flex;
align-items: center;
}
.buttonContainer :global(.close) {
margin: 0;
padding: 0;
}