import {createHash} from 'crypto'; import {existsSync, Stats} from 'fs'; import {mkdir, readFile, stat, writeFile} from 'fs/promises'; import {glob} from 'glob'; import {tmpdir} from 'os'; import {dirname, join, resolve} from 'path'; import {chdir} from 'process'; const packageRoot = resolve(join(__dirname, '..', '..')); chdir(packageRoot); interface JobContext { name: string; inputs: string[]; outputs: string[]; } class JobBuilder { #inputs: string[] = []; #outputs: string[] = []; #callback: (ctx: JobContext) => Promise; #name: string; #value = ''; #force = false; constructor(name: string, callback: (ctx: JobContext) => Promise) { this.#name = name; this.#callback = callback; } get jobHash(): string { return createHash('sha256').update(this.#name).digest('hex'); } force() { this.#force = true; return this; } value(value: string) { this.#value = value; return this; } inputs(inputs: string[]): JobBuilder { this.#inputs = inputs.flatMap(value => { if (glob.hasMagic(value)) { return glob.sync(value).map(value => { // Glob doesn't support `\` on Windows, so we join here. return join(packageRoot, value); }); } return join(packageRoot, value); }); return this; } outputs(outputs: string[]): JobBuilder { if (!this.#name) { this.#name = outputs.join(' and '); } this.#outputs = outputs.map(value => { return join(packageRoot, value); }); return this; } async build(): Promise { console.log(`Running job ${this.#name}...`); // For debugging. if (this.#force) { return this.#run(); } // In case we deleted an output file on purpose. if (!this.getOutputStats()) { return this.#run(); } // Run if the job has a value, but it changes. if (this.#value) { if (!(await this.isValueDifferent())) { return; } return this.#run(); } // Always run when there is no output. if (!this.#outputs.length) { return this.#run(); } // Make-like comparator. if (!(await this.areInputsNewer())) { return; } return this.#run(); } async isValueDifferent(): Promise { const file = join(tmpdir(), `puppeteer/${this.jobHash}.txt`); await mkdir(dirname(file), {recursive: true}); if (!existsSync(file)) { await writeFile(file, this.#value); return true; } return this.#value !== (await readFile(file, 'utf8')); } #outputStats?: Stats[]; async getOutputStats(): Promise { if (this.#outputStats) { return this.#outputStats; } try { this.#outputStats = await Promise.all( this.#outputs.map(output => { return stat(output); }) ); } catch {} return this.#outputStats; } async areInputsNewer(): Promise { const inputStats = await Promise.all( this.#inputs.map(input => { return stat(input); }) ); const outputStats = await this.getOutputStats(); if ( outputStats && outputStats.reduce(reduceMinTime, Infinity) > inputStats.reduce(reduceMaxTime, 0) ) { return false; } return true; } #run(): Promise { return this.#callback({ name: this.#name, inputs: this.#inputs, outputs: this.#outputs, }); } } export const job = ( name: string, callback: (ctx: JobContext) => Promise ): JobBuilder => { return new JobBuilder(name, callback); }; const reduceMaxTime = (time: number, stat: Stats) => { return time < stat.mtimeMs ? stat.mtimeMs : time; }; const reduceMinTime = (time: number, stat: Stats) => { return time > stat.mtimeMs ? stat.mtimeMs : time; };