Quick and Dirty Cache Busting
Published on
When I wrote about refactoring my static site, I mentioned that I wrote a custom Node script to cache-bust some files. At least one person was interested in seeing that script, so here we are.
The Goal
Before I deploy an update to my site that includes changes to CSS or JavaScript files, I want to append a hash to the end of those files (and references to those files in my HTML) so browsers know it’s time to download a fresh copy. So styles/jenny.css
would become styles/jenny-8675309.css
and so on.
Stuff I Considered First
- eleventy-plugin-cache-buster appends a query string variable instead of changing the file name. What’s strange is that the second link in the project’s
README.md
file says query strings aren’t reliable for cache-busting, which scared me away. - Bryce Wray recently wrote about cache-busting Sass files in Eleventy using eleventy-plugin-rev. But in the interest of future stability, I won’t rely on dependencies that aren’t documented.
- I suspect I could use Eleventy custom templates for CSS and JavaScript with some
permalink
trickery, but my previous tech stack heartbreak left an intense emotional need to separate those concerns. - PostCSS Hash works well, but a similar plugin for Rollup didn’t like my configuration.
- There was a promising npm package I found whose name completely escapes me, but it only worked with relative asset paths. (If you find it, please let me know so I can update this post.)
- I used gulp-rev before, which is still actively maintained, but installing Gulp in my project resulted in a bunch of npm security warnings, and I just couldn’t bring myself to start a project that way.
- node-rev and grunt-rev seem to be abandoned.
Prerequisites
My script has the following dependencies:
- fast-glob: Returns a list of files from a glob. This is one of Eleventy’s dependencies already, so it felt like an inexpensive bit of convenience.
- rev-file: Accepts a file path, generates a hash from the file’s contents, and spits back a hashed path. Does not create or rename any files.
- replace-in-file: Finds and replaces text in multiple files.
- From Node, the promises version of
copyFile
and a relative path function.
The Script
I have a file in tools/revision.mjs
that starts with dependencies and a convenience variable:
import fg from "fast-glob";
import { revisionFile } from "rev-file";
import replace from "replace-in-file";
import { copyFile } from "node:fs/promises";
import { relative as pathRelative } from "node:path";
const outputDir = "build";
I kick things off with a function that creates a “manifest” object, similar to what packages like gulp-rev generate. Each key corresponds to an asset, its value to a hashed path for that asset.
async function getManifest() {
// Find the built CSS and JavaScript files
let inFiles = await fg([`${outputDir}/{styles,scripts}/*.{css,js}`]);
// Remove any files that already end in a hash
inFiles = inFiles.filter((filePath) => {
return !filePath.match(/-[\w\d]{10}\.\w+$/g);
});
// Create the manifest object
const manifest = {};
// There is probably a fancier way to loop?
for (let i = 0; i < inFiles.length; i++) {
// The original file path
let inFile = inFiles[i];
// The hashed file path
let outFile = await revisionFile(inFile);
manifest[inFile] = outFile;
}
return manifest;
}
An example of what this returns:
{
"build/styles/jenny.css": "build/styles/jenny-8675309.css"
}
Then, I have a function that accepts that manifest, and creates the actual hashed files. I decided to copy the files instead of renaming them because it seemed less destructive and I was nervous.
async function revision(manifest) {
for (let [src, dest] of Object.entries(manifest)) {
try {
await copyFile(src, dest);
} catch {
console.error(`Failed to revision ${src}`);
}
}
}
Next, I have a function that calls the previous two. Assuming they succeed, it updates all the asset paths in the HTML.
async function revisionAndReplace() {
// Get the manifest
const manifest = await getManifest();
// Wait for the files to actually copy
await revision(manifest);
// Initialize the strings to replace and what to replace them wtih
const from = [];
const to = [];
// Loop through all the assets
for (let [src, dest] of Object.entries(manifest)) {
// Make sure the paths are relative to the build directory
src = pathRelative(outputDir, src);
dest = pathRelative(outputDir, dest);
// Let Tyler know what's happening
console.log(`${src} → ${dest}`);
// Update the asset arrays
from.push(src);
to.push(dest);
}
// Set up arguments for the replace function
const options = {
files: `${outputDir}/**/*.html`,
from,
to,
};
// Actually replace the content
const results = await replace(options);
// Get the files that were affected
const changedFiles = results.filter((result) => result.hasChanged);
// Let Tyler know it finished
console.log(`✔ Updated file references in ${changedFiles.length} files`);
}
The file ends by calling that function:
revisionAndReplace();
I can run the script from the Terminal via node tools/revision.mjs
, but I have that aliased in package.json
as the revision
script.
I know this solution isn’t perfect. It assumes any filename ending with a dash followed by ten characters (dancing-pachyderms.js
) is already hashed. It doesn’t restrict where replacements occur, so I’ve avoided using any actual asset names in this post. But it gets the job done, it’s easy to remove or replace, and it didn’t add any large dependencies to the project.