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
156
config/auto-sw-plugin.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
const util = require('util');
|
||||||
|
const minimatch = require('minimatch');
|
||||||
|
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
|
||||||
|
const WebWorkerTemplatePlugin = require('webpack/lib/webworker/WebWorkerTemplatePlugin');
|
||||||
|
const ParserHelpers = require('webpack/lib/ParserHelpers');
|
||||||
|
|
||||||
|
const NAME = 'auto-sw-plugin';
|
||||||
|
const JS_TYPES = ['auto', 'esm', 'dynamic'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically finds and bundles Service Workers by looking for navigator.serviceWorker.register(..).
|
||||||
|
* An Array of webpack assets is injected into the Service Worker bundle as a `BUILD_ASSETS` global.
|
||||||
|
* Hidden and `.map` files are excluded by default, and this can be customized using the include & exclude options.
|
||||||
|
* @example
|
||||||
|
* // webpack config
|
||||||
|
* plugins: [
|
||||||
|
* new AutoSWPlugin({
|
||||||
|
* exclude: [
|
||||||
|
* '**\/.*', // don't expose hidden files (default)
|
||||||
|
* '**\/*.map', // don't precache sourcemaps (default)
|
||||||
|
* 'index.html' // don't cache the page itself
|
||||||
|
* ]
|
||||||
|
* })
|
||||||
|
* ]
|
||||||
|
* @param {Object} [options={}]
|
||||||
|
* @param {string[]} [options.exclude] Minimatch pattern(s) of which assets to omit from BUILD_ASSETS.
|
||||||
|
* @param {string[]} [options.include] Minimatch pattern(s) of assets to allow in BUILD_ASSETS.
|
||||||
|
*/
|
||||||
|
module.exports = class AutoSWPlugin {
|
||||||
|
constructor(options) {
|
||||||
|
this.options = Object.assign({
|
||||||
|
exclude: [
|
||||||
|
'**/*.map',
|
||||||
|
'**/.*'
|
||||||
|
]
|
||||||
|
}, options || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(compiler) {
|
||||||
|
const serviceWorkers = [];
|
||||||
|
|
||||||
|
compiler.hooks.emit.tapPromise(NAME, compilation => this.emit(compiler, compilation, serviceWorkers));
|
||||||
|
|
||||||
|
compiler.hooks.normalModuleFactory.tap(NAME, (factory) => {
|
||||||
|
for (const type of JS_TYPES) {
|
||||||
|
factory.hooks.parser.for(`javascript/${type}`).tap(NAME, parser => {
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
const processRegisterCall = expr => {
|
||||||
|
const dep = parser.evaluateExpression(expr.arguments[0]);
|
||||||
|
|
||||||
|
if (!dep.isString()) {
|
||||||
|
parser.state.module.warnings.push({
|
||||||
|
message: 'navigator.serviceWorker.register() will only be bundled if passed a String literal.'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = dep.string;
|
||||||
|
const outputFilename = this.options.filename || 'serviceworker.js'
|
||||||
|
const context = parser.state.current.context;
|
||||||
|
serviceWorkers.push({
|
||||||
|
outputFilename,
|
||||||
|
filename,
|
||||||
|
context
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = `__webpack__serviceworker__${++counter}`;
|
||||||
|
ParserHelpers.toConstantDependency(parser, id)(expr.arguments[0]);
|
||||||
|
return ParserHelpers.addParsedVariableToModule(parser, id, '__webpack_public_path__ + ' + JSON.stringify(outputFilename));
|
||||||
|
};
|
||||||
|
|
||||||
|
parser.hooks.call.for('navigator.serviceWorker.register').tap(NAME, processRegisterCall);
|
||||||
|
parser.hooks.call.for('self.navigator.serviceWorker.register').tap(NAME, processRegisterCall);
|
||||||
|
parser.hooks.call.for('window.navigator.serviceWorker.register').tap(NAME, processRegisterCall);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createFilter(list) {
|
||||||
|
const filters = [].concat(list);
|
||||||
|
for (let i=0; i<filters.length; i++) {
|
||||||
|
if (typeof filters[i] === 'string') {
|
||||||
|
filters[i] = minimatch.filter(filters[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(compiler, compilation, serviceWorkers) {
|
||||||
|
let assetMapping = Object.keys(compilation.assets);
|
||||||
|
if (this.options.include) {
|
||||||
|
const filters = this.createFilter(this.options.include);
|
||||||
|
assetMapping = assetMapping.filter(filename => {
|
||||||
|
for (const filter of filters) {
|
||||||
|
if (filter(filename)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.options.exclude) {
|
||||||
|
const filters = this.createFilter(this.options.exclude);
|
||||||
|
assetMapping = assetMapping.filter(filename => {
|
||||||
|
for (const filter of filters) {
|
||||||
|
if (filter(filename)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await Promise.all(serviceWorkers.map(
|
||||||
|
(serviceWorker, index) => this.compileServiceWorker(compiler, compilation, serviceWorker, index, assetMapping)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async compileServiceWorker(compiler, compilation, options, index, assetMapping) {
|
||||||
|
const entryFilename = options.filename;
|
||||||
|
|
||||||
|
const chunkFilename = compiler.options.output.chunkFilename.replace(/\.([a-z]+)$/i, '.serviceworker.$1');
|
||||||
|
const workerOptions = {
|
||||||
|
filename: options.outputFilename, // chunkFilename.replace(/\.?\[(?:chunkhash|contenthash|hash)(:\d+(?::\d+)?)?\]/g, ''),
|
||||||
|
chunkFilename: this.options.chunkFilename || chunkFilename,
|
||||||
|
globalObject: 'self'
|
||||||
|
};
|
||||||
|
|
||||||
|
const childCompiler = compilation.createChildCompiler(NAME, { filename: workerOptions.filename });
|
||||||
|
(new WebWorkerTemplatePlugin(workerOptions)).apply(childCompiler);
|
||||||
|
|
||||||
|
/* The duplication DefinePlugin ends up causing is problematic (it doesn't hoist injections), so we'll do it manually. */
|
||||||
|
// (new DefinePlugin({
|
||||||
|
// BUILD_ASSETS: JSON.stringify(assetMapping)
|
||||||
|
// })).apply(childCompiler);
|
||||||
|
(new SingleEntryPlugin(options.context, entryFilename, workerOptions.filename)).apply(childCompiler);
|
||||||
|
|
||||||
|
const subCache = `subcache ${__dirname} ${entryFilename} ${index}`;
|
||||||
|
let childCompilation;
|
||||||
|
childCompiler.hooks.compilation.tap(NAME, c => {
|
||||||
|
childCompilation = c;
|
||||||
|
if (childCompilation.cache) {
|
||||||
|
if (!childCompilation.cache[subCache]) childCompilation.cache[subCache] = {};
|
||||||
|
childCompilation.cache = childCompilation.cache[subCache];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await (util.promisify(childCompiler.runAsChild.bind(childCompiler)))();
|
||||||
|
|
||||||
|
const original = childCompilation.assets[workerOptions.filename].source();
|
||||||
|
const source = `var BUILD_ASSETS=${JSON.stringify(assetMapping)};\n${original}`;
|
||||||
|
childCompilation.assets[workerOptions.filename] = {
|
||||||
|
source: () => source,
|
||||||
|
size: () => Buffer.byteLength(source, 'utf8')
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(compilation.assets, childCompilation.assets);
|
||||||
|
}
|
||||||
|
};
|
||||||
6
package-lock.json
generated
@@ -5553,6 +5553,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"idb-keyval": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-iFwFN5n00KNNnVxlOOK280SJJfXWY7pbMUOQXdIXehvvc/mGCV/6T2Ae+Pk2KwAkkATDTwfMavOiDH5lrJKWXQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"ieee754": {
|
"ieee754": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"file-loader": "^1.1.11",
|
"file-loader": "^1.1.11",
|
||||||
"html-webpack-plugin": "^3.2.0",
|
"html-webpack-plugin": "^3.2.0",
|
||||||
"husky": "^1.1.2",
|
"husky": "^1.1.2",
|
||||||
|
"idb-keyval": "^3.1.0",
|
||||||
"if-env": "^1.0.4",
|
"if-env": "^1.0.4",
|
||||||
"linkstate": "^1.1.1",
|
"linkstate": "^1.1.1",
|
||||||
"loader-utils": "^1.1.0",
|
"loader-utils": "^1.1.0",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"node-sass": "^4.9.4",
|
"node-sass": "^4.9.4",
|
||||||
"optimize-css-assets-webpack-plugin": "^4.0.3",
|
"optimize-css-assets-webpack-plugin": "^4.0.3",
|
||||||
"pointer-tracker": "^2.0.3",
|
"pointer-tracker": "^2.0.3",
|
||||||
|
"minimatch": "^3.0.4",
|
||||||
"preact": "^8.3.1",
|
"preact": "^8.3.1",
|
||||||
"pretty-bytes": "^5.1.0",
|
"pretty-bytes": "^5.1.0",
|
||||||
"progress-bar-webpack-plugin": "^1.11.0",
|
"progress-bar-webpack-plugin": "^1.11.0",
|
||||||
@@ -62,7 +64,8 @@
|
|||||||
"tslint-react": "^3.6.0",
|
"tslint-react": "^3.6.0",
|
||||||
"typescript": "^2.9.2",
|
"typescript": "^2.9.2",
|
||||||
"typings-for-css-modules-loader": "^1.7.0",
|
"typings-for-css-modules-loader": "^1.7.0",
|
||||||
"webpack": "=4.19.1",
|
"url-loader": "^1.1.2",
|
||||||
|
"webpack": "^4.19.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { nativeDecode, sniffMimeType, canDecodeImage } from '../lib/util';
|
import { nativeDecode, sniffMimeType, canDecodeImage } from '../lib/util';
|
||||||
import Processor from './processor';
|
import Processor from './processor';
|
||||||
|
import webpDataUrl from 'url-loader!./tiny.webp';
|
||||||
|
|
||||||
// tslint:disable-next-line:max-line-length It’s a data URL. Whatcha gonna do?
|
const nativeWebPSupported = canDecodeImage(webpDataUrl);
|
||||||
const webpFile = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=';
|
|
||||||
const nativeWebPSupported = canDecodeImage(webpFile);
|
|
||||||
|
|
||||||
export async function decodeImage(blob: Blob, processor: Processor): Promise<ImageData> {
|
export async function decodeImage(blob: Blob, processor: Processor): Promise<ImageData> {
|
||||||
const mimeType = await sniffMimeType(blob);
|
const mimeType = await sniffMimeType(blob);
|
||||||
|
|||||||
@@ -7,31 +7,46 @@ import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
|
|||||||
async function mozjpegEncode(
|
async function mozjpegEncode(
|
||||||
data: ImageData, options: MozJPEGEncoderOptions,
|
data: ImageData, options: MozJPEGEncoderOptions,
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const { encode } = await import('./mozjpeg/encoder');
|
const { encode } = await import(
|
||||||
|
/* webpackChunkName: "process-mozjpeg-enc" */
|
||||||
|
'./mozjpeg/encoder',
|
||||||
|
);
|
||||||
return encode(data, options);
|
return encode(data, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
|
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);
|
return process(data, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function optiPngEncode(
|
async function optiPngEncode(
|
||||||
data: BufferSource, options: OptiPNGEncoderOptions,
|
data: BufferSource, options: OptiPNGEncoderOptions,
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const { compress } = await import('./optipng/encoder');
|
const { compress } = await import(
|
||||||
|
/* webpackChunkName: "process-optipng" */
|
||||||
|
'./optipng/encoder',
|
||||||
|
);
|
||||||
return compress(data, options);
|
return compress(data, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function webpEncode(
|
async function webpEncode(
|
||||||
data: ImageData, options: WebPEncoderOptions,
|
data: ImageData, options: WebPEncoderOptions,
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const { encode } = await import('./webp/encoder');
|
const { encode } = await import(
|
||||||
|
/* webpackChunkName: "process-webp-enc" */
|
||||||
|
'./webp/encoder',
|
||||||
|
);
|
||||||
return encode(data, options);
|
return encode(data, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
|
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);
|
return decode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ export default class Processor {
|
|||||||
// worker-loader does magic here.
|
// worker-loader does magic here.
|
||||||
// @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the
|
// @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the
|
||||||
// definition can't be overwritten.
|
// 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.
|
// Need to do some TypeScript trickery to make the type match.
|
||||||
this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi;
|
this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/codecs/tiny.webp
Normal file
|
After Width: | Height: | Size: 38 B |
@@ -36,12 +36,17 @@ export default class App extends Component<Props, State> {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
import('../compress').then((module) => {
|
import(
|
||||||
|
/* 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');
|
||||||
|
|
||||||
// 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') {
|
||||||
this.setState(window.STATE);
|
this.setState(window.STATE);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -155,6 +156,12 @@ 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 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
|
// 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
|
||||||
@@ -195,6 +202,17 @@ export default class Compress extends Component<Props, State> {
|
|||||||
super(props);
|
super(props);
|
||||||
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
|
||||||
|
// 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
|
@bind
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -4,13 +4,13 @@ import { bind, linkRef, Fileish } from '../../lib/initial-util';
|
|||||||
import '../custom-els/LoadingSpinner';
|
import '../custom-els/LoadingSpinner';
|
||||||
|
|
||||||
import logo from './imgs/logo.svg';
|
import logo from './imgs/logo.svg';
|
||||||
import largePhoto from './imgs/demos/large-photo.jpg';
|
import largePhoto from './imgs/demos/demo-large-photo.jpg';
|
||||||
import artwork from './imgs/demos/artwork.jpg';
|
import artwork from './imgs/demos/demo-artwork.jpg';
|
||||||
import deviceScreen from './imgs/demos/device-screen.png';
|
import deviceScreen from './imgs/demos/demo-device-screen.png';
|
||||||
import largePhotoIcon from './imgs/demos/large-photo-icon.jpg';
|
import largePhotoIcon from './imgs/demos/icon-demo-large-photo.jpg';
|
||||||
import artworkIcon from './imgs/demos/artwork-icon.jpg';
|
import artworkIcon from './imgs/demos/icon-demo-artwork.jpg';
|
||||||
import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg';
|
import deviceScreenIcon from './imgs/demos/icon-demo-device-screen.jpg';
|
||||||
import logoIcon from './imgs/demos/logo-icon.png';
|
import logoIcon from './imgs/demos/icon-demo-logo.png';
|
||||||
import * as style from './style.scss';
|
import * as style from './style.scss';
|
||||||
import SnackBarElement from '../../lib/SnackBar';
|
import SnackBarElement from '../../lib/SnackBar';
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="theme-color" content="#673ab8">
|
<meta name="theme-color" content="#673ab8">
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
17
src/index.ts
@@ -1,9 +1,14 @@
|
|||||||
declare module '@webcomponents/custom-elements';
|
declare module '@webcomponents/custom-elements';
|
||||||
|
|
||||||
(async function () {
|
function init() {
|
||||||
if (!('customElements' in self)) {
|
|
||||||
await import('@webcomponents/custom-elements');
|
|
||||||
}
|
|
||||||
|
|
||||||
require('./init-app.tsx');
|
require('./init-app.tsx');
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
if (!('customElements' in self)) {
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "wc-polyfill" */
|
||||||
|
'@webcomponents/custom-elements',
|
||||||
|
).then(init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
// "attach" the client-side rendering to it, updating the DOM in-place instead of replacing:
|
||||||
root = render(<App />, document.body, root);
|
root = render(<App />, document.body, root);
|
||||||
|
|
||||||
// In production, this entire condition is removed.
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
// Enable support for React DevTools and some helpful console warnings:
|
// Enable support for React DevTools and some helpful console warnings:
|
||||||
require('preact/debug');
|
require('preact/debug');
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"theme_color": "#673ab8",
|
"theme_color": "#673ab8",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/assets/icon.png",
|
"src": "/assets/icon-large.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "1024x1024"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
5
src/missing-types.d.ts
vendored
@@ -27,3 +27,8 @@ declare module '*.wasm' {
|
|||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'url-loader!*' {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|||||||
63
src/sw/index.ts
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
import '../missing-types';
|
||||||
18
src/sw/tsconfig.json
Normal 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
@@ -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);
|
||||||
|
}
|
||||||
@@ -12,5 +12,6 @@
|
|||||||
"jsxFactory": "h",
|
"jsxFactory": "h",
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"baseUrl": "."
|
"baseUrl": "."
|
||||||
}
|
},
|
||||||
|
"exclude": ["src/sw/**/*"]
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ 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;
|
||||||
const WorkerPlugin = require('worker-plugin');
|
const WorkerPlugin = require('worker-plugin');
|
||||||
|
const AutoSWPlugin = require('./config/auto-sw-plugin');
|
||||||
|
|
||||||
function readJson (filename) {
|
function readJson (filename) {
|
||||||
return JSON.parse(fs.readFileSync(filename));
|
return JSON.parse(fs.readFileSync(filename));
|
||||||
@@ -30,12 +31,14 @@ module.exports = function (_, env) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mode: isProd ? 'production' : 'development',
|
mode: isProd ? 'production' : 'development',
|
||||||
entry: './src/index',
|
entry: {
|
||||||
|
'first-interaction': './src/index'
|
||||||
|
},
|
||||||
devtool: isProd ? 'source-map' : 'inline-source-map',
|
devtool: isProd ? 'source-map' : 'inline-source-map',
|
||||||
stats: 'minimal',
|
stats: 'minimal',
|
||||||
output: {
|
output: {
|
||||||
filename: isProd ? '[name].[chunkhash:5].js' : '[name].js',
|
filename: isProd ? '[name].[chunkhash:5].js' : '[name].js',
|
||||||
chunkFilename: '[name].chunk.[chunkhash:5].js',
|
chunkFilename: '[name].[chunkhash:5].js',
|
||||||
path: path.join(__dirname, 'build'),
|
path: path.join(__dirname, 'build'),
|
||||||
publicPath: '/',
|
publicPath: '/',
|
||||||
globalObject: 'self'
|
globalObject: 'self'
|
||||||
@@ -154,11 +157,17 @@ module.exports = function (_, env) {
|
|||||||
// This is needed to make webpack NOT process wasm files.
|
// This is needed to make webpack NOT process wasm files.
|
||||||
// See https://github.com/webpack/webpack/issues/6725
|
// See https://github.com/webpack/webpack/issues/6725
|
||||||
type: 'javascript/auto',
|
type: 'javascript/auto',
|
||||||
loader: 'file-loader'
|
loader: 'file-loader',
|
||||||
|
options: {
|
||||||
|
name: '[name].[hash:5].[ext]',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.(png|svg|jpg|gif)$/,
|
test: /\.(png|svg|jpg|gif)$/,
|
||||||
loader: 'file-loader'
|
loader: 'file-loader',
|
||||||
|
options: {
|
||||||
|
name: '[name].[hash:5].[ext]',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -195,7 +204,7 @@ module.exports = function (_, env) {
|
|||||||
// See also: https://twitter.com/wsokra/status/970253245733113856
|
// See also: https://twitter.com/wsokra/status/970253245733113856
|
||||||
isProd && new MiniCssExtractPlugin({
|
isProd && new MiniCssExtractPlugin({
|
||||||
filename: '[name].[contenthash:5].css',
|
filename: '[name].[contenthash:5].css',
|
||||||
chunkFilename: '[name].chunk.[contenthash:5].css'
|
chunkFilename: '[name].[contenthash:5].css'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
new OptimizeCssAssetsPlugin({
|
new OptimizeCssAssetsPlugin({
|
||||||
@@ -233,6 +242,8 @@ module.exports = function (_, env) {
|
|||||||
compile: true
|
compile: true
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
new AutoSWPlugin({}),
|
||||||
|
|
||||||
new ScriptExtHtmlPlugin({
|
new ScriptExtHtmlPlugin({
|
||||||
defaultAttribute: 'async'
|
defaultAttribute: 'async'
|
||||||
}),
|
}),
|
||||||
|
|||||||