const fs = require('fs/promises'); const path = require('path'); const xxhash = require('xxhashjs'); function contentHash(str) { var hasher = new xxhash.h32(0); hasher.update(str); return hasher.digest(); } module.exports = function injectServiceWorker(swFile) { let root; let version; let manifestHref; return { name: "injectServiceWorker", apply: "build", enforce: "post", configResolved: config => { root = config.root; version = JSON.parse(config.define.HYDROGEN_VERSION); // unquote }, generateBundle: async function(_, bundle) { const absoluteSwFile = path.resolve(root, swFile); const packageManifest = path.resolve(path.join(__dirname, "../../package.json")); let swSource = await fs.readFile(absoluteSwFile, {encoding: "utf8"}); const assets = Object.values(bundle).filter(a => a.type === "asset"); const cachedFileNames = assets.map(o => o.fileName).filter(fileName => fileName !== "index.html"); const uncachedFileContentMap = { "index.html": assets.find(o => o.fileName === "index.html").source, "sw.js": swSource }; const globalHash = getBuildHash(cachedFileNames, uncachedFileContentMap); swSource = await buildServiceWorker(swSource, version, globalHash, assets); const outputName = path.basename(absoluteSwFile); // TODO: do normal build transformations for service worker too, // I think if we emit it as a chunk rather than an asset it would // but we can't emit chunks anymore in generateBundle I think ... this.emitFile({ type: "asset", fileName: outputName, source: swSource }); } }; } function getBuildHash(cachedFileNames, uncachedFileContentMap) { const unhashedHashes = Object.entries(uncachedFileContentMap).map(([fileName, content]) => { return `${fileName}-${contentHash(Buffer.from(content))}`; }); const globalHashAssets = cachedFileNames.concat(unhashedHashes); globalHashAssets.sort(); return contentHash(globalHashAssets.join(",")).toString(); } const NON_PRECACHED_JS = [ "hydrogen-legacy.js", "olm_legacy.js", // most environments don't need the worker "main.js" ]; function isPreCached(asset) { const {name, fileName} = asset; return name.endsWith(".svg") || name.endsWith(".png") || name.endsWith(".css") || name.endsWith(".wasm") || name.endsWith(".html") || // the index and vendor chunks don't have an extension in `name`, so check extension on `fileName` fileName.endsWith(".js") && !NON_PRECACHED_JS.includes(path.basename(name)); } async function buildServiceWorker(swSource, version, globalHash, assets) { const unhashedPreCachedAssets = []; const hashedPreCachedAssets = []; const hashedCachedOnRequestAssets = []; for (const asset of assets) { const {name: unresolved, fileName: resolved} = asset; if (!unresolved || resolved === unresolved) { unhashedPreCachedAssets.push(resolved); } else if (isPreCached(asset)) { hashedPreCachedAssets.push(resolved); } else { hashedCachedOnRequestAssets.push(resolved); } } const replaceArrayInSource = (name, value) => { const newSource = swSource.replace(`${name} = []`, `${name} = ${JSON.stringify(value)}`); if (newSource === swSource) { throw new Error(`${name} was not found in the service worker source`); } return newSource; }; const replaceStringInSource = (name, value) => { const newSource = swSource.replace(new RegExp(`${name}\\s=\\s"[^"]*"`), `${name} = ${JSON.stringify(value)}`); if (newSource === swSource) { throw new Error(`${name} was not found in the service worker source`); } return newSource; }; // write service worker swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`); swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`); swSource = replaceArrayInSource("UNHASHED_PRECACHED_ASSETS", unhashedPreCachedAssets); swSource = replaceArrayInSource("HASHED_PRECACHED_ASSETS", hashedPreCachedAssets); swSource = replaceArrayInSource("HASHED_CACHED_ON_REQUEST_ASSETS", hashedCachedOnRequestAssets); swSource = replaceStringInSource("NOTIFICATION_BADGE_ICON", assets.find(a => a.name === "icon.png").fileName); return swSource; }