RealContentHashPlugin.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Tobias Koppers @sokra
  4. */
  5. "use strict";
  6. const { SyncBailHook } = require("tapable");
  7. const { CachedSource, CompatSource, RawSource } = require("webpack-sources");
  8. const Compilation = require("../Compilation");
  9. const WebpackError = require("../errors/WebpackError");
  10. const { compareSelect, compareStrings } = require("../util/comparators");
  11. const createHash = require("../util/createHash");
  12. const createHooksRegistry = require("../util/createHooksRegistry");
  13. /**
  14. * @import {
  15. * HashFunction,
  16. * HashDigest
  17. * } from "../../declarations/WebpackOptions"
  18. */
  19. /** @import { Source } from "webpack-sources" */
  20. /** @import { Etag } from "../Cache" */
  21. /** @import { AssetInfo } from "../Compilation" */
  22. /** @import Compiler from "../Compiler" */
  23. /** @typedef {typeof import("../util/Hash")} Hash */
  24. /**
  25. * Defines the comparator type used by this module.
  26. * @template T
  27. * @typedef {import("../util/comparators").Comparator<T>} Comparator
  28. */
  29. /** @type {Hashes} */
  30. const EMPTY_SET = new Set();
  31. /**
  32. * Adds the provided item or item to this object.
  33. * @template T
  34. * @param {T | T[]} itemOrItems item or items
  35. * @param {Set<T>} list list
  36. */
  37. const addToList = (itemOrItems, list) => {
  38. if (Array.isArray(itemOrItems)) {
  39. for (const item of itemOrItems) {
  40. list.add(item);
  41. }
  42. } else if (itemOrItems) {
  43. list.add(itemOrItems);
  44. }
  45. };
  46. /**
  47. * Compares two non-empty buffer chunk arrays for byte-equality without
  48. * allocating a concatenated buffer.
  49. * @param {Buffer[]} a first chunk array
  50. * @param {Buffer[]} b second chunk array
  51. * @returns {boolean} true if the concatenations are byte-equal
  52. */
  53. const bufferArraysEqual = (a, b) => {
  54. let aIdx = 0;
  55. let aOff = 0;
  56. let bIdx = 0;
  57. let bOff = 0;
  58. while (aIdx < a.length && bIdx < b.length) {
  59. const aBuf = a[aIdx];
  60. const bBuf = b[bIdx];
  61. const len = Math.min(aBuf.length - aOff, bBuf.length - bOff);
  62. if (aBuf.compare(bBuf, bOff, bOff + len, aOff, aOff + len) !== 0) {
  63. return false;
  64. }
  65. aOff += len;
  66. bOff += len;
  67. if (aOff === aBuf.length) {
  68. aIdx++;
  69. aOff = 0;
  70. }
  71. if (bOff === bBuf.length) {
  72. bIdx++;
  73. bOff = 0;
  74. }
  75. }
  76. return aIdx === a.length && bIdx === b.length;
  77. };
  78. /**
  79. * Map sources to their buffer chunks and deduplicate by total byte content,
  80. * grouping by total length first to avoid full comparisons.
  81. * @template T
  82. * @param {T[]} input list
  83. * @param {(item: T) => Source} fn map function returning a Source
  84. * @returns {Buffer[][]} unique chunk arrays
  85. */
  86. const mapAndDeduplicateSourceBuffers = (input, fn) => {
  87. /** @type {Map<number, Buffer[][]>} */
  88. const bySize = new Map();
  89. /** @type {Buffer[][]} */
  90. const result = [];
  91. for (const value of input) {
  92. const source = fn(value);
  93. // TODO webpack 6: drop the `buffers` check, require webpack-sources >= 3.4
  94. // and call `source.buffers()` unconditionally.
  95. const chunks =
  96. // TODO remove in webpack 6, this is protection against authors who directly use `webpack-sources` outdated version
  97. typeof source.buffers === "function"
  98. ? source.buffers()
  99. : [source.buffer()];
  100. let total = 0;
  101. for (const c of chunks) total += c.length;
  102. const sameSize = bySize.get(total);
  103. if (sameSize) {
  104. let duplicate = false;
  105. for (const other of sameSize) {
  106. if (bufferArraysEqual(chunks, other)) {
  107. duplicate = true;
  108. break;
  109. }
  110. }
  111. if (duplicate) continue;
  112. sameSize.push(chunks);
  113. } else {
  114. bySize.set(total, [chunks]);
  115. }
  116. result.push(chunks);
  117. }
  118. return result;
  119. };
  120. /**
  121. * Escapes regular expression metacharacters
  122. * @param {string} str String to quote
  123. * @returns {string} Escaped string
  124. */
  125. const quoteMeta = (str) => str.replace(/[-[\]\\/{}()*+?.^$|]/g, "\\$&");
  126. /** @type {WeakMap<Source, CachedSource>} */
  127. const cachedSourceMap = new WeakMap();
  128. /**
  129. * Returns cached source.
  130. * @param {Source} source source
  131. * @returns {CachedSource} cached source
  132. */
  133. const toCachedSource = (source) => {
  134. if (source instanceof CachedSource) {
  135. return source;
  136. }
  137. const entry = cachedSourceMap.get(source);
  138. if (entry !== undefined) return entry;
  139. const newSource = new CachedSource(CompatSource.from(source));
  140. cachedSourceMap.set(source, newSource);
  141. return newSource;
  142. };
  143. /** @typedef {Set<string>} Hashes */
  144. /**
  145. * Defines the asset info for real content hash type used by this module.
  146. * @typedef {object} AssetInfoForRealContentHash
  147. * @property {string} name
  148. * @property {AssetInfo} info
  149. * @property {Source} source
  150. * @property {RawSource | undefined} newSource
  151. * @property {RawSource | undefined} newSourceWithoutOwn
  152. * @property {string} content
  153. * @property {Hashes | undefined} ownHashes
  154. * @property {Promise<void> | undefined} contentComputePromise
  155. * @property {Promise<void> | undefined} contentComputeWithoutOwnPromise
  156. * @property {Hashes | undefined} referencedHashes
  157. * @property {Hashes} hashes
  158. */
  159. const createCompilationHooks = () => ({
  160. /**
  161. * @type {SyncBailHook<[Buffer[], string], string | void>}
  162. * @since 5.8.0
  163. */
  164. updateHash: new SyncBailHook(["content", "oldHash"])
  165. });
  166. /**
  167. * @typedef {ReturnType<typeof createCompilationHooks>} CompilationHooks
  168. */
  169. /**
  170. * Defines the real content hash plugin options type used by this module.
  171. * @typedef {object} RealContentHashPluginOptions
  172. * @property {HashFunction} hashFunction the hash function to use
  173. * @property {HashDigest} hashDigest the hash digest to use
  174. */
  175. const PLUGIN_NAME = "RealContentHashPlugin";
  176. class RealContentHashPlugin {
  177. /**
  178. * Creates an instance of RealContentHashPlugin.
  179. * @param {RealContentHashPluginOptions} options options
  180. */
  181. constructor({ hashFunction, hashDigest }) {
  182. /** @type {HashFunction} */
  183. this._hashFunction = hashFunction;
  184. /** @type {HashDigest} */
  185. this._hashDigest = hashDigest;
  186. }
  187. /**
  188. * Applies the plugin by registering its hooks on the compiler.
  189. * @param {Compiler} compiler the compiler instance
  190. * @returns {void}
  191. */
  192. apply(compiler) {
  193. compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
  194. const cacheAnalyse = compilation.getCache(
  195. "RealContentHashPlugin|analyse"
  196. );
  197. const cacheGenerate = compilation.getCache(
  198. "RealContentHashPlugin|generate"
  199. );
  200. const hooks = RealContentHashPlugin.getCompilationHooks(compilation);
  201. compilation.hooks.processAssets.tapPromise(
  202. {
  203. name: PLUGIN_NAME,
  204. stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH
  205. },
  206. async () => {
  207. const assets = compilation.getAssets();
  208. /** @type {AssetInfoForRealContentHash[]} */
  209. const assetsWithInfo = [];
  210. /** @type {Map<string, [AssetInfoForRealContentHash]>} */
  211. const hashToAssets = new Map();
  212. // Inline `[contenthash:<digest>]` digest per hash, so the recomputed
  213. // real hash is re-encoded in it instead of `output.hashDigest`.
  214. /** @type {Map<string, string>} */
  215. const hashToDigest = new Map();
  216. for (const { source, info, name } of assets) {
  217. const cachedSource = toCachedSource(source);
  218. const content = /** @type {string} */ (cachedSource.source());
  219. /** @type {Hashes} */
  220. const hashes = new Set();
  221. addToList(info.contenthash, hashes);
  222. if (info.contenthashDigest) {
  223. for (const hash of Object.keys(info.contenthashDigest)) {
  224. hashToDigest.set(hash, info.contenthashDigest[hash]);
  225. }
  226. }
  227. /** @type {AssetInfoForRealContentHash} */
  228. const data = {
  229. name,
  230. info,
  231. source: cachedSource,
  232. newSource: undefined,
  233. newSourceWithoutOwn: undefined,
  234. content,
  235. ownHashes: undefined,
  236. contentComputePromise: undefined,
  237. contentComputeWithoutOwnPromise: undefined,
  238. referencedHashes: undefined,
  239. hashes
  240. };
  241. assetsWithInfo.push(data);
  242. for (const hash of hashes) {
  243. const list = hashToAssets.get(hash);
  244. if (list === undefined) {
  245. hashToAssets.set(hash, [data]);
  246. } else {
  247. list.push(data);
  248. }
  249. }
  250. }
  251. if (hashToAssets.size === 0) return;
  252. const hashRegExp = new RegExp(
  253. Array.from(hashToAssets.keys(), quoteMeta).join("|"),
  254. "g"
  255. );
  256. await Promise.all(
  257. assetsWithInfo.map(async (asset) => {
  258. const { name, source, content, hashes } = asset;
  259. if (Buffer.isBuffer(content)) {
  260. asset.referencedHashes = EMPTY_SET;
  261. asset.ownHashes = EMPTY_SET;
  262. return;
  263. }
  264. const etag = cacheAnalyse.mergeEtags(
  265. cacheAnalyse.getLazyHashedEtag(source),
  266. [...hashes].join("|")
  267. );
  268. [asset.referencedHashes, asset.ownHashes] =
  269. await cacheAnalyse.providePromise(name, etag, () => {
  270. /** @type {Hashes} */
  271. const referencedHashes = new Set();
  272. /** @type {Hashes} */
  273. const ownHashes = new Set();
  274. const inContent = content.match(hashRegExp);
  275. if (inContent) {
  276. for (const hash of inContent) {
  277. if (hashes.has(hash)) {
  278. ownHashes.add(hash);
  279. continue;
  280. }
  281. referencedHashes.add(hash);
  282. }
  283. }
  284. return [referencedHashes, ownHashes];
  285. });
  286. })
  287. );
  288. /**
  289. * Returns the referenced hashes.
  290. * @param {string} hash the hash
  291. * @returns {undefined | Hashes} the referenced hashes
  292. */
  293. const getDependencies = (hash) => {
  294. const assets = hashToAssets.get(hash);
  295. if (!assets) {
  296. const referencingAssets = assetsWithInfo.filter((asset) =>
  297. /** @type {Hashes} */ (asset.referencedHashes).has(hash)
  298. );
  299. const err = new WebpackError(`RealContentHashPlugin
  300. Some kind of unexpected caching problem occurred.
  301. An asset was cached with a reference to another asset (${hash}) that's not in the compilation anymore.
  302. Either the asset was incorrectly cached, or the referenced asset should also be restored from cache.
  303. Referenced by:
  304. ${referencingAssets
  305. .map((a) => {
  306. const match = new RegExp(`.{0,20}${quoteMeta(hash)}.{0,20}`).exec(
  307. a.content
  308. );
  309. return ` - ${a.name}: ...${match ? match[0] : "???"}...`;
  310. })
  311. .join("\n")}`);
  312. compilation.errors.push(err);
  313. return;
  314. }
  315. /** @type {Hashes} */
  316. const hashes = new Set();
  317. for (const { referencedHashes, ownHashes } of assets) {
  318. if (!(/** @type {Hashes} */ (ownHashes).has(hash))) {
  319. for (const hash of /** @type {Hashes} */ (ownHashes)) {
  320. hashes.add(hash);
  321. }
  322. }
  323. for (const hash of /** @type {Hashes} */ (referencedHashes)) {
  324. hashes.add(hash);
  325. }
  326. }
  327. return hashes;
  328. };
  329. /**
  330. * Returns the hash info.
  331. * @param {string} hash the hash
  332. * @returns {string} the hash info
  333. */
  334. const hashInfo = (hash) => {
  335. const assets = hashToAssets.get(hash);
  336. return `${hash} (${Array.from(
  337. /** @type {AssetInfoForRealContentHash[]} */ (assets),
  338. (a) => a.name
  339. )})`;
  340. };
  341. /** @type {Hashes} */
  342. const hashesInOrder = new Set();
  343. for (const hash of hashToAssets.keys()) {
  344. /**
  345. * Processes the provided hash.
  346. * @param {string} hash the hash
  347. * @param {Set<string>} stack stack of hashes
  348. */
  349. const add = (hash, stack) => {
  350. const deps = getDependencies(hash);
  351. if (!deps) return;
  352. stack.add(hash);
  353. for (const dep of deps) {
  354. if (hashesInOrder.has(dep)) continue;
  355. if (stack.has(dep)) {
  356. throw new Error(
  357. `Circular hash dependency ${Array.from(
  358. stack,
  359. hashInfo
  360. ).join(" -> ")} -> ${hashInfo(dep)}`
  361. );
  362. }
  363. add(dep, stack);
  364. }
  365. hashesInOrder.add(hash);
  366. stack.delete(hash);
  367. };
  368. if (hashesInOrder.has(hash)) continue;
  369. add(hash, new Set());
  370. }
  371. /** @type {Map<string, string>} */
  372. const hashToNewHash = new Map();
  373. /**
  374. * Returns etag.
  375. * @param {AssetInfoForRealContentHash} asset asset info
  376. * @returns {Etag} etag
  377. */
  378. const getEtag = (asset) =>
  379. cacheGenerate.mergeEtags(
  380. cacheGenerate.getLazyHashedEtag(asset.source),
  381. Array.from(
  382. /** @type {Hashes} */ (asset.referencedHashes),
  383. (hash) => hashToNewHash.get(hash)
  384. ).join("|")
  385. );
  386. /**
  387. * Compute new content.
  388. * @param {AssetInfoForRealContentHash} asset asset info
  389. * @returns {Promise<void>}
  390. */
  391. const computeNewContent = (asset) => {
  392. if (asset.contentComputePromise) return asset.contentComputePromise;
  393. return (asset.contentComputePromise = (async () => {
  394. if (
  395. /** @type {Hashes} */ (asset.ownHashes).size > 0 ||
  396. [.../** @type {Hashes} */ (asset.referencedHashes)].some(
  397. (hash) => hashToNewHash.get(hash) !== hash
  398. )
  399. ) {
  400. const identifier = asset.name;
  401. const etag = getEtag(asset);
  402. asset.newSource = await cacheGenerate.providePromise(
  403. identifier,
  404. etag,
  405. () => {
  406. const newContent = asset.content.replace(
  407. hashRegExp,
  408. (hash) => /** @type {string} */ (hashToNewHash.get(hash))
  409. );
  410. return new RawSource(newContent);
  411. }
  412. );
  413. }
  414. })());
  415. };
  416. /**
  417. * Compute new content without own.
  418. * @param {AssetInfoForRealContentHash} asset asset info
  419. * @returns {Promise<void>}
  420. */
  421. const computeNewContentWithoutOwn = (asset) => {
  422. if (asset.contentComputeWithoutOwnPromise) {
  423. return asset.contentComputeWithoutOwnPromise;
  424. }
  425. return (asset.contentComputeWithoutOwnPromise = (async () => {
  426. if (
  427. /** @type {Hashes} */ (asset.ownHashes).size > 0 ||
  428. [.../** @type {Hashes} */ (asset.referencedHashes)].some(
  429. (hash) => hashToNewHash.get(hash) !== hash
  430. )
  431. ) {
  432. const identifier = `${asset.name}|without-own`;
  433. const etag = getEtag(asset);
  434. asset.newSourceWithoutOwn = await cacheGenerate.providePromise(
  435. identifier,
  436. etag,
  437. () => {
  438. const newContent = asset.content.replace(
  439. hashRegExp,
  440. (hash) => {
  441. if (
  442. /** @type {Hashes} */
  443. (asset.ownHashes).has(hash)
  444. ) {
  445. return "";
  446. }
  447. return /** @type {string} */ (hashToNewHash.get(hash));
  448. }
  449. );
  450. return new RawSource(newContent);
  451. }
  452. );
  453. }
  454. })());
  455. };
  456. /** @type {Comparator<AssetInfoForRealContentHash>} */
  457. const comparator = compareSelect((a) => a.name, compareStrings);
  458. for (const oldHash of hashesInOrder) {
  459. const assets =
  460. /** @type {AssetInfoForRealContentHash[]} */
  461. (hashToAssets.get(oldHash));
  462. assets.sort(comparator);
  463. await Promise.all(
  464. assets.map((asset) =>
  465. /** @type {Hashes} */ (asset.ownHashes).has(oldHash)
  466. ? computeNewContentWithoutOwn(asset)
  467. : computeNewContent(asset)
  468. )
  469. );
  470. const uniqueChunkArrays = mapAndDeduplicateSourceBuffers(
  471. assets,
  472. (asset) => {
  473. if (/** @type {Hashes} */ (asset.ownHashes).has(oldHash)) {
  474. return asset.newSourceWithoutOwn || asset.source;
  475. }
  476. return asset.newSource || asset.source;
  477. }
  478. );
  479. /** @type {string | undefined} */
  480. let newHash;
  481. // Only materialize the public `Buffer[]` (one entry per unique
  482. // asset) when something is tapped; otherwise the hot path feeds
  483. // chunks into the hash directly, avoiding per-asset Buffer.concat.
  484. if (hooks.updateHash.isUsed()) {
  485. const assetsContent = uniqueChunkArrays.map((chunks) =>
  486. chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)
  487. );
  488. newHash =
  489. hooks.updateHash.call(assetsContent, oldHash) || undefined;
  490. }
  491. if (!newHash) {
  492. const hash = createHash(this._hashFunction);
  493. if (compilation.outputOptions.hashSalt) {
  494. hash.update(compilation.outputOptions.hashSalt);
  495. }
  496. for (const chunks of uniqueChunkArrays) {
  497. for (const c of chunks) hash.update(c);
  498. }
  499. const digest = hash.digest(
  500. /** @type {HashDigest} */ (
  501. hashToDigest.get(oldHash) || this._hashDigest
  502. )
  503. );
  504. newHash = digest.slice(0, oldHash.length);
  505. }
  506. hashToNewHash.set(oldHash, newHash);
  507. }
  508. await Promise.all(
  509. assetsWithInfo.map(async (asset) => {
  510. await computeNewContent(asset);
  511. const newName = asset.name.replace(
  512. hashRegExp,
  513. (hash) => /** @type {string} */ (hashToNewHash.get(hash))
  514. );
  515. const infoUpdate = {};
  516. const hash =
  517. /** @type {Exclude<AssetInfo["contenthash"], undefined>} */
  518. (asset.info.contenthash);
  519. infoUpdate.contenthash = Array.isArray(hash)
  520. ? hash.map(
  521. (hash) => /** @type {string} */ (hashToNewHash.get(hash))
  522. )
  523. : /** @type {string} */ (hashToNewHash.get(hash));
  524. if (asset.newSource !== undefined) {
  525. compilation.updateAsset(
  526. asset.name,
  527. asset.newSource,
  528. infoUpdate
  529. );
  530. } else {
  531. compilation.updateAsset(asset.name, asset.source, infoUpdate);
  532. }
  533. if (asset.name !== newName) {
  534. compilation.renameAsset(asset.name, newName);
  535. }
  536. })
  537. );
  538. }
  539. );
  540. });
  541. }
  542. }
  543. RealContentHashPlugin.getCompilationHooks = createHooksRegistry(
  544. createCompilationHooks
  545. );
  546. module.exports = RealContentHashPlugin;