builder/lib/builder/builder.js

const {promisify} = require("util");
const rimraf = promisify(require("rimraf"));
const log = require("@ui5/logger").getGroupLogger("builder:builder");
const resourceFactory = require("@ui5/fs").resourceFactory;
const MemAdapter = require("@ui5/fs").adapters.Memory;
const typeRepository = require("../types/typeRepository");
const taskRepository = require("../tasks/taskRepository");
const BuildContext = require("./BuildContext");


// Set of tasks for development
const devTasks = [
	"replaceCopyright",
	"replaceVersion",
	"replaceBuildtime",
	"buildThemes"
];

/**
 * Calculates the elapsed build time and returns a prettified output
 *
 * @private
 * @param {Array} startTime Array provided by <code>process.hrtime()</code>
 * @returns {string} Difference between now and the provided time array as formatted string
 */
function getElapsedTime(startTime) {
	const prettyHrtime = require("pretty-hrtime");
	const timeDiff = process.hrtime(startTime);
	return prettyHrtime(timeDiff);
}

/**
 * Creates the list of tasks to be executed by the build process
 *
 * Sets specific tasks to be disabled by default, these tasks need to be included explicitly.
 * Based on the selected build mode (dev|selfContained|preload), different tasks are enabled.
 * Tasks can be enabled or disabled. The wildcard <code>*</code> is also supported and affects all tasks.
 *
 * @private
 * @param {object} parameters
 * @param {boolean} parameters.dev Sets development mode, which only runs essential tasks
 * @param {boolean} parameters.selfContained
 *			True if a the build should be self-contained or false for prelead build bundles
 * @param {boolean} parameters.jsdoc True if a JSDoc build should be executed
 * @param {Array} parameters.includedTasks Task list to be included from build
 * @param {Array} parameters.excludedTasks Task list to be excluded from build
 * @returns {Array} Return a task list for the builder
 */
function composeTaskList({dev, selfContained, jsdoc, includedTasks, excludedTasks}) {
	const definedTasks = taskRepository.getAllTaskNames();
	let selectedTasks = definedTasks.reduce((list, key) => {
		list[key] = true;
		return list;
	}, {});

	// Exclude non default tasks
	selectedTasks.generateManifestBundle = false;
	selectedTasks.generateStandaloneAppBundle = false;
	selectedTasks.transformBootstrapHtml = false;
	selectedTasks.generateJsdoc = false;
	selectedTasks.executeJsdocSdkTransformation = false;
	selectedTasks.generateCachebusterInfo = false;
	selectedTasks.generateApiIndex = false;
	selectedTasks.generateThemeDesignerResources = false;

	// Disable generateResourcesJson due to performance.
	// When executed it analyzes each module's AST and therefore
	// takes up much time (~10% more)
	selectedTasks.generateResourcesJson = false;

	if (selfContained) {
		// No preloads, bundle only
		selectedTasks.generateComponentPreload = false;
		selectedTasks.generateStandaloneAppBundle = true;
		selectedTasks.transformBootstrapHtml = true;
		selectedTasks.generateLibraryPreload = false;
	}

	// TODO 3.0: exclude generateVersionInfo if not --all is used

	if (jsdoc) {
		// Include JSDoc tasks
		selectedTasks.generateJsdoc = true;
		selectedTasks.executeJsdocSdkTransformation = true;
		selectedTasks.generateApiIndex = true;

		// Include theme build as required for SDK
		selectedTasks.buildThemes = true;

		// Exclude all tasks not relevant to JSDoc generation
		selectedTasks.replaceCopyright = false;
		selectedTasks.replaceVersion = false;
		selectedTasks.replaceBuildtime = false;
		selectedTasks.generateComponentPreload = false;
		selectedTasks.generateLibraryPreload = false;
		selectedTasks.generateLibraryManifest = false;
		selectedTasks.createDebugFiles = false;
		selectedTasks.uglify = false;
		selectedTasks.generateFlexChangesBundle = false;
		selectedTasks.generateManifestBundle = false;
	}

	// Only run essential tasks in development mode, it is not desired to run time consuming tasks during development.
	if (dev) {
		// Overwrite all other tasks with noop promise
		Object.keys(selectedTasks).forEach((key) => {
			if (devTasks.indexOf(key) === -1) {
				selectedTasks[key] = false;
			}
		});
	}

	// Exclude tasks
	for (let i = 0; i < excludedTasks.length; i++) {
		const taskName = excludedTasks[i];
		if (taskName === "*") {
			Object.keys(selectedTasks).forEach((sKey) => {
				selectedTasks[sKey] = false;
			});
			break;
		}
		if (selectedTasks[taskName] === true) {
			selectedTasks[taskName] = false;
		} else if (typeof selectedTasks[taskName] === "undefined") {
			log.warn(`Unable to exclude task '${taskName}': Task is unknown`);
		}
	}

	// Include tasks
	for (let i = 0; i < includedTasks.length; i++) {
		const taskName = includedTasks[i];
		if (taskName === "*") {
			Object.keys(selectedTasks).forEach((sKey) => {
				selectedTasks[sKey] = true;
			});
			break;
		}
		if (selectedTasks[taskName] === false) {
			selectedTasks[taskName] = true;
		} else if (typeof selectedTasks[taskName] === "undefined") {
			log.warn(`Unable to include task '${taskName}': Task is unknown`);
		}
	}

	// Filter only for tasks that will be executed
	selectedTasks = Object.keys(selectedTasks).filter((task) => selectedTasks[task]);

	return selectedTasks;
}

