mirror of
https://github.com/serverless/serverless.git
synced 2026-01-25 15:07:39 +00:00
565 lines
20 KiB
JavaScript
565 lines
20 KiB
JavaScript
import path from 'path';
|
|
import { readFile, rm, writeFile } from 'fs/promises';
|
|
import { createWriteStream, existsSync } from 'fs';
|
|
import * as esbuild from 'esbuild';
|
|
import utils from '@serverlessinc/sf-core/src/utils.js';
|
|
import archiver from 'archiver';
|
|
import { spawn } from 'child_process';
|
|
import _ from 'lodash';
|
|
import pLimit from 'p-limit';
|
|
|
|
const { log } = utils;
|
|
|
|
const nodeRuntimeRe = /nodejs(?<version>\d+).x/;
|
|
|
|
class Esbuild {
|
|
constructor(serverless, options) {
|
|
this.serverless = serverless;
|
|
this.options = options || {};
|
|
this._functions = undefined;
|
|
|
|
this.hooks = {
|
|
'before:dev-build:build': async () => {
|
|
if (await this._shouldRun('originalHandler')) {
|
|
await this._build('originalHandler');
|
|
}
|
|
},
|
|
'after:dev-build:build': async () => {},
|
|
'before:invoke:local:invoke': async () => {
|
|
if (await this._shouldRun()) {
|
|
await this._build();
|
|
this._setConfigForInvokeLocal();
|
|
}
|
|
},
|
|
'before:package:createDeploymentArtifacts': async () => {
|
|
if (await this._shouldRun()) {
|
|
await this._build();
|
|
await this._preparePackageJson();
|
|
await this._package();
|
|
}
|
|
},
|
|
'before:deploy:function:packageFunction': async () => {
|
|
if (await this._shouldRun()) {
|
|
await this._build();
|
|
await this._preparePackageJson();
|
|
await this._package();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async asyncInit() {
|
|
this._defineSchema();
|
|
}
|
|
|
|
_defineSchema() {
|
|
this.serverless.configSchemaHandler.defineBuildProperty('esbuild', {
|
|
anyOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
// The node modules that should not be bundled
|
|
external: { type: 'array', items: { type: 'string' } },
|
|
// These are node modules that should not be bundled but also not included in the package.json
|
|
exclude: { type: 'array', items: { type: 'string' } },
|
|
// The concurrency to use for building functions. By default it will be set to the number of functions to build.
|
|
// Meaning that all functions will be built concurrently.
|
|
buildConcurrency: { type: 'number' },
|
|
// Whether to bundle or not. Default is true
|
|
bundle: { type: 'boolean' },
|
|
// Whether to minify or not. Default is false
|
|
minify: { type: 'boolean' },
|
|
// If set to a boolean, true, then framework uses external sourcemaps and enables it on functions by default.
|
|
sourcemap: {
|
|
anyOf: [
|
|
{ type: 'boolean' },
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
type: { type: 'string', enum: ['inline', 'linked', 'external'] },
|
|
setNodeOptions: { type: 'boolean' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{ type: 'boolean' },
|
|
],
|
|
});
|
|
}
|
|
|
|
async _shouldRun(handlerPropertyName = 'handler') {
|
|
const functions = await this.functions(handlerPropertyName);
|
|
return Object.keys(functions).length > 0;
|
|
}
|
|
|
|
/**
|
|
* Get a record of functions that should be built by esbuild
|
|
*/
|
|
async functions(handlerPropertyName = 'handler') {
|
|
if (this._functions) {
|
|
return this._functions;
|
|
}
|
|
|
|
const functions = this.options.function
|
|
? { [this.options.function]: this.serverless.service.getFunction(this.options.function) }
|
|
: this.serverless.service.functions;
|
|
|
|
const functionsToBuild = {};
|
|
|
|
for (const [alias, functionObject] of Object.entries(functions)) {
|
|
const shouldBuild = await this._shouldBuildFunction(functionObject, handlerPropertyName);
|
|
if (shouldBuild) {
|
|
functionsToBuild[alias] = functionObject;
|
|
}
|
|
}
|
|
|
|
this._functions = functionsToBuild;
|
|
|
|
return functionsToBuild;
|
|
}
|
|
|
|
/**
|
|
* Take a Function Configuration and determine if it should be built by esbuild
|
|
* @param {Object} functionObject - A Framework Function Configuration Object
|
|
* @returns
|
|
*/
|
|
async _shouldBuildFunction(functionObject, handlerPropertyName = 'handler') {
|
|
if (this.serverless.service.build?.esbuild === false) {
|
|
return false;
|
|
}
|
|
// If handler isn't set then it is a docker function so do not attempt to build
|
|
if (!functionObject[handlerPropertyName]) {
|
|
return false;
|
|
}
|
|
const runtime = functionObject.runtime || this.serverless.service.provider.runtime;
|
|
const functionBuildParam = functionObject.build;
|
|
const providerBuildParam = this.serverless.service.build;
|
|
|
|
// If runtime is not node then should not build
|
|
if (!runtime || !runtime.startsWith('nodejs')) {
|
|
return false;
|
|
}
|
|
|
|
// If the build property is not set then we use the zero-config checking which is simply
|
|
// if the handler is a typescript file
|
|
if (!functionBuildParam && !providerBuildParam) {
|
|
log.debug('Build property not set using default checking behavior for esbuild');
|
|
const extension = await this._extensionForFunction(functionObject[handlerPropertyName]);
|
|
if (extension && ['.ts', '.cts', '.mts', '.tsx'].includes(extension)) {
|
|
log.debug('Build property not set using esbuild since typescript');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// If the build property on the function config is defined and is set to esbuild then
|
|
// framework should build the function, otherwise if the build property is defined
|
|
// but not set to esbuild then it should not be built
|
|
if (functionBuildParam && functionBuildParam === 'esbuild') {
|
|
return true;
|
|
} else if (functionBuildParam) {
|
|
return false;
|
|
}
|
|
|
|
// If the provider build property is set to esbuild then build by default
|
|
if (providerBuildParam && (providerBuildParam === 'esbuild' || providerBuildParam.esbuild)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// This is all the possible extensions that the esbuild plugin can build for
|
|
async _extensionForFunction(functionHandler) {
|
|
const functionName = path.extname(functionHandler).slice(1);
|
|
const handlerPath = functionHandler.replace(`.${functionName}`, '');
|
|
for (const extension of ['.js', '.ts', '.cjs', '.mjs', '.cts', '.mts', '.jsx', '.tsx']) {
|
|
if (existsSync(path.join(this.serverless.config.serviceDir, handlerPath + extension))) {
|
|
return extension;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
_buildProperties() {
|
|
const defaultConfig = { bundle: true, minify: false, sourcemap: true };
|
|
if (
|
|
this.serverless.service.build &&
|
|
this.serverless.service.build !== 'esbuild' &&
|
|
this.serverless.service.build.esbuild
|
|
) {
|
|
const mergedOptions = _.merge(defaultConfig, 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) {
|
|
delete mergedOptions.sourcemap;
|
|
} else if (this.serverless.service.build.esbuild?.sourcemap?.type) {
|
|
if (this.serverless.service.build.esbuild.sourcemap.type === 'linked') {
|
|
mergedOptions.sourcemap = true;
|
|
} else {
|
|
mergedOptions.sourcemap = this.serverless.service.build.esbuild.sourcemap.type;
|
|
}
|
|
}
|
|
return mergedOptions;
|
|
}
|
|
|
|
return defaultConfig;
|
|
}
|
|
|
|
/**
|
|
* Determine which modules to mark as external (i.e. added to the generated package.json) and which modules to be excluded all together
|
|
* @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();
|
|
let external = new Set(buildProperties.external || []);
|
|
let exclude = new Set(buildProperties.exclude || []);
|
|
if (buildProperties.excludes) {
|
|
external = [...external, ...buildProperties.excludes];
|
|
} else {
|
|
const nodeRuntimeMatch = runtime.match(nodeRuntimeRe);
|
|
if (nodeRuntimeMatch) {
|
|
const version = parseInt(nodeRuntimeMatch.groups.version) || 18;
|
|
// If node version is 18 or greater then we need to exclude all @aws-sdk/ packages
|
|
if (version >= 18) {
|
|
external.add('@aws-sdk/*');
|
|
exclude.add('@aws-sdk/*');
|
|
} else {
|
|
external.add('aws-sdk');
|
|
exclude.add('aws-sdk');
|
|
}
|
|
}
|
|
}
|
|
return { external, exclude };
|
|
}
|
|
|
|
/**
|
|
* When invoking locally we need to set the servicePath to the build directory so that invoke local correctly uses the built function and does not
|
|
* attempt to use the typescript file directly.
|
|
*/
|
|
_setConfigForInvokeLocal() {
|
|
this.serverless.config.servicePath = path.join(
|
|
this.serverless.config.serviceDir,
|
|
'.serverless',
|
|
'build'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Take the current build context. Which could be service-wide or a given function and then build it
|
|
* @param {string} handlerPropertyName - The property name of the handler in the function object. In the case of dev mode this will be different, so we need to be able to set it.
|
|
*/
|
|
async _build(handlerPropertyName = 'handler') {
|
|
const functionsToBuild = await this.functions(handlerPropertyName);
|
|
|
|
if (Object.keys(functionsToBuild).length === 0) {
|
|
log.debug('No functions to build with esbuild');
|
|
return;
|
|
}
|
|
|
|
const updatedFunctionsToBuild = {};
|
|
|
|
const buildProperties = this._buildProperties();
|
|
|
|
for (const [alias, functionObject] of Object.entries(functionsToBuild)) {
|
|
const functionName = path.extname(functionObject[handlerPropertyName]).slice(1);
|
|
const handlerPath = functionObject[handlerPropertyName].replace(`.${functionName}`, '');
|
|
const runtime = functionObject.runtime || this.serverless.service.provider.runtime;
|
|
|
|
const external = Array.from(this._getExternal(runtime).external);
|
|
|
|
const extension = await this._extensionForFunction(functionObject[handlerPropertyName]);
|
|
if (extension) {
|
|
// Enrich the functionObject with additional values we will need for building
|
|
updatedFunctionsToBuild[alias] = {
|
|
...functionObject,
|
|
handlerPath: path.join(this.serverless.config.serviceDir, handlerPath + extension),
|
|
extension,
|
|
esbuild: { external },
|
|
};
|
|
}
|
|
}
|
|
|
|
// Determine the concurrency to use for building functions, by default framework will attempt to build
|
|
// all functions concurrently, but this can be overridden by setting the buildConcurrency property.
|
|
const concurrency = buildProperties.buildConcurrency ?? Object.keys(functionsToBuild).length;
|
|
|
|
const limit = pLimit(concurrency);
|
|
|
|
try {
|
|
await Promise.all(
|
|
Object.entries(updatedFunctionsToBuild).map(([alias, functionObject]) => {
|
|
return limit(async () => {
|
|
const functionName = path.extname(functionObject[handlerPropertyName]).slice(1);
|
|
const handlerPath = functionObject[handlerPropertyName].replace(`.${functionName}`, '');
|
|
await esbuild.build({
|
|
...buildProperties,
|
|
platform: 'node',
|
|
external: functionObject.esbuild.external,
|
|
entryPoints: [functionObject.handlerPath],
|
|
outfile: path.join(
|
|
this.serverless.config.serviceDir,
|
|
'.serverless',
|
|
'build',
|
|
handlerPath + '.js'
|
|
),
|
|
logLevel: 'error',
|
|
});
|
|
if (!this.serverless.builtFunctions) {
|
|
this.serverless.builtFunctions = new Set();
|
|
}
|
|
this.serverless.builtFunctions.add(alias);
|
|
if (
|
|
this.serverless.service.build?.esbuild?.sourcemap === undefined ||
|
|
this.serverless.service.build?.esbuild?.sourcemap === true ||
|
|
this.serverless.service.build?.esbuild.sourcemap?.setNodeOptions === true
|
|
) {
|
|
const functionObject = this.serverless.service.getFunction(alias);
|
|
if (functionObject.environment?.NODE_OPTIONS) {
|
|
functionObject.environment.NODE_OPTIONS = `${functionObject.environment.NODE_OPTIONS} --enable-source-maps`;
|
|
} else {
|
|
if (!functionObject.environment) {
|
|
functionObject.environment = {};
|
|
}
|
|
functionObject.environment.NODE_OPTIONS = '--enable-source-maps';
|
|
}
|
|
}
|
|
});
|
|
})
|
|
);
|
|
} catch (err) {
|
|
if (this.serverless.devmodeEnabled === true) {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Take the current build context. Which could be service-wide or a given function and then package it.
|
|
*
|
|
* This function takes package.individually into account and will either create a single zip file to use for all functions or a zip file per function otherwise.
|
|
*
|
|
* @param {string} handlerPropertyName - The property name of the handler in the function object. In the case of dev mode this will be different, so we need to be able to set it.
|
|
*/
|
|
async _package(handlerPropertyName = 'handler') {
|
|
const functions = await this.functions(handlerPropertyName);
|
|
const buildProperties = this._buildProperties();
|
|
|
|
if (Object.keys(functions).length === 0) {
|
|
log.debug('No functions to package');
|
|
return;
|
|
}
|
|
|
|
// If not packaging individually then package all functions together into a single zip
|
|
if (!this.serverless?.service?.package?.individually) {
|
|
await this._packageAll(functions, handlerPropertyName);
|
|
return;
|
|
}
|
|
|
|
const concurrency = buildProperties.buildConcurrency ?? Object.keys(functions).length;
|
|
|
|
const limit = pLimit(concurrency);
|
|
|
|
const zipPromises = Object.entries(functions).map(([functionAlias, functionObject]) => {
|
|
return limit(async () => {
|
|
const zipName = `${this.serverless.service.service}-${functionAlias}.zip`;
|
|
const zipPath = path.join(
|
|
this.serverless.config.serviceDir,
|
|
'.serverless',
|
|
'build',
|
|
zipName
|
|
);
|
|
|
|
const zip = archiver.create('zip');
|
|
const output = createWriteStream(zipPath);
|
|
|
|
const zipPromise = new Promise(async (resolve, reject) => {
|
|
output.on('close', () => resolve(zipPath));
|
|
output.on('error', (err) => reject(err));
|
|
|
|
output.on('open', async () => {
|
|
zip.pipe(output);
|
|
const functionName = path.extname(functionObject[handlerPropertyName]).slice(1);
|
|
const handlerPath = functionObject[handlerPropertyName].replace(`.${functionName}`, '');
|
|
|
|
const handlerZipPath = path.join(
|
|
this.serverless.config.serviceDir,
|
|
'.serverless',
|
|
'build',
|
|
handlerPath + '.js'
|
|
);
|
|
|
|
zip.file(handlerZipPath, { name: `${handlerPath}.js` });
|
|
if (existsSync(`${handlerZipPath}.map`)) {
|
|
zip.file(`${handlerZipPath}.map`, { name: `${handlerPath}.js.map` });
|
|
}
|
|
|
|
zip.directory(
|
|
path.join(this.serverless.config.serviceDir, '.serverless', 'build', 'node_modules'),
|
|
'node_modules'
|
|
);
|
|
await zip.finalize();
|
|
functionObject.package = {
|
|
artifact: zipPath,
|
|
};
|
|
});
|
|
});
|
|
await zipPromise;
|
|
});
|
|
});
|
|
|
|
await Promise.all(zipPromises);
|
|
}
|
|
|
|
async _packageAll(functions, handlerPropertyName = 'handler') {
|
|
const zipName = `${this.serverless.service.service}.zip`;
|
|
const zipPath = path.join(this.serverless.config.serviceDir, '.serverless', 'build', zipName);
|
|
|
|
const zip = archiver.create('zip');
|
|
const output = createWriteStream(zipPath);
|
|
|
|
const zipPromise = new Promise(async (resolve, reject) => {
|
|
output.on('close', () => resolve(zipPath));
|
|
output.on('error', (err) => reject(err));
|
|
|
|
output.on('open', async () => {
|
|
zip.pipe(output);
|
|
|
|
for (const [, functionObject] of Object.entries(functions)) {
|
|
const functionName = path.extname(functionObject[handlerPropertyName]).slice(1);
|
|
const handlerPath = functionObject[handlerPropertyName].replace(`.${functionName}`, '');
|
|
|
|
const handlerZipPath = path.join(
|
|
this.serverless.config.serviceDir,
|
|
'.serverless',
|
|
'build',
|
|
handlerPath + '.js'
|
|
);
|
|
|
|
zip.file(handlerZipPath, { name: `${handlerPath}.js` });
|
|
}
|
|
|
|
zip.directory(
|
|
path.join(this.serverless.config.serviceDir, '.serverless', 'build', 'node_modules'),
|
|
'node_modules'
|
|
);
|
|
|
|
await zip.finalize();
|
|
this.serverless.service.package.artifact = zipPath;
|
|
});
|
|
});
|
|
|
|
await zipPromise;
|
|
}
|
|
|
|
/**
|
|
* Take the package.json and add an updated version with no dev dependencies and external and excluded node_modules taken care of, to the .serverless/build directory
|
|
*/
|
|
async _preparePackageJson() {
|
|
const runtime = this.serverless.service.provider.runtime || 'nodejs18.x';
|
|
|
|
const { external, exclude } = this._getExternal(runtime);
|
|
|
|
const packageJsonPath = path.join(this.serverless.config.serviceDir, 'package.json');
|
|
const packageJsonStr = await readFile(packageJsonPath, 'utf-8');
|
|
const packageJson = JSON.parse(packageJsonStr);
|
|
|
|
const packageJsonNoDevDeps = {
|
|
...packageJson,
|
|
};
|
|
delete packageJsonNoDevDeps.devDependencies;
|
|
|
|
if (packageJson.dependencies) {
|
|
packageJsonNoDevDeps.dependencies = {};
|
|
for (const key of external) {
|
|
if (packageJson.dependencies[key]) {
|
|
packageJsonNoDevDeps.dependencies[key] = packageJson.dependencies[key];
|
|
}
|
|
}
|
|
|
|
for (const key of exclude) {
|
|
if (key === '@aws-sdk/*') {
|
|
const awsSdkPackages = Object.keys(packageJsonNoDevDeps.dependencies).filter((dep) =>
|
|
dep.startsWith('@aws-sdk/')
|
|
);
|
|
for (const awsSdkPackage of awsSdkPackages) {
|
|
delete packageJsonNoDevDeps.dependencies[awsSdkPackage];
|
|
}
|
|
} else {
|
|
delete packageJsonNoDevDeps.dependencies[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
const packageJsonNoDevDepsStr = JSON.stringify(packageJsonNoDevDeps, null, 2);
|
|
|
|
const packageJsonBuildPath = path.join(
|
|
this.serverless.config.serviceDir,
|
|
'.serverless',
|
|
'build',
|
|
'package.json'
|
|
);
|
|
|
|
await writeFile(packageJsonBuildPath, packageJsonNoDevDepsStr);
|
|
|
|
const packager = this._determinePackager();
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const child = spawn(packager, ['install'], {
|
|
cwd: path.join(this.serverless.config.serviceDir, '.serverless', 'build'),
|
|
shell: true,
|
|
});
|
|
child.on('error', (error) => {
|
|
log.error('Error installing dependencies', error);
|
|
reject(error);
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
resolve(code);
|
|
});
|
|
});
|
|
}
|
|
|
|
_determinePackager() {
|
|
if (existsSync(path.join(this.serverless.config.serviceDir, 'yarn.lock'))) {
|
|
return 'yarn';
|
|
} else if (existsSync(path.join(this.serverless.config.serviceDir, 'pnpm-lock.yaml'))) {
|
|
return 'pnpm';
|
|
} else {
|
|
return 'npm';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup, mainly removing build files and directories
|
|
*/
|
|
async _cleanUp() {
|
|
try {
|
|
await rm(path.join(this.serverless.config.serviceDir, '.serverless', 'build'), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
} catch (err) {
|
|
// empty error
|
|
}
|
|
}
|
|
|
|
async _useLocalEsbuild() {
|
|
const packageJsonPath = path.join(this.serverless.serviceDir, 'package.json');
|
|
if (existsSync(packageJsonPath)) {
|
|
const packageJsonStr = await readFile(packageJsonPath, 'utf-8');
|
|
const packageJson = JSON.parse(packageJsonStr);
|
|
return Object.keys(packageJson?.devDependencies || {}).includes('esbuild');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export default Esbuild;
|