@fwdslsh/unify
Version:
A lightweight, framework-free static site generator with Bun native APIs
299 lines (254 loc) • 8.56 kB
JavaScript
/**
* Build Cache for Unify CLI
* Provides efficient file tracking and build caching
*/
import fs from 'fs/promises';
import path from 'path';
import { logger } from '../utils/logger.js';
export class BuildCache {
constructor(cacheDir = '.unify-cache') {
this.cacheDir = cacheDir;
this.hashCache = new Map();
this.dependencyGraph = new Map();
this.isInitialized = false;
}
/**
* Initialize the build cache
*/
async initialize() {
if (this.isInitialized) return;
try {
// Ensure cache directory exists
await fs.mkdir(this.cacheDir, { recursive: true });
// Load existing cache if available
await this.loadCache();
this.isInitialized = true;
logger.debug(`Build cache initialized at ${this.cacheDir}`);
} catch (error) {
logger.warn('Failed to initialize build cache:', error.message);
}
}
/**
* Generate hash for file using Bun's native hashing
* @param {string} filePath - Path to the file
* @returns {Promise<string>} File hash
*/
async hashFile(filePath) {
try {
const file = Bun.file(filePath);
const arrayBuffer = await file.arrayBuffer();
// Use Bun's native hash function (SHA-256 by default)
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(arrayBuffer);
return hasher.digest('hex');
} catch (error) {
logger.warn(`Failed to hash file ${filePath}:`, error.message);
return 'error';
}
}
/**
* Generate hash for string content using Bun's native hashing
* @param {string} content - Content to hash
* @returns {string} Content hash
*/
hashContent(content) {
try {
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(content);
return hasher.digest('hex');
} catch (error) {
logger.warn('Failed to hash content:', error.message);
return 'error';
}
}
/**
* Check if a file has changed since last build
* @param {string} filePath - Path to the file
* @returns {Promise<boolean>} True if file has changed
*/
async hasFileChanged(filePath) {
const currentHash = await this.hashFile(filePath);
const cachedHash = this.hashCache.get(filePath);
const hasChanged = currentHash !== cachedHash;
if (hasChanged) {
logger.debug(`File changed: ${filePath}`);
this.hashCache.set(filePath, currentHash);
}
return hasChanged;
}
/**
* Update hash cache for a file
* @param {string} filePath - Path to the file
* @param {string} [hash] - Pre-computed hash (optional)
*/
async updateFileHash(filePath, hash = null) {
const fileHash = hash || await this.hashFile(filePath);
this.hashCache.set(filePath, fileHash);
}
/**
* Check if any dependencies of a file have changed
* @param {string} filePath - Path to the main file
* @returns {Promise<boolean>} True if any dependency has changed
*/
async haveDependenciesChanged(filePath) {
const dependencies = this.dependencyGraph.get(filePath) || [];
for (const depPath of dependencies) {
if (await this.hasFileChanged(depPath)) {
logger.debug(`Dependency changed: ${depPath} affects ${filePath}`);
return true;
}
}
return false;
}
/**
* Set dependencies for a file
* @param {string} filePath - Path to the main file
* @param {string[]} dependencies - Array of dependency paths
*/
setDependencies(filePath, dependencies) {
this.dependencyGraph.set(filePath, [...dependencies]);
}
/**
* Add a dependency to a file
* @param {string} filePath - Path to the main file
* @param {string} dependencyPath - Path to the dependency
*/
addDependency(filePath, dependencyPath) {
if (!this.dependencyGraph.has(filePath)) {
this.dependencyGraph.set(filePath, []);
}
const dependencies = this.dependencyGraph.get(filePath);
if (!dependencies.includes(dependencyPath)) {
dependencies.push(dependencyPath);
}
}
/**
* Check if a build output is up-to-date
* @param {string} inputPath - Path to the input file
* @param {string} outputPath - Path to the output file
* @returns {Promise<boolean>} True if output is up-to-date
*/
async isUpToDate(inputPath, outputPath) {
try {
// Check if output file exists
await fs.access(outputPath);
// Check if input file has changed
if (await this.hasFileChanged(inputPath)) {
return false;
}
// Check if any dependencies have changed
if (await this.haveDependenciesChanged(inputPath)) {
return false;
}
return true;
} catch (error) {
// Output file doesn't exist or can't be accessed
return false;
}
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
getStats() {
return {
cachedFiles: this.hashCache.size,
dependencyGraphSize: this.dependencyGraph.size,
cacheDir: this.cacheDir,
hashingMethod: 'native-crypto'
};
}
/**
* Load cache from disk
*/
async loadCache() {
const cacheFilePath = path.join(this.cacheDir, 'hash-cache.json');
const depsFilePath = path.join(this.cacheDir, 'deps-cache.json');
try {
// Load hash cache
const hashCacheData = await fs.readFile(cacheFilePath, 'utf-8');
const hashData = JSON.parse(hashCacheData);
this.hashCache = new Map(Object.entries(hashData));
// Load dependency graph
const depsCacheData = await fs.readFile(depsFilePath, 'utf-8');
const depsData = JSON.parse(depsCacheData);
this.dependencyGraph = new Map(Object.entries(depsData));
logger.debug(`Loaded cache: ${this.hashCache.size} files, ${this.dependencyGraph.size} dependency entries`);
} catch (error) {
// Cache files don't exist or are corrupted, start fresh
logger.debug('No existing cache found, starting fresh');
}
}
/**
* Save cache to disk
*/
async saveCache() {
if (!this.isInitialized) return;
const cacheFilePath = path.join(this.cacheDir, 'hash-cache.json');
const depsFilePath = path.join(this.cacheDir, 'deps-cache.json');
try {
// Save hash cache
const hashData = Object.fromEntries(this.hashCache);
await fs.writeFile(cacheFilePath, JSON.stringify(hashData, null, 2));
// Save dependency graph
const depsData = Object.fromEntries(this.dependencyGraph);
await fs.writeFile(depsFilePath, JSON.stringify(depsData, null, 2));
logger.debug('Build cache saved to disk');
} catch (error) {
logger.warn('Failed to save cache:', error.message);
}
}
/**
* Clear the entire cache
*/
async clearCache() {
this.hashCache.clear();
this.dependencyGraph.clear();
try {
// Remove cache files
const cacheFilePath = path.join(this.cacheDir, 'hash-cache.json');
const depsFilePath = path.join(this.cacheDir, 'deps-cache.json');
await fs.unlink(cacheFilePath).catch(() => {});
await fs.unlink(depsFilePath).catch(() => {});
logger.info('Build cache cleared');
} catch (error) {
logger.warn('Error clearing cache files:', error.message);
}
}
/**
* Generate a composite hash for multiple files
* @param {string[]} filePaths - Array of file paths
* @returns {Promise<string>} Composite hash
*/
async hashFiles(filePaths) {
const hashes = await Promise.all(
filePaths.map(filePath => this.hashFile(filePath))
);
const combinedHash = hashes.join('|');
return await this.hashContent(combinedHash);
}
/**
* Check if any file in a group has changed
* @param {string[]} filePaths - Array of file paths
* @param {string} cacheKey - Cache key for the group
* @returns {Promise<boolean>} True if any file has changed
*/
async hasGroupChanged(filePaths, cacheKey) {
const currentHash = await this.hashFiles(filePaths);
const cachedHash = this.hashCache.get(cacheKey);
const hasChanged = currentHash !== cachedHash;
if (hasChanged) {
this.hashCache.set(cacheKey, currentHash);
}
return hasChanged;
}
}
/**
* Factory function to create build cache instance
* @param {string} cacheDir - Cache directory path
* @returns {BuildCache} Cache instance
*/
export function createBuildCache(cacheDir = '.unify-cache') {
const cache = new BuildCache(cacheDir);
return cache;
}