import { createHash } from "node:crypto" import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs" import { join } from "node:path" /** * Content-hash cache for the expensive build steps (`bun run ingest`, * `bun run deploy`). Each step hashes its inputs — source files plus the env * flags that change the output — and stores the digest under * `node_modules/.cache/lekterion/`. A matching digest on the next run means the * inputs are byte-identical, so the step can be skipped. The cache lives in * `node_modules` so it is disposable (a fresh `bun install` rebuilds it) and is * never committed. */ const CACHE_DIR = join("node_modules", ".cache", "lekterion") export type CacheInputs = { /** Files and directories (recursed) whose contents feed the digest. */ paths: string[] /** Path prefixes to skip while recursing (e.g. generated/huge subtrees). */ exclude?: string[] /** Env vars that change the output, mixed in as `KEY=value`. */ env?: Record /** Extra literal tokens to mix in (version constants, etc.). */ extra?: string[] } function excluded(p: string, exclude: string[]): boolean { return exclude.some((e) => p === e || p.startsWith(e + "/")) } function collectFiles(root: string, exclude: string[]): string[] { if (!existsSync(root) || excluded(root, exclude)) return [] if (statSync(root).isFile()) return [root] const out: string[] = [] for (const rel of readdirSync(root, { recursive: true, encoding: "utf8" })) { const p = join(root, rel) if (excluded(p, exclude)) continue if (statSync(p).isFile()) out.push(p) } return out } /** SHA-256 over each input file's path + contents, then the env and extras. */ export function inputHash(inputs: CacheInputs): string { const exclude = inputs.exclude ?? [] const files = [ ...new Set(inputs.paths.flatMap((p) => collectFiles(p, exclude))), ].sort() const h = createHash("sha256") for (const f of files) { h.update(f) h.update("\0") h.update(readFileSync(f)) h.update("\0") } for (const [k, v] of Object.entries(inputs.env ?? {})) h.update(`env:${k}=${v ?? ""}\0`) for (const e of inputs.extra ?? []) h.update(`extra:${e}\0`) return h.digest("hex") } export type BuildCache = { /** True when the stored digest matches — inputs are unchanged. */ hit: boolean /** Persist the current digest so the next run can hit. */ save: () => void } /** Compare `hash` against the stored digest for `name` under the cache dir. */ export function checkCache(name: string, hash: string): BuildCache { const file = join(CACHE_DIR, `${name}.hash`) const prev = existsSync(file) ? readFileSync(file, "utf8").trim() : null return { hit: prev === hash, save() { mkdirSync(CACHE_DIR, { recursive: true }) writeFileSync(file, hash + "\n") }, } }