async function executeCleanupTasks(buildContext) {
	log.info("Executing cleanup tasks...");
	await buildContext.executeCleanupTasks();
}

function registerCleanupSigHooks(buildContext) {
	function createListener(exitCode) {
		return function() {
			// Asynchronously cleanup resources, then exit
			executeCleanupTasks(buildContext).then(() => {
				process.exit(exitCode);
			});
		};
	}

	const processSignals = {
		"SIGHUP": createListener(128 + 1),
		"SIGINT": createListener(128 + 2),
		"SIGTERM": createListener(128 + 15),
		"SIGBREAK": createListener(128 + 21)
	};

	for (const signal of Object.keys(processSignals)) {
		process.on(signal, processSignals[signal]);
	}

	// == TO BE DISCUSSED: Also cleanup for unhandled rejections and exceptions?
	// Add additional events like signals since they are registered on the process
	//	event emitter in a similar fashion
	// processSignals["unhandledRejection"] = createListener(1);
	// process.once("unhandledRejection", processSignals["unhandledRejection"]);
	// processSignals["uncaughtException"] = function(err, origin) {
	// 	const fs = require("fs");
	// 	fs.writeSync(
	// 		process.stderr.fd,
	// 		`Caught exception: ${err}\n` +
	// 		`Exception origin: ${origin}`
	// 	);
	// 	createListener(1)();
	// };
	// process.once("uncaughtException", processSignals["uncaughtException"]);

	return processSignals;
}

function deregisterCleanupSigHooks(signals) {
	for (const signal of Object.keys(signals)) {
		process.removeListener(signal, signals[signal]);
	}
}

/**
 * Builder
 *
 * @public
 * @namespace
 * @alias module:@ui5/builder.builder
 */
