diff --git a/scripts/build-plugins/manifest.js b/scripts/build-plugins/manifest.js new file mode 100644 index 00000000..bcd96fa4 --- /dev/null +++ b/scripts/build-plugins/manifest.js @@ -0,0 +1,46 @@ +const fs = require('fs/promises'); +const path = require('path'); + +module.exports = function injectWebManifest(manifestFile) { + let root; + let manifestHref; + return { + name: "injectWebManifest", + apply: "build", + configResolved: config => { + root = config.root; + }, + transformIndexHtml: { + transform(html) { + return [{ + tag: "link", + attrs: {rel: "manifest", href: manifestHref}, + injectTo: "head" + }]; + }, + }, + generateBundle: async function() { + const absoluteManifestFile = path.resolve(root, manifestFile); + const manifestDir = path.dirname(absoluteManifestFile); + const json = await fs.readFile(absoluteManifestFile, {encoding: "utf8"}); + const manifest = JSON.parse(json); + for (const icon of manifest.icons) { + const iconFileName = path.resolve(manifestDir, icon.src); + const imgData = await fs.readFile(iconFileName); + const ref = this.emitFile({ + type: "asset", + name: path.basename(iconFileName), + source: imgData + }); + icon.src = this.getFileName(ref); + } + const outputName = path.basename(absoluteManifestFile); + const manifestRef = this.emitFile({ + type: "asset", + name: outputName, + source: JSON.stringify(manifest) + }); + manifestHref = this.getFileName(manifestRef); + } + }; +} diff --git a/scripts/build-plugins/service-worker.js b/scripts/build-plugins/service-worker.js new file mode 100644 index 00000000..420618fb --- /dev/null +++ b/scripts/build-plugins/service-worker.js @@ -0,0 +1,110 @@ +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 manifestHref; + return { + name: "injectServiceWorker", + apply: "build", + enforce: "post", + configResolved: config => { + root = config.root; + }, + generateBundle: async function(_, bundle) { + const absoluteSwFile = path.resolve(root, swFile); + const packageManifest = path.resolve(path.join(__dirname, "../../package.json")); + const version = JSON.parse(await fs.readFile(packageManifest, "utf8")).version; + 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); + 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; +} diff --git a/src/platform/web/assets/manifest.json b/src/platform/web/assets/manifest.json index 01728ff9..92dde01a 100644 --- a/src/platform/web/assets/manifest.json +++ b/src/platform/web/assets/manifest.json @@ -5,8 +5,8 @@ "description": "Lightweight matrix client with legacy and mobile browser support", "start_url": "index.html", "icons": [ - {"src": "assets/icon.png", "sizes": "384x384", "type": "image/png"}, - {"src": "assets/icon-maskable.png", "sizes": "384x384", "type": "image/png", "purpose": "maskable"} + {"src": "icon.png", "sizes": "384x384", "type": "image/png"}, + {"src": "icon-maskable.png", "sizes": "384x384", "type": "image/png", "purpose": "maskable"} ], "theme_color": "#0DBD8B" } diff --git a/src/platform/web/index.html b/src/platform/web/index.html index 397aae66..5130cd39 100644 --- a/src/platform/web/index.html +++ b/src/platform/web/index.html @@ -30,6 +30,7 @@ downloadSandbox: downloadSandboxPath, defaultHomeServer: "matrix.org", // NOTE: uncomment this if you want the service worker for local development + // and adjust apply in the service-worker build plugin // serviceWorker: "sw.js", // NOTE: provide push config if you want push notifs for local development // see assets/config.json for what the config looks like diff --git a/vite.config.js b/vite.config.js index 9c119b41..4fd46691 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,3 +1,6 @@ +const injectWebManifest = require("./scripts/build-plugins/manifest"); +const injectServiceWorker = require("./scripts/build-plugins/service-worker"); + export default { public: false, root: "src/platform/web", @@ -12,6 +15,12 @@ export default { }, build: { outDir: "../../../target", - minify: false - } + emptyOutDir: true, + minify: true, + sourcemap: true + }, + plugins: [ + injectWebManifest("assets/manifest.json"), + injectServiceWorker("sw.js") + ] };