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(); } function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) { const swName = path.basename(swFile); let root; let version; let logger; return { name: "hydrogen:injectServiceWorker", apply: "build", enforce: "post", buildStart() { this.emitFile({ type: "chunk", fileName: swName, id: swFile, }); }, configResolved: config => { root = config.root; version = JSON.parse(config.define.DEFINE_VERSION); // unquote logger = config.logger; }, generateBundle: async function(options, bundle) { const otherUnhashedFiles = findUnhashedFileNamesFromBundle(bundle); const unhashedFilenames = [swName].concat(otherUnhashedFiles); const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => { const chunkOrAsset = bundle[fileName]; if (!chunkOrAsset) { throw new Error("could not get content for uncached asset or chunk " + fileName); } map[fileName] = chunkOrAsset.source || chunkOrAsset.code; return map; }, {}); const assets = Object.values(bundle); const hashedFileNames = assets.map(o => o.fileName).filter(fileName => !unhashedFileContentMap[fileName]); const globalHash = getBuildHash(hashedFileNames, unhashedFileContentMap); const placeholderValues = { DEFINE_GLOBAL_HASH: `"${globalHash}"`, ...getCacheFileNamePlaceholderValues(swName, unhashedFilenames, assets, placeholdersPerChunk) }; replacePlaceholdersInChunks(assets, placeholdersPerChunk, placeholderValues); logger.info(`\nBuilt ${version} (${globalHash})`); } }; } function getBuildHash(hashedFileNames, unhashedFileContentMap) { const unhashedHashes = Object.entries(unhashedFileContentMap).map(([fileName, content]) => { return `${fileName}-${contentHash(Buffer.from(content))}`; }); const globalHashAssets = hashedFileNames.concat(unhashedHashes); globalHashAssets.sort(); return contentHash(globalHashAssets.join(",")).toString(); } const NON_PRECACHED_JS = [ "hydrogen-legacy", "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)); } function getCacheFileNamePlaceholderValues(swName, unhashedFilenames, assets) { const unhashedPreCachedAssets = []; const hashedPreCachedAssets = []; const hashedCachedOnRequestAssets = []; for (const asset of assets) { const {name, fileName} = asset; // the service worker should not be cached at all, // it's how updates happen if (fileName === swName) { continue; } else if (unhashedFilenames.includes(fileName)) { unhashedPreCachedAssets.push(fileName); } else if (isPreCached(asset)) { hashedPreCachedAssets.push(fileName); } else { hashedCachedOnRequestAssets.push(fileName); } } return { DEFINE_UNHASHED_PRECACHED_ASSETS: JSON.stringify(unhashedPreCachedAssets), DEFINE_HASHED_PRECACHED_ASSETS: JSON.stringify(hashedPreCachedAssets), DEFINE_HASHED_CACHED_ON_REQUEST_ASSETS: JSON.stringify(hashedCachedOnRequestAssets) } } function replacePlaceholdersInChunks(assets, placeholdersPerChunk, placeholderValues) { for (const [name, placeholderMap] of Object.entries(placeholdersPerChunk)) { const chunk = assets.find(a => a.type === "chunk" && a.name === name); if (!chunk) { throw new Error(`could not find chunk ${name} to replace placeholders`); } for (const [placeholderName, placeholderLiteral] of Object.entries(placeholderMap)) { const replacedValue = placeholderValues[placeholderName]; const oldCode = chunk.code; chunk.code = chunk.code.replaceAll(placeholderLiteral, replacedValue); if (chunk.code === oldCode) { throw new Error(`Could not replace ${placeholderName} in ${name}, looking for literal ${placeholderLiteral}:\n${chunk.code}`); } } } } /** creates a value to be include in the `define` build settings, * but can be replace at the end of the build in certain chunks. * We need this for injecting the global build hash and the final * filenames in the service worker and index chunk. * These values are only known in the generateBundle step, so we * replace them by unique strings wrapped in a prompt call so no * transformation will touch them (minifying, ...) and we can do a * string replacement still at the end of the build. */ function definePlaceholderValue(mode, name, devValue) { if (mode === "production") { // note that `prompt(...)` will never be in the final output, it's replaced by the final value // once we know at the end of the build what it is and just used as a temporary value during the build // as something that will not be transformed. // I first considered Symbol but it's not inconceivable that babel would transform this. return `prompt(${JSON.stringify(name)})`; } else { return JSON.stringify(devValue); } } function createPlaceholderValues(mode) { return { DEFINE_GLOBAL_HASH: definePlaceholderValue(mode, "DEFINE_GLOBAL_HASH", null), DEFINE_UNHASHED_PRECACHED_ASSETS: definePlaceholderValue(mode, "UNHASHED_PRECACHED_ASSETS", []), DEFINE_HASHED_PRECACHED_ASSETS: definePlaceholderValue(mode, "HASHED_PRECACHED_ASSETS", []), DEFINE_HASHED_CACHED_ON_REQUEST_ASSETS: definePlaceholderValue(mode, "HASHED_CACHED_ON_REQUEST_ASSETS", []), }; } module.exports = {injectServiceWorker, createPlaceholderValues};