Add a serviceworker (#234)

* Add a serviceworker

* rename + fix random extra character

* Fixing worker typings

* Fixing types properly this time.

* Once of those rare cases where this matters.

* Naming the things.

* Move registration to the app (so we can use snackbar later)

* Moving SW plugin later so it picks up things like HTML

* MVP service worker

* Two stage-service worker

* Fix prerendering by conditionally awaiting Custom Elements polyfill.

* Fix icon 404's

* add doc comment to autoswplugin

* Fix type
This commit is contained in:
Jason Miller
2018-11-08 07:02:05 -05:00
committed by Jake Archibald
parent e4e130c5d6
commit 7d42d4f973
28 changed files with 450 additions and 36 deletions

View File

@@ -1,9 +1,8 @@
import { nativeDecode, sniffMimeType, canDecodeImage } from '../lib/util';
import Processor from './processor';
import webpDataUrl from 'url-loader!./tiny.webp';
// tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?
const webpFile = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=';
const nativeWebPSupported = canDecodeImage(webpFile);
const nativeWebPSupported = canDecodeImage(webpDataUrl);
export async function decodeImage(blob: Blob, processor: Processor): Promise<ImageData> {
const mimeType = await sniffMimeType(blob);

View File

@@ -7,31 +7,46 @@ import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
async function mozjpegEncode(
data: ImageData, options: MozJPEGEncoderOptions,
): Promise<ArrayBuffer> {
const { encode } = await import('./mozjpeg/encoder');
const { encode } = await import(
/* webpackChunkName: "process-mozjpeg-enc" */
'./mozjpeg/encoder',
);
return encode(data, options);
}
async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
const { process } = await import('./imagequant/processor');
const { process } = await import(
/* webpackChunkName: "process-imagequant" */
'./imagequant/processor',
);
return process(data, opts);
}
async function optiPngEncode(
data: BufferSource, options: OptiPNGEncoderOptions,
): Promise<ArrayBuffer> {
const { compress } = await import('./optipng/encoder');
const { compress } = await import(
/* webpackChunkName: "process-optipng" */
'./optipng/encoder',
);
return compress(data, options);
}
async function webpEncode(
data: ImageData, options: WebPEncoderOptions,
): Promise<ArrayBuffer> {
const { encode } = await import('./webp/encoder');
const { encode } = await import(
/* webpackChunkName: "process-webp-enc" */
'./webp/encoder',
);
return encode(data, options);
}
async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import('./webp/decoder');
const { decode } = await import(
/* webpackChunkName: "process-webp-dec" */
'./webp/decoder',
);
return decode(data);
}

View File

@@ -61,7 +61,10 @@ export default class Processor {
// worker-loader does magic here.
// @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the
// definition can't be overwritten.
this._worker = new Worker('./processor-worker.ts', { type: 'module' }) as Worker;
this._worker = new Worker(
'./processor-worker.ts',
{ name: 'processor-worker', type: 'module' },
) as Worker;
// Need to do some TypeScript trickery to make the type match.
this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi;
}

BIN
src/codecs/tiny.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 B

View File

