UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

354 lines (315 loc) 13.5 kB
const fs = require('node:fs') const path = require('node:path') const { inspect } = require('node:util') const cds = require('../cds'), { _log: log } = cds const { sortMessagesSeverityAware, deduplicateMessages, CompilationError } = cds.compiler const { relativePaths, BuildError, BuildMessage, resolveRequiredSapModels, redactCredentials } = require('./util') const { SEVERITIES, LOG_LEVELS } = require('./constants') const BuildTaskFactory = require('./buildTaskFactory') const InternalBuildPlugin = require('./provider/internalBuildPlugin') const COMPILATION_ERROR = 'CompilationError' const COMPILE_MESSAGE = 'CompileMessage' const DEBUG = cds.debug('cli|build') const LOG_LIMIT = 30 // TODO replace with cds.utils.clors.colors.enabled in cds-dk 9 const colors = process.stdout.isTTY && !process.env.NO_COLOR || process.env.FORCE_COLOR class BuildTaskEngine { constructor(options) { this._taskFactory = new BuildTaskFactory(options) } get taskFactory() { return this._taskFactory } get options() { return this.taskFactory.options } async processTasks(tasks) { const startTime = Date.now() tasks ??= await this.taskFactory.getTasks() console.log(`building project with`, inspect({ versions: { cds: cds.version, compiler: cds.compiler.version(), dk: require('../../package.json').version }, target: cds.env.build.target, tasks: tasks.map(({ _uses, ...task }) => task), ...(!this.options.clean && { clean: this.options.clean }), }, { depth: 11, colors, compact: 3, breakLength: 250 })) DEBUG?.("cds configuration settings:") DEBUG?.(redactCredentials(cds.env)) // validate required @sap namespace models - log only const unresolved = BuildTaskEngine._resolveRequiredSapServices(tasks) if (unresolved.length > 0) { throw new BuildError(`Required CDS models [${unresolved.join(', ')}] cannot be resolved. Run the 'npm install' command to install up-to-date versions of the missing packages.`) } // create build plugins const plugins = [] tasks.forEach((task) => { if (task) { const plugin = this._createPlugin(task) plugins.push(plugin) } }) try { await this._executePrepare(plugins) await this._executeCleanBuildTasks(plugins) // throwing Exception in case of compilation errors const buildResult = await this._executeBuildTasks(plugins) await this._writeGenerationLog(plugins) this._logOutput(plugins) this._logMessages(BuildTaskEngine._getMessages(plugins)) this._logTimer(startTime, Date.now()) return buildResult } catch (error) { this._logOutput(plugins) throw error } } /** * Plugin#prepare has been deprecated and was never part of the public API. * Currently only used by internal FioriBuildPlugin. * @deprecated * @param {*} plugins * @returns */ async _executePrepare(plugins) { const pluginGroups = new Map() // group plugins by type plugins.forEach(plugin => { pluginGroups.has(plugin.task.for) ? pluginGroups.get(plugin.task.for).push(plugin) : pluginGroups.set(plugin.task.for, [plugin]) }) const promises = [] for (let pluginGroup of pluginGroups.values()) { promises.push(this._doPrepare(pluginGroup)) } return Promise.all(promises) } /** * @deprecated * @param {*} pluginGroup */ async _doPrepare(pluginGroup) { for (let plugin of pluginGroup) { // prepare has been deprecated if (plugin instanceof InternalBuildPlugin) { DEBUG?.(`preparing, plugin [${plugin.constructor.name}], src [${relativePaths(cds.root, plugin.task.src)}]`) const result = await plugin.prepare() if (result === false) { break } } } } async _executeCleanBuildTasks(plugins) { if (this.options.clean) { // clean entire build staging folder once if (cds.env.build.target !== '.') { const target = path.resolve(cds.root, cds.env.build.target) DEBUG?.(`cleaning staging folder ${target}`) await fs.promises.rm(target, { force: true, recursive: true }) } const results = await Promise.allSettled(plugins.map((plugin) => { DEBUG?.(`cleaning, plugin [${plugin.constructor.name}], src [${relativePaths(cds.root, plugin.task.src)}]`) return plugin.clean() })) // check for errors and throw exception this._resolveHandlerResponse(results) } } async _executeBuildTasks(plugins) { // sort plugins based on priority in plugins = plugins.sort((a, b) => { return a.priority === b.priority ? 0 : a.priority > b.priority ? -1 : 1 }) // group plugins with same priority in order to execute in parallel const buildPipeline = plugins.reduce((acc, plugin) => { if (acc.length === 0) { acc.push([plugin]) } else { const currGroup = acc[acc.length - 1] if (currGroup[0].priority === plugin.priority) { currGroup.push(plugin) } else { acc.push([plugin]) } } return acc }, []) const results = await this._executePipeline(buildPipeline) // check for errors and throw exception - return results otherwise including any compiler and build status messages return this._resolveHandlerResponse(results, BuildTaskEngine._getMessages(plugins)) } async _executePipeline(pipeline) { let allResults = [] for (const group of pipeline) { const results = await Promise.allSettled(group.map((plugin) => { DEBUG?.(`building, plugin [${plugin.constructor.name}], src [${relativePaths(cds.root, plugin.task.src)}]`) return plugin.build() .then(pluginResult => { return Promise.resolve({ task: plugin.task, result: pluginResult, messages: this._sortMessagesUnique(plugin.messages) }) }) })) allResults = allResults.concat(results) } return allResults } _resolveHandlerResponse(results, pluginMessages = []) { const errors = [] const resolvedResults = results.reduce((acc, r) => { if (r.status === 'fulfilled') { acc.push(r.value) } if (r.status === 'rejected' && r.reason) { errors.push(r.reason) } return acc }, []) if (errors.length > 0) { const error = errors.find(e => e.constructor.name !== COMPILATION_ERROR) if (error) { // throw original BuildError including existing BuildMessages error.messages = this._sortMessagesUnique(pluginMessages.filter(m => m instanceof BuildMessage), error.messages) throw error } // propagate existing CompilationErrors // merge all existing compilation messages into a single CompilationError // compiler warning and info messages are returned as plugin messages const compileErrors = errors.filter(e => e.constructor.name === COMPILATION_ERROR) const compileMessages = pluginMessages.filter(message => message.constructor.name === COMPILE_MESSAGE) if (compileErrors.length) { throw new CompilationError(this._sortMessagesUnique(BuildTaskEngine._getErrorMessages(compileErrors), compileMessages)) } } return resolvedResults } _createPlugin(task) { const plugin = this.taskFactory.createPlugin(task) plugin.init() if (!(plugin instanceof InternalBuildPlugin) && plugin.priority >= 0 && plugin.priority <= 512) { throw new Error( `Invalid priority value for ${plugin.constructor.name}. The valid priority value range for custom plugins is -1024..-1 and 512..1024. Range 0..512 is blocked for internal plugins. The higher the value the earlier the plugin is run.` ) } this._logTaskHandler(plugin) return plugin } _logOutput(plugins) { // log all generated files const files = BuildTaskEngine._getOutput(plugins) if (files.length > 0) { const term = require('../util/term') if (!process.env.DEBUG && files.length > LOG_LIMIT) { const length = files.length files.length = LOG_LIMIT files.push(`... ${length - files.length} more. Run with DEBUG=build to show all files.`) } console.log(`done > wrote output to:\n ${term.dim(files.join("\n "))}\n`) } } async _writeGenerationLog(plugins) { const outputFile = cds.env.build.outputfile || process.env.GENERATION_LOG if (outputFile) { const files = BuildTaskEngine._getOutput(plugins) console.log(`writing generation log to [${outputFile}]\n`) try { await fs.promises.mkdir(path.dirname(outputFile), { recursive: true }) await fs.promises.writeFile(outputFile, files.join('\n')) } catch (error) { console.error(`failed to write generation log`) console.error(error.stack || error) } } } static _getOutput(plugins) { const files = plugins.reduce((acc, plugin) => acc.concat(plugin.files), []).sort() return files.map(file => { if (path.isAbsolute(cds.env.build.target)) { return file } return path.relative(cds.root, file) }) } _logTimer(start, end) { console.log(`build completed in ${end - start} ms`) } _logTaskHandler(plugin) { DEBUG?.(`plugin ${plugin.constructor.name}`) DEBUG?.(`details src [${relativePaths(cds.root, plugin.task.src)}], dest [${relativePaths(cds.root, plugin.task.dest)}], options [${JSON.stringify(plugin.task.options)}]`) } _logMessages(messages) { if (messages.length > 0) { const options = { log: console.log, "log-level": this._getLogLevel() // ensures that for tests the correct cds.env is used } deduplicateMessages(messages) sortMessagesSeverityAware(messages) log(messages, options) } } static _resolveRequiredSapServices(tasks) { const taskModelPaths = tasks.reduce((acc, task) => { const model = task.options?.model if (model) { if (Array.isArray(model)) { model.forEach(m => acc.add(m)) } else { acc.add(model) } } return acc }, new Set()) return resolveRequiredSapModels([...taskModelPaths]) } /** * Returns a sorted and flattened list of all messages extracted from the given errors. * @param {Array<Error>} errors */ static _getErrorMessages(errors) { let messages = [] // flatten all compile messages in order to filter duplicates and sort later on errors.forEach(error => { if (Array.isArray(error.errors) && error.errors.length > 0) { messages = messages.concat(this._getErrorMessages(error.errors)) } else { messages.push(error) } }) return messages } /** * Returns compiler messages and validation messages issued by plugins. * @param {Array} plugins */ static _getMessages(plugins) { return plugins.reduce((acc, plugin) => acc.concat(plugin.messages), []) } /** * Sort and filter the given errors of type CompileMessage or BuildMessage according to their severity and location, * but leave any other errors untouched as part of the result array.<br> * The log level ('command line' or 'cds.env' option) is used to filter the given messages. * @param {...Error} messages */ _sortMessagesUnique(...messages) { const logLevelIdx = LOG_LEVELS.indexOf(this._getLogLevel()) // flatten messages = messages.reduce((acc, m) => acc.concat(m), []) // filter according to log-level const filteredMessages = messages.filter(message => message && (!message.severity || logLevelIdx >= SEVERITIES.indexOf(message.severity))) // remove duplicates deduplicateMessages(filteredMessages) // sort return sortMessagesSeverityAware(filteredMessages) } /** * Return user defined log level or default value 'warn' */ _getLogLevel() { return this.options["log-level"] || cds.env["log-level"] } } module.exports = BuildTaskEngine