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)))();
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')

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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<Props, State> {
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') {

View File

@@ -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<HTMLImageElement> {
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<Props, State> {
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<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');
});
import('../../lib/offliner').then(({ mainAppLoaded }) => mainAppLoaded());
}
@bind

View File

@@ -8,12 +8,9 @@ export interface SnackOptions {
function createSnack(message: string, options: SnackOptions): [Element, Promise<string>] {
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');

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;
export default value;
}
declare var VERSION: string;

View File

@@ -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;
}
});

View File

@@ -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:

View File

@@ -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' },