builder/lib/processors/minifier.js

import {fileURLToPath} from "node:url";
import posixPath from "node:path/posix";
import {promisify} from "node:util";
import os from "node:os";
import workerpool from "workerpool";
import Resource from "@ui5/fs/Resource";
import {getLogger} from "@ui5/logger";
const log = getLogger("builder:processors:minifier");
import {setTimeout as setTimeoutPromise} from "node:timers/promises";

const debugFileRegex = /((?:\.view|\.fragment|\.controller|\.designtime|\.support)?\.js)$/;

const MIN_WORKERS = 2;
const MAX_WORKERS = 4;
const osCpus = os.cpus().length || 1;
const maxWorkers = Math.max(Math.min(osCpus - 1, MAX_WORKERS), MIN_WORKERS);

const sourceMappingUrlPattern = /\/\/# sourceMappingURL=(\S+)\s*$/;
const httpPattern = /^https?:\/\//i;

// Shared workerpool across all executions until the taskUtil cleanup is triggered
let pool;

function getPool(taskUtil) {
	if (!pool) {
		log.verbose(`Creating workerpool with up to ${maxWorkers} workers (available CPU cores: ${osCpus})`);
		const workerPath = fileURLToPath(new URL("./minifierWorker.js", import.meta.url));
		pool = workerpool.pool(workerPath, {
			workerType: "auto",
			maxWorkers
		});
		taskUtil.registerCleanupTask((force) => {
			const attemptPoolTermination = async () => {
				log.verbose(`Attempt to terminate the workerpool...`);

				if (!pool) {
					return;
				}

				// There are many stats that could be used, but these ones seem the most
				// convenient. When all the (available) workers are idle, then it's safe to terminate.
				let {idleWorkers, totalWorkers} = pool.stats();
				while (idleWorkers !== totalWorkers && !force) {
					await setTimeoutPromise(100); // Wait a bit workers to finish and try again

					if (!pool) { // pool might have been terminated in the meantime
						return;
					}
					({idleWorkers, totalWorkers} = pool.stats());
				}

				const poolToBeTerminated = pool;
				pool = null;
				return poolToBeTerminated.terminate(force);
			};

			return attemptPoolTermination();
		});
	}
	return pool;
}

async function minifyInWorker(options, taskUtil) {
	return getPool(taskUtil).exec("execMinification", [options]);
}

async function extractAndRemoveSourceMappingUrl(resource) {
	const resourceContent = await resource.getString();
	const resourcePath = resource.getPath();
	const sourceMappingUrlMatch = resourceContent.match(sourceMappingUrlPattern);
	if (sourceMappingUrlMatch) {
		const sourceMappingUrl = sourceMappingUrlMatch[1];
		if (log.isLevelEnabled("silly")) {
			log.silly(`Found source map reference in content of resource ${resourcePath}: ${sourceMappingUrl}`);
		}

		// Strip sourceMappingURL from the resource to be minified
		// It is not required anymore and will be replaced for in the minified resource
		// and its debug variant anyways
		resource.setString(resourceContent.replace(sourceMappingUrlPattern, ""));
		return sourceMappingUrl;
	}
	return null;
}

async function getSourceMapFromUrl({sourceMappingUrl, resourcePath, readFile}) {
	// =======================================================================
	// This code is almost identical to code located in lbt/bundle/Builder.js
	// Please try to update both places when making changes
	// =======================================================================
	if (sourceMappingUrl.startsWith("data:")) {
		// Data-URI indicates an inline source map
		const expectedTypeAndEncoding = "data:application/json;charset=utf-8;base64,";
		if (sourceMappingUrl.startsWith(expectedTypeAndEncoding)) {
			const base64Content = sourceMappingUrl.slice(expectedTypeAndEncoding.length);
			// Create a resource with a path suggesting it's the source map for the resource
			// (which it is but inlined)
			return Buffer.from(base64Content, "base64").toString();
		} else {
			log.warn(
				`Source map reference in resource ${resourcePath} is a data URI but has an unexpected` +
				`encoding: ${sourceMappingUrl}. Expected it to start with ` +
				`"data:application/json;charset=utf-8;base64,"`);
		}
	} else if (httpPattern.test(sourceMappingUrl)) {
		log.warn(`Source map reference in resource ${resourcePath} is an absolute URL. ` +
			`Currently, only relative URLs are supported.`);
	} else if (posixPath.isAbsolute(sourceMappingUrl)) {
		log.warn(`Source map reference in resource ${resourcePath} is an absolute path. ` +
			`Currently, only relative paths are supported.`);
	} else {
		const sourceMapPath = posixPath.join(posixPath.dirname(resourcePath), sourceMappingUrl);

		try {
			const sourceMapContent = await readFile(sourceMapPath);
			return sourceMapContent.toString();
		} catch (e) {
			// No input source map
			log.warn(`Unable to read source map for resource ${resourcePath}: ${e.message}`);
		}
	}
}

/**
 * @public
 * @module @ui5/builder/processors/minifier
 */

/**
 * Result set
 *
 * @public
 * @typedef {object} MinifierResult
 * @property {@ui5/fs/Resource} resource Minified resource
 * @property {@ui5/fs/Resource} dbgResource Debug (non-minified) variant
 * @property {@ui5/fs/Resource} sourceMap Source Map
 */

