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.
This commit is contained in:
Jake Archibald
2018-11-09 09:13:14 -08:00
committed by GitHub
parent 6b76ea0a6f
commit 71f893cb44
11 changed files with 129 additions and 59 deletions

View File

@@ -144,8 +144,10 @@ module.exports = class AutoSWPlugin {
await (util.promisify(childCompiler.runAsChild.bind(childCompiler)))(); 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 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] = { childCompilation.assets[workerOptions.filename] = {
source: () => source, source: () => source,
size: () => Buffer.byteLength(source, 'utf8') size: () => Buffer.byteLength(source, 'utf8')

8
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "squoosh", "name": "squoosh",
"version": "0.0.0", "version": "0.1.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -14245,12 +14245,6 @@
"uuid": "^3.3.2" "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": { "webpack-sources": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.2.0.tgz", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.2.0.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "squoosh", "name": "squoosh",
"version": "0.0.0", "version": "0.1.0",
"license": "apache-2.0", "license": "apache-2.0",
"scripts": { "scripts": {
"start": "webpack serve --host 0.0.0.0 --hot", "start": "webpack serve --host 0.0.0.0 --hot",
@@ -71,7 +71,6 @@
"webpack-bundle-analyzer": "^2.13.1", "webpack-bundle-analyzer": "^2.13.1",
"webpack-cli": "^2.1.5", "webpack-cli": "^2.1.5",
"webpack-dev-server": "^3.1.5", "webpack-dev-server": "^3.1.5",
"webpack-plugin-replace": "^1.1.1",
"worker-plugin": "^1.1.1" "worker-plugin": "^1.1.1"
} }
} }

View File

@@ -12,6 +12,15 @@ import '../custom-els/LoadingSpinner';
// This is imported for TypeScript only. It isn't used. // This is imported for TypeScript only. It isn't used.
import Compress from '../compress'; import Compress from '../compress';
const compressPromise = import(
/* webpackChunkName: "main-app" */
'../compress',
);
const offlinerPromise = import(
/* webpackChunkName: "offliner" */
'../../lib/offliner',
);
export interface SourceImage { export interface SourceImage {
file: File | Fileish; file: File | Fileish;
data: ImageData; data: ImageData;
@@ -36,16 +45,13 @@ export default class App extends Component<Props, State> {
constructor() { constructor() {
super(); super();
import( compressPromise.then((module) => {
/* webpackChunkName: "main-app" */
'../compress',
).then((module) => {
this.setState({ Compress: module.default }); this.setState({ Compress: module.default });
}).catch(() => { }).catch(() => {
this.showSnack('Failed to load app'); this.showSnack('Failed to load app');
}); });
navigator.serviceWorker.register('../../sw'); offlinerPromise.then(({ offliner }) => offliner(this.showSnack));
// In development, persist application state across hot reloads: // In development, persist application state across hot reloads:
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {

View File

@@ -1,5 +1,4 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { get, set } from 'idb-keyval';
import { bind, Fileish } from '../../lib/initial-util'; import { bind, Fileish } from '../../lib/initial-util';
import { blobToImg, drawableToImageData, blobToText } from '../../lib/util'; import { blobToImg, drawableToImageData, blobToText } from '../../lib/util';
@@ -156,12 +155,6 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
return blobToImg(new Blob([newSource], { type: 'image/svg+xml' })); 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 // These are only used in the mobile view
const resultTitles = ['Top', 'Bottom']; const resultTitles = ['Top', 'Bottom'];
// These are only used in the desktop view // These are only used in the desktop view
@@ -203,16 +196,7 @@ export default class Compress extends Component<Props, State> {
this.widthQuery.addListener(this.onMobileWidthChange); this.widthQuery.addListener(this.onMobileWidthChange);
this.updateFile(props.file); this.updateFile(props.file);
// If this is the first time the user has interacted with the app, tell the service worker to import('../../lib/offliner').then(({ mainAppLoaded }) => mainAppLoaded());
// cache all the codecs.
get<boolean | undefined>('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');
});
} }
@bind @bind

View File

@@ -8,12 +8,9 @@ export interface SnackOptions {
function createSnack(message: string, options: SnackOptions): [Element, Promise<string>] { function createSnack(message: string, options: SnackOptions): [Element, Promise<string>] {
const { const {
timeout = 0, timeout = 0,
actions = [], actions = ['dismiss'],
} = options; } = options;
// Provide a default 'dismiss' action
if (!timeout && actions.length === 0) actions.push('dismiss');
const el = document.createElement('div'); const el = document.createElement('div');
el.className = style.snackbar; el.className = style.snackbar;
el.setAttribute('aria-live', 'assertive'); el.setAttribute('aria-live', 'assertive');

91
src/lib/offliner.ts Normal file
View File

@@ -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<ServiceWorker> {
if (reg.installing) return reg.installing;
return new Promise<ServiceWorker>((resolve) => {
reg.addEventListener(
'updatefound',
() => resolve(reg.installing!),
{ once: true },
);
});
}
/** Wait a service worker to become waiting */
async function updateReady(reg: ServiceWorkerRegistration): Promise<void> {
if (reg.waiting) return;
const installing = await installingWorker(reg);
return new Promise<void>((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<boolean | undefined>('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');
}

View File

@@ -32,3 +32,5 @@ declare module 'url-loader!*' {
const value: string; const value: string;
export default value; export default value;
} }
declare var VERSION: string;

View File

@@ -8,8 +8,7 @@ declare var self: ServiceWorkerGlobalScope;
// This is populated by webpack. // This is populated by webpack.
declare var BUILD_ASSETS: string[]; declare var BUILD_ASSETS: string[];
const version = '1.0.0'; const versionedCache = 'static-' + VERSION;
const versionedCache = 'static-' + version;
const dynamicCache = 'dynamic'; const dynamicCache = 'dynamic';
const expectedCaches = [versionedCache, dynamicCache]; const expectedCaches = [versionedCache, dynamicCache];
@@ -28,6 +27,8 @@ self.addEventListener('install', (event) => {
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
self.clients.claim();
event.waitUntil(async function () { event.waitUntil(async function () {
// Remove old caches. // Remove old caches.
const promises = (await caches.keys()).map((cacheName) => { const promises = (await caches.keys()).map((cacheName) => {
@@ -57,7 +58,12 @@ self.addEventListener('fetch', (event) => {
}); });
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
if (event.data === 'cache-all') { switch (event.data) {
case 'cache-all':
event.waitUntil(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS)); event.waitUntil(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS));
break;
case 'skip-waiting':
self.skipWaiting();
break;
} }
}); });

View File

@@ -60,6 +60,8 @@ export async function cacheBasics(cacheName: string, buildAssets: string[]) {
'first-interaction.', 'first-interaction.',
// Main app JS & CSS: // Main app JS & CSS:
'main-app.', 'main-app.',
// Service worker handler:
'offliner.',
// Little icons for the demo images on the homescreen: // Little icons for the demo images on the homescreen:
'icon-demo-', 'icon-demo-',
// Site logo: // Site logo:

View File

@@ -8,7 +8,6 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlPlugin = require('html-webpack-plugin'); const HtmlPlugin = require('html-webpack-plugin');
const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin'); const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin');
const ReplacePlugin = require('webpack-plugin-replace');
const CopyPlugin = require('copy-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin');
const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin'); const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
@@ -20,6 +19,8 @@ function readJson (filename) {
return JSON.parse(fs.readFileSync(filename)); return JSON.parse(fs.readFileSync(filename));
} }
const VERSION = readJson('./package.json').version;
module.exports = function (_, env) { module.exports = function (_, env) {
const isProd = env.mode === 'production'; const isProd = env.mode === 'production';
const nodeModules = path.join(__dirname, 'node_modules'); const nodeModules = path.join(__dirname, 'node_modules');
@@ -142,12 +143,6 @@ module.exports = function (_, env) {
exclude: nodeModules, exclude: nodeModules,
loader: 'ts-loader' 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`. // 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$/, test: /\/codecs\/.*\.js$/,
@@ -243,7 +238,9 @@ module.exports = function (_, env) {
compile: true compile: true
}), }),
new AutoSWPlugin({}), new AutoSWPlugin({
version: VERSION
}),
new ScriptExtHtmlPlugin({ new ScriptExtHtmlPlugin({
inline: ['first'] inline: ['first']
@@ -251,22 +248,12 @@ module.exports = function (_, env) {
// Inline constants during build, so they can be folded by UglifyJS. // Inline constants during build, so they can be folded by UglifyJS.
new webpack.DefinePlugin({ new webpack.DefinePlugin({
VERSION: JSON.stringify(VERSION),
// We set node.process=false later in this config. // We set node.process=false later in this config.
// Here we make sure if (process && process.foo) still works: // Here we make sure if (process && process.foo) still works:
process: '{}' 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` // Copying files via Webpack allows them to be served dynamically by `webpack serve`
new CopyPlugin([ new CopyPlugin([
{ from: 'src/manifest.json', to: 'manifest.json' }, { from: 'src/manifest.json', to: 'manifest.json' },