Allow transforming ESBuild configuration (@angular-devkit/build-angular:browser-esbuild) and align interface with the rest of builders
arturovt opened this issue ยท 23 comments
Command
build
Description
The @angular-devkit/build-angular:browser-esbuild
builder is missing the ability to transform the ESBuild configuration as it's done in @angular-devkit/build-angular:browser
. The executeBrowserBuilder
function from the @angular-devkit/build-angular:browser
builder accepts the transforms
object as an optional argument at the end with the webpackConfiguration
property. This allows many packages, such as @angular-builders/custom-webpack
, to extend the Webpack configuration and then call executeBrowserBuilder
at the end.
Describe the solution you'd like
The buildEsbuildBrowser
function could have a third transforms
argument with properties like esbuildConfiguration
(like transforms.webpackConfiguration
) and have a type of ExecutionTransformer<esbuild.BuildOptions>
. This will basically pass the base configuration into the transformer and allow customizing it, then pass the configuration back to bundleCode -> esbuild.build
.
That would align the builder interface with the rest of builders (:browser
, :server
, :karma
, and extract-i18n
).
Describe alternatives you've considered
No response
Due to the experimental status of the builder and the potential for internal changes, we are not currently exposing a similar set of transformation options. However, as the builder moves towards a stable status, we will be reevaluating what types of customization options will be provided.
To help us better provide a flexible set of options, would it be possible to provide more details as to your project use cases in this area?
@clydin I'm currently using the @angular-builders/custom-webpack
package, which allows custom Webpack configuration and merges it with the base one. It uses transform.webpackConfiguration
internally to do that. I have custom Webpack loaders that do some code transformations. I'd want to do the same for the ESBuild builder (by using ESBuild plugins). We could add a new 3rd party package, @angular-builders/custom-esbuild
, that would allow proving custom ESBuild configuration and merging it with the base one. We're currently unable to implement that package because the browser-esbuild
builder functionality is a bit restricted and we're not able to hook in it (like it's possible to do with :browser
or :server
builder). A transform
object will also align the builder interface with other existing builders. I thought ESBuild builder should've had this option since it's already done in other builders, and it's a standard.
Esbuild API is rather different from Webpack and also the usage. In the CLI there are multiple esbuild instances to cater for multiple entryfile types. (TypeScript/JS and CSS/SASS etc..).
It is worth mentioning that by design esbuild API is more lean and restrictive to a certain set of built-in features.
@arturovt, so to be clear you'd like a way to provide custom esbuild plugins to do some code transformations?
@alan-agius4 at the end - yes, let's say I'd want to add this https://esbuild.github.io/plugins/#using-plugins.
Another one is using pug instead of html as the template language for our project. Basically pug transpiles into html so that angular can use it.
We currently use @angular-builders/custom-webpack for this.
In our case, we would just need "provide" as an option, so we can inject the build env, build date, app version and commit hash in our app.
This feature request is now candidate for our backlog! In the next phase, the community has 60 days to upvote. If the request receives more than 20 upvotes, we'll move it to our consideration list.
You can find more details about the feature request process in our documentation.
Another one is using pug instead of html as the template language for our project. Basically pug transpiles into html so that angular can use it.
We currently use @angular-builders/custom-webpack for this.
I imagine whatever extensibility supports this use-case would support mine as well. We hook into the index.html generation to extract script/style tags and write them to new HTML files that can be included inside of a (non-SPA) server-side template. Also using custom-webpack currently.
Also, another one which I just remembered, is that we extend the Webpack config to provide a module alias (lodash
-> lodash-es
and also use IgnorePlugin
to exclude moment locales).
We would like to use https://esbuild.github.io/api/#define api as alternative to https://webpack.js.org/plugins/define-plugin/.
As far as I see, custom-webpack
builder is used a lot so it would be great to be able to set 3rd-party plugins so more projects can start using esbuild
in my use case, I need to add some imports to externals[]
, in webpack its difficult to import an arbitrary import as it must exist at build time, but I found out its possible with esbuild
There are many things we could write plugins for
- a plugin that handles imports to images. Like:
import myAssetWebp from 'images/my/asset.webp' // bundler throws if this file does not exist
console.log(myAssetWebp); // outputs: "https://my-example-cdn.org/asset-<hash>.webp"
-
import svgs to inline them
-
postprocess css via purgecss to throw an error if a selector was found that is not actually used
-
A plugin that provides/updates resources created by your non-node.js backend in watch mode (like translations, routes, environment, api)
--
Yes, all these can be done without plugins by doing them in a separate process. But integrating them via webpack loader or esbuild plugin feels a bit more seamless. Especially in watch mode.
@clydin @alan-agius4 are there any updates on this?
Just adding another reason why it would be needed (for us)
- We are currently using custom webpack config for Sentry configuration
- There's already Sentry plugin for ES Build, and for that we would need this feature also for esbuild
Whoever is interested to get going, I tried to use the exported methods. Seems like vite-server wasn't directly exposed, but works as well.
Here my scuffed but working draft:
create a build.js
file and run via node build.js
. It serves on port 4200 (like ng serve
). Swich the useViteDevServer
variable to false
, and it does a single build via esbuild (like ng build
). In customEsbuildPlugins
you can do whatever you want.
const {buildApplication, executeDevServerBuilder} = require('@angular-devkit/build-angular')
const {AngularWorkspace} = require('@angular/cli/src/utilities/config')
const useViteDevServer = true;
const production = !useViteDevServer;
const watch = useViteDevServer;
const target = production ? 'production' : 'development';
const customEsbuildPlugins = [{
name: 'my-esbuild-plugin',
setup: (build) => {
console.log('do esbuild plugin stuff');
}
}]
async function main() {
const workspace = await AngularWorkspace.load(process.cwd());
const projectInAngularJson = Array.from(workspace.projects.keys())[0]
const buildConfiguration = workspace.projects.get(projectInAngularJson).targets.get('build')
const target = production ? 'production' : 'development';
const angularJsonRootOptions = buildConfiguration.options;
const angularJsonTargetOptions = buildConfiguration.configurations[target];
const options = {
...angularJsonRootOptions,
...angularJsonTargetOptions,
buildTarget: `build:${target}`,
watch,
liveReload: watch,
};
const context = {
workspaceRoot: workspace.filePath,
logger: console,
target: {project: projectInAngularJson},
getProjectMetadata() {
return {}
},
getBuilderNameForTarget() {
return '@angular-devkit/build-angular:application';
},
getTargetOptions(target) {
return {...options};
},
validateOptions(options) {
return options;
},
addTeardown() {
},
};
if (useViteDevServer) {
executeDevServerBuilder({...options}, context, undefined, {buildPlugins: customEsbuildPlugins}).subscribe({
error: console.error
})
return;
}
const build = await buildApplication({...options}, context, {buildPlugins: customEsbuildPlugins});
for await (let result of build) {
console.log(result)
}
}
main();
(edit: updated sample for angular v17.0.0 / angular cli v17.0.0-rc.5)
Would be nice if there was a helper for the context object. And if theAngularWorkspace
was public to read the angular.json. But it's something we can work with :)
Also trying to use @angular-devkit/build-angular:browser-esbuild, but blocked due to use of @sentry/webpack-plugin, which requires a customWebpackConfig.
Also trying to use @angular-devkit/build-angular:browser-esbuild, but blocked due to use of @sentry/webpack-plugin, which requires a customWebpackConfig.
Sentry do have a esbuild plugin (https://www.npmjs.com/package/@sentry/esbuild-plugin) however, you would need to write something that wraps buildApplication from the @angular-devkit/build-angular library since the angular.json can't be configured with this (yet?).
@dlq84 you can, just write your own "ng" ^^ I edited @sod "ng" clone with optimizations and a few args/flags:
/* eslint-disable no-undef */
import { randomUUID } from 'crypto'
import { AngularWorkspace } from '@angular/cli/src/utilities/config.js'
import { buildApplication, executeDevServerBuilder } from '@angular-devkit/build-angular'
import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin'
import { xxhash128 } from 'hash-wasm'
import { table } from 'table'
import dotenv from 'dotenv'
import packageJson from './package.json' assert { type: 'json' }
import { getHash } from './scripts/utils.mjs'
import indexHtmlTransform from './scripts/index-transform.mjs'
dotenv.config()
const version = packageJson.version
const useViteDevelopmentServer = process.argv.includes('serve')
const production = process.argv.includes('build')
const watch = useViteDevelopmentServer || process.argv.includes('--watch')
const target = production ? 'production' : 'development'
const workspace = await AngularWorkspace.load(process.cwd())
const projectKey = [...workspace.projects.keys()][0]
const workspaceProject = workspace.projects.get(projectKey)
const buildConfiguration = workspaceProject.targets.get('build')
const options = {
...buildConfiguration.options,
...buildConfiguration.configurations[target],
buildTarget: `build:${target}`,
liveReload: watch,
indexHtmlTransform,
deleteOutputPath: !watch,
ssl: process.argv.includes('--ssl'),
crossOrigin: 'none',
aot: true,
verbose: false,
watch,
}
console.log('[>>]', options)
const context = {
workspaceRoot: workspace.filePath,
logger: console,
target: { project: projectKey },
getProjectMetadata() {
return {}
},
getBuilderNameForTarget() {
return '@angular-devkit/build-angular:application'
},
getTargetOptions(target) {
console.log(target)
return { ...options }
},
validateOptions(options) {
return options
},
addTeardown() {},
}
const esbuildPlugins = [
{
name: 'plugin-define',
setup: async (build) => {
const initialOptions = build.initialOptions
initialOptions.define = initialOptions.define || {}
process.env['NODE_ENV'] = production ? 'production' : 'development'
initialOptions.define['BUILD_ENV'] = JSON.stringify(process.env['NODE_ENV'])
initialOptions.define['BUILD_DATE'] = JSON.stringify(new Date().toISOString())
process.env['API_ENV'] = 'staging'
// process.env['API_ENV'] = process.env['NODE_ENV'] === 'development' ? 'staging' : 'prod'
initialOptions.define['API_ENV'] = JSON.stringify(process.env['API_ENV'])
initialOptions.define['APP_VERSION'] = JSON.stringify(version)
initialOptions.define['APP_HASH'] = JSON.stringify(getHash())
process.env['NONCE'] = await xxhash128(randomUUID())
initialOptions.define['NONCE'] = JSON.stringify(process.env['NONCE'])
build.onStart(() => {
console.log('\n\n' + table(Object.entries(initialOptions.define)))
})
},
},
]
if (useViteDevelopmentServer) {
const serveConfiguration = workspaceProject.targets.get('serve')
executeDevServerBuilder(
{
...options,
...serveConfiguration.options,
...serveConfiguration.configurations[target],
},
context,
undefined,
{
buildPlugins: esbuildPlugins,
},
).subscribe({
error: console.error,
})
} else {
if (process.env['RELEASE_SENTRY']) {
esbuildPlugins.push(
sentryEsbuildPlugin({
org: 'oooooooo',
project: 'oooooo',
authToken: process.env['SENTRY_TOKEN'],
telemetry: true,
sourcemaps: {
assets: './www/browser/**',
ignore: ['./node_modules/**'],
},
release: {
name: version,
dist: version.split('.')[0],
cleanArtifacts: true,
setCommits: {
auto: true,
},
deploy: {
env: target,
name: version,
},
},
}),
)
}
const build = buildApplication(options, context, esbuildPlugins)
for await (const result of build) {
console.log('[>>]', result)
}
}
Thanks I will look into this but unfortunately lack of support for esBuild plugins isn't the only thing holding us back from switching to esbuild at the moment. Hopefully the support will come soon...
One of our libraries is currently not working with esbuild. Plus I'd rather wait for actual support for esbuild plugins for now, especially since build-angular:browser is still very much supported.
I've managed to reduce my need for webpack plugins so I am only left with a need for the define 'plugin' to replace some constants (IS_PRODUCTION, IS_DEV, SIGNAL_DEBUGGING_ENABLED) for tree shaking. May need to go back to webpack for now since I have quite a lot of these scattered around.
I hope that simple situations like this can be made easy relatively soon, starting by finding out what are the most common needs. The custom webpack builder has a lot of downloads.
For my need it is as simple as setting parameters when calling esbuild's define option, but there's no supported way to easily add these:
esbuild --define:id=text --define:str=\"text\"
This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.
Read more about our automatic conversation locking policy.
This action has been performed automatically by a bot.