From 71f893cb44dd577c3ab3c9980a64b84910cf90bc Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Fri, 9 Nov 2018 09:13:14 -0800 Subject: [PATCH] Enhanced offline (#249) * Notification of updates & reloading * Using version in service worker & allowing version to appear elsewhere * Stupid file * Ditching changelog for now. Using package json. * Ugh. --- config/auto-sw-plugin.js | 4 +- package-lock.json | 8 +-- package.json | 3 +- src/components/App/index.tsx | 16 ++++-- src/components/compress/index.tsx | 18 +----- src/lib/SnackBar/index.ts | 5 +- src/lib/offliner.ts | 91 +++++++++++++++++++++++++++++++ src/missing-types.d.ts | 2 + src/sw/index.ts | 14 +++-- src/sw/util.ts | 2 + webpack.config.js | 25 ++------- 11 files changed, 129 insertions(+), 59 deletions(-) create mode 100644 src/lib/offliner.ts diff --git a/config/auto-sw-plugin.js b/config/auto-sw-plugin.js index d00c5ea1..5223f254 100644 --- a/config/auto-sw-plugin.js +++ b/config/auto-sw-plugin.js @@ -144,8 +144,10 @@ module.exports = class AutoSWPlugin { await (util.promisify(childCompiler.runAsChild.bind(childCompiler)))(); + const versionVar = this.options.version ? + `var VERSION = ${JSON.stringify(this.options.version)};` : ''; const original = childCompilation.assets[workerOptions.filename].source(); - const source = `var BUILD_ASSETS=${JSON.stringify(assetMapping)};\n${original}`; + const source = `${versionVar}var BUILD_ASSETS=${JSON.stringify(assetMapping)};${original}`; childCompilation.assets[workerOptions.filename] = { source: () => source, size: () => Buffer.byteLength(source, 'utf8') diff --git a/package-lock.json b/package-lock.json index aede524f..6802e256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "squoosh", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -14245,12 +14245,6 @@ "uuid": "^3.3.2" } }, - "webpack-plugin-replace": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/webpack-plugin-replace/-/webpack-plugin-replace-1.1.1.tgz", - "integrity": "sha1-mBZkf+/Jin0XAPk/K0tvSBzyAWE=", - "dev": true - }, "webpack-sources": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.2.0.tgz", diff --git a/package.json b/package.json index 65fa0fe0..670121e3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "squoosh", - "version": "0.0.0", + "version": "0.1.0", "license": "apache-2.0", "scripts": { "start": "webpack serve --host 0.0.0.0 --hot", @@ -71,7 +71,6 @@ "webpack-bundle-analyzer": "^2.13.1", "webpack-cli": "^2.1.5", "webpack-dev-server": "^3.1.5", - "webpack-plugin-replace": "^1.1.1", "worker-plugin": "^1.1.1" } } diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 4c7889cd..794ecb0c 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -12,6 +12,15 @@ import '../custom-els/LoadingSpinner'; // This is imported for TypeScript only. It isn't used. import Compress from '../compress'; +const compressPromise = import( + /* webpackChunkName: "main-app" */ + '../compress', +); +const offlinerPromise = import( + /* webpackChunkName: "offliner" */ + '../../lib/offliner', +); + export interface SourceImage { file: File | Fileish; data: ImageData; @@ -36,16 +45,13 @@ export default class App extends Component { constructor() { super(); - import( - /* webpackChunkName: "main-app" */ - '../compress', - ).then((module) => { + compressPromise.then((module) => { this.setState({ Compress: module.default }); }).catch(() => { this.showSnack('Failed to load app'); }); - navigator.serviceWorker.register('../../sw'); + offlinerPromise.then(({ offliner }) => offliner(this.showSnack)); // In development, persist application state across hot reloads: if (process.env.NODE_ENV === 'development') { diff --git a/src/components/compress/index.tsx b/src/components/compress/index.tsx index 62948abd..b3046511 100644 --- a/src/components/compress/index.tsx +++ b/src/components/compress/index.tsx @@ -1,5 +1,4 @@ import { h, Component } from 'preact'; -import { get, set } from 'idb-keyval'; import { bind, Fileish } from '../../lib/initial-util'; import { blobToImg, drawableToImageData, blobToText } from '../../lib/util'; @@ -156,12 +155,6 @@ async function processSvg(blob: Blob): Promise { return blobToImg(new Blob([newSource], { type: 'image/svg+xml' })); } -async function getMostActiveServiceWorker() { - const reg = await navigator.serviceWorker.getRegistration(); - if (!reg) return null; - return reg.active || reg.waiting || reg.installing; -} - // These are only used in the mobile view const resultTitles = ['Top', 'Bottom']; // These are only used in the desktop view @@ -203,16 +196,7 @@ export default class Compress extends Component { this.widthQuery.addListener(this.onMobileWidthChange); this.updateFile(props.file); - // If this is the first time the user has interacted with the app, tell the service worker to - // cache all the codecs. - get('user-interacted') - .then(async (userInteracted: boolean | undefined) => { - if (userInteracted) return; - set('user-interacted', true); - const serviceWorker = await getMostActiveServiceWorker(); - if (!serviceWorker) return; // Service worker not installing yet. - serviceWorker.postMessage('cache-all'); - }); + import('../../lib/offliner').then(({ mainAppLoaded }) => mainAppLoaded()); } @bind diff --git a/src/lib/SnackBar/index.ts b/src/lib/SnackBar/index.ts index 8e55eabc..e790d7ab 100644 --- a/src/lib/SnackBar/index.ts +++ b/src/lib/SnackBar/index.ts @@ -8,12 +8,9 @@ export interface SnackOptions { function createSnack(message: string, options: SnackOptions): [Element, Promise] { const { timeout = 0, - actions = [], + actions = ['dismiss'], } = options; - // Provide a default 'dismiss' action - if (!timeout && actions.length === 0) actions.push('dismiss'); - const el = document.createElement('div'); el.className = style.snackbar; el.setAttribute('aria-live', 'assertive'); diff --git a/src/lib/offliner.ts b/src/lib/offliner.ts new file mode 100644 index 00000000..040a9891 --- /dev/null +++ b/src/lib/offliner.ts @@ -0,0 +1,91 @@ +import { get, set } from 'idb-keyval'; + +// Just for TypeScript +import SnackBarElement from './SnackBar'; + +/** Tell the service worker to skip waiting */ +async function skipWaiting() { + const reg = await navigator.serviceWorker.getRegistration(); + if (!reg || !reg.waiting) return; + reg.waiting.postMessage('skip-waiting'); +} + +/** Find the service worker that's 'active' or closest to 'active' */ +async function getMostActiveServiceWorker() { + const reg = await navigator.serviceWorker.getRegistration(); + if (!reg) return null; + return reg.active || reg.waiting || reg.installing; +} + +/** Wait for an installing worker */ +async function installingWorker(reg: ServiceWorkerRegistration): Promise { + if (reg.installing) return reg.installing; + return new Promise((resolve) => { + reg.addEventListener( + 'updatefound', + () => resolve(reg.installing!), + { once: true }, + ); + }); +} + +/** Wait a service worker to become waiting */ +async function updateReady(reg: ServiceWorkerRegistration): Promise { + if (reg.waiting) return; + const installing = await installingWorker(reg); + return new Promise((resolve) => { + installing.addEventListener('statechange', () => { + if (installing.state === 'installed') resolve(); + }); + }); +} + +/** Set up the service worker and monitor changes */ +export async function offliner(showSnack: SnackBarElement['showSnackbar']) { + if (process.env.NODE_ENV === 'production') { + navigator.serviceWorker.register('../sw'); + } + + const hasController = !!navigator.serviceWorker.controller; + + // Look for changes in the controller + navigator.serviceWorker.addEventListener('controllerchange', async () => { + // Is it the first install? + if (!hasController) { + showSnack('Ready to work offline', { timeout: 5000 }); + return; + } + + // Otherwise reload (the user will have agreed to this). + location.reload(); + }); + + const reg = await navigator.serviceWorker.getRegistration(); + // Service worker not registered yet. + if (!reg) return; + // Look for updates + await updateReady(reg); + + // Ask the user if they want to update. + const result = await showSnack('Update available', { + actions: ['reload', 'dismiss'], + }); + + // Tell the waiting worker to activate, this will change the controller and cause a reload (see + // 'controllerchange') + if (result === 'reload') skipWaiting(); +} + +/** + * Tell the service worker the main app has loaded. If it's the first time the service worker has + * heard about this, cache the heavier assets like codecs. + */ +export async function mainAppLoaded() { + // If the user has already interacted, no need to tell the service worker anything. + const userInteracted = await get('user-interacted'); + if (userInteracted) return; + set('user-interacted', true); + const serviceWorker = await getMostActiveServiceWorker(); + if (!serviceWorker) return; // Service worker not installing yet. + serviceWorker.postMessage('cache-all'); +} diff --git a/src/missing-types.d.ts b/src/missing-types.d.ts index 491ab790..7f18e87e 100644 --- a/src/missing-types.d.ts +++ b/src/missing-types.d.ts @@ -32,3 +32,5 @@ declare module 'url-loader!*' { const value: string; export default value; } + +declare var VERSION: string; diff --git a/src/sw/index.ts b/src/sw/index.ts index 310d2c84..d834bdda 100644 --- a/src/sw/index.ts +++ b/src/sw/index.ts @@ -8,8 +8,7 @@ declare var self: ServiceWorkerGlobalScope; // This is populated by webpack. declare var BUILD_ASSETS: string[]; -const version = '1.0.0'; -const versionedCache = 'static-' + version; +const versionedCache = 'static-' + VERSION; const dynamicCache = 'dynamic'; const expectedCaches = [versionedCache, dynamicCache]; @@ -28,6 +27,8 @@ self.addEventListener('install', (event) => { }); self.addEventListener('activate', (event) => { + self.clients.claim(); + event.waitUntil(async function () { // Remove old caches. const promises = (await caches.keys()).map((cacheName) => { @@ -57,7 +58,12 @@ self.addEventListener('fetch', (event) => { }); self.addEventListener('message', (event) => { - if (event.data === 'cache-all') { - event.waitUntil(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS)); + switch (event.data) { + case 'cache-all': + event.waitUntil(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS)); + break; + case 'skip-waiting': + self.skipWaiting(); + break; } }); diff --git a/src/sw/util.ts b/src/sw/util.ts index 40e2fcc9..6d922c86 100644 --- a/src/sw/util.ts +++ b/src/sw/util.ts @@ -60,6 +60,8 @@ export async function cacheBasics(cacheName: string, buildAssets: string[]) { 'first-interaction.', // Main app JS & CSS: 'main-app.', + // Service worker handler: + 'offliner.', // Little icons for the demo images on the homescreen: 'icon-demo-', // Site logo: diff --git a/webpack.config.js b/webpack.config.js index fb2f611e..f4e7a717 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,7 +8,6 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const HtmlPlugin = require('html-webpack-plugin'); const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin'); -const ReplacePlugin = require('webpack-plugin-replace'); const CopyPlugin = require('copy-webpack-plugin'); const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; @@ -20,6 +19,8 @@ function readJson (filename) { return JSON.parse(fs.readFileSync(filename)); } +const VERSION = readJson('./package.json').version; + module.exports = function (_, env) { const isProd = env.mode === 'production'; const nodeModules = path.join(__dirname, 'node_modules'); @@ -142,12 +143,6 @@ module.exports = function (_, env) { exclude: nodeModules, loader: 'ts-loader' }, - { - test: /\.jsx?$/, - loader: 'babel-loader', - // Don't respect any Babel RC files found on the filesystem: - options: Object.assign(readJson('.babelrc'), { babelrc: false }) - }, { // All the codec files define a global with the same name as their file name. `exports-loader` attaches those to `module.exports`. test: /\/codecs\/.*\.js$/, @@ -243,7 +238,9 @@ module.exports = function (_, env) { compile: true }), - new AutoSWPlugin({}), + new AutoSWPlugin({ + version: VERSION + }), new ScriptExtHtmlPlugin({ inline: ['first'] @@ -251,22 +248,12 @@ module.exports = function (_, env) { // Inline constants during build, so they can be folded by UglifyJS. new webpack.DefinePlugin({ + VERSION: JSON.stringify(VERSION), // We set node.process=false later in this config. // Here we make sure if (process && process.foo) still works: process: '{}' }), - // Babel embeds helpful error messages into transpiled classes that we don't need in production. - // Here we replace the constructor and message with a static throw, leaving the message to be DCE'd. - // This is useful since it shows the message in SourceMapped code when debugging. - isProd && new ReplacePlugin({ - include: /babel-helper$/, - patterns: [{ - regex: /throw\s+(?:new\s+)?((?:Type|Reference)?Error)\s*\(/g, - value: (s, type) => `throw 'babel error'; (` - }] - }), - // Copying files via Webpack allows them to be served dynamically by `webpack serve` new CopyPlugin([ { from: 'src/manifest.json', to: 'manifest.json' },