diff --git a/lib/data-url-plugin.js b/lib/data-url-plugin.js new file mode 100644 index 00000000..cf725d4d --- /dev/null +++ b/lib/data-url-plugin.js @@ -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', + )}';`; + }, + }; +} diff --git a/lib/sw-plugin.js b/lib/sw-plugin.js new file mode 100644 index 00000000..ff473c2d --- /dev/null +++ b/lib/sw-plugin.js @@ -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; + }, + }; +} diff --git a/missing-types.d.ts b/missing-types.d.ts index c01a181a..fd9b62de 100644 --- a/missing-types.d.ts +++ b/missing-types.d.ts @@ -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[]; diff --git a/package-lock.json b/package-lock.json index fd0d93cb..a3691be2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f0d21e03..d77ec383 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/rollup.config.js b/rollup.config.js index dca28d67..aca0b5f3 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -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, ), diff --git a/src/client/initial-app/App/index.tsx b/src/client/initial-app/App/index.tsx index c3b2df32..05d25ffc 100644 --- a/src/client/initial-app/App/index.tsx +++ b/src/client/initial-app/App/index.tsx @@ -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(); diff --git a/src/client/lazy-app/sw-bridge/index.ts b/src/client/lazy-app/sw-bridge/index.ts new file mode 100644 index 00000000..13322685 --- /dev/null +++ b/src/client/lazy-app/sw-bridge/index.ts @@ -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 { + if (reg.installing) return reg.installing; + return new Promise((resolve) => { + reg.addEventListener('updatefound', () => resolve(reg.installing!), { + once: true, + }); + }); +} + +/** Wait a service worker to become waiting */ +async function updateReady(reg: ServiceWorkerRegistration): Promise { + if (reg.waiting) return; + const installing = await installingWorker(reg); + return new Promise((resolve) => { + installing.addEventListener('statechange', () => { + if (installing.state === 'installed') resolve(); + }); + }); +} + +/** Wait for a shared image */ +export function getSharedImage(): Promise { + 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('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'); +} diff --git a/src/client/missing-types.d.ts b/src/client/missing-types.d.ts index 92689ade..30a22647 100644 --- a/src/client/missing-types.d.ts +++ b/src/client/missing-types.d.ts @@ -19,4 +19,9 @@ interface Navigator { declare module 'add-css:*' {} +declare module 'service-worker:*' { + const url: string; + export default url; +} + declare module 'preact/debug' {} diff --git a/src/sw/index.ts b/src/sw/index.ts new file mode 100644 index 00000000..697a7a21 --- /dev/null +++ b/src/sw/index.ts @@ -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(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; + } +}); diff --git a/src/sw/missing-types.d.ts b/src/sw/missing-types.d.ts new file mode 100644 index 00000000..7c35bff9 --- /dev/null +++ b/src/sw/missing-types.d.ts @@ -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. + */ +/// + +declare const VERSION: string; +declare const ASSETS: string[]; diff --git a/src/sw/tiny.avif b/src/sw/tiny.avif new file mode 100644 index 00000000..bea7e3da Binary files /dev/null and b/src/sw/tiny.avif differ diff --git a/src_old/codecs/tiny.webp b/src/sw/tiny.webp similarity index 100% rename from src_old/codecs/tiny.webp rename to src/sw/tiny.webp diff --git a/src/sw/tsconfig.json b/src/sw/tsconfig.json new file mode 100644 index 00000000..ea96385f --- /dev/null +++ b/src/sw/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["webworker", "esnext"] + } +} diff --git a/src/sw/util.ts b/src/sw/util.ts new file mode 100644 index 00000000..d14492cb --- /dev/null +++ b/src/sw/util.ts @@ -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(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 void)[]>(); + +/** + * Wait on a message with a particular event.data value. + * + * @param dataVal The event.data value. + */ +function nextMessage(dataVal: string): Promise { + 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(); +}); diff --git a/tsconfig.json b/tsconfig.json index 8763e88b..0bd1c448 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "references": [ { "path": "./src/client" }, { "path": "./src/static-build" }, - { "path": "./src/shared" } + { "path": "./src/shared" }, + { "path": "./src/sw" } ] }