You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
408 lines
12 KiB
408 lines
12 KiB
/* |
|
MIT License http://www.opensource.org/licenses/mit-license.php |
|
Author Tobias Koppers @sokra |
|
*/ |
|
|
|
"use strict"; |
|
|
|
const { SyncBailHook } = require("tapable"); |
|
const { RawSource, CachedSource, CompatSource } = require("webpack-sources"); |
|
const Compilation = require("../Compilation"); |
|
const WebpackError = require("../WebpackError"); |
|
const { compareSelect, compareStrings } = require("../util/comparators"); |
|
const createHash = require("../util/createHash"); |
|
|
|
/** @typedef {import("webpack-sources").Source} Source */ |
|
/** @typedef {import("../Compilation").AssetInfo} AssetInfo */ |
|
/** @typedef {import("../Compiler")} Compiler */ |
|
|
|
const EMPTY_SET = new Set(); |
|
|
|
const addToList = (itemOrItems, list) => { |
|
if (Array.isArray(itemOrItems)) { |
|
for (const item of itemOrItems) { |
|
list.add(item); |
|
} |
|
} else if (itemOrItems) { |
|
list.add(itemOrItems); |
|
} |
|
}; |
|
|
|
/** |
|
* @template T |
|
* @param {T[]} input list |
|
* @param {function(T): Buffer} fn map function |
|
* @returns {Buffer[]} buffers without duplicates |
|
*/ |
|
const mapAndDeduplicateBuffers = (input, fn) => { |
|
// Buffer.equals compares size first so this should be efficient enough |
|
// If it becomes a performance problem we can use a map and group by size |
|
// instead of looping over all assets. |
|
const result = []; |
|
outer: for (const value of input) { |
|
const buf = fn(value); |
|
for (const other of result) { |
|
if (buf.equals(other)) continue outer; |
|
} |
|
result.push(buf); |
|
} |
|
return result; |
|
}; |
|
|
|
/** |
|
* Escapes regular expression metacharacters |
|
* @param {string} str String to quote |
|
* @returns {string} Escaped string |
|
*/ |
|
const quoteMeta = str => { |
|
return str.replace(/[-[\]\\/{}()*+?.^$|]/g, "\\$&"); |
|
}; |
|
|
|
const cachedSourceMap = new WeakMap(); |
|
|
|
const toCachedSource = source => { |
|
if (source instanceof CachedSource) { |
|
return source; |
|
} |
|
const entry = cachedSourceMap.get(source); |
|
if (entry !== undefined) return entry; |
|
const newSource = new CachedSource(CompatSource.from(source)); |
|
cachedSourceMap.set(source, newSource); |
|
return newSource; |
|
}; |
|
|
|
/** |
|
* @typedef {Object} AssetInfoForRealContentHash |
|
* @property {string} name |
|
* @property {AssetInfo} info |
|
* @property {Source} source |
|
* @property {RawSource | undefined} newSource |
|
* @property {RawSource | undefined} newSourceWithoutOwn |
|
* @property {string} content |
|
* @property {Set<string>} ownHashes |
|
* @property {Promise} contentComputePromise |
|
* @property {Promise} contentComputeWithoutOwnPromise |
|
* @property {Set<string>} referencedHashes |
|
* @property {Set<string>} hashes |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} CompilationHooks |
|
* @property {SyncBailHook<[Buffer[], string], string>} updateHash |
|
*/ |
|
|
|
/** @type {WeakMap<Compilation, CompilationHooks>} */ |
|
const compilationHooksMap = new WeakMap(); |
|
|
|
class RealContentHashPlugin { |
|
/** |
|
* @param {Compilation} compilation the compilation |
|
* @returns {CompilationHooks} the attached hooks |
|
*/ |
|
static getCompilationHooks(compilation) { |
|
if (!(compilation instanceof Compilation)) { |
|
throw new TypeError( |
|
"The 'compilation' argument must be an instance of Compilation" |
|
); |
|
} |
|
let hooks = compilationHooksMap.get(compilation); |
|
if (hooks === undefined) { |
|
hooks = { |
|
updateHash: new SyncBailHook(["content", "oldHash"]) |
|
}; |
|
compilationHooksMap.set(compilation, hooks); |
|
} |
|
return hooks; |
|
} |
|
|
|
constructor({ hashFunction, hashDigest }) { |
|
this._hashFunction = hashFunction; |
|
this._hashDigest = hashDigest; |
|
} |
|
|
|
/** |
|
* Apply the plugin |
|
* @param {Compiler} compiler the compiler instance |
|
* @returns {void} |
|
*/ |
|
apply(compiler) { |
|
compiler.hooks.compilation.tap("RealContentHashPlugin", compilation => { |
|
const cacheAnalyse = compilation.getCache( |
|
"RealContentHashPlugin|analyse" |
|
); |
|
const cacheGenerate = compilation.getCache( |
|
"RealContentHashPlugin|generate" |
|
); |
|
const hooks = RealContentHashPlugin.getCompilationHooks(compilation); |
|
compilation.hooks.processAssets.tapPromise( |
|
{ |
|
name: "RealContentHashPlugin", |
|
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH |
|
}, |
|
async () => { |
|
const assets = compilation.getAssets(); |
|
/** @type {AssetInfoForRealContentHash[]} */ |
|
const assetsWithInfo = []; |
|
const hashToAssets = new Map(); |
|
for (const { source, info, name } of assets) { |
|
const cachedSource = toCachedSource(source); |
|
const content = cachedSource.source(); |
|
/** @type {Set<string>} */ |
|
const hashes = new Set(); |
|
addToList(info.contenthash, hashes); |
|
const data = { |
|
name, |
|
info, |
|
source: cachedSource, |
|
/** @type {RawSource | undefined} */ |
|
newSource: undefined, |
|
/** @type {RawSource | undefined} */ |
|
newSourceWithoutOwn: undefined, |
|
content, |
|
/** @type {Set<string>} */ |
|
ownHashes: undefined, |
|
contentComputePromise: undefined, |
|
contentComputeWithoutOwnPromise: undefined, |
|
/** @type {Set<string>} */ |
|
referencedHashes: undefined, |
|
hashes |
|
}; |
|
assetsWithInfo.push(data); |
|
for (const hash of hashes) { |
|
const list = hashToAssets.get(hash); |
|
if (list === undefined) { |
|
hashToAssets.set(hash, [data]); |
|
} else { |
|
list.push(data); |
|
} |
|
} |
|
} |
|
if (hashToAssets.size === 0) return; |
|
const hashRegExp = new RegExp( |
|
Array.from(hashToAssets.keys(), quoteMeta).join("|"), |
|
"g" |
|
); |
|
await Promise.all( |
|
assetsWithInfo.map(async asset => { |
|
const { name, source, content, hashes } = asset; |
|
if (Buffer.isBuffer(content)) { |
|
asset.referencedHashes = EMPTY_SET; |
|
asset.ownHashes = EMPTY_SET; |
|
return; |
|
} |
|
const etag = cacheAnalyse.mergeEtags( |
|
cacheAnalyse.getLazyHashedEtag(source), |
|
Array.from(hashes).join("|") |
|
); |
|
[asset.referencedHashes, asset.ownHashes] = |
|
await cacheAnalyse.providePromise(name, etag, () => { |
|
const referencedHashes = new Set(); |
|
let ownHashes = new Set(); |
|
const inContent = content.match(hashRegExp); |
|
if (inContent) { |
|
for (const hash of inContent) { |
|
if (hashes.has(hash)) { |
|
ownHashes.add(hash); |
|
continue; |
|
} |
|
referencedHashes.add(hash); |
|
} |
|
} |
|
return [referencedHashes, ownHashes]; |
|
}); |
|
}) |
|
); |
|
const getDependencies = hash => { |
|
const assets = hashToAssets.get(hash); |
|
if (!assets) { |
|
const referencingAssets = assetsWithInfo.filter(asset => |
|
asset.referencedHashes.has(hash) |
|
); |
|
const err = new WebpackError(`RealContentHashPlugin |
|
Some kind of unexpected caching problem occurred. |
|
An asset was cached with a reference to another asset (${hash}) that's not in the compilation anymore. |
|
Either the asset was incorrectly cached, or the referenced asset should also be restored from cache. |
|
Referenced by: |
|
${referencingAssets |
|
.map(a => { |
|
const match = new RegExp(`.{0,20}${quoteMeta(hash)}.{0,20}`).exec( |
|
a.content |
|
); |
|
return ` - ${a.name}: ...${match ? match[0] : "???"}...`; |
|
}) |
|
.join("\n")}`); |
|
compilation.errors.push(err); |
|
return undefined; |
|
} |
|
const hashes = new Set(); |
|
for (const { referencedHashes, ownHashes } of assets) { |
|
if (!ownHashes.has(hash)) { |
|
for (const hash of ownHashes) { |
|
hashes.add(hash); |
|
} |
|
} |
|
for (const hash of referencedHashes) { |
|
hashes.add(hash); |
|
} |
|
} |
|
return hashes; |
|
}; |
|
const hashInfo = hash => { |
|
const assets = hashToAssets.get(hash); |
|
return `${hash} (${Array.from(assets, a => a.name)})`; |
|
}; |
|
const hashesInOrder = new Set(); |
|
for (const hash of hashToAssets.keys()) { |
|
const add = (hash, stack) => { |
|
const deps = getDependencies(hash); |
|
if (!deps) return; |
|
stack.add(hash); |
|
for (const dep of deps) { |
|
if (hashesInOrder.has(dep)) continue; |
|
if (stack.has(dep)) { |
|
throw new Error( |
|
`Circular hash dependency ${Array.from( |
|
stack, |
|
hashInfo |
|
).join(" -> ")} -> ${hashInfo(dep)}` |
|
); |
|
} |
|
add(dep, stack); |
|
} |
|
hashesInOrder.add(hash); |
|
stack.delete(hash); |
|
}; |
|
if (hashesInOrder.has(hash)) continue; |
|
add(hash, new Set()); |
|
} |
|
const hashToNewHash = new Map(); |
|
const getEtag = asset => |
|
cacheGenerate.mergeEtags( |
|
cacheGenerate.getLazyHashedEtag(asset.source), |
|
Array.from(asset.referencedHashes, hash => |
|
hashToNewHash.get(hash) |
|
).join("|") |
|
); |
|
const computeNewContent = asset => { |
|
if (asset.contentComputePromise) return asset.contentComputePromise; |
|
return (asset.contentComputePromise = (async () => { |
|
if ( |
|
asset.ownHashes.size > 0 || |
|
Array.from(asset.referencedHashes).some( |
|
hash => hashToNewHash.get(hash) !== hash |
|
) |
|
) { |
|
const identifier = asset.name; |
|
const etag = getEtag(asset); |
|
asset.newSource = await cacheGenerate.providePromise( |
|
identifier, |
|
etag, |
|
() => { |
|
const newContent = asset.content.replace(hashRegExp, hash => |
|
hashToNewHash.get(hash) |
|
); |
|
return new RawSource(newContent); |
|
} |
|
); |
|
} |
|
})()); |
|
}; |
|
const computeNewContentWithoutOwn = asset => { |
|
if (asset.contentComputeWithoutOwnPromise) |
|
return asset.contentComputeWithoutOwnPromise; |
|
return (asset.contentComputeWithoutOwnPromise = (async () => { |
|
if ( |
|
asset.ownHashes.size > 0 || |
|
Array.from(asset.referencedHashes).some( |
|
hash => hashToNewHash.get(hash) !== hash |
|
) |
|
) { |
|
const identifier = asset.name + "|without-own"; |
|
const etag = getEtag(asset); |
|
asset.newSourceWithoutOwn = await cacheGenerate.providePromise( |
|
identifier, |
|
etag, |
|
() => { |
|
const newContent = asset.content.replace( |
|
hashRegExp, |
|
hash => { |
|
if (asset.ownHashes.has(hash)) { |
|
return ""; |
|
} |
|
return hashToNewHash.get(hash); |
|
} |
|
); |
|
return new RawSource(newContent); |
|
} |
|
); |
|
} |
|
})()); |
|
}; |
|
const comparator = compareSelect(a => a.name, compareStrings); |
|
for (const oldHash of hashesInOrder) { |
|
const assets = hashToAssets.get(oldHash); |
|
assets.sort(comparator); |
|
const hash = createHash(this._hashFunction); |
|
await Promise.all( |
|
assets.map(asset => |
|
asset.ownHashes.has(oldHash) |
|
? computeNewContentWithoutOwn(asset) |
|
: computeNewContent(asset) |
|
) |
|
); |
|
const assetsContent = mapAndDeduplicateBuffers(assets, asset => { |
|
if (asset.ownHashes.has(oldHash)) { |
|
return asset.newSourceWithoutOwn |
|
? asset.newSourceWithoutOwn.buffer() |
|
: asset.source.buffer(); |
|
} else { |
|
return asset.newSource |
|
? asset.newSource.buffer() |
|
: asset.source.buffer(); |
|
} |
|
}); |
|
let newHash = hooks.updateHash.call(assetsContent, oldHash); |
|
if (!newHash) { |
|
for (const content of assetsContent) { |
|
hash.update(content); |
|
} |
|
const digest = hash.digest(this._hashDigest); |
|
newHash = /** @type {string} */ (digest.slice(0, oldHash.length)); |
|
} |
|
hashToNewHash.set(oldHash, newHash); |
|
} |
|
await Promise.all( |
|
assetsWithInfo.map(async asset => { |
|
await computeNewContent(asset); |
|
const newName = asset.name.replace(hashRegExp, hash => |
|
hashToNewHash.get(hash) |
|
); |
|
|
|
const infoUpdate = {}; |
|
const hash = asset.info.contenthash; |
|
infoUpdate.contenthash = Array.isArray(hash) |
|
? hash.map(hash => hashToNewHash.get(hash)) |
|
: hashToNewHash.get(hash); |
|
|
|
if (asset.newSource !== undefined) { |
|
compilation.updateAsset( |
|
asset.name, |
|
asset.newSource, |
|
infoUpdate |
|
); |
|
} else { |
|
compilation.updateAsset(asset.name, asset.source, infoUpdate); |
|
} |
|
|
|
if (asset.name !== newName) { |
|
compilation.renameAsset(asset.name, newName); |
|
} |
|
}) |
|
); |
|
} |
|
); |
|
}); |
|
} |
|
} |
|
|
|
module.exports = RealContentHashPlugin;
|
|
|