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

Prerequisites

My script has the following dependencies:

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.