@eklingen/file-hash-cache
Version:
Simple SHA-1 hash cache for tracking file changes.
238 lines (187 loc) • 6.03 kB
JavaScript
import { webcrypto } from 'node:crypto'
import { access, readFile, writeFile } from 'node:fs/promises'
import { relative as relativePath, resolve as resolvePath } from 'node:path'
// Check if path exists
export const pathExists = async (path = '') => {
try {
await access(path)
return true
} catch {
return false
}
}
// Sort keys by alphabetical order
const sortKeys = obj =>
Object.keys(obj)
.sort()
.reduce((acc, key) => {
acc[key] = obj[key]
return acc
}, {})
export default class FileHashCache {
constructor(defaultKey = 'file', cacheFile = '.hash-cache.json', cacheRoot = process.cwd(), projectRoot = process.cwd(), enableBypass = false) {
this.defaultKey = defaultKey
this.defaultEncoding = 'utf-8'
this.projectRoot = projectRoot
this.cacheRoot = cacheRoot
this.cacheFile = cacheFile
this.cachePath = resolvePath(process.cwd(), this.cacheRoot, this.cacheFile)
this.enabled = !enableBypass
this.encoder = new TextEncoder()
this.hashCache = {}
}
// Load the cache from disk
async load(key = null, prune = false) {
if (!this.enabled) {
return
}
if (!this.hashCache.length && (await pathExists(this.cachePath))) {
const contents = await readFile(this.cachePath, { encoding: 'utf8' })
// Wrap JSON.parse in try/catch because it will throw an error if the file is empty or malformed
try {
this.hashCache = JSON.parse(contents)
} catch {
this.hashCache = {}
}
if (prune) {
await this.pruneEntries()
}
}
// Create an empty entry for the key, if it doesn't exist
if (key && !this.hashCache[key]) {
this.hashCache[key] = {}
}
this.#sortEntries()
}
// Save the cache to disk
async save(prune = false) {
if (!this.enabled) {
return
}
if (prune) {
await this.pruneEntries()
}
this.#sortEntries()
await writeFile(this.cachePath, JSON.stringify(this.hashCache, null, 2) + '\n', { encoding: 'utf8' })
}
// Clear the cache
async clear(save = false) {
if (!this.enabled) {
return
}
this.hashCache = {}
if (save) {
await this.save(false)
}
}
// Get the SHA-1 hash of a file and update the cache
async updateEntry(filepath = '', key = this.defaultKey, encoding = this.defaultEncoding) {
if (!this.enabled) {
return true
}
if (!filepath || !(await pathExists(filepath))) {
return false
}
const fileKey = relativePath(this.projectRoot, filepath)
const fileHash = await this.#getFileHash(filepath, encoding)
if (!this.hashCache[key]) {
await this.load(key)
}
this.hashCache[key][fileKey] = fileHash
return true
}
// Check if a file has changed since the last SHA-1 hash was calculated
async fileHasChanged(filepath = '', key = this.defaultKey, encoding = this.defaultEncoding) {
if (!this.enabled) {
return true
}
const fileKey = relativePath(this.projectRoot, filepath)
const fileHash = await this.#getFileHash(filepath, encoding)
if (!this.hashCache[key]) {
await this.load(key)
}
const cachedHash = this.hashCache[key][fileKey] || ''
const fileHasChanged = (fileHash !== cachedHash)
if (fileHasChanged) {
this.hashCache[key][fileKey] = fileHash
}
return fileHasChanged
}
// Compare two files by their SHA-1 hash
async compareFiles(firstFilepath = '', secondFilepath = '', encoding = this.defaultEncoding) {
if (!this.enabled) {
return false
}
if (!firstFilepath || !secondFilepath || !(await pathExists(firstFilepath)) || !(await pathExists(secondFilepath))) {
return false
}
const firstFileContents = await readFile(firstFilepath, { encoding })
const secondFileContents = await readFile(secondFilepath, { encoding })
const firstFileHash = await this.#getContentHash(firstFileContents || '', encoding)
const secondFileHash = await this.#getContentHash(secondFileContents || '', encoding)
const filesAreIdentical = firstFileHash === secondFileHash
return filesAreIdentical
}
// Prune stale entries from the cache
async pruneEntries() {
if (!this.enabled) {
return
}
for (const key of Object.keys(this.hashCache)) {
for (const fileKey of Object.keys(this.hashCache[key])) {
if (!(await pathExists(resolvePath(process.cwd(), this.projectRoot, fileKey)))) {
delete this.hashCache[key][fileKey]
}
}
}
}
// Sort cache entries alphabetically
#sortEntries() {
if (!this.enabled) {
return
}
for (const key of Object.keys(this.hashCache)) {
this.hashCache[key] = sortKeys(this.hashCache[key])
}
this.hashCache = sortKeys(this.hashCache)
}
// Get the SHA-1 hash of a string
async #getContentHash(contents = '', encoding = this.defaultEncoding) {
if (!this.enabled) {
return ''
}
if (!contents || !contents.length) {
return ''
}
const data = encoding ? this.encoder.encode(contents.toString(encoding)) : contents
const arrayBuffer = await webcrypto.subtle.digest('SHA-1', data)
const hash = Buffer.from(arrayBuffer).toString('base64')
return hash
}
// Get the SHA-1 hash of a file
async #getFileHash(filepath = '', encoding = this.defaultEncoding) {
if (!this.enabled) {
return ''
}
if (!filepath || !(await pathExists(filepath))) {
return ''
}
const fileContents = await readFile(filepath, { encoding })
if (!fileContents || !fileContents.length) {
return ''
}
const contentHash = await this.#getContentHash(fileContents, encoding)
return contentHash
}
// Remove entries from the cache by key
async removeEntriesByKeys(...keys) {
if (!this.enabled) {
return
}
for (const key of Object.keys(this.hashCache)) {
if (keys.includes(key)) {
delete this.hashCache[key]
}
}
}
}