Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/generate-cache-version.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const crypto = require("crypto");
const os = require("os");
const path = require("path");

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1425,6 +1459,8 @@ class TerserPlugin {
apply(compiler) {
const pluginName = this.constructor.name;

this.saltCacheVersion(compiler);

let validated = false;
const validateOptions = () => {
if (validated) {
Expand Down
300 changes: 300 additions & 0 deletions test/generate-filesystem-cache.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
9 changes: 9 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ declare class TerserPlugin<T = import("terser").MinifyOptions> {
* @returns {{ implementation: MinimizerImplementation<T>, options: MinimizerOptions<T>, 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
Expand Down
Loading