From 2cb089e5c58b5e2511928fa7bb4dc1a221876e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eslam=20=CE=BB=20Hefnawy?= Date: Wed, 10 Jul 2024 07:35:04 -0700 Subject: [PATCH] feat: adds support for esbuild plugins (SC-2514) (#12662) * feat: adds support for esbuild plugins * chore: update docs * chore: memoize build function --- docs/providers/aws/guide/building.md | 43 ++++++++++++++++++++++ lib/plugins/esbuild/index.js | 53 +++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/docs/providers/aws/guide/building.md b/docs/providers/aws/guide/building.md index 25e604cd1..329c65950 100644 --- a/docs/providers/aws/guide/building.md +++ b/docs/providers/aws/guide/building.md @@ -46,6 +46,49 @@ build: setNodeOptions: true ``` +You may also configure esbuild with a JavaScript file, which is useful if you want to use esbuild plugins. Here's an example: + +```yml +build: + esbuild: + # Path to the esbuild config file relative to the `serverless.yml` file + configFile: ./esbuild.config.js +``` + +The JavaScript file must export a function that returns an esbuild configuration object. For your convenience, the **serverless** instance is passed to that function. + +Here's an example of the `esbuild.config.js` file that uses the `esbuild-plugin-env` plugin: + +**ESM:** + +```js +/** + * don't forget to set the `"type": "module"` property in `package.json` + * and install the `esbuild-plugin-env` package + */ +import env from 'esbuild-plugin-env' + +export default (serverless) => { + return { + external: ['@aws-sdk/client-s3'], + plugins: [env()], + } +} +``` + +**CommonJS:** + +```js +const env = require('esbuild-plugin-env') + +module.exports = (serverless) => { + return { + external: ['@aws-sdk/client-s3'], + plugins: [env()], + } +} +``` + ## Plugin Conflicts Please note, plugins that build your code will not work unless you opt out of the default build experience. Some of the plugins affected are: diff --git a/lib/plugins/esbuild/index.js b/lib/plugins/esbuild/index.js index d4f764ac9..6c5a1fe6b 100644 --- a/lib/plugins/esbuild/index.js +++ b/lib/plugins/esbuild/index.js @@ -1,4 +1,5 @@ import path from 'path' +import { pathToFileURL } from 'url' import { readFile, rm, writeFile, stat } from 'fs/promises' import { createWriteStream, existsSync } from 'fs' import * as esbuild from 'esbuild' @@ -19,6 +20,8 @@ class Esbuild { this.options = options || {} this._functions = undefined + this._buildProperties = _.memoize(this._buildProperties.bind(this)) + this.hooks = { 'before:dev-build:build': async () => { if (await this._shouldRun('originalHandler')) { @@ -273,17 +276,50 @@ class Esbuild { return undefined } - _buildProperties() { + async _buildProperties() { const defaultConfig = { bundle: true, minify: false, sourcemap: true } if ( this.serverless.service.build && this.serverless.service.build !== 'esbuild' && this.serverless.service.build.esbuild ) { + // For advanced use cases, users can provide a js file that exports a function that returns esbuild configuration options + // This is useful for when users want to use esbuild plugins (which require calling a function) or other advanced configurations + // That you can't really do in serverless.yml + let jsConfig = {} + if (this.serverless.service.build.esbuild.configFile) { + // Resolve the absolute path to the config file + const configFilePath = path.resolve( + this.serverless.config.serviceDir, + this.serverless.service.build.esbuild.configFile, + ) + + // This is a dynamic import because we want to support both CommonJS and ESM + const configFile = await import(pathToFileURL(configFilePath).href) + + const configFunction = configFile.default || configFile + + // Print a nice error message if the export is not a function + if (typeof configFunction !== 'function') { + throw new ServerlessError( + `Your build config "${path.basename(configFilePath)}" file must export a function that returns esbuild configuration options. For more details, please refer to the documentation: https://www.serverless.com/framework/docs/providers/aws/guide/building`, + 'ESBUILD_CONFIG_ERROR', + ) + } + + // Passing the serverless instance can be useful + // Ref: https://github.com/floydspace/serverless-esbuild/issues/168 + jsConfig = await configFunction(this.serverless) + } + + // Users can use both serverless.yml and js file to configure esbuild + // The yml config will take precedence over js config const mergedOptions = _.merge( defaultConfig, + jsConfig, this.serverless.service.build.esbuild, ) + if (this.serverless.service.build.esbuild.sourcemap === true) { mergedOptions.sourcemap = true } else if (this.serverless.service.build.esbuild.sourcemap === false) { @@ -307,8 +343,8 @@ class Esbuild { * @param {string} runtime - The provider.runtime or functionObject.runtime value used to determine which version of the AWS SDK to exclude * @returns */ - _getExternal(runtime) { - const buildProperties = this._buildProperties() + async _getExternal(runtime) { + const buildProperties = await this._buildProperties() let external = new Set(buildProperties.external || []) let exclude = new Set(buildProperties.exclude || []) if (buildProperties.excludes) { @@ -356,7 +392,7 @@ class Esbuild { const updatedFunctionsToBuild = {} - const buildProperties = this._buildProperties() + const buildProperties = await this._buildProperties() for (const [alias, functionObject] of Object.entries(functionsToBuild)) { const functionName = path @@ -369,7 +405,7 @@ class Esbuild { const runtime = functionObject.runtime || this.serverless.service.provider.runtime - const external = Array.from(this._getExternal(runtime).external) + const external = Array.from((await this._getExternal(runtime)).external) const extension = await this._extensionForFunction( functionObject[handlerPropertyName], @@ -426,6 +462,7 @@ class Esbuild { // Remove the following properties from the esbuildProps as they are not valid esbuild properties delete esbuildProps.exclude delete esbuildProps.buildConcurrency + delete esbuildProps.configFile await esbuild.build(esbuildProps) if (!this.serverless.builtFunctions) { @@ -474,7 +511,7 @@ class Esbuild { */ async _package(handlerPropertyName = 'handler') { const functions = await this.functions(handlerPropertyName) - const buildProperties = this._buildProperties() + const buildProperties = await this._buildProperties() if (Object.keys(functions).length === 0) { log.debug('No functions to package') @@ -672,7 +709,7 @@ class Esbuild { async _preparePackageJson() { const runtime = this.serverless.service.provider.runtime || 'nodejs18.x' - const { external, exclude } = this._getExternal(runtime) + const { external, exclude } = await this._getExternal(runtime) const packageJsonPath = path.join( this.serverless.config.serviceDir, @@ -686,7 +723,7 @@ class Esbuild { } delete packageJsonNoDevDeps.devDependencies - const buildProperties = this._buildProperties() + const buildProperties = await this._buildProperties() if (packageJson.dependencies) { if (buildProperties.packages !== 'external') {