Service worker building (but not quite right yet)

This commit is contained in:
Jake Archibald
2020-09-24 17:35:02 +01:00
parent 3f2466f44d
commit e11b4cf22c
16 changed files with 554 additions and 6 deletions

41
lib/data-url-plugin.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { promises as fs } from 'fs';
import { lookup as lookupMime } from 'mime-types';
const prefix = 'data-url:';
export default function dataURLPlugin() {
return {
name: 'data-url-plugin',
async resolveId(id, importer) {
if (!id.startsWith(prefix)) return;
return (
prefix + (await this.resolve(id.slice(prefix.length), importer)).id
);
},
async load(id) {
if (!id.startsWith(prefix)) return;
const realId = id.slice(prefix.length);
this.addWatchFile(realId);
const source = await fs.readFile(realId);
const type = lookupMime(realId) || 'text/plain';
return `export default 'data:${type};base64,${source.toString(
'base64',
)}';`;
},
};
}

67
lib/sw-plugin.js Normal file
View File

@@ -0,0 +1,67 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createHash } from 'crypto';
import { posix } from 'path';
const importPrefix = 'service-worker:';
export default function serviceWorkerPlugin({
output = 'sw.js',
filterAssets = () => true,
} = {}) {
return {
name: 'service-worker',
async resolveId(id, importer) {
if (!id.startsWith(importPrefix)) return;
const plainId = id.slice(importPrefix.length);
const result = await this.resolve(plainId, importer);
if (!result) return;
return importPrefix + result.id;
},
load(id) {
if (!id.startsWith(importPrefix)) return;
const fileId = this.emitFile({
type: 'chunk',
id: id.slice(importPrefix.length),
fileName: output,
});
return `export default import.meta.ROLLUP_FILE_URL_${fileId};`;
},
generateBundle(options, bundle) {
const swChunk = bundle[output];
const toCacheInSW = Object.values(bundle).filter(
(item) => item !== swChunk && filterAssets(item),
);
const urls = toCacheInSW.map(
(item) =>
posix
.relative(posix.dirname(output), item.fileName)
.replace(/((?<=^|\/)index)?\.html?$/, '') || '.',
);
const versionHash = createHash('sha1');
for (const item of toCacheInSW) {
versionHash.update(item.code || item.source);
}
const version = versionHash.digest('hex');
swChunk.code =
`const ASSETS = ${JSON.stringify(urls)};\n` +
`const VERSION = ${JSON.stringify(version)};\n` +
swChunk.code;
},
};
}

5
missing-types.d.ts vendored
View File

@@ -27,6 +27,11 @@ declare module 'css:*' {
export default source;
}
declare module 'data-url:*' {
const url: string;
export default url;
}
declare var ga: {
(...args: any[]): void;
q: any[];

6
package-lock.json generated
View File

@@ -2041,6 +2041,12 @@
}
}
},
"idb-keyval": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-3.2.0.tgz",
"integrity": "sha512-slx8Q6oywCCSfKgPgL0sEsXtPVnSbTLWpyiDcu6msHOyKOLari1TD1qocXVCft80umnkk3/Qqh3lwoFt8T/BPQ==",
"dev": true
},
"ignore": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",

View File

@@ -20,8 +20,10 @@
"del": "^5.1.0",
"file-drop-element": "^1.0.1",
"husky": "^4.3.0",
"idb-keyval": "^3.2.0",
"lint-staged": "^10.4.0",
"lodash.camelcase": "^4.3.0",
"mime-types": "^2.1.27",
"postcss": "^7.0.34",
"postcss-modules": "^3.2.2",
"postcss-nested": "^4.2.3",

View File

@@ -29,6 +29,8 @@ import runScript from './lib/run-script';
import emitFiles from './lib/emit-files-plugin';
import imageWorkerPlugin from './lib/image-worker-plugin';
import initialCssPlugin from './lib/initial-css-plugin';
import serviceWorkerPlugin from './lib/sw-plugin';
import dataURLPlugin from './lib/data-url-plugin';
function resolveFileUrl({ fileName }) {
return JSON.stringify(fileName.replace(/^static\//, '/'));
@@ -41,6 +43,19 @@ function resolveImportMeta(property, { chunkId }) {
return `new URL(${resolveFileUrl({ fileName: chunkId })}, location).href`;
}
const dir = '.tmp/build';
const staticPath = 'static/c/[name]-[hash][extname]';
const jsPath = staticPath.replace('[extname]', '.js');
function jsFileName(chunkInfo) {
if (!chunkInfo.facadeModuleId) return jsPath;
const parsedPath = path.parse(chunkInfo.facadeModuleId);
if (parsedPath.name !== 'index') return jsPath;
// Come up with a better name than 'index'
const name = parsedPath.dir.split('/').slice(-1);
return jsPath.replace('[name]', name);
}
export default async function ({ watch }) {
const omtLoaderPromise = fsp.readFile(
path.join(__dirname, 'lib', 'omt.ejs'),
@@ -60,13 +75,13 @@ export default async function ({ watch }) {
'src/client',
'src/shared',
'src/features',
'src/sw',
'codecs',
]),
urlPlugin(),
dataURLPlugin(),
cssPlugin(resolveFileUrl),
];
const dir = '.tmp/build';
const staticPath = 'static/c/[name]-[hash][extname]';
return {
input: 'src/static-build/index.tsx',
@@ -86,6 +101,7 @@ export default async function ({ watch }) {
plugins: [
{ resolveFileUrl, resolveImportMeta },
OMT({ loader: await omtLoaderPromise }),
serviceWorkerPlugin({ output: 'static/sw.js' }),
...commonPlugins(),
commonjs(),
resolve(),
@@ -96,8 +112,8 @@ export default async function ({ watch }) {
{
dir,
format: 'amd',
chunkFileNames: staticPath.replace('[extname]', '.js'),
entryFileNames: staticPath.replace('[extname]', '.js'),
chunkFileNames: jsFileName,
entryFileNames: jsFileName,
},
resolveFileUrl,
),

View File

@@ -15,7 +15,9 @@ import 'shared/initial-app/custom-els/loading-spinner';
const ROUTE_EDITOR = '/editor';
//const compressPromise = import('../compress');
//const swBridgePromise = import('../../lib/sw-bridge');
const swBridgePromise = import('client/lazy-app/sw-bridge');
console.log(swBridgePromise);
function back() {
window.history.back();

View File

@@ -0,0 +1,111 @@
import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar';
import { get, set } from 'idb-keyval';
import swUrl from 'service-worker:sw';
/** 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();
});
});
}
/** Wait for a shared image */
export function getSharedImage(): Promise<File> {
return new Promise((resolve) => {
const onmessage = (event: MessageEvent) => {
if (event.data.action !== 'load-image') return;
resolve(event.data.file);
navigator.serviceWorker.removeEventListener('message', onmessage);
};
navigator.serviceWorker.addEventListener('message', onmessage);
// This message is picked up by the service worker - it's how it knows we're ready to receive
// the file.
navigator.serviceWorker.controller!.postMessage('share-ready');
});
}
/** Set up the service worker and monitor changes */
export async function offliner(showSnack: SnackBarElement['showSnackbar']) {
if (__PRODUCTION__) navigator.serviceWorker.register(swUrl);
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();
});
// If we don't have a controller, we don't need to check for updates we've just loaded from the
// network.
if (!hasController) return;
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

@@ -19,4 +19,9 @@ interface Navigator {
declare module 'add-css:*' {}
declare module 'service-worker:*' {
const url: string;
export default url;
}
declare module 'preact/debug' {}

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

@@ -0,0 +1,91 @@
import {
cacheOrNetworkAndCache,
cleanupCache,
cacheOrNetwork,
cacheBasics,
cacheAdditionalProcessors,
serveShareTarget,
} 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 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) => {
self.clients.claim();
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) => {
const url = new URL(event.request.url);
// Don't care about other-origin URLs
if (url.origin !== location.origin) return;
if (
url.pathname === '/' &&
url.searchParams.has('share-target') &&
event.request.method === 'POST'
) {
serveShareTarget(event);
return;
}
// We only care about GET from here on in.
if (event.request.method !== 'GET') 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) => {
switch (event.data) {
case 'cache-all':
event.waitUntil(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS));
break;
case 'skip-waiting':
self.skipWaiting();
break;
}
});

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

@@ -0,0 +1,16 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../missing-types.d.ts" />
declare const VERSION: string;
declare const ASSETS: string[];

BIN
src/sw/tiny.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

View File

Before

Width:  |  Height:  |  Size: 38 B

After

Width:  |  Height:  |  Size: 38 B

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

@@ -0,0 +1,6 @@
{
"extends": "../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["webworker", "esnext"]
}
}

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

@@ -0,0 +1,179 @@
import webpDataUrl from 'data-url:./tiny.webp';
import avifDataUrl from 'data-url:./tiny.avif';
// Give TypeScript the correct global.
declare var self: ServiceWorkerGlobalScope;
export function cacheOrNetwork(event: FetchEvent): void {
event.respondWith(
(async function () {
const cachedResponse = await caches.match(event.request, {
ignoreSearch: true,
});
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 serveShareTarget(event: FetchEvent): void {
const dataPromise = event.request.formData();
// Redirect so the user can refresh the page without resending data.
// @ts-ignore It doesn't like me giving a response to respondWith, although it's allowed.
event.respondWith(Response.redirect('/?share-target'));
event.waitUntil(
(async function () {
// The page sends this message to tell the service worker it's ready to receive the file.
await nextMessage('share-ready');
const client = await self.clients.get(event.resultingClientId);
const data = await dataPromise;
const file = data.get('file');
client!.postMessage({ file, action: 'load-image' });
})(),
);
}
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);
})(),
);
}
function getAssetsWithPrefix(assets: string[], prefixes: string[]) {
return assets.filter((asset) =>
prefixes.some((prefix) => asset.startsWith(prefix)),
);
}
export async function cacheBasics(cacheName: string, buildAssets: string[]) {
const toCache = ['/', '/assets/favicon.ico'];
const prefixesToCache = [
// Main app JS & CSS:
'main-app.',
// Service worker handler:
'offliner.',
// Little icons for the demo images on the homescreen:
'icon-demo-',
// Site logo:
'logo.',
];
const prefixMatches = getAssetsWithPrefix(buildAssets, prefixesToCache);
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 = getAssetsWithPrefix(buildAssets, prefixesToCache);
const wasm = buildAssets.filter((asset) => asset.endsWith('.wasm'));
toCache.push(...prefixMatches, ...wasm);
const [supportsWebP, supportsAvif] = await Promise.all(
[webpDataUrl, avifDataUrl].map(async (dataUrl) => {
if (!self.createImageBitmap) return false;
const response = await fetch(dataUrl);
const blob = await response.blob();
return createImageBitmap(blob).then(
() => true,
() => false,
);
}),
);
// No point caching decoders the browser already supports:
toCache = toCache.filter(
(asset) =>
(supportsWebP ? !/webp[\-_]dec/.test(asset) : true) &&
(supportsAvif ? !/avif[\-_]dec/.test(asset) : true),
);
const cache = await caches.open(cacheName);
await cache.addAll(toCache);
}
const nextMessageResolveMap = new Map<string, (() => void)[]>();
/**
* Wait on a message with a particular event.data value.
*
* @param dataVal The event.data value.
*/
function nextMessage(dataVal: string): Promise<void> {
return new Promise((resolve) => {
if (!nextMessageResolveMap.has(dataVal)) {
nextMessageResolveMap.set(dataVal, []);
}
nextMessageResolveMap.get(dataVal)!.push(resolve);
});
}
self.addEventListener('message', (event) => {
const resolvers = nextMessageResolveMap.get(event.data);
if (!resolvers) return;
nextMessageResolveMap.delete(event.data);
for (const resolve of resolvers) resolve();
});

View File

@@ -4,6 +4,7 @@
"references": [
{ "path": "./src/client" },
{ "path": "./src/static-build" },
{ "path": "./src/shared" }
{ "path": "./src/shared" },
{ "path": "./src/sw" }
]
}