@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
354 lines (315 loc) • 13.5 kB
JavaScript
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