468 lines
14 KiB
JavaScript
468 lines
14 KiB
JavaScript
/*
|
|
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("../Cache").Etag} Etag */
|
|
/** @typedef {import("../Compilation").AssetInfo} AssetInfo */
|
|
/** @typedef {import("../Compiler")} Compiler */
|
|
/** @typedef {typeof import("../util/Hash")} Hash */
|
|
|
|
const EMPTY_SET = new Set();
|
|
|
|
/**
|
|
* @template T
|
|
* @param {T | T[]} itemOrItems item or items
|
|
* @param {Set<T>} list list
|
|
*/
|
|
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();
|
|
|
|
/**
|
|
* @param {Source} source source
|
|
* @returns {CachedSource} cached source
|
|
*/
|
|
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 {Set<string>} OwnHashes */
|
|
/** @typedef {Set<string>} ReferencedHashes */
|
|
/** @typedef {Set<string>} Hashes */
|
|
|
|
/**
|
|
* @typedef {Object} AssetInfoForRealContentHash
|
|
* @property {string} name
|
|
* @property {AssetInfo} info
|
|
* @property {Source} source
|
|
* @property {RawSource | undefined} newSource
|
|
* @property {RawSource | undefined} newSourceWithoutOwn
|
|
* @property {string} content
|
|
* @property {OwnHashes | undefined} ownHashes
|
|
* @property {Promise<void> | undefined} contentComputePromise
|
|
* @property {Promise<void> | undefined} contentComputeWithoutOwnPromise
|
|
* @property {ReferencedHashes | undefined} referencedHashes
|
|
* @property {Hashes} 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;
|
|
}
|
|
|
|
/**
|
|
* @param {Object} options options object
|
|
* @param {string | Hash} options.hashFunction the hash function to use
|
|
* @param {string} options.hashDigest the hash digest to use
|
|
*/
|
|
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 = [];
|
|
/** @type {Map<string, [AssetInfoForRealContentHash]>} */
|
|
const hashToAssets = new Map();
|
|
for (const { source, info, name } of assets) {
|
|
const cachedSource = toCachedSource(source);
|
|
const content = /** @type {string} */ (cachedSource.source());
|
|
/** @type {Hashes} */
|
|
const hashes = new Set();
|
|
addToList(info.contenthash, hashes);
|
|
/** @type {AssetInfoForRealContentHash} */
|
|
const data = {
|
|
name,
|
|
info,
|
|
source: cachedSource,
|
|
newSource: undefined,
|
|
newSourceWithoutOwn: undefined,
|
|
content,
|
|
ownHashes: undefined,
|
|
contentComputePromise: undefined,
|
|
contentComputeWithoutOwnPromise: undefined,
|
|
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];
|
|
});
|
|
})
|
|
);
|
|
/**
|
|
* @param {string} hash the hash
|
|
* @returns {undefined | ReferencedHashes} the referenced hashes
|
|
*/
|
|
const getDependencies = hash => {
|
|
const assets = hashToAssets.get(hash);
|
|
if (!assets) {
|
|
const referencingAssets = assetsWithInfo.filter(asset =>
|
|
/** @type {ReferencedHashes} */ (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 (!(/** @type {OwnHashes} */ (ownHashes).has(hash))) {
|
|
for (const hash of /** @type {OwnHashes} */ (ownHashes)) {
|
|
hashes.add(hash);
|
|
}
|
|
}
|
|
for (const hash of /** @type {ReferencedHashes} */ (
|
|
referencedHashes
|
|
)) {
|
|
hashes.add(hash);
|
|
}
|
|
}
|
|
return hashes;
|
|
};
|
|
/**
|
|
* @param {string} hash the hash
|
|
* @returns {string} the hash info
|
|
*/
|
|
const hashInfo = hash => {
|
|
const assets = hashToAssets.get(hash);
|
|
return `${hash} (${Array.from(
|
|
/** @type {AssetInfoForRealContentHash[]} */ (assets),
|
|
a => a.name
|
|
)})`;
|
|
};
|
|
const hashesInOrder = new Set();
|
|
for (const hash of hashToAssets.keys()) {
|
|
/**
|
|
* @param {string} hash the hash
|
|
* @param {Set<string>} stack stack of hashes
|
|
*/
|
|
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();
|
|
/**
|
|
* @param {AssetInfoForRealContentHash} asset asset info
|
|
* @returns {Etag} etag
|
|
*/
|
|
const getEtag = asset =>
|
|
cacheGenerate.mergeEtags(
|
|
cacheGenerate.getLazyHashedEtag(asset.source),
|
|
Array.from(
|
|
/** @type {ReferencedHashes} */ (asset.referencedHashes),
|
|
hash => hashToNewHash.get(hash)
|
|
).join("|")
|
|
);
|
|
/**
|
|
* @param {AssetInfoForRealContentHash} asset asset info
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const computeNewContent = asset => {
|
|
if (asset.contentComputePromise) return asset.contentComputePromise;
|
|
return (asset.contentComputePromise = (async () => {
|
|
if (
|
|
/** @type {OwnHashes} */ (asset.ownHashes).size > 0 ||
|
|
Array.from(
|
|
/** @type {ReferencedHashes} */
|
|
(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);
|
|
}
|
|
);
|
|
}
|
|
})());
|
|
};
|
|
/**
|
|
* @param {AssetInfoForRealContentHash} asset asset info
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const computeNewContentWithoutOwn = asset => {
|
|
if (asset.contentComputeWithoutOwnPromise)
|
|
return asset.contentComputeWithoutOwnPromise;
|
|
return (asset.contentComputeWithoutOwnPromise = (async () => {
|
|
if (
|
|
/** @type {OwnHashes} */ (asset.ownHashes).size > 0 ||
|
|
Array.from(
|
|
/** @type {ReferencedHashes} */
|
|
(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 (
|
|
/** @type {OwnHashes} */ (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 =
|
|
/** @type {AssetInfoForRealContentHash[]} */
|
|
(hashToAssets.get(oldHash));
|
|
assets.sort(comparator);
|
|
await Promise.all(
|
|
assets.map(asset =>
|
|
/** @type {OwnHashes} */ (asset.ownHashes).has(oldHash)
|
|
? computeNewContentWithoutOwn(asset)
|
|
: computeNewContent(asset)
|
|
)
|
|
);
|
|
const assetsContent = mapAndDeduplicateBuffers(assets, asset => {
|
|
if (/** @type {OwnHashes} */ (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) {
|
|
const hash = createHash(this._hashFunction);
|
|
if (compilation.outputOptions.hashSalt) {
|
|
hash.update(compilation.outputOptions.hashSalt);
|
|
}
|
|
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;
|