From 31dd88fe89d934a1178b3e50f1e390e5ccf2e4fb Mon Sep 17 00:00:00 2001 From: John Grisham Date: Tue, 5 Dec 2023 10:06:43 -0600 Subject: [PATCH] feat: add support for resolve extensions esbuild setting (#510) * feat(resolveExtensions): added support for custom resolve extensions esbuild setting * feat(resolveExtensions): added stripResolveExtensions option to remove file extension prefixes * feat(resolveExtensions): renamed to stripEntryResolveExtensions as esbuild setting, updated readme --- README.md | 1 + src/bundle.ts | 1 + src/constants.ts | 1 + src/helper.ts | 23 +++++++++++++++++--- src/index.ts | 8 ++++++- src/pack.ts | 13 ++++++++++-- src/tests/helper.test.ts | 45 ++++++++++++++++++++++++++++++++++++++-- src/types.ts | 1 + 8 files changed, 85 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d1d679bb..a5758eac 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ See [example folder](examples) for some example configurations. | `watch` | Watch options for `serverless-offline`. | [Watch Options](#watch-options) | | `skipBuild` | Avoid rebuilding lambda artifacts in favor of reusing previous build artifacts. | `false` | | `skipBuildExcludeFns` | An array of lambda names that will always be rebuilt if `skipBuild` is set to `true` and bundling individually. This is helpful for dynamically generated functions like serverless-plugin-warmup. | `[]` | +| `stripEntryResolveExtensions` | A boolean that determines if entrypoints using custom file extensions provided in the `resolveExtensions` ESbuild setting should be stripped of their custom extension upon packing the final bundle for that file. Example: `myLambda.custom.ts` would result in `myLambda.js` instead of `myLambda.custom.js`. #### Default Esbuild Options diff --git a/src/bundle.ts b/src/bundle.ts index e8824cf1..8b0cf67c 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -39,6 +39,7 @@ export async function bundle(this: EsbuildServerlessPlugin): Promise { 'nodeExternals', 'skipBuild', 'skipBuildExcludeFns', + 'stripEntryResolveExtensions', ].reduce>((options, optionName) => { const { [optionName]: _, ...rest } = options; diff --git a/src/constants.ts b/src/constants.ts index a1d29242..0e4b6413 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,3 +2,4 @@ export const SERVERLESS_FOLDER = '.serverless'; export const BUILD_FOLDER = '.build'; export const WORK_FOLDER = '.esbuild'; export const ONLY_PREFIX = '__only_'; +export const DEFAULT_EXTENSIONS = ['.ts', '.js', '.jsx', '.tsx']; diff --git a/src/helper.ts b/src/helper.ts index af3b66f7..1ef3a124 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -9,8 +9,9 @@ import { uniq } from 'ramda'; import type Serverless from 'serverless'; import type ServerlessPlugin from 'serverless/classes/Plugin'; -import type { Configuration, DependencyMap, FunctionEntry } from './types'; +import type { Configuration, DependencyMap, FunctionEntry, IFile } from './types'; import type { EsbuildFunctionDefinitionHandler } from './types'; +import { DEFAULT_EXTENSIONS } from './constants'; export function asArray(data: T | T[]): T[] { return Array.isArray(data) ? data : [data]; @@ -27,7 +28,8 @@ export function assertIsString(input: unknown, message = 'input is not a string' export function extractFunctionEntries( cwd: string, provider: string, - functions: Record + functions: Record, + resolveExtensions?: string[] ): FunctionEntry[] { // The Google provider will use the entrypoint not from the definition of the // handler function, but instead from the package.json:main field, or via a @@ -69,7 +71,7 @@ export function extractFunctionEntries( // replace only last instance to allow the same name for file and handler const fileName = handler.substring(0, fnNameLastAppearanceIndex); - const extensions = ['.ts', '.js', '.jsx', '.tsx']; + const extensions = resolveExtensions ?? DEFAULT_EXTENSIONS; for (const extension of extensions) { // Check if the .{extension} files exists. If so return that to watch @@ -313,3 +315,18 @@ export const buildServerlessV3LoggerFromLegacyLogger = ( verbose: legacyLogger.log.bind(legacyLogger), success: legacyLogger.log.bind(legacyLogger), }); + +export const stripEntryResolveExtensions = (file: IFile, extensions: string[]): IFile => { + const resolveExtensionMatch = file.localPath.match(extensions.map((ext) => ext).join('|')); + + if (resolveExtensionMatch?.length && !DEFAULT_EXTENSIONS.includes(resolveExtensionMatch[0])) { + const extensionParts = resolveExtensionMatch[0].split('.'); + + return { + ...file, + localPath: file.localPath.replace(resolveExtensionMatch[0], `.${extensionParts[extensionParts.length - 1]}`), + }; + } + + return file; +}; diff --git a/src/index.ts b/src/index.ts index f8a7ba99..e4e3c35c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -320,6 +320,7 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { outputFileExtension: '.js', skipBuild: false, skipBuildExcludeFns: [], + stripEntryResolveExtensions: false, }; const providerRuntime = this.serverless.service.provider.runtime; @@ -345,7 +346,12 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { } get functionEntries() { - return extractFunctionEntries(this.serviceDirPath, this.serverless.service.provider.name, this.functions); + return extractFunctionEntries( + this.serviceDirPath, + this.serverless.service.provider.name, + this.functions, + this.buildOptions?.resolveExtensions + ); } watch(): void { diff --git a/src/pack.ts b/src/pack.ts index 649eafc1..d0dce850 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -9,7 +9,7 @@ import semver from 'semver'; import type Serverless from 'serverless'; import { ONLY_PREFIX, SERVERLESS_FOLDER } from './constants'; -import { assertIsString, doSharePath, flatDep, getDepsFromBundle, isESM } from './helper'; +import { assertIsString, doSharePath, flatDep, getDepsFromBundle, isESM, stripEntryResolveExtensions } from './helper'; import { getPackager } from './packagers'; import { humanSize, trimExtension, zip } from './utils'; @@ -114,7 +114,16 @@ export async function pack(this: EsbuildServerlessPlugin) { onlyFiles: true, }) .filter((file) => !excludedFiles.includes(file)) - .map((localPath) => ({ localPath, rootPath: path.join(buildDirPath, localPath) })); + .map((localPath) => ({ localPath, rootPath: path.join(buildDirPath, localPath) })) + .map((file) => { + if (this.buildOptions?.resolveExtensions && this.buildOptions.resolveExtensions.length > 0) { + if (this.buildOptions.stripEntryResolveExtensions) { + return stripEntryResolveExtensions(file, this.buildOptions.resolveExtensions); + } + } + + return file; + }); if (isEmpty(files)) { this.log.verbose('Packaging: No files found. Skipping esbuild.'); diff --git a/src/tests/helper.test.ts b/src/tests/helper.test.ts index cbec499d..9709d464 100644 --- a/src/tests/helper.test.ts +++ b/src/tests/helper.test.ts @@ -2,9 +2,9 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import { extractFunctionEntries, flatDep, getDepsFromBundle, isESM } from '../helper'; +import { extractFunctionEntries, flatDep, getDepsFromBundle, isESM, stripEntryResolveExtensions } from '../helper'; -import type { Configuration, DependencyMap } from '../types'; +import type { Configuration, DependencyMap, IFile } from '../types'; jest.mock('fs-extra'); @@ -135,6 +135,35 @@ describe('extractFunctionEntries', () => { ]); }); + it('should allow resolve extensions custom Esbuild setting', () => { + jest.mocked(fs.existsSync).mockReturnValue(true); + const functionDefinitions = { + function1: { + events: [], + handler: './file1.handler', + }, + function2: { + events: [], + handler: './file2.handler', + }, + }; + + const fileNames = extractFunctionEntries(cwd, 'aws', functionDefinitions, ['.custom.ts']); + + expect(fileNames).toStrictEqual([ + { + entry: 'file1.custom.ts', + func: functionDefinitions.function1, + functionAlias: 'function1', + }, + { + entry: 'file2.custom.ts', + func: functionDefinitions.function2, + functionAlias: 'function2', + }, + ]); + }); + it('should not return entries for handlers which have skipEsbuild set to true', async () => { jest.mocked(fs.existsSync).mockReturnValue(true); const functionDefinitions = { @@ -614,3 +643,15 @@ describe('flatDeps', () => { }); }); }); + +describe('stripEntryResolveExtensions', () => { + it('should remove custom extension prefixes', () => { + const result = stripEntryResolveExtensions({ localPath: 'test.custom.js' } as IFile, ['.custom.js']); + expect(result.localPath).toEqual('test.js'); + }); + + it('should ignore prefixes not inside the resolve extensions list', () => { + const result = stripEntryResolveExtensions({ localPath: 'test.other.js' } as IFile, ['.custom.js']); + expect(result.localPath).toEqual('test.other.js'); + }); +}); diff --git a/src/types.ts b/src/types.ts index e6a16722..6b5bf3f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,7 @@ export interface Configuration extends EsbuildOptions { nodeExternals?: NodeExternalsOptions; skipBuild?: boolean; skipBuildExcludeFns: string[]; + stripEntryResolveExtensions?: boolean; } export interface EsbuildFunctionDefinitionHandler extends Serverless.FunctionDefinitionHandler {