keep hashed files in cache as long as they don't change

This commit is contained in:
Bruno Windels 2020-10-02 19:54:24 +02:00
parent 23ec98fb2f
commit 8dc2816d6e
4 changed files with 165 additions and 82 deletions

View file

@ -15,6 +15,7 @@
<body class="hydrogen"> <body class="hydrogen">
<script id="version" type="disabled"> <script id="version" type="disabled">
window.HYDROGEN_VERSION = "%%VERSION%%"; window.HYDROGEN_VERSION = "%%VERSION%%";
window.HYDROGEN_GLOBAL_HASH = "%%GLOBAL_HASH%%";
</script> </script>
<script id="main" type="module"> <script id="main" type="module">
import {main} from "./src/main.js"; import {main} from "./src/main.js";

View file

@ -48,12 +48,10 @@ const cssSrcDir = path.join(projectDir, "src/ui/web/css/");
const program = new commander.Command(); const program = new commander.Command();
program program
.option("--no-offline", "make a build without a service worker or appcache manifest") .option("--modern-only", "don't make a legacy build")
program.parse(process.argv); program.parse(process.argv);
const {noOffline} = program;
const offline = !noOffline;
async function build() { async function build({modernOnly}) {
// get version number // get version number
const version = JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).version; const version = JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).version;
@ -72,17 +70,22 @@ async function build() {
const olmAssets = await copyFolder(path.join(projectDir, "lib/olm/"), assets.directory); const olmAssets = await copyFolder(path.join(projectDir, "lib/olm/"), assets.directory);
assets.addSubMap(olmAssets); assets.addSubMap(olmAssets);
await assets.write(`hydrogen.js`, await buildJs("src/main.js")); await assets.write(`hydrogen.js`, await buildJs("src/main.js"));
if (!modernOnly) {
await assets.write(`hydrogen-legacy.js`, await buildJsLegacy(["src/main.js", 'src/legacy-polyfill.js', 'src/legacy-extras.js'])); await assets.write(`hydrogen-legacy.js`, await buildJsLegacy(["src/main.js", 'src/legacy-polyfill.js', 'src/legacy-extras.js']));
await assets.write(`worker.js`, await buildJsLegacy(["src/worker.js", 'src/worker-polyfill.js'])); await assets.write(`worker.js`, await buildJsLegacy(["src/worker.js", 'src/worker-polyfill.js']));
}
// creates the directories where the theme css bundles are placed in, // creates the directories where the theme css bundles are placed in,
// and writes to assets, so the build bundles can translate them, so do it first // and writes to assets, so the build bundles can translate them, so do it first
await copyThemeAssets(themes, assets); await copyThemeAssets(themes, assets);
await buildCssBundles(buildCssLegacy, themes, assets); await buildCssBundles(buildCssLegacy, themes, assets);
if (offline) { await buildManifest(assets);
await buildOffline(version, assets); // all assets have been added, create a hash from all assets name to cache unhashed files like index.html by
} const globalHashAssets = Array.from(assets).map(([, resolved]) => resolved);
await buildHtml(doc, version, assets); globalHashAssets.sort();
console.log(`built hydrogen ${version} successfully with ${assets.all().length} files`); const globalHash = contentHash(globalHashAssets.join(","));
await buildServiceWorker(globalHash, assets);
await buildHtml(doc, version, globalHash, modernOnly, assets);
console.log(`built hydrogen ${version} (${globalHash}) successfully with ${assets.size} files`);
} }
async function findThemes(doc, callback) { async function findThemes(doc, callback) {
@ -121,7 +124,7 @@ async function copyThemeAssets(themes, assets) {
return assets; return assets;
} }
async function buildHtml(doc, version, assets) { async function buildHtml(doc, version, globalHash, modernOnly, assets) {
// transform html file // transform html file
// change path to main.css to css bundle // change path to main.css to css bundle
doc("link[rel=stylesheet]:not([title])").attr("href", assets.resolve(`hydrogen.css`)); doc("link[rel=stylesheet]:not([title])").attr("href", assets.resolve(`hydrogen.css`));
@ -130,29 +133,32 @@ async function buildHtml(doc, version, assets) {
theme.attr("href", assets.resolve(`themes/${themeName}/bundle.css`)); theme.attr("href", assets.resolve(`themes/${themeName}/bundle.css`));
}); });
const pathsJSON = JSON.stringify({ const pathsJSON = JSON.stringify({
worker: assets.resolve(`worker.js`), worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null,
olm: { olm: {
wasm: assets.resolve("olm.wasm"), wasm: assets.resolve("olm.wasm"),
legacyBundle: assets.resolve("olm_legacy.js"), legacyBundle: assets.resolve("olm_legacy.js"),
wasmBundle: assets.resolve("olm.js"), wasmBundle: assets.resolve("olm.js"),
} }
}); });
doc("script#main").replaceWith( const mainScripts = [
`<script type="module">import {main} from "./${assets.resolve(`hydrogen.js`)}"; main(document.body, ${pathsJSON});</script>` + `<script type="module">import {main} from "./${assets.resolve(`hydrogen.js`)}"; main(document.body, ${pathsJSON});</script>`
`<script type="text/javascript" nomodule src="${assets.resolve(`hydrogen-legacy.js`)}"></script>` + ];
`<script type="text/javascript" nomodule>hydrogenBundle.main(document.body, ${pathsJSON}, hydrogenBundle.legacyExtras);</script>`); if (!modernOnly) {
removeOrEnableScript(doc("script#service-worker"), offline); mainScripts.push(
`<script type="text/javascript" nomodule src="${assets.resolve(`hydrogen-legacy.js`)}"></script>`,
`<script type="text/javascript" nomodule>hydrogenBundle.main(document.body, ${pathsJSON}, hydrogenBundle.legacyExtras);</script>`
);
}
doc("script#main").replaceWith(mainScripts.join(""));
doc("script#service-worker").attr("type", "text/javascript");
const versionScript = doc("script#version"); const versionScript = doc("script#version");
versionScript.attr("type", "text/javascript"); versionScript.attr("type", "text/javascript");
let vSource = versionScript.contents().text(); let vSource = versionScript.contents().text();
vSource = vSource.replace(`"%%VERSION%%"`, `"${version}"`); vSource = vSource.replace(`"%%VERSION%%"`, `"${version}"`);
vSource = vSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
versionScript.text(vSource); versionScript.text(vSource);
if (offline) {
doc("html").attr("manifest", assets.resolve("manifest.appcache"));
doc("head").append(`<link rel="manifest" href="${assets.resolve("manifest.json")}">`); doc("head").append(`<link rel="manifest" href="${assets.resolve("manifest.json")}">`);
}
await assets.writeUnhashed("index.html", doc.html()); await assets.writeUnhashed("index.html", doc.html());
} }
@ -205,7 +211,22 @@ async function buildJsLegacy(inputFiles) {
return code; return code;
} }
async function buildOffline(version, assets) { const SERVICEWORKER_NONCACHED_ASSETS = [
"hydrogen-legacy.js",
"olm_legacy.js",
"sw.js",
];
function isPreCached(asset) {
return asset.endsWith(".svg") ||
asset.endsWith(".png") ||
asset.endsWith(".css") ||
asset.endsWith(".wasm") ||
// most environments don't need the worker
asset.endsWith(".js") && asset !== "worker.js";
}
async function buildManifest(assets) {
const webManifest = JSON.parse(await fs.readFile(path.join(projectDir, "assets/manifest.json"), "utf8")); const webManifest = JSON.parse(await fs.readFile(path.join(projectDir, "assets/manifest.json"), "utf8"));
// copy manifest icons // copy manifest icons
for (const icon of webManifest.icons) { for (const icon of webManifest.icons) {
@ -213,25 +234,33 @@ async function buildOffline(version, assets) {
const iconTargetPath = path.basename(icon.src); const iconTargetPath = path.basename(icon.src);
icon.src = await assets.write(iconTargetPath, iconData); icon.src = await assets.write(iconTargetPath, iconData);
} }
// write appcache manifest await assets.write("manifest.json", JSON.stringify(webManifest));
const appCacheLines = [ }
`CACHE MANIFEST`,
`# v${version}`, async function buildServiceWorker(globalHash, assets) {
`NETWORK`, const unhashedPreCachedAssets = ["index.html"];
`"*"`, const hashedPreCachedAssets = [];
`CACHE`, const hashedCachedOnRequestAssets = [];
];
appCacheLines.push(...assets.allWithout(["hydrogen.js"])); for (const [unresolved, resolved] of assets) {
const swOfflineFiles = assets.allWithout(["hydrogen-legacy.js", "olm_legacy.js"]); if (SERVICEWORKER_NONCACHED_ASSETS.includes(unresolved)) {
const appCacheManifest = appCacheLines.join("\n") + "\n"; continue;
await assets.writeUnhashed("manifest.appcache", appCacheManifest); } else if (unresolved === resolved) {
unhashedPreCachedAssets.push(resolved);
} else if (isPreCached(unresolved)) {
hashedPreCachedAssets.push(resolved);
} else {
hashedCachedOnRequestAssets.push(resolved);
}
}
// write service worker // write service worker
let swSource = await fs.readFile(path.join(projectDir, "src/service-worker.template.js"), "utf8"); let swSource = await fs.readFile(path.join(projectDir, "src/service-worker.template.js"), "utf8");
swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`); swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
swSource = swSource.replace(`"%%OFFLINE_FILES%%"`, JSON.stringify(swOfflineFiles)); swSource = swSource.replace(`"%%UNHASHED_PRECACHED_ASSETS%%"`, JSON.stringify(unhashedPreCachedAssets));
swSource = swSource.replace(`"%%HASHED_PRECACHED_ASSETS%%"`, JSON.stringify(hashedPreCachedAssets));
swSource = swSource.replace(`"%%HASHED_CACHED_ON_REQUEST_ASSETS%%"`, JSON.stringify(hashedCachedOnRequestAssets));
// service worker should not have a hashed name as it is polled by the browser for updates // service worker should not have a hashed name as it is polled by the browser for updates
await assets.writeUnhashed("sw.js", swSource); await assets.writeUnhashed("sw.js", swSource);
await assets.write("manifest.json", JSON.stringify(webManifest));
} }
async function buildCssBundles(buildFn, themes, assets) { async function buildCssBundles(buildFn, themes, assets) {
@ -281,14 +310,6 @@ async function buildCssLegacy(entryPath, urlMapper = null) {
return result.css; return result.css;
} }
function removeOrEnableScript(scriptNode, enable) {
if (enable) {
scriptNode.attr("type", "text/javascript");
} else {
scriptNode.remove();
}
}
async function removeDirIfExists(targetDir) { async function removeDirIfExists(targetDir) {
try { try {
await fs.rmdir(targetDir, {recursive: true}); await fs.rmdir(targetDir, {recursive: true});
@ -397,16 +418,25 @@ class AssetMap {
} }
} }
all() { [Symbol.iterator]() {
return Array.from(this._assets.values()); return this._assets.entries();
} }
allWithout(excluded) { isUnhashed(relPath) {
excluded = excluded.map(p => this.resolve(p)); const resolvedPath = this._assets.get(relPath);
return this.all().filter(p => excluded.indexOf(p) === -1); if (!resolvedPath) {
throw new Error("Unknown asset: " + relPath);
}
return relPath === resolvedPath;
}
get size() {
return this._assets.size;
}
has(relPath) {
return this._assets.has(relPath);
} }
} }
build(program).catch(err => console.error(err));
build().catch(err => console.error(err));

View file

@ -14,38 +14,90 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const VERSION = "%%VERSION%%"; const GLOBAL_HASH = "%%GLOBAL_HASH%%";
const OFFLINE_FILES = "%%OFFLINE_FILES%%"; const UNHASHED_PRECACHED_ASSETS = "%%UNHASHED_PRECACHED_ASSETS%%";
// TODO: cache these files when requested const HASHED_PRECACHED_ASSETS = "%%HASHED_PRECACHED_ASSETS%%";
// The difficulty is that these are relative filenames, and we don't have access to document.baseURI const HASHED_CACHED_ON_REQUEST_ASSETS = "%%HASHED_CACHED_ON_REQUEST_ASSETS%%";
// Clients.match({type: "window"}).url and assume they are all the same? they really should be ... safari doesn't support this though const unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`;
const CACHE_FILES = "%%CACHE_FILES%%"; const hashedCacheName = `hydrogen-assets`;
const cacheName = `hydrogen-${VERSION}`;
self.addEventListener('install', function(e) { self.addEventListener('install', function(e) {
e.waitUntil( e.waitUntil((async () => {
caches.open(cacheName).then(function(cache) { const unhashedCache = await caches.open(unhashedCacheName);
return cache.addAll(OFFLINE_FILES); await unhashedCache.addAll(UNHASHED_PRECACHED_ASSETS);
}) const hashedCache = await caches.open(hashedCacheName);
); await Promise.all(HASHED_PRECACHED_ASSETS.map(async asset => {
}); if (!await hashedCache.match(asset)) {
await hashedCache.add(asset);
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keyList) => {
return Promise.all(keyList.map((key) => {
if (key !== cacheName) {
return caches.delete(key);
} }
})); }));
}) })());
); });
async function purgeOldCaches() {
// remove any caches we don't know about
const keyList = await caches.keys();
for (const key of keyList) {
if (key !== unhashedCacheName && key !== hashedCacheName) {
await caches.delete(key);
}
}
// remove the cache for any old hashed resource
const hashedCache = await caches.open(hashedCacheName);
const keys = await hashedCache.keys();
const hashedAssetURLs =
HASHED_PRECACHED_ASSETS
.concat(HASHED_CACHED_ON_REQUEST_ASSETS)
.map(a => new URL(a, self.registration.scope).href);
for (const request of keys) {
if (!hashedAssetURLs.some(url => url === request.url)) {
hashedCache.delete(request);
}
}
}
self.addEventListener('activate', (event) => {
event.waitUntil(purgeOldCaches());
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
event.respondWith( event.respondWith(handleRequest(event.request));
caches.open(cacheName)
.then(cache => cache.match(event.request))
.then((response) => response || fetch(event.request))
);
}); });
async function handleRequest(request) {
const baseURL = self.registration.scope;
if (request.url === baseURL) {
request = new Request(new URL("index.html", baseURL));
}
let response = await readCache(request);
if (!response) {
response = await fetch(request);
await updateCache(request, response);
}
return response;
}
async function updateCache(request, response) {
const baseURL = self.registration.scope;
if(!request.url.startsWith(baseURL)) {
return;
}
let assetName = request.url.substr(baseURL.length);
if (HASHED_CACHED_ON_REQUEST_ASSETS.includes(assetName)) {
const cache = await caches.open(hashedCacheName);
await cache.put(request, response.clone());
}
}
async function readCache(request) {
const unhashedCache = await caches.open(unhashedCacheName);
let response = await unhashedCache.match(request);
if (response) {
return response;
}
const hashedCache = await caches.open(hashedCacheName);
response = await hashedCache.match(request);
return response;
}

View file

@ -18,7 +18,7 @@ export function hydrogenGithubLink(t) {
if (window.HYDROGEN_VERSION) { if (window.HYDROGEN_VERSION) {
return t.a({target: "_blank", return t.a({target: "_blank",
href: `https://github.com/vector-im/hydrogen-web/releases/tag/v${window.HYDROGEN_VERSION}`}, href: `https://github.com/vector-im/hydrogen-web/releases/tag/v${window.HYDROGEN_VERSION}`},
`Hydrogen v${window.HYDROGEN_VERSION} on Github`); `Hydrogen v${window.HYDROGEN_VERSION} (${window.HYDROGEN_GLOBAL_HASH}) on Github`);
} else { } else {
return t.a({target: "_blank", href: "https://github.com/vector-im/hydrogen-web"}, return t.a({target: "_blank", href: "https://github.com/vector-im/hydrogen-web"},
"Hydrogen on Github"); "Hydrogen on Github");