/**
 * Minifies the supplied resources.
 *
 * @public
 * @function default
 * @static
 *
 * @param {object} parameters Parameters
 * @param {@ui5/fs/Resource[]} parameters.resources List of resources to be processed
 * @param {fs|module:@ui5/fs/fsInterface} parameters.fs Node fs or custom
 *    [fs interface]{@link module:@ui5/fs/fsInterface}. Required when setting "readSourceMappingUrl" to true
 * @param {@ui5/builder/tasks/TaskUtil|object} [parameters.taskUtil] TaskUtil instance.
 *    Required when using the <code>useWorkers</code> option
 * @param {object} [parameters.options] Options
 * @param {boolean} [parameters.options.readSourceMappingUrl=false]
 *   Whether to make use of any existing source maps referenced in the resources to be minified. Use this option to
 *   preserve references to the original source files, such as TypeScript files, in the generated source map.<br>
 *   If a resource has been modified by a previous task, any existing source map will be ignored regardless of this
 *    setting. This is to ensure that no inconsistent source maps are used. Check the verbose log for details.
 * @param {boolean} [parameters.options.addSourceMappingUrl=true]
 *   Whether to add a sourceMappingURL reference to the end of the minified resource
 * @param {boolean} [parameters.options.useWorkers=false]
 *  Whether to offload the minification task onto separate CPU threads. This often speeds up the build process
 * @returns {Promise<module:@ui5/builder/processors/minifier~MinifierResult[]>}
 *   Promise resolving with object of resource, dbgResource and sourceMap
 */
export default async function({
	resources, fs, taskUtil, options: {readSourceMappingUrl = false, addSourceMappingUrl = true, useWorkers = false
	} = {}}) {
	let minify;
	if (readSourceMappingUrl && !fs) {
		throw new Error(`Option 'readSourceMappingUrl' requires parameter 'fs' to be provided`);
	}

	if (useWorkers) {
		if (!taskUtil) {
			// TaskUtil is required for worker support
			throw new Error(`Minifier: Option 'useWorkers' requires a taskUtil instance to be provided`);
		}
		minify = minifyInWorker;
	} else {
		// Do not use workerpool
		minify = (await import("./minifierWorker.js")).default;
	}

	return Promise.all(resources.map(async (resource) => {
		const resourcePath = resource.getPath();
		const dbgPath = resourcePath.replace(debugFileRegex, "-dbg$1");
		const dbgFilename = posixPath.basename(dbgPath);

		const filename = posixPath.basename(resource.getPath());

		const sourceMapOptions = {
			filename
		};
		if (addSourceMappingUrl) {
			sourceMapOptions.url = filename + ".map";
		}

		// Remember contentModified flag before making changes to the resource via setString
		const resourceContentModified = resource.getSourceMetadata()?.contentModified;

		// In any case: Extract *and remove* source map reference from resource before cloning it
		const sourceMappingUrl = await extractAndRemoveSourceMappingUrl(resource);

		const code = await resource.getString();
		// Create debug variant based off the original resource before minification
		const dbgResource = await resource.clone();
		dbgResource.setPath(dbgPath);

		let dbgSourceMapResource;
		if (sourceMappingUrl) {
			if (resourceContentModified) {
				log.verbose(
					`Source map found in resource will be ignored because the resource has been ` +
					`modified in a previous task: ${resourcePath}`);
			} else if (readSourceMappingUrl) {
				// Try to find a source map reference in the to-be-minified resource
				// If we find one, provide it to terser as an input source map and keep using it for the
				// debug variant of the resource
				const sourceMapContent = await getSourceMapFromUrl({
					sourceMappingUrl,
					resourcePath,
					readFile: promisify(fs.readFile)
				});

				if (sourceMapContent) {
					const sourceMapJson = JSON.parse(sourceMapContent);

					if (sourceMapJson.sections) {
						// TODO 5.0
						// Module "@jridgewell/trace-mapping" (used by Terser) can't handle index map sections lacking
						// a "names" array. Since this is a common occurrence for UI5 Tooling bundles, we search for
						// such cases here and fix them until https://github.com/jridgewell/trace-mapping/pull/29 is
						// resolved and Terser upgraded the dependency

						// Create a dedicated clone before modifying the source map as to not alter the debug source map
						const clonedSourceMapJson = JSON.parse(sourceMapContent);
						clonedSourceMapJson.sections.forEach(({map}) => {
							if (!map.names) {
								// Add missing names array
								map.names = [];
							}
						});
						// Use modified source map as input source map
						sourceMapOptions.content = JSON.stringify(clonedSourceMapJson);
					} else {
						// Provide source map to terser as "input source map"
						sourceMapOptions.content = sourceMapContent;
					}

					// Use the original source map for the debug variant of the resource
					// First update the file reference within the source map
					sourceMapJson.file = dbgFilename;

					// Then create a new resource
					dbgSourceMapResource = new Resource({
						string: JSON.stringify(sourceMapJson),
						path: dbgPath + ".map"
					});
					// And reference the resource in the debug resource
					dbgResource.setString(code + `//# sourceMappingURL=${dbgFilename}.map\n`);
				}
			} else {
				// If the original resource content was unmodified and the input source map was not parsed,
				// re-add the original source map reference to the debug variant
				if (!sourceMappingUrl.startsWith("data:") && !sourceMappingUrl.endsWith(filename + ".map")) {
					// Do not re-add inline source maps as well as references to the source map of
					// the minified resource
					dbgResource.setString(code + `//# sourceMappingURL=${sourceMappingUrl}\n`);
				}
			}
		}

		const result = await minify({
			filename,
			dbgFilename,
			code,
			sourceMapOptions
		}, taskUtil);
		resource.setString(result.code);
		const sourceMapResource = new Resource({
			path: resource.getPath() + ".map",
			string: result.map
		});
		return {resource, dbgResource, sourceMapResource, dbgSourceMapResource};
	}));
}

export const __localFunctions__ = (process.env.NODE_ENV === "test") ?
	{getSourceMapFromUrl} : undefined;