Skip to content

Commit

Permalink
Enhanced offline (GoogleChromeLabs#249)
Browse files Browse the repository at this point in the history
* Notification of updates & reloading

* Using version in service worker & allowing version to appear elsewhere

* Stupid file

* Ditching changelog for now. Using package json.

* Ugh.
  • Loading branch information
jakearchibald authored Nov 9, 2018
1 parent 6b76ea0 commit 71f893c
Show file tree
Hide file tree
Showing 11 changed files with 129 additions and 59 deletions.
4 changes: 3 additions & 1 deletion config/auto-sw-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 1 addition & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
}
}
16 changes: 11 additions & 5 deletions src/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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') {
Expand Down
18 changes: 1 addition & 17 deletions src/components/compress/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions src/lib/SnackBar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
91 changes: 91 additions & 0 deletions src/lib/offliner.ts
Original file line number Diff line number Diff line change
@@ -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');
}
2 changes: 2 additions & 0 deletions src/missing-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ declare module 'url-loader!*' {
const value: string;
export default value;
}

declare var VERSION: string;
14 changes: 10 additions & 4 deletions src/sw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
}
});
2 changes: 2 additions & 0 deletions src/sw/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 6 additions & 19 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand Down Expand Up @@ -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$/,
Expand Down Expand Up @@ -243,30 +238,22 @@ module.exports = function (_, env) {
compile: true
}),

new AutoSWPlugin({}),
new AutoSWPlugin({
version: VERSION
}),

new ScriptExtHtmlPlugin({
inline: ['first']
}),

// 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' },
Expand Down

0 comments on commit 71f893c

Please sign in to comment.