@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
308 lines (282 loc) • 15.7 kB
JavaScript
const cds = require('../../../cds')
const { path, exists, rimraf, isdir, read, write } = cds.utils
const cmd = require('../../../util/command')
const { BuildError, getWorkspaces, findWorkspaceRoot, normalizePath } = require('../../util')
const { ODATA_VERSION_V2, FOLDER_GEN, CONTENT_EDMX, CONTENT_PACKAGELOCK_JSON,
CONTENT_NPMRC, CONTENT_CDSRC_JSON, CONTENT_ENV, CONTENT_DEFAULT_ENV_JSON, FLAVOR_LOCALIZED_EDMX,
OPTION_WS_PACK, SEVERITY_WARNING } = require('../../constants');
const LOG = cds.log('cli|build')
const DEBUG = cds.debug('cli|build')
const NODE_ENGINE_VERSION = '>=20'
const { exec } = require('node:child_process')
const pexec = require('node:util').promisify(exec)
module.exports = class NodejsBuildPlugin extends require('../edmx') {
static get taskDefaults() { return { src: normalizePath(cds.env.folders.srv) } }
static hasTask() {
const src = path.join(cds.root, this.taskDefaults?.src || 'srv')
const hasSrc = exists(src) || (this.taskDefaults?.src === '.')
return cds.env['project-nature'] === 'nodejs' && hasSrc && !cds.env.extends
}
init() {
super.init()
// fallback if src has been defined as '.'
this.destSrv = this.isStagingBuild() ? path.resolve(this.task.dest, cds.env.folders.srv) : path.join(this.task.dest, FOLDER_GEN)
}
options() {
const options = super.options()
if (cds.env.requires.extensibility || cds.env.requires.toggles) {
options.flavor = 'xtended'
}
return options
}
async build() {
if (!exists('package.json')) {
throw new BuildError(`No 'package.json' found in project root folder '${cds.root}'`)
}
const destSrv = this.isStagingBuild() ? this.destSrv : path.resolve(this.destSrv, cds.env.folders.srv)
const destRoot = this.isStagingBuild() ? this.task.dest : this.destSrv
if (cds.env.odata.version === ODATA_VERSION_V2) {
// log warning as nodejs is only supporting odata version V4
this.pushMessage("OData v2 is not supported by node runtime. Make sure to define OData v2 in cds configuration.", SEVERITY_WARNING)
}
// by default model contains all features
const model = await this.model()
if (!model && !this.context.options[OPTION_WS_PACK]) {
return
}
if (model) {
const { dictionary, sources } = await this.compileAll(model, destSrv, destRoot)
// collect and write language bundles into single i18n.json file
// await this.collectAllLanguageBundles(dictionary, sources, destRoot, destRoot)
await this.collectAllLanguageBundles(dictionary, sources, destSrv, destRoot)
if (!this.hasBuildOption(CONTENT_EDMX, false)) {
const compileOptions = { [FLAVOR_LOCALIZED_EDMX]: this.hasBuildOption(FLAVOR_LOCALIZED_EDMX, true) }
// inferred flavor is required by edmx compiler backend
// using cds.compile instead of cds.compiler.compileSources ensures that cds.env options are correctly read
const baseModel = dictionary.base.meta.flavor !== 'inferred' ? await cds.compile(sources.base, super.options(), 'inferred') : dictionary.base
await this.compileToEdmx(baseModel, path.join(this.destSrv, 'odata', cds.env.odata.version), compileOptions)
}
}
if (this.isStagingBuild()) {
const srcSrv = this.task.src === cds.root ? path.resolve(this.task.src, cds.env.folders.srv) : this.task.src
await this._copyNativeContent(cds.root, srcSrv, destRoot, destSrv)
try {
// add minimal node engine to effective package.json.
// CloudFoundry uses this entry to choose the node engine in buildpack and may
// fall back to an outdated version if this entry is missing.
const packageJsonPath = path.join(destRoot, 'package.json')
const packageJsonContent = require(packageJsonPath)
if (!Object.hasOwn(packageJsonContent, 'engines') || !Object.hasOwn(packageJsonContent.engines, 'node')) {
DEBUG?.(`Amending package.json with node engine version ${NODE_ENGINE_VERSION}`)
packageJsonContent.engines = packageJsonContent.engines ?? {}
packageJsonContent.engines.node = NODE_ENGINE_VERSION
await this.write(JSON.stringify(packageJsonContent, 0, 2)).to(packageJsonPath)
}
} catch (e) {
DEBUG?.(e)
// silently ignore package json not being found
if (e.code !== 'MODULE_NOT_FOUND') throw e
}
if (this.context.options[OPTION_WS_PACK]) {
await this._packWorkspaceDependencies(destRoot)
}
}
await this.#cleanupPackageJson(destRoot)
}
/**
* Removes devDeps that are invalid semver, pointing to workspace or file locations,
* and also removes workspace entries. If needed, creates a new package-lock.json in build destination.
*/
async #cleanupPackageJson() {
/** @param {string | undefined} version */
const valid = version => version && !version.match('^(?:workspace|file):')
const packageJsonPath = path.join(this.task.dest, 'package.json')
try {
const packageJson = await read(packageJsonPath)
DEBUG?.(`Sanitizing the package.json for "${this.task.src}'...`)
let modified = false
// remove the workspace configuration
if (packageJson.workspaces) {
delete packageJson.workspaces
modified = true
}
// remove the workspace dev dependencies and the cds-plugin-ui5
if (packageJson.devDependencies) {
packageJson.devDependencies = Object.entries(packageJson.devDependencies)
.reduce((acc, [dep, version]) => {
if (valid(version)) {
acc[dep] = version
}
return acc
}, {})
modified = true
}
// overwrite the package.json if it was modified only
if (modified) {
await write(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf-8')
// update the package-lock.json if it exists
if (exists(path.join(this.task.dest, 'package-lock.json'))) {
DEBUG?.(`Updating the package-lock.json for '${this.task.src}'...`)
// run the npm install --package-lock-only to only update the package-lock.json
// without installing the dependencies to node_modules
try {
await pexec('npm install --package-lock-only', { cwd: this.task.dest })
} catch (e) {
DEBUG?.(`Failed to update the package-lock.json for '${this.task.src}'! Error: ${e.code} - ${e.message}`)
}
}
}
} catch (e) {
// ENOENT: package.json not found -> this is okay for monorepos
if (e.code !== 'ENOENT') throw e
}
}
async clean() {
// staging build content is deleted by default
if (cds.env.build.target === '.') {
// delete the entire 'task.dest' folder otherwise, for details see #constructor
// - the value of the folder 'src' has been appended to the origin 'task.dest' dir
DEBUG?.(`Deleting build target folder ${this.destSrv}`)
await rimraf(this.isStagingBuild() ? this.task.dest : this.destSrv)
}
}
async _copyNativeContent(srcRoot, srcSrv, destRoot, destSrv) {
// project/srv/** -> 'gen/srv/srv/**'
const filesFilter = await this.copySrvContent(srcSrv, destRoot, destSrv)
// project/* -> 'gen/srv/*'
await this.copyProjectRootContent(srcRoot, destRoot, (entry) => !filesFilter.includes(path.basename(entry)))
}
/**
* Copy files for nodejs staging builds from the given <em>src</em>' folder (e.g. 'project/srv') to either <em>destRoot</em> (e.g. 'project/gen/srv')
* or <em>destSrv</em> (e.g. 'project/gen/srv/srv') folders according to the file semantics.
* Files with project semantics like 'package.json' or '.npmrc' file are copied to <em>destRoot</em> while others like '.js' service handlers
* are copied to <em>destSrv</em>.
* @param {*} src
* @param {*} destRoot - folder name representing the app root folder (e.g. gen/srv)
* @param {*} destSrv - folder name representing the app sub-folder (e.g. gen/srv/srv)
* @returns the list of files that have been copied
*/
async copySrvContent(src, destRoot, destSrv) {
const srvRootBlockList = RegExp('package\\.json$|package-lock\\.json$|\\.npmrc$|\\.cdsrc\\.json$')
const srvBlockList = RegExp('\\.cds$|csn\\.json$|\\.csn$|manifest\\.y.?ml$|\\.env($|\\..*$)|default-env\\.json$|\\.cdsrc-private.json$')
const srvRootFileNames = []
// 1. copy all files to 'destSrv' except those contained in the blocklist (including node_modules)
// project/srv -> 'gen/srv/srv'
await super.copyNativeContent(src, destSrv, (entry) => {
if (isdir(entry)) {
// TODO shall not copy language bundles - return !/(\/|\\)(node_modules|_i18n)(\/|\\)?$/.test(entry)
return !/(\/|\\)node_modules(\/|\\)?$/.test(entry)
}
// make sure the file exists on srv root level - see https://github.tools.sap/cap/issues/issues/12077
if (srvRootBlockList.test(entry) && path.dirname(entry) === src) {
srvRootFileNames.push(path.basename(entry))
return false
}
return !srvBlockList.test(entry)
})
// 2. copy dedicated files like package.json, .npmrc to 'destRoot'
// project/srv -> 'gen/srv'
let srvAllowList = "package\\.json$" // always copy package.json, modify only if CONTENT_PACKAGE_JSON is true
srvAllowList += !this.hasBuildOption(CONTENT_PACKAGELOCK_JSON, false) ? "|package-lock\\.json$" : ""
srvAllowList += !this.hasBuildOption(CONTENT_NPMRC, false) ? "|\\.npmrc$" : ""
srvAllowList += !this.hasBuildOption(CONTENT_CDSRC_JSON, false) ? "|\\.cdsrc\\.json$" : ""
srvAllowList += this.hasBuildOption(CONTENT_ENV, true) ? "|\\.env($|\\..*$)" : ""
srvAllowList += this.hasBuildOption(CONTENT_DEFAULT_ENV_JSON, true) ? "|default-env\\.json$" : ""
srvAllowList = new RegExp(srvAllowList)
await Promise.all(srvRootFileNames.map(fileName => {
if (srvAllowList.test(fileName)) {
return this.copy(path.join(src, fileName)).to(path.join(destRoot, fileName))
}
}))
return srvRootFileNames
}
/**
* Copy dedicated files (files with project semantics like package.json, .npmrc, .cdsrc, etc.)
* from the given <em>src</em> folder (e.g. 'project') into the given <em>dest</em> folder (e.g. 'project/gen/srv')
* @param {*} src
* @param {*} dest
* @param {*} filter - copy file if filter function returns true
*/
async copyProjectRootContent(src, dest, filter) {
let { folders = ['i18n'] } = cds.env.i18n
folders.push('handlers')
folders = folders.map(folder => path.join(src, folder))
let srvAllowList = "package\\.json$" // always copy package.json, modify only if CONTENT_PACKAGE_JSON is true
srvAllowList += !this.hasBuildOption(CONTENT_PACKAGELOCK_JSON, false) ? "|package-lock\\.json$" : ""
srvAllowList += !this.hasBuildOption(CONTENT_NPMRC, false) ? "|\\.npmrc$" : ""
srvAllowList += !this.hasBuildOption(CONTENT_CDSRC_JSON, false) ? "|\\.cdsrc\\.json$" : ""
srvAllowList += this.hasBuildOption(CONTENT_ENV, true) ? "|\\.env($|\\..*$)" : ""
srvAllowList += this.hasBuildOption(CONTENT_DEFAULT_ENV_JSON, true) ? "|default-env\\.json$" : ""
srvAllowList = new RegExp(srvAllowList)
await super.copyNativeContent(src, dest, (entry) => {
if (isdir(entry)) {
return folders.some(folder => entry.startsWith(folder))
}
if (/\.js$|\.properties$/.test(entry)) {
return true
}
return srvAllowList.test(entry) && (!filter || filter.call(this, entry))
})
}
async _packWorkspaceDependencies(dest) {
const wsRoot = findWorkspaceRoot(cds.root)
const appPkg = require(path.join(dest, 'package.json'))
const allWorkspaces = await getWorkspaces(wsRoot, true, false)
let workspaces
if (this.task.options.workspaces) {
const arrayify = x => Array.isArray(x) ? x : [x]
const relevantWorkspaces = new Set(arrayify(this.task.options.workspaces))
workspaces = allWorkspaces
.filter(ws => relevantWorkspaces.has(ws.workspace))
} else {
// devDependencies are not considered
workspaces = allWorkspaces
}
if (workspaces.length) {
let pkgDescriptors
try {
pkgDescriptors = await NodejsBuildPlugin._execNpmPack(dest, wsRoot, workspaces.map(ws => ws.workspace))
} catch (e) {
DEBUG?.(e)
throw new BuildError(`Failed to package npm workspace dependencies\n${e.message}`)
}
if (pkgDescriptors?.length) {
let changed
for (const pkgDescriptor of pkgDescriptors) {
const { name, filename: fileName } = pkgDescriptor
const filePath = path.join(dest, fileName)
// we pack workspaces that are not directly part of appPkg.dependencies
// to include transitive dependencies, i.e. we are packing A, which has a dep
// on workspace B, which has a dep on C. A will not have a direct dependency
// on C, but it will be included in the build transitively, and therefore
// has to be added to the package.json as well, so they are available for the
// packaged B.
if (appPkg.dependencies?.[name] || workspaces.some(ws => ws.packageName === name)) {
appPkg.dependencies[name] = `file:${fileName}`
this.pushFile(filePath)
changed |= true
}
}
if (changed) {
await this.write(JSON.stringify(appPkg, 0, 2)).to(path.join(dest, 'package.json'))
} else {
throw new BuildError('No matching workspaces found!')
}
}
}
}
/**
* @param {import('fs').PathLike} dest - destination folder
* @param {import('fs').PathLike} wsRoot - workspace root folder
* @param {string[]} workspaces - list of workspace names
* @returns {Promise<JSON>} - returns a promise that resolves to the result of the command
* @private
*/
static async _execNpmPack(dest, wsRoot, workspaces) {
const args = ['pack', '-ws', '--json', `--pack-destination=${dest}`]
workspaces.forEach(w => args.push(`-w=${w}`))
const result = await cmd.spawnCommand('npm', args, { cwd: wsRoot }, true, !!LOG._debug)
DEBUG?.(result)
return JSON.parse(result)
}
}