From 1c59aa5c1e6004a39bc936250f621944c3968c3a Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 16 Aug 2019 11:36:11 -0400 Subject: [PATCH] refactor(@angular-devkit/build-angular): cache downlevel bundles --- package.json | 1 + .../angular_devkit/build_angular/package.json | 2 + .../build_angular/src/browser/index.ts | 160 +++++++++++++++--- .../build_angular/src/utils/process-bundle.ts | 54 +++++- .../e2e/tests/build/differential-cache.ts | 83 +++++++++ yarn.lock | 97 ++++++++--- 6 files changed, 347 insertions(+), 50 deletions(-) create mode 100644 tests/legacy-cli/e2e/tests/build/differential-cache.ts diff --git a/package.json b/package.json index 9a75b7ca2ebd..816491f29e25 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@types/clean-css": "^4.2.1", "@types/copy-webpack-plugin": "^4.4.1", "@types/express": "^4.16.0", + "@types/find-cache-dir": "^2.0.0", "@types/glob": "^7.0.0", "@types/inquirer": "^0.0.44", "@types/jasmine": "^3.3.8", diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index 1dfef579d82b..c38d00b6a159 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -17,12 +17,14 @@ "ajv": "6.10.2", "autoprefixer": "9.6.1", "browserslist": "4.6.6", + "cacache": "12.0.2", "caniuse-lite": "1.0.30000989", "circular-dependency-plugin": "5.2.0", "clean-css": "4.2.1", "copy-webpack-plugin": "5.0.4", "core-js": "3.2.1", "file-loader": "4.2.0", + "find-cache-dir": "3.0.0", "glob": "7.1.4", "istanbul-instrumenter-loader": "3.0.1", "karma-source-map-support": "1.4.0", diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index 96f21cb59f75..fb89ad2fee10 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -24,7 +24,10 @@ import { virtualFs, } from '@angular-devkit/core'; import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import { createHash } from 'crypto'; +import * as findCacheDirectory from 'find-cache-dir'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { from, of } from 'rxjs'; import { bufferCount, catchError, concatMap, map, mergeScan, switchMap } from 'rxjs/operators'; @@ -62,6 +65,7 @@ import { normalizeOptimization, normalizeSourceMaps, } from '../utils'; +import { CacheKey, ProcessBundleOptions } from '../utils/process-bundle'; import { assertCompatibleAngularVersion } from '../utils/version'; import { generateBrowserWebpackConfigFromContext, @@ -70,6 +74,10 @@ import { } from '../utils/webpack-browser-config'; import { Schema as BrowserBuilderSchema } from './schema'; +const cacache = require('cacache'); +const cacheDownlevelPath = findCacheDirectory({ name: 'angular-build-dl' }); +const packageVersion = require('../../package.json').version; + export type BrowserBuilderOutput = json.JsonObject & BuilderOutput & { outputPath: string; @@ -240,6 +248,7 @@ export function buildWebpackBrowser( 1, ), bufferCount(configs.length), + // tslint:disable-next-line: no-big-function switchMap(async buildEvents => { configs.length = 0; const success = buildEvents.every(r => r.success); @@ -274,9 +283,10 @@ export function buildWebpackBrowser( optimize: normalizeOptimization(options.optimization).scripts, sourceMaps: sourceMapOptions.scripts, hiddenSourceMaps: sourceMapOptions.hidden, + vendorSourceMaps: sourceMapOptions.vendor, }; - const actions: {}[] = []; + const actions: ProcessBundleOptions[] = []; const seen = new Set(); for (const file of emittedFiles) { // Scripts and non-javascript files are not processed @@ -348,6 +358,7 @@ export function buildWebpackBrowser( code, map, runtime: file.file.startsWith('runtime'), + ignoreOriginal: es5Polyfills, }); // Add the newly created ES5 bundles to the index as nomodule scripts @@ -359,30 +370,133 @@ export function buildWebpackBrowser( // Execute the bundle processing actions context.logger.info('Generating ES5 bundles for differential loading...'); - await new Promise((resolve, reject) => { - const workerFile = require.resolve('../utils/process-bundle'); - const workers = workerFarm( - { - maxRetries: 1, - }, - path.extname(workerFile) !== '.ts' - ? workerFile - : require.resolve('../utils/process-bundle-bootstrap'), - ['process'], - ); - let completed = 0; - const workCallback = (error: Error | null) => { - if (error) { - workerFarm.end(workers); - reject(error); - } else if (++completed === actions.length) { - workerFarm.end(workers); - resolve(); + + const processActions: typeof actions = []; + const cacheActions: { src: string; dest: string }[] = []; + for (const action of actions) { + // Create base cache key with elements: + // * package version - different build-angular versions cause different final outputs + // * code length/hash - ensure cached version matches the same input code + const codeHash = createHash('sha1') + .update(action.code) + .digest('hex'); + const baseCacheKey = `${packageVersion}|${action.code.length}|${codeHash}`; + + // Postfix added to sourcemap cache keys when vendor sourcemaps are present + // Allows non-destructive caching of both variants + const SourceMapVendorPostfix = + !!action.sourceMaps && action.vendorSourceMaps ? '|vendor' : ''; + + // Determine cache entries required based on build settings + const cacheKeys = []; + + // If optimizing and the original is not ignored, add original as required + if ((action.optimize || action.optimizeOnly) && !action.ignoreOriginal) { + cacheKeys[CacheKey.OriginalCode] = baseCacheKey + '|orig'; + + // If sourcemaps are enabled, add original sourcemap as required + if (action.sourceMaps) { + cacheKeys[CacheKey.OriginalMap] = + baseCacheKey + SourceMapVendorPostfix + '|orig-map'; + } + } + // If not only optimizing, add downlevel as required + if (!action.optimizeOnly) { + cacheKeys[CacheKey.DownlevelCode] = baseCacheKey + '|dl'; + + // If sourcemaps are enabled, add downlevel sourcemap as required + if (action.sourceMaps) { + cacheKeys[CacheKey.DownlevelMap] = + baseCacheKey + SourceMapVendorPostfix + '|dl-map'; + } + } + + // Attempt to get required cache entries + const cacheEntries = []; + for (const key of cacheKeys) { + if (key) { + cacheEntries.push(await cacache.get.info(cacheDownlevelPath, key)); + } else { + cacheEntries.push(null); + } + } + + // Check if required cache entries are present + let cached = cacheKeys.length > 0; + for (let i = 0; i < cacheKeys.length; ++i) { + if (cacheKeys[i] && !cacheEntries[i]) { + cached = false; + break; + } + } + + // If all required cached entries are present, use the cached entries + // Otherwise process the files + if (cached) { + if (cacheEntries[CacheKey.OriginalCode]) { + cacheActions.push({ + src: cacheEntries[CacheKey.OriginalCode].path, + dest: action.filename, + }); } - }; + if (cacheEntries[CacheKey.OriginalMap]) { + cacheActions.push({ + src: cacheEntries[CacheKey.OriginalMap].path, + dest: action.filename + '.map', + }); + } + if (cacheEntries[CacheKey.DownlevelCode]) { + cacheActions.push({ + src: cacheEntries[CacheKey.DownlevelCode].path, + dest: action.filename.replace('es2015', 'es5'), + }); + } + if (cacheEntries[CacheKey.DownlevelMap]) { + cacheActions.push({ + src: cacheEntries[CacheKey.DownlevelMap].path, + dest: action.filename.replace('es2015', 'es5') + '.map', + }); + } + } else { + processActions.push({ + ...action, + cacheKeys, + cachePath: cacheDownlevelPath || undefined, + }); + } + } + + for (const action of cacheActions) { + fs.copyFileSync(action.src, action.dest, fs.constants.COPYFILE_FICLONE); + } + + if (processActions.length > 0) { + await new Promise((resolve, reject) => { + const workerFile = require.resolve('../utils/process-bundle'); + const workers = workerFarm( + { + maxRetries: 1, + }, + path.extname(workerFile) !== '.ts' + ? workerFile + : require.resolve('../utils/process-bundle-bootstrap'), + ['process'], + ); + let completed = 0; + const workCallback = (error: Error | null) => { + if (error) { + workerFarm.end(workers); + reject(error); + } else if (++completed === processActions.length) { + workerFarm.end(workers); + resolve(); + } + }; + + processActions.forEach(action => workers['process'](action, workCallback)); + }); + } - actions.forEach(action => workers['process'](action, workCallback)); - }); context.logger.info('ES5 bundle generation complete.'); } else { const { emittedFiles = [] } = firstBuild; diff --git a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts index 6ed9cc5900a5..48d3d26873a1 100644 --- a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts +++ b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts @@ -11,16 +11,28 @@ import { SourceMapConsumer, SourceMapGenerator } from 'source-map'; import { minify } from 'terser'; const { transformAsync } = require('@babel/core'); +const cacache = require('cacache'); -interface ProcessBundleOptions { +export interface ProcessBundleOptions { filename: string; code: string; map?: string; - sourceMaps: boolean; - hiddenSourceMaps: boolean; - runtime: boolean; + sourceMaps?: boolean; + hiddenSourceMaps?: boolean; + vendorSourceMaps?: boolean; + runtime?: boolean; optimize: boolean; optimizeOnly?: boolean; + ignoreOriginal?: boolean; + cacheKeys?: (string | null)[]; + cachePath?: string; +} + +export const enum CacheKey { + OriginalCode = 0, + OriginalMap = 1, + DownlevelCode = 2, + DownlevelMap = 3, } export function process( @@ -31,6 +43,10 @@ export function process( } async function processWorker(options: ProcessBundleOptions): Promise { + if (!options.cacheKeys) { + options.cacheKeys = []; + } + // If no downlevelling required than just mangle code and return if (options.optimizeOnly) { return mangleOriginal(options); @@ -139,7 +155,9 @@ async function processWorker(options: ProcessBundleOptions): Promise { map = result.map; // Mangle original code - mangleOriginal(options); + if (!options.ignoreOriginal) { + await mangleOriginal(options); + } } else if (map) { map = JSON.stringify(map); } @@ -149,13 +167,20 @@ async function processWorker(options: ProcessBundleOptions): Promise { code += `\n//# sourceMappingURL=${path.basename(newFilePath)}.map`; } + if (options.cachePath && options.cacheKeys[CacheKey.DownlevelMap]) { + await cacache.put(options.cachePath, options.cacheKeys[CacheKey.DownlevelMap], map); + } + fs.writeFileSync(newFilePath + '.map', map); } + if (options.cachePath && options.cacheKeys[CacheKey.DownlevelCode]) { + await cacache.put(options.cachePath, options.cacheKeys[CacheKey.DownlevelCode], code); + } fs.writeFileSync(newFilePath, code); } -function mangleOriginal(options: ProcessBundleOptions): void { +async function mangleOriginal(options: ProcessBundleOptions): Promise { const resultOriginal = minify(options.code, { compress: false, ecma: 6, @@ -176,8 +201,25 @@ function mangleOriginal(options: ProcessBundleOptions): void { throw resultOriginal.error; } + if (options.cachePath && options.cacheKeys && options.cacheKeys[CacheKey.OriginalCode]) { + await cacache.put( + options.cachePath, + options.cacheKeys[CacheKey.OriginalCode], + resultOriginal.code, + ); + } + fs.writeFileSync(options.filename, resultOriginal.code); + if (resultOriginal.map) { + if (options.cachePath && options.cacheKeys && options.cacheKeys[CacheKey.OriginalMap]) { + await cacache.put( + options.cachePath, + options.cacheKeys[CacheKey.OriginalMap], + resultOriginal.map, + ); + } + fs.writeFileSync(options.filename + '.map', resultOriginal.map); } } diff --git a/tests/legacy-cli/e2e/tests/build/differential-cache.ts b/tests/legacy-cli/e2e/tests/build/differential-cache.ts new file mode 100644 index 000000000000..fe9fc766a1ae --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/differential-cache.ts @@ -0,0 +1,83 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import { rimraf } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +function generateFileHashMap(): Map { + const hashes = new Map(); + + fs.readdirSync('./dist/test-project').forEach(name => { + const data = fs.readFileSync('./dist/test-project/' + name); + const hash = crypto + .createHash('sha1') + .update(data) + .digest('hex'); + + hashes.set(name, hash); + }); + + return hashes; +} + +function validateHashes( + oldHashes: Map, + newHashes: Map, + shouldChange: Array, +): void { + oldHashes.forEach((hash, name) => { + if (hash === newHashes.get(name)) { + if (shouldChange.includes(name)) { + throw new Error(`"${name}" did not change hash (${hash})...`); + } + } else if (!shouldChange.includes(name)) { + throw new Error(`"${name}" changed hash (${hash})...`); + } + }); +} + +export default async function() { + let oldHashes: Map; + let newHashes: Map; + + // Remove the cache so that an initial build and build with cache can be tested + await rimraf('./node_modules/.cache'); + + let start = Date.now(); + await ng('build'); + let initial = Date.now() - start; + oldHashes = generateFileHashMap(); + + start = Date.now(); + await ng('build'); + let cached = Date.now() - start; + newHashes = generateFileHashMap(); + + validateHashes(oldHashes, newHashes, []); + + if (cached > initial * 0.70) { + throw new Error( + `Cached build time [${cached}] should not be greater than 70% of initial build time [${initial}].`, + ); + } + + // Remove the cache so that an initial build and build with cache can be tested + await rimraf('./node_modules/.cache'); + + start = Date.now(); + await ng('build', '--prod'); + initial = Date.now() - start; + oldHashes = generateFileHashMap(); + + start = Date.now(); + await ng('build', '--prod'); + cached = Date.now() - start; + newHashes = generateFileHashMap(); + + if (cached > initial * 0.70) { + throw new Error( + `Cached build time [${cached}] should not be greater than 70% of initial build time [${initial}].`, + ); + } + + validateHashes(oldHashes, newHashes, []); +} diff --git a/yarn.lock b/yarn.lock index 2c481c390108..f1bf1f399f5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1031,6 +1031,11 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/find-cache-dir@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/find-cache-dir/-/find-cache-dir-2.0.0.tgz#6ee79b947b8e51ce8c565fc8278822b2605609db" + integrity sha512-LHAReDNv7IVTE2Q+nPcRBgUZAUKPJIvR7efMrWgx69442KMoMK+QYjtTtK9WGUdaqUYVLkd/0cvCfb55LFWsVw== + "@types/form-data@*": version "2.2.1" resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e" @@ -2569,6 +2574,27 @@ bytes@3.1.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== +cacache@12.0.2, cacache@^12.0.2: + version "12.0.2" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.2.tgz#8db03205e36089a3df6954c66ce92541441ac46c" + integrity sha512-ifKgxH2CKhJEg6tNdAwziu6Q33EvuG26tYcda6PT3WKisZcYDXsnEdnRv67Po3yCzFfaSoMjGZzJyD2c3DT1dg== + dependencies: + bluebird "^3.5.5" + chownr "^1.1.1" + figgy-pudding "^3.5.1" + glob "^7.1.4" + graceful-fs "^4.1.15" + infer-owner "^1.0.3" + lru-cache "^5.1.1" + mississippi "^3.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.3" + ssri "^6.0.1" + unique-filename "^1.1.1" + y18n "^4.0.0" + cacache@^11.0.1, cacache@^11.2.0: version "11.2.0" resolved "https://registry.yarnpkg.com/cacache/-/cacache-11.2.0.tgz#617bdc0b02844af56310e411c0878941d5739965" @@ -2629,27 +2655,6 @@ cacache@^12.0.0: unique-filename "^1.1.1" y18n "^4.0.0" -cacache@^12.0.2: - version "12.0.2" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.2.tgz#8db03205e36089a3df6954c66ce92541441ac46c" - integrity sha512-ifKgxH2CKhJEg6tNdAwziu6Q33EvuG26tYcda6PT3WKisZcYDXsnEdnRv67Po3yCzFfaSoMjGZzJyD2c3DT1dg== - dependencies: - bluebird "^3.5.5" - chownr "^1.1.1" - figgy-pudding "^3.5.1" - glob "^7.1.4" - graceful-fs "^4.1.15" - infer-owner "^1.0.3" - lru-cache "^5.1.1" - mississippi "^3.0.0" - mkdirp "^0.5.1" - move-concurrently "^1.0.1" - promise-inflight "^1.0.1" - rimraf "^2.6.3" - ssri "^6.0.1" - unique-filename "^1.1.1" - y18n "^4.0.0" - cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -4872,6 +4877,15 @@ finalhandler@~1.1.2: statuses "~1.5.0" unpipe "~1.0.0" +find-cache-dir@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.0.0.tgz#cd4b7dd97b7185b7e17dbfe2d6e4115ee3eeb8fc" + integrity sha512-t7ulV1fmbxh5G9l/492O1p5+EBbr3uwpt6odhFTMc+nWyhmbloe+ja9BZ8pIBtqFWhOmCWVjx+pTW4zDkFoclw== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.0" + pkg-dir "^4.1.0" + find-cache-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" @@ -4908,6 +4922,14 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" +find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + flatted@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916" @@ -7072,6 +7094,13 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + lockfile@1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/lockfile/-/lockfile-1.0.4.tgz#07f819d25ae48f87e538e6578b6964a4981a5609" @@ -7310,6 +7339,13 @@ make-dir@^2.0.0, make-dir@^2.1.0: pify "^4.0.1" semver "^5.6.0" +make-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.0.0.tgz#1b5f39f6b9270ed33f9f054c5c0f84304989f801" + integrity sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw== + dependencies: + semver "^6.0.0" + make-error@^1.1.1: version "1.3.5" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" @@ -8423,6 +8459,13 @@ p-locate@^3.0.0: dependencies: p-limit "^2.0.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-map@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" @@ -8663,6 +8706,11 @@ path-exists@^3.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -8776,6 +8824,13 @@ pkg-dir@^3.0.0: dependencies: find-up "^3.0.0" +pkg-dir@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + pkginfo@0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff"