diff --git a/.changeset/generate-cache-version.md b/.changeset/generate-cache-version.md new file mode 100644 index 0000000..fcd2d76 --- /dev/null +++ b/.changeset/generate-cache-version.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": patch +--- + +invalidate the persistent cache when `generate` or `generatorOptions` change, so a restored module no longer keeps the previous generator's bytes and name diff --git a/README.md b/README.md index d879c64..b4687fc 100644 --- a/README.md +++ b/README.md @@ -623,6 +623,18 @@ module.exports = { import webp from "./image.jpg?as=webp"; ``` +Under [`cache.type: "filesystem"`](https://webpack.js.org/configuration/cache/#cachetype) +a module is restored from the pack rather than rebuilt across runs. That +restored result is the generator's, so changing `generate` or +`generatorOptions` has to invalidate the pack, and the plugin adds their +identity to +[`cache.version`](https://webpack.js.org/configuration/cache/#cacheversion) so +it does. This needs the plugin to be in the config — `plugins` or +`optimization.minimizer` — since webpack builds the cache while it applies +them; a plugin applied by hand after `webpack()` returns is too late to reach +it, and a changed generator would then be ignored until the cache directory is +removed. + > **Note** > > `generate` needs a webpack whose `NormalModule` `processResult` hook can be diff --git a/src/index.js b/src/index.js index 7c2138a..87b93df 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +const crypto = require("crypto"); const os = require("os"); const path = require("path"); @@ -1128,6 +1129,39 @@ class TerserPlugin { }; } + /** + * Carries the generator's identity into the persistent cache's version. + * A generator rewrites a module's own build result, which the pack restores + * without rebuilding, and nothing per-module keys on a plugin. + * @private + * @param {Compiler} compiler compiler + * @returns {void} + */ + saltCacheVersion(compiler) { + const { generator } = this.options; + const { cache } = compiler.options; + + if (!generator || !cache || cache.type !== "filesystem") { + return; + } + + const implementations = Array.isArray(generator.implementation) + ? generator.implementation + : [generator.implementation]; + // Source rather than `getMinimizerVersion`: a generator already travels as + // source, and a custom one has no version to read. + const identity = getSerializeJavascript()({ + generator: implementations.map(String), + options: generator.options, + }); + + cache.version = `${cache.version || ""}|TerserPlugin-generate-${crypto + .createHash("sha256") + .update(identity) + .digest("hex") + .slice(0, 16)}`; + } + /** * Minify one source a module embeds in another language's output — CSS or * HTML reaching the bundle inside a JavaScript string literal, an @@ -1425,6 +1459,8 @@ class TerserPlugin { apply(compiler) { const pluginName = this.constructor.name; + this.saltCacheVersion(compiler); + let validated = false; const validateOptions = () => { if (validated) { diff --git a/test/generate-filesystem-cache.test.js b/test/generate-filesystem-cache.test.js new file mode 100644 index 0000000..bf20025 --- /dev/null +++ b/test/generate-filesystem-cache.test.js @@ -0,0 +1,300 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import MinimizerPlugin from "../src"; +import { replaceExtension } from "../src/utils"; + +import { getCompiler, getErrors } from "./helpers"; + +// Renaming an asset needs `NormalModule`'s `processResult` hook to be able to +// await. Read off what the build did rather than off a version number: the +// release carrying it is not out yet, so a version test would claim the +// capability on every webpack released before it. +/** + * @param {import("webpack").Stats} stats stats + * @returns {boolean} true when the plugin reported that it cannot await + */ +function reportedNoAwait(stats) { + return getErrors(stats).join("\n").includes("hook can await"); +} + +/** + * A stand-in for an encoder: it prefixes the bytes and says what the result is + * now called, which is all the plugin needs to rename the asset. + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer, filename: string }} the re-encoded result + */ +function toWebp(input) { + const [[name, code]] = Object.entries(input); + + toWebp.calls += 1; + + return { + code: Buffer.concat([Buffer.from("WEBP:"), Buffer.from(code)]), + filename: replaceExtension(name, "webp"), + }; +} + +toWebp.supportsBinary = () => true; +toWebp.supportsWorker = () => false; +toWebp.calls = 0; + +/** + * A second encoder. Its source differs from `toWebp`'s deliberately: source is + * what tells two generators apart when neither reports a version. + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer, filename: string }} the re-encoded result + */ +function toAvif(input) { + const [[name, code]] = Object.entries(input); + const marker = Buffer.from("AVIF:"); + + toAvif.calls += 1; + + return { + code: Buffer.concat([marker, Buffer.from(code)]), + filename: replaceExtension(name, "avif"), + }; +} + +toAvif.supportsBinary = () => true; +toAvif.supportsWorker = () => false; +toAvif.calls = 0; + +/** + * @param {string} directory directory to remove, with everything under it + * @returns {void} + */ +function removeRecursive(directory) { + for (const entry of fs.readdirSync(directory)) { + const full = path.join(directory, entry); + + if (fs.statSync(full).isDirectory()) { + removeRecursive(full); + } else { + fs.unlinkSync(full); + } + } + + fs.rmdirSync(directory); +} + +/** + * @param {import("webpack").Stats} stats stats + * @param {string} name the module's short name + * @returns {{ cached: boolean, built: boolean }} how it came to be in the build + */ +function moduleState(stats, name) { + const { modules } = stats.toJson({ + all: false, + modules: true, + cachedModules: true, + }); + const found = modules.find((item) => item.name === name); + + return { cached: Boolean(found.cached), built: Boolean(found.built) }; +} + +describe("generate option with the filesystem cache", () => { + let context; + let cacheDirectory; + + beforeEach(() => { + toWebp.calls = 0; + toAvif.calls = 0; + context = fs.mkdtempSync(path.join(os.tmpdir(), "minimizer-fs-cache-")); + cacheDirectory = path.join(context, "cache"); + + fs.writeFileSync( + path.join(context, "index.js"), + 'import jpg from "./image.jpg";\n\n// eslint-disable-next-line no-console\nconsole.log(jpg);\n', + ); + fs.writeFileSync(path.join(context, "image.jpg"), Buffer.from("first")); + }); + + afterEach(() => { + removeRecursive(context); + }); + + /** + * Runs one build against the shared cache directory and closes the compiler, + * which is what writes the pack out for the next run to read. + * @param {object=} options plugin options overriding the defaults + * @returns {Promise<{ stats: import("webpack").Stats, assets: string[], read: (name: string) => string }>} what the build produced + */ + function run(options) { + const compiler = getCompiler({ + context, + entry: path.join(context, "index.js"), + // Through the config rather than a later `apply`: the cache strategy is + // built while webpack applies the configured plugins, so a plugin + // applied after `webpack()` returns cannot reach its version. + plugins: [ + new MinimizerPlugin({ + test: /\.jpe?g$/i, + generate: toWebp, + ...options, + }), + ], + cache: { + type: "filesystem", + cacheDirectory, + // The test writes no config file for webpack to watch, and the + // default points at one that does not exist here. + buildDependencies: {}, + }, + module: { + rules: [ + { + test: /\.jpe?g$/i, + type: "asset/resource", + generator: { filename: "[name][ext]" }, + }, + ], + }, + }); + + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + compiler.close(() => reject(error)); + + return; + } + + // Nothing is read here: on a webpack that cannot await, the build + // reports an error and emits no bundle, and the caller checks that + // before asking for one. + const assets = Object.keys(stats.compilation.assets); + const output = stats.compilation.outputOptions.path; + + compiler.close((closeError) => { + if (closeError) { + reject(closeError); + + return; + } + + resolve({ + stats, + assets, + read: (name) => + compiler.outputFileSystem + .readFileSync(path.join(output, name)) + .toString(), + }); + }); + }); + }); + } + + it("should keep the rename when the module is restored from the pack", async () => { + const first = await run(); + + if (reportedNoAwait(first.stats)) { + return; + } + + expect(getErrors(first.stats)).toEqual([]); + expect(first.assets).toContain("image.webp"); + expect(toWebp.calls).toBe(1); + + const second = await run(); + + expect(getErrors(second.stats)).toEqual([]); + + // The second compiler is a new one reading the pack the first wrote, so + // the image module is restored rather than rebuilt. `assetResource` is + // serialized with it, which is what has to carry the rename -- a + // `matchResource` would not survive. + expect(toWebp.calls).toBe(1); + expect(moduleState(second.stats, "./image.jpg")).toEqual({ + cached: true, + built: false, + }); + expect(second.assets).toContain("image.webp"); + expect(second.assets).not.toContain("image.jpg"); + const bundle = second.read("main.js"); + + expect(bundle).toContain('"image.webp"'); + expect(bundle).not.toContain('"image.jpg"'); + }); + + it("should re-encode when the image changed between runs", async () => { + const first = await run(); + + if (reportedNoAwait(first.stats)) { + return; + } + + expect(first.assets).toContain("image.webp"); + + fs.writeFileSync(path.join(context, "image.jpg"), Buffer.from("second")); + + const second = await run(); + + expect(getErrors(second.stats)).toEqual([]); + // New bytes, so neither the module nor the generator's own cache entry + // may answer from the pack. + expect(moduleState(second.stats, "./image.jpg").built).toBe(true); + expect(toWebp.calls).toBe(2); + expect(second.assets).toContain("image.webp"); + expect(second.assets).not.toContain("image.jpg"); + }); + + it("should re-run a changed generator against a warm pack", async () => { + const first = await run(); + + if (reportedNoAwait(first.stats)) { + return; + } + + expect(first.assets).toContain("image.webp"); + + const second = await run({ generate: toAvif }); + + expect(getErrors(second.stats)).toEqual([]); + // Nothing per-module keys on the plugin, so without the generator in the + // pack's version the restored module would keep the previous generator's + // bytes and name. + expect(toAvif.calls).toBe(1); + expect(second.assets).toContain("image.avif"); + expect(second.assets).not.toContain("image.webp"); + expect(second.read("image.avif")).toBe("AVIF:first"); + }); + + it("should re-run the generator when only its options changed", async () => { + const first = await run({ generatorOptions: { quality: 50 } }); + + if (reportedNoAwait(first.stats)) { + return; + } + + expect(toWebp.calls).toBe(1); + + const second = await run({ generatorOptions: { quality: 90 } }); + + expect(getErrors(second.stats)).toEqual([]); + expect(toWebp.calls).toBe(2); + expect(second.assets).toContain("image.webp"); + }); + + it("should read an array of generators for the identity too", async () => { + const first = await run({ generate: [toWebp] }); + + if (reportedNoAwait(first.stats)) { + return; + } + + expect(first.assets).toContain("image.webp"); + expect(toWebp.calls).toBe(1); + + const second = await run({ generate: [toAvif] }); + + expect(getErrors(second.stats)).toEqual([]); + expect(toAvif.calls).toBe(1); + expect(second.assets).toContain("image.avif"); + expect(second.assets).not.toContain("image.webp"); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 71c6f39..5c54ee7 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -89,6 +89,15 @@ declare class TerserPlugin { * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] } | undefined} every configured minimizer, or undefined when nothing nested could be reached */ private embeddedMinimizer; + /** + * Carries the generator's identity into the persistent cache's version. + * A generator rewrites a module's own build result, which the pack restores + * without rebuilding, and nothing per-module keys on a plugin. + * @private + * @param {Compiler} compiler compiler + * @returns {void} + */ + private saltCacheVersion; /** * Minify one source a module embeds in another language's output — CSS or * HTML reaching the bundle inside a JavaScript string literal, an