cachekill
Version:
Simple command line cache busting tool which fingerprints files with a content hash.
148 lines (133 loc) • 5.53 kB
text/typescript
import path from 'path';
import glob from 'fast-glob';
import crypto from 'crypto';
import replace from 'replace-in-file';
import { promises as fs } from 'fs';
/**
* @typedef {object} SourcePaths
* @property {string} SourcePaths.path Original file path: path/file.js
* @property {string} SourcePaths.newPath Path of the fingerprinted file:
* path/file-HASH.js.
*/
type SourcePaths = {
path: string;
newPath: string;
};
/**
* @typedef {object} SourceBases
* @property {string} SourceBases.base Original filename: file.js.
* @property {string} SourceBases.newBase Name of the fingerprinted file:
* file-HASH.js.
*/
type SourceBases = {
base: string;
newBase: string;
};
/**
* @typedef {object} Result
* @property {SourcePaths[]} sourcePaths Paths of processed sources files.
* @property {string[]} [targetPaths] Paths of processed target files.
*/
type Result = {
sourcePaths: SourcePaths[];
targetPaths?: string[];
};
/**
* Fingerprints sourceFiles (either creating copies or renaming them) with a md5
* content hash and replaces references to those files in targetFiles with the
* new source filenames.
*
* @param {stirng|stirng[]} sourceFiles Paths or globs of files to
* fingerprint.
* @param {string|string[]} [targetFiles] Paths or globs of files with
* references to sourceFiles.
* @param {number} [hashLength=32] Length of the resulting hash
* (sliced md5 hash, max 32).
* @param {boolean} [rename=false] If true, renames source files
* instead of generating copies.
* @param {string} [pattern='{name}-{hash}{ext}'] Format of the new or renamed
* files. It must contain {name},
* {hash} and {ext} placeholders.
* @return {Promise<Result>} A relation of the processed
* source and target files.
*/
export async function cachekill(sourceFiles: string | string[],
targetFiles?: string | string[],
hashLength: number = 32,
rename: boolean = false,
pattern: string = '{name}-{hash}{ext}'): Promise<Result> {
const sourceBases = [];
const sourcePaths = [];
const sources = await glob(sourceFiles);
const targets = ((Array.isArray(targetFiles) && targetFiles.length)
|| typeof targetFiles === 'string')
&& await glob(targetFiles);
// Make sure that source files are sorted in a reverse alphabetical order,
// so files with common filename endings don't get wrongly replaced. E.g.:
// ['file.js', 'other-file.js'] --> ['other-file.js', 'file.js'].
sources.sort().reverse();
for (const filePath of sources) {
const hash = await getHash(filePath, hashLength);
const parsedPath = path.parse(filePath);
const newBase = pattern
.replace('{name}', parsedPath.name)
.replace('{hash}', hash)
.replace('{ext}', parsedPath.ext);
const newPath = `${parsedPath.dir}${path.sep}${newBase}`;
sourceBases.push({ newBase, base: parsedPath.base });
sourcePaths.push({ newPath, path: filePath });
}
if (targets) {
if (rename) {
await replaceReferences(sourceBases, targets);
} else {
// If files get copied instead of renamed and there are source files
// that are targets too, we need to update those targets, so we do the
// filename replacements in the copied files and not in the originals.
for (const obj of sourcePaths) {
const index = targets.indexOf(obj.path);
if (index !== -1) {
targets[index] = obj.newPath;
}
}
}
}
const operation = rename ? fs.rename : fs.copyFile;
for (const obj of sourcePaths) {
await operation(obj.path, obj.newPath);
}
// If files got copied instead of renamed, replacement of references
// must be done after generating the copies, not in the originals.
if (targets && !rename) {
await replaceReferences(sourceBases, targets);
}
return { sourcePaths, targetPaths: targets || undefined };
}
/**
* Replaces the old file bases with the new ones in target files.
*
* @param {SourceBases[]} sourceBases New and old file bases.
* @param {string[]} targets Paths to target files.
* @return {Promise}
*/
async function replaceReferences(sourceBases: SourceBases[],
targets: string[]): Promise<void> {
await replace.replaceInFile({
from: sourceBases.map(obj => new RegExp(obj.base, 'g')),
to: sourceBases.map(obj => obj.newBase),
files: targets
});
}
/**
* Generates a md5 hash of the file in filePath.
*
* @param {stirng} filePath Path to the file to process.
* @param {number} [hashLength=32] Length of the resulting hash.
* @return {Promise<string>} Hash in hex notation.
*/
async function getHash(filePath: string,
hashLength: number = 32): Promise<string> {
const hash = crypto.createHash('md5');
hash.update(await fs.readFile(filePath));
return hash.digest('hex').slice(0, hashLength);
}