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 version; let logger; let mode; return { name: "hydrogen:injectServiceWorker", apply: "build", enforce: "post", buildStart() { this.emitFile({ type: "chunk", fileName: swName, id: swFile, }); }, configResolved: (config) => { mode = config.mode; 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, mode ), }; 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, mode ) { const unhashedPreCachedAssets = []; const hashedPreCachedAssets = []; const hashedCachedOnRequestAssets = []; if (mode === "production") { 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}` ); } } } } /** 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" || mode === "sdk") { // 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); } } /** * Returns the short sha for the latest git commit * @see https://stackoverflow.com/a/35778030 */ function getLatestGitCommitHash() { try { return require("child_process") .execSync("git rev-parse --short HEAD") .toString() .trim(); } catch { return "could_not_fetch_sha"; } } function createPlaceholderValues(mode) { return { DEFINE_GLOBAL_HASH: definePlaceholderValue( mode, "DEFINE_GLOBAL_HASH", `git commit: ${getLatestGitCommitHash()}` ), 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 };