module.exports = {
	/**
	 * Configures the project build and starts it.
	 *
	 * @public
	 * @param {object} parameters Parameters
	 * @param {object} parameters.tree Project tree as generated by the
	 * 									[@ui5/project.normalizer]{@link module:@ui5/project.normalizer}
	 * @param {string} parameters.destPath Target path
	 * @param {boolean} [parameters.cleanDest=false] Decides whether project should clean the target path before build
	 * @param {boolean} [parameters.buildDependencies=false] Decides whether project dependencies are built as well
	 * @param {Array.<string|RegExp>} [parameters.includedDependencies=[]]
	 *			List of build dependencies to be included if buildDependencies is true
	 * @param {Array.<string|RegExp>} [parameters.excludedDependencies=[]]
	 *			List of build dependencies to be excluded if buildDependencies is true.
	 *			If the wildcard '*' is provided, only the included dependencies will be built.
	 * @param {boolean} [parameters.dev=false]
	 *			Decides whether a development build should be activated (skips non-essential and time-intensive tasks)
	 * @param {boolean} [parameters.selfContained=false] Flag to activate self contained build
	 * @param {boolean} [parameters.jsdoc=false] Flag to activate JSDoc build
	 * @param {Array.<string>} [parameters.includedTasks=[]] List of tasks to be included
	 * @param {Array.<string>} [parameters.excludedTasks=[]] List of tasks to be excluded.
	 * 							If the wildcard '*' is provided, only the included tasks will be executed.
	 * @param {Array.<string>} [parameters.devExcludeProject=[]] List of projects to be excluded from development build
	 * @returns {Promise} Promise resolving to <code>undefined</code> once build has finished
	 */
	async build({
		tree, destPath, cleanDest = false,
		buildDependencies = false, includedDependencies = [], excludedDependencies = [],
		dev = false, selfContained = false, jsdoc = false,
		includedTasks = [], excludedTasks = [], devExcludeProject = []
	}) {
		const startTime = process.hrtime();
		log.info(`Building project ${tree.metadata.name}` + (buildDependencies ? "" : " not") +
			" including dependencies..." + (dev ? " [dev mode]" : ""));
		log.verbose(`Building to ${destPath}...`);

		const selectedTasks = composeTaskList({dev, selfContained, jsdoc, includedTasks, excludedTasks});

		const fsTarget = resourceFactory.createAdapter({
			fsBasePath: destPath,
			virBasePath: "/"
		});

		const buildContext = new BuildContext({rootProject: tree});
		const cleanupSigHooks = registerCleanupSigHooks(buildContext);

		const projects = {}; // Unique project index to prevent building the same project multiple times
		const projectWriters = {}; // Collection of memory adapters of already built libraries
		function projectFilter(project) {
			function projectMatchesAny(deps) {
				return deps.some((dep) => dep instanceof RegExp ?
					dep.test(project.metadata.name) : dep === project.metadata.name);
			}

			// if everything is included, this overrules exclude lists
			if (includedDependencies.includes("*")) return true;
			let test = !excludedDependencies.includes("*"); // exclude everything?

			if (test && projectMatchesAny(excludedDependencies)) {
				test = false;
			}
			if (!test && projectMatchesAny(includedDependencies)) {
				test = true;
			}

			return test;
		}

		const projectCountMarker = {};
		function projectCount(project, count = 0) {
			if (buildDependencies) {
				count = project.dependencies.filter(projectFilter).reduce((depCount, depProject) => {
					return projectCount(depProject, depCount);
				}, count);
			}
			if (!projectCountMarker[project.metadata.name]) {
				count++;
				projectCountMarker[project.metadata.name] = true;
			}
			return count;
		}
		const buildLogger = log.createTaskLogger("🛠 ", projectCount(tree));

		function buildProject(project) {
			const projectBasePath = `/resources/${project.metadata.namespace}`;
			let depPromise;
			let projectTasks = selectedTasks;

			// Build dependencies in sequence as it is far easier to detect issues and reduces
			// side effects or other issues such as too many open files
			if (buildDependencies) {
				depPromise = project.dependencies.filter(projectFilter).reduce(function(p, depProject) {
					return p.then(() => buildProject(depProject));
				}, Promise.resolve());
			} else {
				depPromise = Promise.resolve();
			}

			// Build the project after all dependencies have been built
			return depPromise.then(() => {
				if (projects[project.metadata.name]) {
					return Promise.resolve();
				} else {
					projects[project.metadata.name] = true;
				}
				buildLogger.startWork(`Building project ${project.metadata.name}`);

				const projectType = typeRepository.getType(project.type);
				const resourceCollections = resourceFactory.createCollectionsForTree(project, {
					virtualReaders: projectWriters,
					getVirtualBasePathPrefix: function({project, virBasePath}) {
						if (project.type === "application" && project.metadata.namespace) {
							return projectBasePath;
						}
					},
					getProjectExcludes: function(project) {
						if (project.builder && project.builder.resources) {
							return project.builder.resources.excludes;
						}
					}
				});

				const writer = new MemAdapter({
					virBasePath: "/"
				});
				// Store project writer as virtual reader for parent projects
				//	so they can access the build results of this project
				projectWriters[project.metadata.name] = writer;

				// TODO: Add getter for writer of DuplexColection
				const workspace = resourceFactory.createWorkspace({
					virBasePath: "/",
					writer,
					reader: resourceCollections.source,
					name: project.metadata.name
				});

				const projectContext = buildContext.createProjectContext({
					project, // TODO 2.0: Add project facade object/instance here
					resources: {
						workspace,
						dependencies: resourceCollections.dependencies
					}
				});

				const TaskUtil = require("../tasks/TaskUtil");
				const taskUtil = new TaskUtil({
					projectBuildContext: projectContext
				});

				if (dev && devExcludeProject.indexOf(project.metadata.name) !== -1) {
					projectTasks = composeTaskList({dev: false, selfContained, includedTasks, excludedTasks});
				}

				return projectType.build({
					resourceCollections: {
						workspace,
						dependencies: resourceCollections.dependencies
					},
					tasks: projectTasks,
					project,
					parentLogger: log,
					taskUtil
				}).then(() => {
					log.verbose("Finished building project %s. Writing out files...", project.metadata.name);
					buildLogger.completeWork(1);

					return workspace.byGlob("/**/*").then((resources) => {
						const tagCollection = projectContext.getResourceTagCollection();
						return Promise.all(resources.map((resource) => {
							if (tagCollection.getTag(resource, projectContext.STANDARD_TAGS.OmitFromBuildResult)) {
								log.verbose(`Skipping write of resource tagged as "OmitFromBuildResult": ` +
									resource.getPath());
								return; // Skip target write for this resource
							}
							if (projectContext.isRootProject() && project.type === "application" &&
									project.metadata.namespace) {
								// Root-application projects only: Remove namespace prefix if given
								const resourcePath = resource.getPath();
								if (resourcePath.startsWith(projectBasePath)) {
									resource.setPath(resourcePath.replace(projectBasePath, ""));
								}
							}
							return fsTarget.write(resource);
						}));
					});
				});
			});
		}

		try {
			if (cleanDest) {
				await rimraf(destPath);
			}
			await buildProject(tree);
			log.info(`Build succeeded in ${getElapsedTime(startTime)}`);
		} catch (err) {
			log.error(`Build failed in ${getElapsedTime(startTime)}`);
			throw err;
		} finally {
			deregisterCleanupSigHooks(cleanupSigHooks);
			await executeCleanupTasks(buildContext);
		}
	}
};

// Export local function for testing only
/* istanbul ignore else */
if (process.env.NODE_ENV === "test") {
	module.exports._composeTaskList = composeTaskList;
}