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">
<script id="version" type="disabled">
window.HYDROGEN_VERSION = "%%VERSION%%";
window.HYDROGEN_GLOBAL_HASH = "%%GLOBAL_HASH%%";
</script>
<script id="main" type="module">
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();
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);
const {noOffline} = program;
const offline = !noOffline;
async function build() {
async function build({modernOnly}) {
// get version number
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);
assets.addSubMap(olmAssets);
await assets.write(`hydrogen.js`, await buildJs("src/main.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']));
if (!modernOnly) {
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']));
}
// 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
await copyThemeAssets(themes, assets);
await buildCssBundles(buildCssLegacy, themes, assets);
if (offline) {
await buildOffline(version, assets);
}
await buildHtml(doc, version, assets);
console.log(`built hydrogen ${version} successfully with ${assets.all().length} files`);
await buildManifest(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);
globalHashAssets.sort();
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) {
@ -121,7 +124,7 @@ async function copyThemeAssets(themes, assets) {
return assets;
}
async function buildHtml(doc, version, assets) {
async function buildHtml(doc, version, globalHash, modernOnly, assets) {
// transform html file
// change path to main.css to css bundle
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`));
});
const pathsJSON = JSON.stringify({
worker: assets.resolve(`worker.js`),
worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null,
olm: {
wasm: assets.resolve("olm.wasm"),
legacyBundle: assets.resolve("olm_legacy.js"),
wasmBundle: assets.resolve("olm.js"),
}
});
doc("script#main").replaceWith(
`<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>`);
removeOrEnableScript(doc("script#service-worker"), offline);
const mainScripts = [
`<script type="module">import {main} from "./${assets.resolve(`hydrogen.js`)}"; main(document.body, ${pathsJSON});</script>`
];
if (!modernOnly) {
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");
versionScript.attr("type", "text/javascript");
let vSource = versionScript.contents().text();
vSource = vSource.replace(`"%%VERSION%%"`, `"${version}"`);
vSource = vSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
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());
}
@ -205,7 +211,22 @@ async function buildJsLegacy(inputFiles) {
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"));
// copy manifest icons
for (const icon of webManifest.icons) {
@ -213,25 +234,33 @@ async function buildOffline(version, assets) {
const iconTargetPath = path.basename(icon.src);
icon.src = await assets.write(iconTargetPath, iconData);
}
// write appcache manifest
const appCacheLines = [
`CACHE MANIFEST`,
`# v${version}`,
`NETWORK`,
`"*"`,
`CACHE`,
];
appCacheLines.push(...assets.allWithout(["hydrogen.js"]));
const swOfflineFiles = assets.allWithout(["hydrogen-legacy.js", "olm_legacy.js"]);
const appCacheManifest = appCacheLines.join("\n") + "\n";
await assets.writeUnhashed("manifest.appcache", appCacheManifest);
await assets.write("manifest.json", JSON.stringify(webManifest));
}
async function buildServiceWorker(globalHash, assets) {
const unhashedPreCachedAssets = ["index.html"];
const hashedPreCachedAssets = [];
const hashedCachedOnRequestAssets = [];
for (const [unresolved, resolved] of assets) {
if (SERVICEWORKER_NONCACHED_ASSETS.includes(unresolved)) {
continue;
} else if (unresolved === resolved) {
unhashedPreCachedAssets.push(resolved);
} else if (isPreCached(unresolved)) {
hashedPreCachedAssets.push(resolved);
} else {
hashedCachedOnRequestAssets.push(resolved);
}
}
// write service worker
let swSource = await fs.readFile(path.join(projectDir, "src/service-worker.template.js"), "utf8");
swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`);
swSource = swSource.replace(`"%%OFFLINE_FILES%%"`, JSON.stringify(swOfflineFiles));
swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
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
await assets.writeUnhashed("sw.js", swSource);
await assets.write("manifest.json", JSON.stringify(webManifest));
}
async function buildCssBundles(buildFn, themes, assets) {
@ -281,14 +310,6 @@ async function buildCssLegacy(entryPath, urlMapper = null) {
return result.css;
}
function removeOrEnableScript(scriptNode, enable) {
if (enable) {
scriptNode.attr("type", "text/javascript");
} else {
scriptNode.remove();
}
}
async function removeDirIfExists(targetDir) {
try {
await fs.rmdir(targetDir, {recursive: true});
@ -397,16 +418,25 @@ class AssetMap {
}
}
all() {
return Array.from(this._assets.values());
[Symbol.iterator]() {
return this._assets.entries();
}
allWithout(excluded) {
excluded = excluded.map(p => this.resolve(p));
return this.all().filter(p => excluded.indexOf(p) === -1);
isUnhashed(relPath) {
const resolvedPath = this._assets.get(relPath);
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().catch(err => console.error(err));
build(program).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.
*/
const VERSION = "%%VERSION%%";
const OFFLINE_FILES = "%%OFFLINE_FILES%%";
// TODO: cache these files when requested
// The difficulty is that these are relative filenames, and we don't have access to document.baseURI
// Clients.match({type: "window"}).url and assume they are all the same? they really should be ... safari doesn't support this though
const CACHE_FILES = "%%CACHE_FILES%%";
const cacheName = `hydrogen-${VERSION}`;
const GLOBAL_HASH = "%%GLOBAL_HASH%%";
const UNHASHED_PRECACHED_ASSETS = "%%UNHASHED_PRECACHED_ASSETS%%";
const HASHED_PRECACHED_ASSETS = "%%HASHED_PRECACHED_ASSETS%%";
const HASHED_CACHED_ON_REQUEST_ASSETS = "%%HASHED_CACHED_ON_REQUEST_ASSETS%%";
const unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`;
const hashedCacheName = `hydrogen-assets`;
self.addEventListener('install', function(e) {
e.waitUntil(
caches.open(cacheName).then(function(cache) {
return cache.addAll(OFFLINE_FILES);
})
);
e.waitUntil((async () => {
const unhashedCache = await caches.open(unhashedCacheName);
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) => {
event.respondWith(
caches.open(cacheName)
.then(cache => cache.match(event.request))
.then((response) => response || fetch(event.request))
);
event.respondWith(handleRequest(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) {
return t.a({target: "_blank",
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 {
return t.a({target: "_blank", href: "https://github.com/vector-im/hydrogen-web"},
"Hydrogen on Github");