diff --git a/.changeset/cached-hash-update-buffer.md b/.changeset/cached-hash-update-buffer.md new file mode 100644 index 0000000..b201f52 --- /dev/null +++ b/.changeset/cached-hash-update-buffer.md @@ -0,0 +1,5 @@ +--- +"webpack-sources": patch +--- + +Hand out a recorded hash update as buffers, so cached data carries no big string. diff --git a/lib/CachedSource.js b/lib/CachedSource.js index 88c3e84..1fd0fd2 100644 --- a/lib/CachedSource.js +++ b/lib/CachedSource.js @@ -56,6 +56,25 @@ const mapToBufferedMap = (map) => { return bufferedMap; }; +/** + * Encodes every string a recorded hash update holds as a buffer, so cached data + * handed to a caller carries no big string: a string goes into the serialized + * payload, where it is copied in and rebuilt on every deserialization, while a + * buffer is carried alongside it. Hashing reads a string as utf8, so the + * encoded update hashes the same. It encodes in place, so a source handed out + * repeatedly is encoded once, and one that never is keeps its string uncopied. + * @param {(string | Buffer)[]=} update recorded hash update + * @returns {(string | Buffer)[]=} the update, holding no string + */ +const bufferHashUpdate = (update) => { + if (update === undefined) return undefined; + for (let index = 0; index < update.length; index++) { + const item = update[index]; + if (typeof item === "string") update[index] = Buffer.from(item, "utf8"); + } + return update; +}; + /** * @param {null | BufferedMap} bufferedMap buffered map * @returns {null | RawSourceMap} map @@ -209,7 +228,7 @@ class CachedSource extends Source { : undefined, size: this._cachedSize, maps: bufferedMaps, - hash: this._cachedHashUpdate, + hash: bufferHashUpdate(this._cachedHashUpdate), }; } diff --git a/test/CachedSource.js b/test/CachedSource.js index b92d892..fdc44ea 100644 --- a/test/CachedSource.js +++ b/test/CachedSource.js @@ -637,4 +637,34 @@ describe.each([ expect(clone.buffer()).toEqual(source.buffer()); expect(clone.size()).toEqual(source.size()); }); + + it("should hand out a recorded hash update without a big string", () => { + const big = "a".repeat(200000); + const source = new CachedSource(new OriginalSource(big, "big.js")); + const internal = /** @type {{ _cachedHashUpdate: unknown[] }} */ ( + /** @type {unknown} */ (source) + ); + + source.updateHash(crypto.createHash("md5")); + + // a source nobody asks cached data of keeps its own string, uncopied + expect(internal._cachedHashUpdate).toContain(big); + + const cachedData = source.getCachedData(); + expect(cachedData.hash).toBeDefined(); + for (const item of /** @type {(string | Buffer)[]} */ (cachedData.hash)) { + expect(typeof item).not.toBe("string"); + } + + // asking again encodes nothing: the recording already holds buffers + expect(source.getCachedData().hash).toBe(cachedData.hash); + + // @ts-expect-error for tests + const clone = new CachedSource(null, cachedData); + const cloneHash = crypto.createHash("md5"); + clone.updateHash(cloneHash); + const sourceHash = crypto.createHash("md5"); + source.updateHash(sourceHash); + expect(cloneHash.digest("hex")).toBe(sourceHash.digest("hex")); + }); });