builder/lib/processors/minifier.js

  1. import {fileURLToPath} from "node:url";
  2. import posixPath from "node:path/posix";
  3. import {promisify} from "node:util";
  4. import os from "node:os";
  5. import workerpool from "workerpool";
  6. import Resource from "@ui5/fs/Resource";
  7. import {getLogger} from "@ui5/logger";
  8. const log = getLogger("builder:processors:minifier");
  9. import {setTimeout as setTimeoutPromise} from "node:timers/promises";
  10. const debugFileRegex = /((?:\.view|\.fragment|\.controller|\.designtime|\.support)?\.js)$/;
  11. const MIN_WORKERS = 2;
  12. const MAX_WORKERS = 4;
  13. const osCpus = os.cpus().length || 1;
  14. const maxWorkers = Math.max(Math.min(osCpus - 1, MAX_WORKERS), MIN_WORKERS);
  15. const sourceMappingUrlPattern = /\/\/# sourceMappingURL=(\S+)\s*$/;
  16. const httpPattern = /^https?:\/\//i;
  17. // Shared workerpool across all executions until the taskUtil cleanup is triggered
  18. let pool;
  19. function getPool(taskUtil) {
  20. if (!pool) {
  21. log.verbose(`Creating workerpool with up to ${maxWorkers} workers (available CPU cores: ${osCpus})`);
  22. const workerPath = fileURLToPath(new URL("./minifierWorker.js", import.meta.url));
  23. pool = workerpool.pool(workerPath, {
  24. workerType: "auto",
  25. maxWorkers
  26. });
  27. taskUtil.registerCleanupTask((force) => {
  28. const attemptPoolTermination = async () => {
  29. log.verbose(`Attempt to terminate the workerpool...`);
  30. if (!pool) {
  31. return;
  32. }
  33. // There are many stats that could be used, but these ones seem the most
  34. // convenient. When all the (available) workers are idle, then it's safe to terminate.
  35. let {idleWorkers, totalWorkers} = pool.stats();
  36. while (idleWorkers !== totalWorkers && !force) {
  37. await setTimeoutPromise(100); // Wait a bit workers to finish and try again
  38. if (!pool) { // pool might have been terminated in the meantime
  39. return;
  40. }
  41. ({idleWorkers, totalWorkers} = pool.stats());
  42. }
  43. const poolToBeTerminated = pool;
  44. pool = null;
  45. return poolToBeTerminated.terminate(force);
  46. };
  47. return attemptPoolTermination();
  48. });
  49. }
  50. return pool;
  51. }
  52. async function minifyInWorker(options, taskUtil) {
  53. return getPool(taskUtil).exec("execMinification", [options]);
  54. }
  55. async function extractAndRemoveSourceMappingUrl(resource) {
  56. const resourceContent = await resource.getString();
  57. const resourcePath = resource.getPath();
  58. const sourceMappingUrlMatch = resourceContent.match(sourceMappingUrlPattern);
  59. if (sourceMappingUrlMatch) {
  60. const sourceMappingUrl = sourceMappingUrlMatch[1];
  61. if (log.isLevelEnabled("silly")) {
  62. log.silly(`Found source map reference in content of resource ${resourcePath}: ${sourceMappingUrl}`);
  63. }
  64. // Strip sourceMappingURL from the resource to be minified
  65. // It is not required anymore and will be replaced for in the minified resource
  66. // and its debug variant anyways
  67. resource.setString(resourceContent.replace(sourceMappingUrlPattern, ""));
  68. return sourceMappingUrl;
  69. }
  70. return null;
  71. }
  72. async function getSourceMapFromUrl({sourceMappingUrl, resourcePath, readFile}) {
  73. // =======================================================================
  74. // This code is almost identical to code located in lbt/bundle/Builder.js
  75. // Please try to update both places when making changes
  76. // =======================================================================
  77. if (sourceMappingUrl.startsWith("data:")) {
  78. // Data-URI indicates an inline source map
  79. const expectedTypeAndEncoding = "data:application/json;charset=utf-8;base64,";
  80. if (sourceMappingUrl.startsWith(expectedTypeAndEncoding)) {
  81. const base64Content = sourceMappingUrl.slice(expectedTypeAndEncoding.length);
  82. // Create a resource with a path suggesting it's the source map for the resource
  83. // (which it is but inlined)
  84. return Buffer.from(base64Content, "base64").toString();
  85. } else {
  86. log.warn(
  87. `Source map reference in resource ${resourcePath} is a data URI but has an unexpected` +
  88. `encoding: ${sourceMappingUrl}. Expected it to start with ` +
  89. `"data:application/json;charset=utf-8;base64,"`);
  90. }
  91. } else if (httpPattern.test(sourceMappingUrl)) {
  92. log.warn(`Source map reference in resource ${resourcePath} is an absolute URL. ` +
  93. `Currently, only relative URLs are supported.`);
  94. } else if (posixPath.isAbsolute(sourceMappingUrl)) {
  95. log.warn(`Source map reference in resource ${resourcePath} is an absolute path. ` +
  96. `Currently, only relative paths are supported.`);
  97. } else {
  98. const sourceMapPath = posixPath.join(posixPath.dirname(resourcePath), sourceMappingUrl);
  99. try {
  100. const sourceMapContent = await readFile(sourceMapPath);
  101. return sourceMapContent.toString();
  102. } catch (e) {
  103. // No input source map
  104. log.warn(`Unable to read source map for resource ${resourcePath}: ${e.message}`);
  105. }
  106. }
  107. }
  108. /**
  109. * @public
  110. * @module @ui5/builder/processors/minifier
  111. */
  112. /**
  113. * Result set
  114. *
  115. * @public
  116. * @typedef {object} MinifierResult
  117. * @property {@ui5/fs/Resource} resource Minified resource
  118. * @property {@ui5/fs/Resource} dbgResource Debug (non-minified) variant
  119. * @property {@ui5/fs/Resource} sourceMap Source Map
  120. */
  121. /**
  122. * Minifies the supplied resources.
  123. *
  124. * @public
  125. * @function default
  126. * @static
  127. *
  128. * @param {object} parameters Parameters
  129. * @param {@ui5/fs/Resource[]} parameters.resources List of resources to be processed
  130. * @param {fs|module:@ui5/fs/fsInterface} parameters.fs Node fs or custom
  131. * [fs interface]{@link module:@ui5/fs/fsInterface}. Required when setting "readSourceMappingUrl" to true
  132. * @param {@ui5/builder/tasks/TaskUtil|object} [parameters.taskUtil] TaskUtil instance.
  133. * Required when using the <code>useWorkers</code> option
  134. * @param {object} [parameters.options] Options
  135. * @param {boolean} [parameters.options.readSourceMappingUrl=false]
  136. * Whether to make use of any existing source maps referenced in the resources to be minified. Use this option to
  137. * preserve references to the original source files, such as TypeScript files, in the generated source map.<br>
  138. * If a resource has been modified by a previous task, any existing source map will be ignored regardless of this
  139. * setting. This is to ensure that no inconsistent source maps are used. Check the verbose log for details.
  140. * @param {boolean} [parameters.options.addSourceMappingUrl=true]
  141. * Whether to add a sourceMappingURL reference to the end of the minified resource
  142. * @param {boolean} [parameters.options.useWorkers=false]
  143. * Whether to offload the minification task onto separate CPU threads. This often speeds up the build process
  144. * @returns {Promise<module:@ui5/builder/processors/minifier~MinifierResult[]>}
  145. * Promise resolving with object of resource, dbgResource and sourceMap
  146. */
  147. export default async function({
  148. resources, fs, taskUtil, options: {readSourceMappingUrl = false, addSourceMappingUrl = true, useWorkers = false
  149. } = {}}) {
  150. let minify;
  151. if (readSourceMappingUrl && !fs) {
  152. throw new Error(`Option 'readSourceMappingUrl' requires parameter 'fs' to be provided`);
  153. }
  154. if (useWorkers) {
  155. if (!taskUtil) {
  156. // TaskUtil is required for worker support
  157. throw new Error(`Minifier: Option 'useWorkers' requires a taskUtil instance to be provided`);
  158. }
  159. minify = minifyInWorker;
  160. } else {
  161. // Do not use workerpool
  162. minify = (await import("./minifierWorker.js")).default;
  163. }
  164. return Promise.all(resources.map(async (resource) => {
  165. const resourcePath = resource.getPath();
  166. const dbgPath = resourcePath.replace(debugFileRegex, "-dbg$1");
  167. const dbgFilename = posixPath.basename(dbgPath);
  168. const filename = posixPath.basename(resource.getPath());
  169. const sourceMapOptions = {
  170. filename
  171. };
  172. if (addSourceMappingUrl) {
  173. sourceMapOptions.url = filename + ".map";
  174. }
  175. // Remember contentModified flag before making changes to the resource via setString
  176. const resourceContentModified = resource.getSourceMetadata()?.contentModified;
  177. // In any case: Extract *and remove* source map reference from resource before cloning it
  178. const sourceMappingUrl = await extractAndRemoveSourceMappingUrl(resource);
  179. const code = await resource.getString();
  180. // Create debug variant based off the original resource before minification
  181. const dbgResource = await resource.clone();
  182. dbgResource.setPath(dbgPath);
  183. let dbgSourceMapResource;
  184. if (sourceMappingUrl) {
  185. if (resourceContentModified) {
  186. log.verbose(
  187. `Source map found in resource will be ignored because the resource has been ` +
  188. `modified in a previous task: ${resourcePath}`);
  189. } else if (readSourceMappingUrl) {
  190. // Try to find a source map reference in the to-be-minified resource
  191. // If we find one, provide it to terser as an input source map and keep using it for the
  192. // debug variant of the resource
  193. const sourceMapContent = await getSourceMapFromUrl({
  194. sourceMappingUrl,
  195. resourcePath,
  196. readFile: promisify(fs.readFile)
  197. });
  198. if (sourceMapContent) {
  199. const sourceMapJson = JSON.parse(sourceMapContent);
  200. if (sourceMapJson.sections) {
  201. // TODO 5.0
  202. // Module "@jridgewell/trace-mapping" (used by Terser) can't handle index map sections lacking
  203. // a "names" array. Since this is a common occurrence for UI5 Tooling bundles, we search for
  204. // such cases here and fix them until https://github.com/jridgewell/trace-mapping/pull/29 is
  205. // resolved and Terser upgraded the dependency
  206. // Create a dedicated clone before modifying the source map as to not alter the debug source map
  207. const clonedSourceMapJson = JSON.parse(sourceMapContent);
  208. clonedSourceMapJson.sections.forEach(({map}) => {
  209. if (!map.names) {
  210. // Add missing names array
  211. map.names = [];
  212. }
  213. });
  214. // Use modified source map as input source map
  215. sourceMapOptions.content = JSON.stringify(clonedSourceMapJson);
  216. } else {
  217. // Provide source map to terser as "input source map"
  218. sourceMapOptions.content = sourceMapContent;
  219. }
  220. // Use the original source map for the debug variant of the resource
  221. // First update the file reference within the source map
  222. sourceMapJson.file = dbgFilename;
  223. // Then create a new resource
  224. dbgSourceMapResource = new Resource({
  225. string: JSON.stringify(sourceMapJson),
  226. path: dbgPath + ".map"
  227. });
  228. // And reference the resource in the debug resource
  229. dbgResource.setString(code + `//# sourceMappingURL=${dbgFilename}.map\n`);
  230. }
  231. } else {
  232. // If the original resource content was unmodified and the input source map was not parsed,
  233. // re-add the original source map reference to the debug variant
  234. if (!sourceMappingUrl.startsWith("data:") && !sourceMappingUrl.endsWith(filename + ".map")) {
  235. // Do not re-add inline source maps as well as references to the source map of
  236. // the minified resource
  237. dbgResource.setString(code + `//# sourceMappingURL=${sourceMappingUrl}\n`);
  238. }
  239. }
  240. }
  241. const result = await minify({
  242. filename,
  243. dbgFilename,
  244. code,
  245. sourceMapOptions
  246. }, taskUtil);
  247. resource.setString(result.code);
  248. const sourceMapResource = new Resource({
  249. path: resource.getPath() + ".map",
  250. string: result.map
  251. });
  252. return {resource, dbgResource, sourceMapResource, dbgSourceMapResource};
  253. }));
  254. }
  255. export const __localFunctions__ = (process.env.NODE_ENV === "test") ?
  256. {getSourceMapFromUrl} : undefined;