UNPKG

asset-cache-bust

Version:

Bust your asset cache using querystring hashes based on actual asset content

121 lines (108 loc) 4.62 kB
const Task = require('data.task'); const Maybe = require('data.maybe'); const { readFile } = require('./taskfs.js'); const path = require('path'); const urlUtil = require('url'); const { List } = require('immutable-ext'); const htmlParser = require('htmlparser2'); const fingerPrinter = require('fingerprinting'); const { id, always, taskFromNullable, getObjectProperty, noop } = require('./util.js'); // extractAssetUrlsFromHtml : String -> Task e (List String) const extractAssetUrlsFromHtml = html => { return new Task((reject, resolve) => { const styleHrefs = []; const parser = new htmlParser.Parser({ onopentag(tagName, attrs){ if (!('data-finger-print' in attrs)) return; switch (tagName) { case 'link': styleHrefs.push(attrs.href); break; case 'script': styleHrefs.push(attrs.src); break; default: return; } }, onend(){ resolve(List(styleHrefs)); }, onerror: reject }); parser.parseComplete(html); }); }; // createFileFingerPrint : String -> Maybe Any -> String -> { original: String, fingerPrinted: String } const createFileFingerPrint = fileName => assetRootReplacement => contents => { const url = assetRootReplacement .chain(newRoot => { if (typeof newRoot !== 'string') return Maybe.Nothing(); return Maybe.Just(urlUtil.resolve(newRoot, fileName)); }) .getOrElse(fileName); const parsedUrl = urlUtil.parse(url, true); parsedUrl.search = null; const fingerPrint = fingerPrinter(fileName, { format: '{hash}', content: contents }); parsedUrl.query.v = fingerPrint.file; return { original: fileName, fingerPrinted: urlUtil.format(parsedUrl) }; }; // extractFileNameFromUrl : String -> Task e String const extractFileNameFromUrl = url => { return new Task((reject, resolve) => !url ? reject({ message: 'url cannot be null' }) : resolve(urlUtil.parse(url).pathname)); }; // mergeRootWithFileName : String -> String -> String const mergeRootWithFileName = root => fileName => { return path.join(root, fileName); }; // getLoggingFunction : String -> Object -> Function const getLoggingFunction = name => logger => { const loggingFn = logger .chain(getObjectProperty(name)) .getOrElse(noop); if (typeof loggingFn !== 'function') throw new TypeError(`logger.info is not a function`); return loggingFn; }; // readAssetFile : Maybe Object -> String -> String const readAssetFile = logger => fileName => { const info = getLoggingFunction('info')(logger) return readFile(fileName) .map(fileContents => { info(`'${fileName}' -> Found matched asset, processing...`); return fileContents; }) .rejectedMap(err => { info(`'${fileName}' -> Skipping matched asset because it could not be found. Path searched: ${err.path}`); return err; }); }; // cacheBustHtml : (String, String[, Object]) -> Task e String const cacheBustHtml = (html, assetsRoot, options) => { return taskFromNullable({ message: 'The asset root cannot be null or undefined' })(assetsRoot) .chain(assetsRoot => extractAssetUrlsFromHtml(html) .chain(styleHrefs => styleHrefs .traverse(Task.of, styleHref => extractFileNameFromUrl(styleHref) .map(mergeRootWithFileName(assetsRoot)) .chain(readAssetFile(getObjectProperty('logger')(options))) // TODO: How do I pull this out so I can unit test this properly? .map(createFileFingerPrint(styleHref)(getObjectProperty('replaceAssetRoot')(options))) .orElse(always(Task.of())) ) ) .map(fingerPrints => fingerPrints.filter(id)) .map(fingerPrints => fingerPrints .reduce((acc, fingerPrint) => acc.replace(fingerPrint.original, fingerPrint.fingerPrinted), html) ) ) }; module.exports = cacheBustHtml;