Custom UI5 Builder Tasks
The UI5 Build Extensibility enables you to enhance the build process of any UI5 project. In addition to the standard tasks, custom tasks can be created.
The UI5 community already created many custom tasks which you can integrate into your project. They are often prefixed by ui5-task- to make them easily searchable in the npm registry.
Please note that custom tasks from third parties can not only modify your project but also execute arbitrary code on your system. In fact, this is the case for all npm packages you install. Always act with the according care and follow best practices.
Configuration
You can configure your build process with additional build task. These custom tasks are defined in the project configuration.
To hook your custom tasks into the different build phases of a project, they need to reference other tasks to be executed before or after. This can be a standard task or another custom task. Standard tasks that are disabled, even though they are not executed, can still be referenced by custom tasks, which will be performed in their designated position.
In the below example, when building the library my.library the custom babel task will be executed before the standard task generateComponentPreload.
Another custom task called render-markdown-files is then executed immediately after the standard task minify.
Example: Basic configuration
# In this example configuration, two custom tasks are defined: 'babel' and 'render-markdown-files'.
specVersion: "5.0"
type: library
metadata:
name: my.library
builder:
customTasks:
- name: babel
beforeTask: generateComponentPreload
- name: render-markdown-files
afterTask: minify
configuration:
markdownStyle:
firstH1IsTitle: trueExample: Connect multiple custom tasks
You can also connect multiple custom tasks with each other. The order in the configuration is important in this case. You have to make sure that a task is defined before you reference it via beforeTask or afterTask.
# In this example, 'my-custom-task-2' gets executed after 'my-custom-task-1'.
specVersion: "5.0"
type: library
metadata:
name: my.library
builder:
customTasks:
- name: my-custom-task-1
beforeTask: generateComponentPreload
- name: my-custom-task-2
afterTask: my-custom-task-1Custom Task Extension
A custom task extension consists of a ui5.yaml and a task implementation. It can be a standalone module or part of an existing UI5 project.
Example: ui5.yaml
specVersion: "5.0"
kind: extension
type: task
metadata:
name: render-markdown-files
task:
path: lib/tasks/renderMarkdownFiles.jsTask extensions can be standalone modules which are handled as dependencies.
Alternatively you can implement a task extension as part of your UI5 project. In that case, the configuration of the extension is part of your project configuration inside the ui5.yaml as shown below.
The task extension will then be automatically collected and processed during the processing of the project.
Example: Custom Task Extension defined in UI5 project
# Project configuration for the above example
specVersion: "5.0"
kind: project
type: library
metadata:
name: my.library
builder:
customTasks:
- name: render-markdown-files
afterTask: minify
configuration:
markdownStyle:
firstH1IsTitle: true
---
# Task extension as part of your project
specVersion: "5.0"
kind: extension
type: task
metadata:
name: render-markdown-files
task:
path: lib/tasks/renderMarkdownFiles.jsTask Implementation
From Specification Version 5.0, a custom task module's default export is a factory function that receives the task options and returns a descriptor object. The descriptor declares the task's work and its optional callbacks in one place:
task— a traditional task body (see the signature below), orsteps— an array of steps for a cache-aware task (exactly one oftaskorstepsmust be defined)requiresDependencies— optional; whether the task needs access to the resources of the project's dependencies. Whenfalse(the default), thedependenciesreader is not provideddetermineRequiredDependencies— optional, see Required DependenciesdetermineBuildSignature— optional callback contributing to the project's build signaturespecVersion— optional; if set, it must equal the task extension's configured Specification Version
The factory receives only the options object (never readers or a taskUtil), so it must be pure: it may branch on options and precompute values, but it must not read or write resources. Those happen in the task body (or in the step callbacks).
INFO
Below Specification Version 5.0, the default export is the task body itself (the signature shown below), and the callbacks are top-level named exports. The factory-and-descriptor model, cache-aware steps, and the determineBuildSignature callback are available from Specification Version 5.0.
export default function(options) {
return {
task: myTask
};
};
/**
* Custom task API
*
* @param {object} parameters
*
* @param {module:@ui5/fs.AbstractReader} parameters.dependencies
* Reader to access resources of the project's dependencies
* @param {@ui5/logger/Logger} parameters.log
* Logger instance for use in the custom task.
* This parameter is only available to custom task extensions
* defining Specification Version 3.0 and later.
* @param {object} parameters.options Options
* @param {string} parameters.options.projectName
* Name of the project currently being built
* @param {string} parameters.options.projectNamespace
* Namespace of the project currently being built
* @param {string} parameters.options.configuration
* Custom task configuration, as defined in the project's ui5.yaml
* @param {string} parameters.options.taskName
* Name of the custom task.
* This parameter is only provided to custom task extensions
* defining Specification Version 3.0 and later.
* @param {@ui5/builder.tasks.TaskUtil} parameters.taskUtil
* Specification Version-dependent interface to a TaskUtil instance.
* See the corresponding API reference for details:
* https://ui5.github.io/cli/v5/api/@ui5_project_build_helpers_TaskUtil.html
* @param {module:@ui5/fs.DuplexCollection} parameters.workspace
* Reader/Writer to access and modify resources of the
* project currently being built
* @returns {Promise<undefined>}
* Promise resolving once the task has finished
*/
async function myTask({dependencies, log, options, taskUtil, workspace}) {
// [...]
};module.exports = function(options) {
return {
task: myTask
};
};
/**
* Custom task API
*
* @param {object} parameters
*
* @param {module:@ui5/fs.AbstractReader} parameters.dependencies
* Reader to access resources of the project's dependencies
* @param {@ui5/logger/Logger} parameters.log
* Logger instance for use in the custom task.
* This parameter is only available to custom task extensions
* defining Specification Version 3.0 and later.
* @param {object} parameters.options Options
* @param {string} parameters.options.projectName
* Name of the project currently being built
* @param {string} parameters.options.projectNamespace
* Namespace of the project currently being built
* @param {string} parameters.options.configuration
* Custom task configuration, as defined in the project's ui5.yaml
* @param {string} parameters.options.taskName
* Name of the custom task.
* This parameter is only provided to custom task extensions
* defining Specification Version 3.0 and later.
* @param {@ui5/builder.tasks.TaskUtil} parameters.taskUtil
* Specification Version-dependent interface to a TaskUtil instance.
* See the corresponding API reference for details:
* https://ui5.github.io/cli/v5/api/@ui5_project_build_helpers_TaskUtil.html
* @param {module:@ui5/fs.DuplexCollection} parameters.workspace
* Reader/Writer to access and modify resources of the
* project currently being built
* @returns {Promise<undefined>}
* Promise resolving once the task has finished
*/
async function myTask({dependencies, log, options, taskUtil, workspace}) {
// [...]
};Required Dependencies
INFO
This functionality has been added with UI5 CLI v3.0.0
Custom tasks can declare an optional determineRequiredDependencies callback to control which dependency-resources are made available through the dependencies-reader that is provided to the task. By reducing the amount of required dependencies or by not requiring any, UI5 CLI might be able to build a project faster.
From Specification Version 5.0 the callback is a property of the descriptor the task factory returns. Below 5.0 it is a top-level named export of the task module.
Before executing a task, UI5 CLI will ensure that all required dependencies have been built.
If this callback is not provided, UI5 CLI will make an assumption as to whether the custom task requires access to any resources of dependencies based on the defined Specification Version of the custom task extension:
- Specification Version 3.0 and later: If no callback is provided, UI5 CLI assumes that no dependencies are required. In this case, the
dependenciesparameter will be omitted. - Specification Versions before 3.0: If no callback is provided, UI5 CLI assumes that all dependencies are required.
For more details, see also RFC 0012 UI5 CLI Extension API v3
export default function(options) {
return {
determineRequiredDependencies,
task
};
};
/**
* Callback function to define the list of required dependencies
*
* @param {object} parameters
* @param {Set} parameters.availableDependencies
* Set containing the names of all direct dependencies of
* the project currently being built.
* @param {function} parameters.getDependencies
* Identical to TaskUtil#getDependencies
* (see https://ui5.github.io/cli/v5/api/@ui5_project_build_helpers_TaskUtil.html).
* Creates a list of names of all direct dependencies
* of a given project.
* @param {function} parameters.getProject
* Identical to TaskUtil#getProject
* (see https://ui5.github.io/cli/v5/api/@ui5_project_build_helpers_TaskUtil.html).
* Retrieves a Project-instance for a given project name.
* @param {object} parameters.options
* Identical to the options given to the standard task function.
* @returns {Promise<Set>}
* Promise resolving with a Set containing all dependencies
* that should be made available to the task.
* UI5 CLI will ensure that those dependencies have been
* built before executing the task.
*/
async function determineRequiredDependencies({availableDependencies, getDependencies, getProject, options}) {
// "availableDependencies" could look like this: Set(3) { "sap.ui.core", "sap.m", "my.lib" }
// Reduce list of required dependencies: Do not require any UI5 framework projects
availableDependencies.forEach((depName) => {
if (getProject(depName).isFrameworkProject()) {
availableDependencies.delete(depName)
}
});
// => Only resources of project "my.lib" will be available to the task
return availableDependencies;
}module.exports = function(options) {
return {
determineRequiredDependencies,
task
};
};
/**
* Callback function to define the list of required dependencies
*
* @param {object} parameters
* @param {Set} parameters.availableDependencies
* Set containing the names of all direct dependencies of
* the project currently being built.
* @param {function} parameters.getDependencies
* Identical to TaskUtil#getDependencies
* (see https://ui5.github.io/cli/v5/api/@ui5_project_build_helpers_TaskUtil.html).
* Creates a list of names of all direct dependencies
* of a given project.
* @param {function} parameters.getProject
* Identical to TaskUtil#getProject
* (see https://ui5.github.io/cli/v5/api/@ui5_project_build_helpers_TaskUtil.html).
* Retrieves a Project-instance for a given project name.
* @param {object} parameters.options
* Identical to the options given to the standard task function.
* @returns {Promise<Set>}
* Promise resolving with a Set containing all dependencies
* that should be made available to the task.
* UI5 CLI will ensure that those dependencies have been
* built before executing the task.
*/
async function determineRequiredDependencies({availableDependencies, getDependencies, getProject, options}) {
// "availableDependencies" could look like this: Set(3) { "sap.ui.core", "sap.m", "my.lib" }
// Reduce list of required dependencies: Do not require any UI5 framework projects
availableDependencies.forEach((depName) => {
if (getProject(depName).isFrameworkProject()) {
availableDependencies.delete(depName)
}
});
// => Only resources of project "my.lib" will be available to the task
return availableDependencies;
}"Cache-aware" Tasks
Due to UI5 Builder and UI5 Server supporting build caches of task data, custom tasks can opt into this behavior to improve performance. A cache-aware task is a step-based task: its descriptor returns a steps array instead of a task body. The steps describe the work. The build cache tracks each step's inputs and, on a delta build, re-runs only the steps (and, within a step, only the keys) whose inputs changed, restoring the rest from cache. A task that returns a task body, runs on a Specification Version below 5.0, or has no cache available, processes all resources from scratch.
Step-based custom tasks are available from Specification Version 5.0. A task opts in by returning a steps array (instead of task) from its descriptor:
export default function(options) {
return {
steps: [ /* steps */ ]
};
};module.exports = function(options) {
return {
steps: [ /* steps */ ]
};
};The factory receives the task options only (the same options object the task body receives); it never receives readers or a taskUtil, so it cannot close over build state. It must be pure over options: it may branch on options.projectNamespace, precompute glob patterns, or include or omit steps, but it must not read or write resources. Every input a step reads arrives through the step's own callback arguments.
There are two kinds of steps, run in array order:
- Scalar step
{name, needs?, run}— runs once.run: async ({needs, workspace, dependencies, taskUtil, options}) => value? - Map step
{name, needs?, keys, each}— runs once per key.keys: async ({needs, workspace, dependencies, taskUtil, options}) => keySetenumerates the keys, andeach: async (key, {needs, workspace, dependencies, taskUtil, options}) => value?processes one key. A key is a resource or a stable string.
Each step gets its own name (a non-empty string, unique within the task). A map step is the usual cache-aware shape: the build cache treats every key as its own cached invocation, so a delta build re-processes only the keys whose inputs changed.
A step may list earlier step names in needs; those steps' return values then arrive as needs.<name> in the step's callbacks. A step may reference only steps that come before it in the array, so there can be no cycle. A step's return value (resources, or any JSON-serializable value) folds into the cache signature of every step that consumes it, so a changed producer re-runs its consumers.
Best Practices for Cache-aware Tasks
- Keep tasks deterministic: Given the same inputs, always produce the same outputs.
- Keep the factory pure: The factory must only return the descriptor (with its
steps) fromoptions. Never read or write resources in the factory; do that in the step callbacks. - Read and write only through the callback arguments: Every input a step reads must arrive through its
workspace,dependencies,taskUtil, orneedsarguments, and every write must go through the step's ownworkspace. A read or write that bypasses these is not recorded, so the cache cannot track it and a delta build can serve stale output. - Import heavy processors lazily: UI5 CLI calls the factory on every build to discover the steps, even when every step is a cache hit. Import heavy modules inside the step callbacks (for example
const p = (await import("./processor.js")).default), not at the top of the module, so a fully cached build does not load them. - Split work carefully: Only use a map step if each key can be processed independently. Two keys that run concurrently must not write the same path.
- Name steps stably: Reuse the same step
nameacross builds so the cached data is found.
Examples
The following code snippets show examples for custom task implementations.
Example: lib/tasks/renderMarkdownFiles.js
This example is making use of the resourceFactory TaskUtil API to create new resources based on the output of a third-party module for rendering Markdown files. The created resources are added to the build result by writing them into the provided workspace. This task is a step-based, cache-aware task: a single map step renders one Markdown resource per key, so a delta build re-renders only the files that changed. The renderMarkdown processor is imported lazily inside the each callback, so a fully cached build never loads it.
import path from "node:path";
import {getLogger} from "@ui5/logger";
const log = getLogger("builder:tasks:renderMarkdownFiles");
/*
* Render all .md (Markdown) files in the project to HTML
*/
export default function(options) {
return {steps: [{
name: "render",
// One cached invocation per Markdown file, so a delta build re-renders only the files that changed.
keys: async ({workspace}) => workspace.byGlob("**/*.md"),
each: async (resource, {workspace, taskUtil}) => {
// Import the processor lazily so a fully cached build does not load it
const renderMarkdown = (await import("./renderMarkdown.js")).default;
const {createResource} = taskUtil.resourceFactory;
const markdownResourcePath = resource.getPath();
log.info(`Rendering markdown file ${markdownResourcePath}...`);
const htmlString = await renderMarkdown(await resource.getString(), options.configuration);
// Note: @ui5/fs virtual paths are always (on *all* platforms) POSIX. Therefore using path.posix here
const newResourceName = path.posix.basename(markdownResourcePath, ".md") + ".html";
const newResourcePath = path.posix.join(path.posix.dirname(markdownResourcePath), newResourceName);
await workspace.write(createResource({
path: newResourcePath,
string: htmlString
}));
}
}]};
};const path = require("node:path");
const {getLogger} = require("@ui5/logger");
const log = getLogger("builder:tasks:renderMarkdownFiles");
/*
* Render all .md (Markdown) files in the project to HTML
*/
module.exports = function(options) {
return {steps: [{
name: "render",
// One cached invocation per Markdown file, so a delta build re-renders only the files that changed.
keys: async ({workspace}) => workspace.byGlob("**/*.md"),
each: async (resource, {workspace, taskUtil}) => {
// Import the processor lazily so a fully cached build does not load it
const renderMarkdown = require("./renderMarkdown.js");
const {createResource} = taskUtil.resourceFactory;
const markdownResourcePath = resource.getPath();
log.info(`Rendering markdown file ${markdownResourcePath}...`);
const htmlString = await renderMarkdown(await resource.getString(), options.configuration);
// Note: @ui5/fs virtual paths are always (on *all* platforms) POSIX. Therefore using path.posix here
const newResourceName = path.posix.basename(markdownResourcePath, ".md") + ".html";
const newResourcePath = path.posix.join(path.posix.dirname(markdownResourcePath), newResourceName);
await workspace.write(createResource({
path: newResourcePath,
string: htmlString
}));
}
}]};
};WARNING
Depending on your project setup, UI5 CLI tends to open many files simultaneously during a build. To prevent errors like EMFILE: too many open files, we urge custom task implementations to use the graceful-fs module as a drop-in replacement for the native fs module in case it is used.
Tasks should ideally use the reader/writer APIs provided by UI5 CLI for working with project resources.
Example: lib/tasks/compileLicenseSummary.js
This example is making use of multiple TaskUtil APIs to retrieve additional information about the project currently being built (taskUtil.getProject()) and its direct dependencies (taskUtil.getDependencies()). Project configuration files like package.json can be accessed directly using project.getRootReader().
import path from "node:path";
export default function(options) {
return {task};
};
/*
* Compile a list of all licenses of the project's dependencies
* and write it to "dependency-license-summary.json
*/
async function task({dependencies, log, options, taskUtil, workspace}) {
const {createResource} = taskUtil.resourceFactory;
const licenses = new Map();
const projectsVisited = new Set();
async function processProject(project) {
return Promise.all(taskUtil.getDependencies().map(async (projectName) => {
if (projectsVisited.has(projectName)) {
return;
}
projectsVisited.add(projectName);
const project = taskUtil.getProject(projectName);
const pkgResource = await project.getRootReader().byPath("../package.json");
if (pkgResource) {
const pkg = JSON.parse(await pkgResource.getString())
// Add project to list of licenses
if (licenses.has(pkg.license)) {
licenses.get(pkg.license).push(project.getName());
} else {
// License not yet in map. Define it
licenses.set(pkg.license, [project.getName()]);
}
} else {
log.info(`Could not find package.json file in project ${project.getName()}`);
}
return processProject(project);
}));
}
// Start processing dependencies of the root project
await processProject(taskUtil.getProject());
const summaryResource = createResource({
path: "/dependency-license-summary.json",
string: JSON.stringify(Object.fromEntries(licenses), null, "\t")
});
await workspace.write(summaryResource);
};const path = require("node:path");
module.exports = function(options) {
return {task};
};
/*
* Compile a list of all licenses of the project's dependencies
* and write it to "dependency-license-summary.json"
*/
async function task({dependencies, log, options, taskUtil, workspace}) {
const {createResource} = taskUtil.resourceFactory;
const licenses = new Map();
const projectsVisited = new Set();
async function processProject(project) {
return Promise.all(taskUtil.getDependencies().map(async (projectName) => {
if (projectsVisited.has(projectName)) {
return;
}
projectsVisited.add(projectName);
const project = taskUtil.getProject(projectName);
const pkgResource = await project.getRootReader().byPath("/package.json");
if (pkgResource) {
const pkg = JSON.parse(await pkgResource.getString())
// Add project to list of licenses
if (licenses.has(pkg.license)) {
licenses.get(pkg.license).push(project.getName());
} else {
// License not yet in map. Define it
licenses.set(pkg.license, [project.getName()]);
}
} else {
log.info(`Could not find package.json file in project ${project.getName()}`);
}
return processProject(project);
}));
}
// Start processing dependencies of the root project
await processProject(taskUtil.getProject());
const summaryResource = createResource({
path: "/dependency-license-summary.json",
string: JSON.stringify(Object.fromEntries(licenses), null, "\t")
});
await workspace.write(summaryResource);
};Helper Class TaskUtil
Custom tasks defining Specification Version 2.2 or higher have access to an interface of a TaskUtil instance.
In this case, a taskUtil object is provided as a part of the custom task's parameters. Depending on the specification version of the custom task, a set of helper functions is available to the implementation. The lowest required specification version for every function is listed in the TaskUtil API reference.