@@ -36,12 +36,17 @@ export default class App extends Component<Props, State> {
constructor() {
super();
import('../compress').then((module) => {
import(
/* webpackChunkName: "main-app" */
'../compress',
).then((module) => {
this.setState({ Compress: module.default });
}).catch(() => {
this.showSnack('Failed to load app');
});
navigator.serviceWorker.register('../../sw');
// In development, persist application state across hot reloads:
if (process.env.NODE_ENV === 'development') {
this.setState(window.STATE);

View File

@@ -1,4 +1,5 @@
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';
@@ -155,6 +156,12 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
return blobToImg(new Blob([newSource], { type: 'image/svg+xml' }));
}
async function getOldestServiceWorker() {
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
@@ -195,6 +202,17 @@ export default class Compress extends Component<Props, State> {
super(props);
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 getOldestServiceWorker();
if (!serviceWorker) return; // Service worker not installing yet.
serviceWorker.postMessage('cache-all');
});
}
@bind

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -4,13 +4,13 @@ import { bind, linkRef, Fileish } from '../../lib/initial-util';
import '../custom-els/LoadingSpinner';
import logo from './imgs/logo.svg';
import largePhoto from './imgs/demos/large-photo.jpg';
import artwork from './imgs/demos/artwork.jpg';
import deviceScreen from './imgs/demos/device-screen.png';
import largePhotoIcon from './imgs/demos/large-photo-icon.jpg';
import artworkIcon from './imgs/demos/artwork-icon.jpg';
import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg';
import logoIcon from './imgs/demos/logo-icon.png';
import largePhoto from './imgs/demos/demo-large-photo.jpg';
import artwork from './imgs/demos/demo-artwork.jpg';
import deviceScreen from './imgs/demos/demo-device-screen.png';
import largePhotoIcon from './imgs/demos/icon-demo-large-photo.jpg';
import artworkIcon from './imgs/demos/icon-demo-artwork.jpg';
import deviceScreenIcon from './imgs/demos/icon-demo-device-screen.jpg';
import logoIcon from './imgs/demos/icon-demo-logo.png';
import * as style from './style.scss';
import SnackBarElement from '../../lib/SnackBar';

View File

@@ -8,6 +8,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#673ab8">
<link rel="shortcut icon" href="/assets/favicon.ico">
<link rel="manifest" href="/manifest.json">
</head>
<body>

View File

@@ -1,9 +1,14 @@
declare module '@webcomponents/custom-elements';
(async function () {
if (!('customElements' in self)) {
await import('@webcomponents/custom-elements');
}
function init() {
require('./init-app.tsx');
})();
}
if (!('customElements' in self)) {
import(
/* webpackChunkName: "wc-polyfill" */
'@webcomponents/custom-elements',
).then(init);
} else {
init();
}

View File

@@ -9,8 +9,7 @@ let root = document.querySelector('#app') || undefined;
// "attach" the client-side rendering to it, updating the DOM in-place instead of replacing:
root = render(<App />, document.body, root);
// In production, this entire condition is removed.
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV !== 'production') {
// Enable support for React DevTools and some helpful console warnings:
require('preact/debug');

View File

@@ -8,9 +8,9 @@
"theme_color": "#673ab8",
"icons": [
{
"src": "/assets/icon.png",
"src": "/assets/icon-large.png",
"type": "image/png",
"sizes": "512x512"
"sizes": "1024x1024"
}
]
}
}

View File

@@ -27,3 +27,8 @@ declare module '*.wasm' {
const content: string;
export default content;
}
declare module 'url-loader!*' {
const value: string;
export default value;
}

63
src/sw/index.ts Normal file
View File

@@ -0,0 +1,63 @@
import {
cacheOrNetworkAndCache, cleanupCache, cacheOrNetwork, cacheBasics, cacheAdditionalProcessors,
} from './util';
import { get } from 'idb-keyval';
// Give TypeScript the correct global.
declare var self: ServiceWorkerGlobalScope;
// This is populated by webpack.
declare var BUILD_ASSETS: string[];
const version = '1.0.0';
const versionedCache = 'static-' + version;
const dynamicCache = 'dynamic';
const expectedCaches = [versionedCache, dynamicCache];
self.addEventListener('install', (event) => {
event.waitUntil(async function () {
const promises = [];
promises.push(cacheBasics(versionedCache, BUILD_ASSETS));
// If the user has already interacted with the app, update the codecs too.
if (await get('user-interacted')) {
promises.push(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS));
}
await Promise.all(promises);
}());
});
self.addEventListener('activate', (event) => {
event.waitUntil(async function () {
// Remove old caches.
const promises = (await caches.keys()).map((cacheName) => {
if (!expectedCaches.includes(cacheName)) return caches.delete(cacheName);
});
await Promise.all<any>(promises);
}());
});
self.addEventListener('fetch', (event) => {
// We only care about GET.
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Don't care about other-origin URLs
if (url.origin !== location.origin) return;
if (url.pathname.startsWith('/demo-') || url.pathname.startsWith('/wc-polyfill')) {
cacheOrNetworkAndCache(event, dynamicCache);
cleanupCache(event, dynamicCache, BUILD_ASSETS);
return;
}
cacheOrNetwork(event);
});
self.addEventListener('message', (event) => {
if (event.data === 'cache-all') {
event.waitUntil(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS));
}
});

