UNPKG

@sap/cds-dk

Version:

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

308 lines (282 loc) 15.7 kB
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) } }