All files cache.ts

88.37% Statements 38/43
88.88% Branches 16/18
90.9% Functions 10/11
88.88% Lines 32/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142          4x 4x 4x               4x       25x                                                                               16x 16x 16x 16x                       5x 4x 4x 4x 2x   1x           9x 8x 8x 8x               10x 9x 7x         14x         9x 8x 8x 4x 4x               7x 6x 6x 6x                                  
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, dirname, resolve } from 'node:path';
 
/** Default directory (relative to `rootDir`) where all CLI caches live. */
export const DEFAULT_CACHE_DIR = '.contractkit/cache';
const BUILD_CACHE_FILENAME = 'build.json';
const HTTP_CACHE_DIRNAME = 'http';
 
/** Map of source file path → sha256 hex of its content (or synthetic keys like `__plugin_<key>__`). */
export interface FileHashMap {
    [filePath: string]: string;
}
 
/** Synthetic cache key recording the compiler-stack version. A mismatch on load invalidates the entire cache. */
export const COMPILER_FINGERPRINT_KEY = '__compiler__';
 
/** Compute a stable sha256 hex digest for a string of content. */
export function computeHash(content: string): string {
    return createHash('sha256').update(content).digest('hex');
}
 
/**
 * Minimal key-value interface for plugin extension HTTP responses. `get` returns
 * `null` on a cache miss; `set` is best-effort and silently swallows write
 * failures so a broken cache never blocks the build.
 */
export interface HttpCache {
    get(url: string): string | null;
    set(url: string, body: string): void;
}
 
export interface CacheServiceOptions {
    /** When false, every read returns empty/null and every write is a no-op. */
    enabled: boolean;
    /** Directory (relative to `rootDir` or absolute) used as the cache root. Defaults to `.contractkit/cache`. */
    dir?: string;
}
 
/**
 * Unified cache service. Owns one root directory under which both the build
 * cache (file/plugin hashes, single JSON file) and the HTTP response cache
 * (one blob per URL hash) live.
 *
 * Layout:
 *   <root>/build.json              — FileHashMap from the previous run
 *   <root>/http/<sha256(url)>      — fetched HTTP response bodies
 *
 * When `enabled` is false the service is a no-op: reads return empty/null and
 * writes do nothing. Disk failures (corrupted JSON, unwritable directory) fall
 * through to the empty/null path so a broken cache never fails the build.
 */
export class CacheService {
    readonly enabled: boolean;
    readonly root: string;
    private readonly buildCachePath: string;
    private readonly httpCacheDir: string;
 
    constructor(rootDir: string, options: CacheServiceOptions) {
        this.enabled = options.enabled;
        this.root = resolve(rootDir, options.dir ?? DEFAULT_CACHE_DIR);
        this.buildCachePath = join(this.root, BUILD_CACHE_FILENAME);
        this.httpCacheDir = join(this.root, HTTP_CACHE_DIRNAME);
    }
 
    /**
     * Load the previous run's `FileHashMap` from disk, or return `{}` when
     * disabled, unreadable, or stamped with a different compiler fingerprint.
     * When `expectedFingerprint` is provided and doesn't match the cache's
     * stored `__compiler__` key, the cache is treated as empty so an upgrade
     * of `@contractkit/core` (or any plugin that influences codegen) forces a
     * full rebuild on the next run.
     */
    loadBuildCache(expectedFingerprint?: string): FileHashMap {
        if (!this.enabled) return {};
        try {
            const cache = JSON.parse(readFileSync(this.buildCachePath, 'utf-8')) as FileHashMap;
            if (expectedFingerprint !== undefined && cache[COMPILER_FINGERPRINT_KEY] !== expectedFingerprint) return {};
            return cache;
        } catch {
            return {};
        }
    }
 
    /** Persist a `FileHashMap` for the next run. No-op when disabled; write errors are swallowed. */
    saveBuildCache(cache: FileHashMap): void {
        if (!this.enabled) return;
        try {
            mkdirSync(dirname(this.buildCachePath), { recursive: true });
            writeFileSync(this.buildCachePath, JSON.stringify(cache, null, 2), 'utf-8');
        } catch {
            // best-effort
        }
    }
 
    /** HTTP cache view backed by this service, suitable for passing into the plugin-extension resolver. */
    httpCache(): HttpCache {
        return {
            get: (url) => this.getHttpResponse(url),
            set: (url, body) => this.setHttpResponse(url, body),
        };
    }
 
    private urlPath(url: string): string {
        return join(this.httpCacheDir, computeHash(url));
    }
 
    /** Read a previously cached HTTP body for `url`, or `null` on miss / when disabled / on read error. */
    getHttpResponse(url: string): string | null {
        if (!this.enabled) return null;
        const path = this.urlPath(url);
        if (!existsSync(path)) return null;
        try {
            return readFileSync(path, 'utf-8');
        } catch {
            return null;
        }
    }
 
    /** Persist an HTTP response body keyed by the URL's sha256. No-op when disabled; write errors are swallowed. */
    setHttpResponse(url: string, body: string): void {
        if (!this.enabled) return;
        try {
            mkdirSync(this.httpCacheDir, { recursive: true });
            writeFileSync(this.urlPath(url), body, 'utf-8');
        } catch {
            // best-effort
        }
    }
}
 
/**
 * Returns true when `filePath`'s `content` no longer matches the hash stored in
 * `cache`, or when `outPath` does not exist on disk. Used by plugin output
 * gating to decide whether a file needs regeneration.
 */
export function isFileChanged(filePath: string, content: string, outPath: string, cache: FileHashMap): boolean {
    if (!existsSync(outPath)) return true;
    const currentHash = computeHash(content);
    return cache[filePath] !== currentHash;
}