image-minimizer-webpack-plugin
Version:
Webpack loader and plugin to optimize (compress) images using imagemin
878 lines (730 loc) • 22.4 kB
JavaScript
const path = require("path");
/** @typedef {import("./index").WorkerResult} WorkerResult */
/** @typedef {import("./index").SquooshOptions} SquooshOptions */
/** @typedef {import("imagemin").Options} ImageminOptions */
/** @typedef {import("webpack").WebpackError} WebpackError */
const notSettled = Symbol("not-settled");
/**
* @template T
* @typedef {() => Promise<T>} Task
*/
/**
* Run tasks with limited concurency.
* @template T
* @param {number} limit - Limit of tasks that run at once.
* @param {Task<T>[]} tasks - List of tasks to run.
* @returns {Promise<T[]>} A promise that fulfills to an array of the results
*/
function throttleAll(limit, tasks) {
if (!Number.isInteger(limit) || limit < 1) {
throw new TypeError(`Expected 'limit' to be a finite number > 0, got \`${limit}\` (${typeof limit})`);
}
if (!Array.isArray(tasks) || !tasks.every(task => typeof task === "function")) {
throw new TypeError("Expected 'tasks' to be a list of functions returning a promise");
}
return new Promise((resolve, reject) => {
// eslint-disable-next-line unicorn/new-for-builtins
const result = Array(tasks.length).fill(notSettled);
const entries = tasks.entries();
const next = () => {
const {
done,
value
} = entries.next();
if (done) {
const isLast = !result.includes(notSettled);
if (isLast) {
// eslint-disable-next-line node/callback-return
resolve(result);
}
return;
}
const [index, task] = value;
/**
* @param {T} i
*/
const onFulfilled = i => {
result[index] = i;
next();
};
task().then(onFulfilled, reject);
}; // eslint-disable-next-line unicorn/new-for-builtins
Array(limit).fill(0).forEach(next);
});
}
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
const WINDOWS_PATH_REGEX = /^[a-zA-Z]:\\/;
/**
* @param {string} url
* @returns {boolean}
*/
function isAbsoluteURL(url) {
if (WINDOWS_PATH_REGEX.test(url)) {
return false;
}
return ABSOLUTE_URL_REGEX.test(url);
}
/**
* @callback Uint8ArrayUtf8ByteString
* @param {number[] | Uint8Array} array
* @param {number} start
* @param {number} end
* @returns {string}
*/
/** @type {Uint8ArrayUtf8ByteString} */
const uint8ArrayUtf8ByteString = (array, start, end) => String.fromCodePoint(...array.slice(start, end));
/**
* @callback StringToBytes
* @param {string} string
* @returns {number[]}
*/
/** @type {StringToBytes} */
const stringToBytes = string => // eslint-disable-next-line unicorn/prefer-code-point
[...string].map(character => character.charCodeAt(0));
/**
* @param {ArrayBuffer | ArrayLike<number>} input
* @returns {{ext: string, mime: string} | undefined}
*/
function fileTypeFromBuffer(input) {
if (!(input instanceof Uint8Array || input instanceof ArrayBuffer || Buffer.isBuffer(input))) {
throw new TypeError(`Expected the \`input\` argument to be of type \`Uint8Array\` or \`Buffer\` or \`ArrayBuffer\`, got \`${typeof input}\``);
}
const buffer = input instanceof Uint8Array ? input : new Uint8Array(input);
if (!(buffer && buffer.length > 1)) {
return;
}
/**
* @param {number[]} header
* @param {{offset: number, mask?: number[]}} [options]
* @returns {boolean}
*/
const check = (header, options) => {
// eslint-disable-next-line no-param-reassign
options = {
offset: 0,
...options
};
for (let i = 0; i < header.length; i++) {
if (options.mask) {
// eslint-disable-next-line no-bitwise
if (header[i] !== (options.mask[i] & buffer[i + options.offset])) {
return false;
}
} else if (header[i] !== buffer[i + options.offset]) {
return false;
}
}
return true;
};
/**
* @param {string} header
* @param {{offset: number, mask?: number[]}} [options]
* @returns {boolean}
*/
const checkString = (header, options) => check(stringToBytes(header), options);
if (check([0xff, 0xd8, 0xff])) {
return {
ext: "jpg",
mime: "image/jpeg"
};
}
if (check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
// APNG format (https://wiki.mozilla.org/APNG_Specification)
// 1. Find the first IDAT (image data) chunk (49 44 41 54)
// 2. Check if there is an "acTL" chunk before the IDAT one (61 63 54 4C)
// Offset calculated as follows:
// - 8 bytes: PNG signature
// - 4 (length) + 4 (chunk type) + 13 (chunk data) + 4 (CRC): IHDR chunk
const startIndex = 33;
const firstImageDataChunkIndex = buffer.findIndex((el, i) => i >= startIndex && buffer[i] === 0x49 && buffer[i + 1] === 0x44 && buffer[i + 2] === 0x41 && buffer[i + 3] === 0x54);
const sliced = buffer.subarray(startIndex, firstImageDataChunkIndex);
if (sliced.findIndex((el, i) => sliced[i] === 0x61 && sliced[i + 1] === 0x63 && sliced[i + 2] === 0x54 && sliced[i + 3] === 0x4c) >= 0) {
return {
ext: "apng",
mime: "image/apng"
};
}
return {
ext: "png",
mime: "image/png"
};
}
if (check([0x47, 0x49, 0x46])) {
return {
ext: "gif",
mime: "image/gif"
};
}
if (check([0x57, 0x45, 0x42, 0x50], {
offset: 8
})) {
return {
ext: "webp",
mime: "image/webp"
};
}
if (check([0x46, 0x4c, 0x49, 0x46])) {
return {
ext: "flif",
mime: "image/flif"
};
} // `cr2`, `orf`, and `arw` need to be before `tif` check
if ((check([0x49, 0x49, 0x2a, 0x0]) || check([0x4d, 0x4d, 0x0, 0x2a])) && check([0x43, 0x52], {
offset: 8
})) {
return {
ext: "cr2",
mime: "image/x-canon-cr2"
};
}
if (check([0x49, 0x49, 0x52, 0x4f, 0x08, 0x00, 0x00, 0x00, 0x18])) {
return {
ext: "orf",
mime: "image/x-olympus-orf"
};
}
if (check([0x49, 0x49, 0x2a, 0x00]) && (check([0x10, 0xfb, 0x86, 0x01], {
offset: 4
}) || check([0x08, 0x00, 0x00, 0x00], {
offset: 4
})) && // This pattern differentiates ARW from other TIFF-ish file types:
check([0x00, 0xfe, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x03, 0x01], {
offset: 9
})) {
return {
ext: "arw",
mime: "image/x-sony-arw"
};
}
if (check([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]) && (check([0x2d, 0x00, 0xfe, 0x00], {
offset: 8
}) || check([0x27, 0x00, 0xfe, 0x00], {
offset: 8
}))) {
return {
ext: "dng",
mime: "image/x-adobe-dng"
};
}
if (check([0x49, 0x49, 0x2a, 0x00]) && check([0x1c, 0x00, 0xfe, 0x00], {
offset: 8
})) {
return {
ext: "nef",
mime: "image/x-nikon-nef"
};
}
if (check([0x49, 0x49, 0x55, 0x00, 0x18, 0x00, 0x00, 0x00, 0x88, 0xe7, 0x74, 0xd8])) {
return {
ext: "rw2",
mime: "image/x-panasonic-rw2"
};
} // `raf` is here just to keep all the raw image detectors together.
if (checkString("FUJIFILMCCD-RAW")) {
return {
ext: "raf",
mime: "image/x-fujifilm-raf"
};
}
if (check([0x49, 0x49, 0x2a, 0x0]) || check([0x4d, 0x4d, 0x0, 0x2a])) {
return {
ext: "tif",
mime: "image/tiff"
};
}
if (check([0x42, 0x4d])) {
return {
ext: "bmp",
mime: "image/bmp"
};
}
if (check([0x49, 0x49, 0xbc])) {
return {
ext: "jxr",
mime: "image/vnd.ms-photo"
};
}
if (check([0x38, 0x42, 0x50, 0x53])) {
return {
ext: "psd",
mime: "image/vnd.adobe.photoshop"
};
}
if (checkString("ftyp", {
offset: 4
}) && // eslint-disable-next-line no-bitwise
(buffer[8] & 0x60) !== 0x00 // Brand major, first character ASCII?
) {
// They all can have MIME `video/mp4` except `application/mp4` special-case which is hard to detect.
// For some cases, we're specific, everything else falls to `video/mp4` with `mp4` extension.
const brandMajor = uint8ArrayUtf8ByteString(buffer, 8, 12).replace("\0", " ").trim(); // eslint-disable-next-line default-case
switch (brandMajor) {
case "avif":
return {
ext: "avif",
mime: "image/avif"
};
case "mif1":
return {
ext: "heic",
mime: "image/heif"
};
case "msf1":
return {
ext: "heic",
mime: "image/heif-sequence"
};
case "heic":
case "heix":
return {
ext: "heic",
mime: "image/heic"
};
case "hevc":
case "hevx":
return {
ext: "heic",
mime: "image/heic-sequence"
};
}
}
if (check([0x00, 0x00, 0x01, 0x00])) {
return {
ext: "ico",
mime: "image/x-icon"
};
}
if (check([0x00, 0x00, 0x02, 0x00])) {
return {
ext: "cur",
mime: "image/x-icon"
};
}
if (check([0x42, 0x50, 0x47, 0xfb])) {
return {
ext: "bpg",
mime: "image/bpg"
};
}
if (check([0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a])) {
// JPEG-2000 family
if (check([0x6a, 0x70, 0x32, 0x20], {
offset: 20
})) {
return {
ext: "jp2",
mime: "image/jp2"
};
}
if (check([0x6a, 0x70, 0x78, 0x20], {
offset: 20
})) {
return {
ext: "jpx",
mime: "image/jpx"
};
}
if (check([0x6a, 0x70, 0x6d, 0x20], {
offset: 20
})) {
return {
ext: "jpm",
mime: "image/jpm"
};
}
if (check([0x6d, 0x6a, 0x70, 0x32], {
offset: 20
})) {
return {
ext: "mj2",
mime: "image/mj2"
};
}
}
if (check([0xff, 0x0a]) || check([0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a])) {
return {
ext: "jxl",
mime: "image/jxl"
};
}
if (check([0xab, 0x4b, 0x54, 0x58, 0x20, 0x31, 0x31, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a])) {
return {
ext: "ktx",
mime: "image/ktx"
};
}
}
/**
* @typedef {Object} MetaData
* @property {Array<Error>} warnings
* @property {Array<Error>} errors
*/
class InvalidConfigError extends Error {
/**
* @param {string | undefined} message
*/
constructor(message) {
super(message);
this.name = "InvalidConfigError";
}
}
/**
* @template T
* @param {ImageminOptions} imageminConfig
* @returns {Promise<ImageminOptions>}
*/
async function imageminNormalizeConfig(imageminConfig) {
if (!imageminConfig || !imageminConfig.plugins || imageminConfig.plugins && imageminConfig.plugins.length === 0) {
throw new Error("No plugins found for `imagemin`, please read documentation");
}
/**
* @type {import("imagemin").Plugin[]}
*/
const plugins = [];
for (const plugin of imageminConfig.plugins) {
const isPluginArray = Array.isArray(plugin);
if (typeof plugin === "string" || isPluginArray) {
const pluginName = isPluginArray ? plugin[0] : plugin;
const pluginOptions = isPluginArray ? plugin[1] : undefined;
let requiredPlugin = null;
let requiredPluginName = `imagemin-${pluginName}`;
try {
// @ts-ignore
// eslint-disable-next-line no-await-in-loop
requiredPlugin = (await import(requiredPluginName)).default(pluginOptions);
} catch {
requiredPluginName = pluginName;
try {
// @ts-ignore
// eslint-disable-next-line no-await-in-loop
requiredPlugin = (await import(requiredPluginName)).default(pluginOptions);
} catch {
const pluginNameForError = pluginName.startsWith("imagemin") ? pluginName : `imagemin-${pluginName}`;
throw new Error(`Unknown plugin: ${pluginNameForError}\n\nDid you forget to install the plugin?\nYou can install it with:\n\n$ npm install ${pluginNameForError} --save-dev\n$ yarn add ${pluginNameForError} --dev`);
} // Nothing
} // let version = "unknown";
// try {
// // eslint-disable-next-line import/no-dynamic-require
// ({ version } = require(`${requiredPluginName}/package.json`));
// } catch {
// // Nothing
// }
// /** @type {Array<Object>} imageminConfig.pluginsMeta */
// pluginsMeta.push([
// {
// name: requiredPluginName,
// options: pluginOptions || {},
// version,
// },
// ]);
plugins.push(requiredPlugin);
} else {
throw new InvalidConfigError(`Invalid plugin configuration '${JSON.stringify(plugin)}', plugin configuration should be 'string' or '[string, object]'"`);
}
}
return {
plugins
};
}
/**
* @template T
* @param {WorkerResult} original
* @param {T} minimizerOptions
* @returns {Promise<WorkerResult>}
*/
async function imageminGenerate(original, minimizerOptions) {
const minimizerOptionsNormalized =
/** @type {ImageminOptions} */
await imageminNormalizeConfig(
/** @type {ImageminOptions} */
/** @type {?} */
minimizerOptions || {}); // @ts-ignore
// eslint-disable-next-line node/no-unpublished-import
const imagemin = (await import("imagemin")).default;
let result;
try {
// @ts-ignore
result = await imagemin.buffer(original.data, minimizerOptionsNormalized);
} catch (error) {
const originalError = error instanceof Error ? error : new Error(
/** @type {string} */
error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return original;
}
const {
ext: extOutput
} = fileTypeFromBuffer(result) || {};
const extInput = path.extname(original.filename).slice(1).toLowerCase();
let newFilename = original.filename;
if (extOutput && extInput !== extOutput) {
newFilename = original.filename.replace(new RegExp(`${extInput}$`), `${extOutput}`);
}
return {
filename: newFilename,
data: result,
warnings: [...original.warnings],
errors: [...original.errors],
info: { ...original.info,
generated: true,
generatedBy: original.info && original.info.generatedBy ? ["imagemin", ...original.info.generatedBy] : ["imagemin"]
}
};
}
/**
* @template T
* @param {WorkerResult} original
* @param {T} options
* @returns {Promise<WorkerResult>}
*/
async function imageminMinify(original, options) {
const minimizerOptionsNormalized =
/** @type {ImageminOptions} */
await imageminNormalizeConfig(
/** @type {ImageminOptions} */
/** @type {?} */
options || {}); // @ts-ignore
// eslint-disable-next-line node/no-unpublished-import
const imagemin = (await import("imagemin")).default;
let result;
try {
// @ts-ignore
result = await imagemin.buffer(original.data, minimizerOptionsNormalized);
} catch (error) {
const originalError = error instanceof Error ? error : new Error(
/** @type {string} */
error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return original;
}
if (!isAbsoluteURL(original.filename)) {
const extInput = path.extname(original.filename).slice(1).toLowerCase();
const {
ext: extOutput
} = fileTypeFromBuffer(result) || {};
if (extOutput && extInput !== extOutput) {
original.warnings.push(new Error(`"imageminMinify" function do not support generate to "${extOutput}" from "${original.filename}". Please use "imageminGenerate" function.`));
return original;
}
}
return {
filename: original.filename,
data: result,
warnings: [...original.warnings],
errors: [...original.errors],
info: { ...original.info,
minimized: true,
minimizedBy: original.info && original.info.minimizedBy ? ["imagemin", ...original.info.minimizedBy] : ["imagemin"]
}
};
}
/**
* @type {any}
*/
let pool;
function squooshImagePoolCreate() {
// eslint-disable-next-line node/no-unpublished-require
const {
ImagePool
} = require("@squoosh/lib"); // TODO https://github.com/GoogleChromeLabs/squoosh/issues/1111,
// TODO https://github.com/GoogleChromeLabs/squoosh/issues/1012
//
// Due to the above errors, we use the value "1", it is faster and consumes less memory in common use.
//
// Also we don't know how many image (modules are built asynchronously) we will have so we can't setup
// the correct value and creating child processes takes a long time, unfortunately there is no perfect solution here,
// maybe we should provide an option for this (or API for warm up), so if you are reading this feel free to open the issue
return new ImagePool(1);
}
function squooshImagePoolSetup() {
if (!pool) {
pool = squooshImagePoolCreate(); // workarounds for https://github.com/GoogleChromeLabs/squoosh/issues/1152
// @ts-ignore
delete globalThis.navigator;
}
}
async function squooshImagePoolTeardown() {
if (pool) {
await pool.close(); // eslint-disable-next-line require-atomic-updates
pool = undefined;
}
}
/**
* @template T
* @param {WorkerResult} original
* @param {T} minifyOptions
* @returns {Promise<WorkerResult>}
*/
async function squooshGenerate(original, minifyOptions) {
// eslint-disable-next-line node/no-unpublished-require
const squoosh = require("@squoosh/lib");
const isReusePool = Boolean(pool);
const imagePool = pool || squooshImagePoolCreate();
const image = imagePool.ingestImage(new Uint8Array(original.data));
const squooshOptions =
/** @type {SquooshOptions} */
minifyOptions || {};
/**
* @type {undefined | Object.<string, any>}
*/
let preprocessors;
for (const preprocessor of Object.keys(squoosh.preprocessors)) {
if (typeof squooshOptions[preprocessor] !== "undefined") {
if (!preprocessors) {
preprocessors = {};
}
preprocessors[preprocessor] = squooshOptions[preprocessor];
}
}
if (typeof preprocessors !== "undefined") {
await image.preprocess(preprocessors);
}
const {
encodeOptions
} = squooshOptions;
try {
await image.encode(encodeOptions);
} catch (error) {
if (!isReusePool) {
await imagePool.close();
}
const originalError = error instanceof Error ? error : new Error(
/** @type {string} */
error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return original;
}
if (!isReusePool) {
await imagePool.close();
}
if (Object.keys(image.encodedWith).length === 0) {
original.errors.push(new Error(`No result from 'squoosh' for '${original.filename}', please configure the 'encodeOptions' option to generate images`));
return original;
}
if (Object.keys(image.encodedWith).length > 1) {
original.errors.push(new Error(`Multiple values for the 'encodeOptions' option is not supported for '${original.filename}', specify only one codec for the generator`));
return original;
}
const ext = path.extname(original.filename).toLowerCase();
const {
binary,
extension
} = await Object.values(image.encodedWith)[0];
const newFilename = original.filename.replace(new RegExp(`${ext}$`), `.${extension}`);
return {
filename: newFilename,
data: Buffer.from(binary),
warnings: [...original.warnings],
errors: [...original.errors],
info: { ...original.info,
generated: true,
generatedBy: original.info && original.info.generatedBy ? ["squoosh", ...original.info.generatedBy] : ["squoosh"]
}
};
}
squooshGenerate.setup = squooshImagePoolSetup;
squooshGenerate.teardown = squooshImagePoolTeardown;
/**
* @template T
* @param {WorkerResult} original
* @param {T} options
* @returns {Promise<WorkerResult>}
*/
async function squooshMinify(original, options) {
// eslint-disable-next-line node/no-unpublished-require
const squoosh = require("@squoosh/lib");
const {
encoders
} = squoosh;
/**
* @type {Record<string, string>}
*/
const targets = {};
for (const [codec, {
extension
}] of Object.entries(encoders)) {
const extensionNormalized = extension.toLowerCase();
if (extensionNormalized === "jpg") {
targets.jpeg = codec;
}
targets[extensionNormalized] = codec;
}
const ext = path.extname(original.filename).slice(1).toLowerCase();
const targetCodec = targets[ext];
if (!targetCodec) {
return original;
}
const isReusePool = Boolean(pool);
const imagePool = pool || squooshImagePoolCreate();
const image = imagePool.ingestImage(new Uint8Array(original.data));
const squooshOptions =
/** @type {SquooshOptions} */
options || {};
/**
* @type {undefined | Object.<string, any>}
*/
let preprocessors;
for (const preprocessor of Object.keys(squoosh.preprocessors)) {
if (typeof squooshOptions[preprocessor] !== "undefined") {
if (!preprocessors) {
preprocessors = {};
}
preprocessors[preprocessor] = squooshOptions[preprocessor];
}
}
if (typeof preprocessors !== "undefined") {
await image.preprocess(preprocessors);
}
const {
encodeOptions = {}
} = squooshOptions;
if (!encodeOptions[targetCodec]) {
encodeOptions[targetCodec] = {};
}
try {
await image.encode({
[targetCodec]: encodeOptions[targetCodec]
});
} catch (error) {
if (!isReusePool) {
await imagePool.close();
}
const originalError = error instanceof Error ? error : new Error(
/** @type {string} */
error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return original;
}
if (!isReusePool) {
await imagePool.close();
}
const {
binary
} = await image.encodedWith[targets[ext]];
return {
filename: original.filename,
data: Buffer.from(binary),
warnings: [...original.warnings],
errors: [...original.errors],
info: { ...original.info,
minimized: true,
minimizedBy: original.info && original.info.minimizedBy ? ["squoosh", ...original.info.minimizedBy] : ["squoosh"]
}
};
}
squooshMinify.setup = squooshImagePoolSetup;
squooshMinify.teardown = squooshImagePoolTeardown;
module.exports = {
throttleAll,
isAbsoluteURL,
imageminNormalizeConfig,
imageminMinify,
imageminGenerate,
squooshMinify,
squooshGenerate
};
;