UNPKG

wireit

Version:

Upgrade your npm scripts to make them smarter and more efficient

164 lines 6.6 kB
/** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { createHash } from 'crypto'; import { createReadStream } from './util/fs.js'; import { glob } from './util/glob.js'; import { scriptReferenceToString } from './config.js'; /** * The fingerprint of a script. Converts lazily between string and data object * forms. */ export class Fingerprint { static fromString(string) { const fingerprint = new Fingerprint(); fingerprint.#str = string; return fingerprint; } /** * Generate the fingerprint data object for a script based on its current * configuration, input files, and the fingerprints of its dependencies. */ static async compute(script, dependencyFingerprints) { let allDependenciesAreFullyTracked = true; const filteredDependencyFingerprints = []; for (const [dep, depFingerprint] of dependencyFingerprints) { if (!dep.cascade) { // cascade: false means the fingerprint of the dependency isn't // directly inherited. continue; } if (!depFingerprint.data.fullyTracked) { allDependenciesAreFullyTracked = false; } filteredDependencyFingerprints.push([ scriptReferenceToString(dep.config), depFingerprint.hash, ]); } let fileHashes; if (script.files?.values.length) { const files = await glob(script.files.values, { cwd: script.packageDir, followSymlinks: true, // TODO(aomarks) This means that empty directories are not reflected in // the fingerprint, however an empty directory could modify the behavior // of a script. We should probably include empty directories; we'll just // need special handling when we compute the fingerprint, because there // is no hash we can compute. includeDirectories: false, // We must expand directories here, because we need the complete // explicit list of files to hash. expandDirectories: true, throwIfOutsideCwd: false, }); // TODO(aomarks) Instead of reading and hashing every input file on every // build, use inode/mtime/ctime/size metadata (which is much faster to // read) as a heuristic to detect files that have likely changed, and // otherwise re-use cached hashes that we store in e.g. // ".wireit/<script>/hashes". const erroredFilePaths = []; fileHashes = await Promise.all(files.map(async (file) => { const absolutePath = file.path; const hash = createHash('sha256'); try { const stream = await createReadStream(absolutePath); for await (const chunk of stream) { hash.update(chunk); } } catch (error) { // It's possible for a file to be deleted between the // time it is globbed and the time it is fingerprinted. const { code } = error; if (code !== /* does not exist */ 'ENOENT') { throw error; } erroredFilePaths.push(absolutePath); } return [file.path, hash.digest('hex')]; })); if (erroredFilePaths.length > 0) { return { ok: false, error: { type: 'failure', reason: 'input-file-deleted-unexpectedly', script: script, filePaths: erroredFilePaths, }, }; } } else { fileHashes = []; } const fullyTracked = // If any any dependency is not fully tracked, then we can't be either, // because we can't know if there was an undeclared input that this script // depends on. allDependenciesAreFullyTracked && // A no-command script. Doesn't ever do anything itsef, so always fully // tracked. (script.command === undefined || // A service. Fully tracked if we know its inputs. Can't produce output. (script.service !== undefined && script.files !== undefined) || // A standard script. Fully tracked if we know both its inputs and // outputs. (script.files !== undefined && script.output !== undefined)); const fingerprint = new Fingerprint(); // Note: The order of all fields is important so that we can do fast string // comparison. const data = { fullyTracked, platform: process.platform, arch: process.arch, nodeVersion: process.version, command: script.command?.value, extraArgs: script.extraArgs ?? [], clean: script.clean, files: Object.fromEntries(fileHashes.sort(([aFile], [bFile]) => aFile.localeCompare(bFile))), output: script.output?.values ?? [], dependencies: Object.fromEntries(filteredDependencyFingerprints.sort(([aRef], [bRef]) => aRef.localeCompare(bRef))), service: script.service === undefined ? undefined : { readyWhen: { lineMatches: script.service.readyWhen.lineMatches?.toString(), }, }, env: script.env, }; fingerprint.#data = data; return { ok: true, value: fingerprint }; } #str; #data; #hash; get string() { if (this.#str === undefined) { this.#str = JSON.stringify(this.#data); } return this.#str; } get data() { if (this.#data === undefined) { this.#data = JSON.parse(this.#str); } return this.#data; } get hash() { if (this.#hash === undefined) { this.#hash = createHash('sha256') .update(this.string) .digest('hex'); } return this.#hash; } equal(other) { return this.string === other.string; } } //# sourceMappingURL=fingerprint.js.map