1
src/sw/missing-types.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
import '../missing-types';

18
src/sw/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"target": "esnext",
"module": "esnext",
"lib": [
"webworker",
"esnext"
],
"moduleResolution": "node",
"experimentalDecorators": true,
"noUnusedLocals": true,
"sourceMap": true,
"allowJs": false,
"baseUrl": "."
}
}

105
src/sw/util.ts Normal file
View File

@@ -0,0 +1,105 @@
import webpDataUrl from 'url-loader!../codecs/tiny.webp';
export function cacheOrNetwork(event: FetchEvent): void {
event.respondWith(async function () {
const cachedResponse = await caches.match(event.request);
return cachedResponse || fetch(event.request);
}());
}
export function cacheOrNetworkAndCache(event: FetchEvent, cacheName: string): void {
event.respondWith(async function () {
const { request } = event;
// Return from cache if possible.
const cachedResponse = await caches.match(request);
if (cachedResponse) return cachedResponse;
// Else go to the network.
const response = await fetch(request);
const responseToCache = response.clone();
event.waitUntil(async function () {
// Cache what we fetched.
const cache = await caches.open(cacheName);
await cache.put(request, responseToCache);
}());
// Return the network response.
return response;
}());
}
export function cleanupCache(event: FetchEvent, cacheName: string, keepAssets: string[]) {
event.waitUntil(async function () {
const cache = await caches.open(cacheName);
// Clean old entries from the dynamic cache.
const requests = await cache.keys();
const promises = requests.map((cachedRequest) => {
// Get pathname without leading /
const assetPath = new URL(cachedRequest.url).pathname.slice(1);
// If it isn't one of our keepAssets, we don't need it anymore.
if (!keepAssets.includes(assetPath)) return cache.delete(cachedRequest);
});
await Promise.all<any>(promises);
}());
}
export async function cacheBasics(cacheName: string, buildAssets: string[]) {
const toCache = ['/', '/assets/favicon.ico'];
const prefixesToCache = [
// First interaction JS & CSS:
'first-interaction.',
// Main app JS & CSS:
'main-app.',
// Little icons for the demo images on the homescreen:
'icon-demo-',
// Site logo:
'logo.',
];
const prefixMatches = buildAssets.filter(
asset => prefixesToCache.some(prefix => asset.startsWith(prefix)),
);
toCache.push(...prefixMatches);
const cache = await caches.open(cacheName);
await cache.addAll(toCache);
}
export async function cacheAdditionalProcessors(cacheName: string, buildAssets: string[]) {
let toCache = [];
const prefixesToCache = [
// Worker which handles image processing:
'processor-worker.',
// processor-worker imports:
'process-',
];
const prefixMatches = buildAssets.filter(
asset => prefixesToCache.some(prefix => asset.startsWith(prefix)),
);
const wasm = buildAssets.filter(asset => asset.endsWith('.wasm'));
toCache.push(...prefixMatches, ...wasm);
const supportsWebP = await (async () => {
if (!self.createImageBitmap) return false;
const response = await fetch(webpDataUrl);
const blob = await response.blob();
return createImageBitmap(blob).then(() => true, () => false);
})();
// No point caching the WebP decoder if it's supported natively:
if (supportsWebP) {
toCache = toCache.filter(asset => !/webp[\-_]dec/.test(asset));
}
const cache = await caches.open(cacheName);
await cache.addAll(toCache);
}