From b799b34bfa9dade813a16f26a04ad52f2aa830eb Mon Sep 17 00:00:00 2001 From: Chris Williams Date: Fri, 15 Jul 2022 10:45:28 +0100 Subject: [PATCH] feat: add support for .mjs file output --- README.md | 1 + src/bundle.ts | 27 +++- src/index.ts | 1 + src/tests/bundle.test.ts | 313 ++++++++++++++++++++++++++++++++++++++- src/types.ts | 1 + 5 files changed, 341 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3a313c1d..c382c2d0 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ See [example folder](examples) for some example configurations. | `nativeZip` | Uses the system's `zip` executable to create archives. _NOTE_: This will produce non-deterministic archives which causes a Serverless deployment update on every deploy. | `false` | | `outputBuildFolder` | The output folder for Esbuild builds within the work folder. | `'.build'` | | `outputWorkFolder` | The output folder for this plugin where all the bundle preparation is done. | `'.esbuild'` | +| `outputFileExtension` | The file extension used for the bundled output file. This will override the esbuild `outExtension` option | `'.js'` | | `packagePath` | Path to the `package.json` file for `external` dependency resolution. | `'./package.json'` | | `packager` | Packager to use for `external` dependency resolution. Values: `npm`, `yarn`, `pnpm` | `'npm'` | | `packagerOptions` | Extra options for packagers for `external` dependency resolution. | [Packager Options](#packager-options) | diff --git a/src/bundle.ts b/src/bundle.ts index f94b801a..6dea772e 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -27,6 +27,29 @@ export async function bundle(this: EsbuildServerlessPlugin, incremental = false) plugins: this.plugins, }; + if ( + this.buildOptions.platform === 'neutral' && + this.buildOptions.outputFileExtension === '.cjs' + ) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Serverless typings (as of v3.0.2) are incorrect + throw new this.serverless.classes.Error( + 'ERROR: platform "neutral" should not output a file with extension ".cjs".' + ); + } + + if (this.buildOptions.platform === 'node' && this.buildOptions.outputFileExtension === '.mjs') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Serverless typings (as of v3.0.2) are incorrect + throw new this.serverless.classes.Error( + 'ERROR: platform "node" should not output a file with extension ".mjs".' + ); + } + + if (this.buildOptions.outputFileExtension !== '.js') { + config.outExtension = { '.js': this.buildOptions.outputFileExtension }; + } + // esbuild v0.7.0 introduced config options validation, so I have to delete plugin specific options from esbuild config. delete config['concurrency']; delete config['exclude']; @@ -38,10 +61,12 @@ export async function bundle(this: EsbuildServerlessPlugin, incremental = false) delete config['packagerOptions']; delete config['installExtraArgs']; delete config['disableIncremental']; + delete config['outputFileExtension']; /** Build the files */ const bundleMapper = async (entry: string): Promise => { - const bundlePath = entry.slice(0, entry.lastIndexOf('.')) + '.js'; + const bundlePath = + entry.slice(0, entry.lastIndexOf('.')) + this.buildOptions.outputFileExtension; // check cache if (this.buildCache) { diff --git a/src/index.ts b/src/index.ts index 95968411..9f4bb76c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -242,6 +242,7 @@ class EsbuildServerlessPlugin implements ServerlessPlugin { keepOutputDirectory: false, packagerOptions: {}, platform: 'node', + outputFileExtension: '.js', }; const runtimeMatcher = providerRuntimeMatcher[this.serverless.service.provider.name]; diff --git a/src/tests/bundle.test.ts b/src/tests/bundle.test.ts index 51e93ad1..3619306f 100644 --- a/src/tests/bundle.test.ts +++ b/src/tests/bundle.test.ts @@ -2,7 +2,7 @@ import { PartialDeep } from 'type-fest'; import EsbuildServerlessPlugin from '..'; import { bundle } from '../bundle'; import { build } from 'esbuild'; -import { FunctionBuildResult, FunctionEntry } from '../types'; +import { Configuration, FunctionBuildResult, FunctionEntry } from '../types'; import pMap from 'p-map'; import { mocked } from 'ts-jest/utils'; @@ -16,6 +16,9 @@ const esbuildPlugin = (override?: Partial): EsbuildServ cli: { log: jest.fn(), }, + classes: { + Error: Error, + }, }, buildOptions: { concurrency: Infinity, @@ -30,6 +33,7 @@ const esbuildPlugin = (override?: Partial): EsbuildServ keepOutputDirectory: false, packagerOptions: {}, platform: 'node', + outputFileExtension: '.js', }, plugins: [], buildDirPath: '/workdir/.esbuild', @@ -194,3 +198,310 @@ it('should filter out non esbuild options', async () => { target: 'node12', }); }); + +describe('buildOption platform node', () => { + it('should set buildResults buildPath after compilation is complete with default extension', async () => { + const functionEntries: FunctionEntry[] = [ + { + entry: 'file1.ts', + func: { + events: [], + handler: 'file1.handler', + }, + functionAlias: 'func1', + }, + { + entry: 'file2.ts', + func: { + events: [], + handler: 'file2.handler', + }, + functionAlias: 'func2', + }, + ]; + + const expectedResults: FunctionBuildResult[] = [ + { + bundlePath: 'file1.js', + func: { events: [], handler: 'file1.handler' }, + functionAlias: 'func1', + }, + { + bundlePath: 'file2.js', + func: { events: [], handler: 'file2.handler' }, + functionAlias: 'func2', + }, + ]; + + const plugin = esbuildPlugin({ functionEntries }); + + await bundle.call(plugin); + + expect(plugin.buildResults).toStrictEqual(expectedResults); + }); + + it('should set buildResults buildPath after compilation is complete with ".cjs" extension', async () => { + const functionEntries: FunctionEntry[] = [ + { + entry: 'file1.ts', + func: { + events: [], + handler: 'file1.handler', + }, + functionAlias: 'func1', + }, + { + entry: 'file2.ts', + func: { + events: [], + handler: 'file2.handler', + }, + functionAlias: 'func2', + }, + ]; + + const buildOptions: Partial = { + concurrency: Infinity, + bundle: true, + target: 'node12', + external: [], + exclude: ['aws-sdk'], + nativeZip: false, + packager: 'npm', + installExtraArgs: [], + watch: {}, + keepOutputDirectory: false, + packagerOptions: {}, + platform: 'node', + outputFileExtension: '.cjs', + }; + + const expectedResults: FunctionBuildResult[] = [ + { + bundlePath: 'file1.cjs', + func: { events: [], handler: 'file1.handler' }, + functionAlias: 'func1', + }, + { + bundlePath: 'file2.cjs', + func: { events: [], handler: 'file2.handler' }, + functionAlias: 'func2', + }, + ]; + + const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); + + await bundle.call(plugin); + + expect(plugin.buildResults).toStrictEqual(expectedResults); + }); + + it('should error when trying to use ".mjs" extension', async () => { + const functionEntries: FunctionEntry[] = [ + { + entry: 'file1.ts', + func: { + events: [], + handler: 'file1.handler', + }, + functionAlias: 'func1', + }, + { + entry: 'file2.ts', + func: { + events: [], + handler: 'file2.handler', + }, + functionAlias: 'func2', + }, + ]; + + const buildOptions: Partial = { + concurrency: Infinity, + bundle: true, + target: 'node12', + external: [], + exclude: ['aws-sdk'], + nativeZip: false, + packager: 'npm', + installExtraArgs: [], + watch: {}, + keepOutputDirectory: false, + packagerOptions: {}, + platform: 'node', + outputFileExtension: '.mjs', + }; + + const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); + + const expectedError = 'ERROR: platform "node" should not output a file with extension ".mjs".'; + + try { + await bundle.call(plugin); + } catch (error) { + expect(error).toHaveProperty('message', expectedError); + } + }); +}); + +describe('buildOption platform neutral', () => { + it('should set buildResults buildPath after compilation is complete with default extension', async () => { + const functionEntries: FunctionEntry[] = [ + { + entry: 'file1.ts', + func: { + events: [], + handler: 'file1.handler', + }, + functionAlias: 'func1', + }, + { + entry: 'file2.ts', + func: { + events: [], + handler: 'file2.handler', + }, + functionAlias: 'func2', + }, + ]; + + const buildOptions: Partial = { + concurrency: Infinity, + bundle: true, + target: 'node12', + external: [], + exclude: ['aws-sdk'], + nativeZip: false, + packager: 'npm', + installExtraArgs: [], + watch: {}, + keepOutputDirectory: false, + packagerOptions: {}, + platform: 'neutral', + outputFileExtension: '.js', + }; + + const expectedResults: FunctionBuildResult[] = [ + { + bundlePath: 'file1.js', + func: { events: [], handler: 'file1.handler' }, + functionAlias: 'func1', + }, + { + bundlePath: 'file2.js', + func: { events: [], handler: 'file2.handler' }, + functionAlias: 'func2', + }, + ]; + + const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); + + await bundle.call(plugin); + + expect(plugin.buildResults).toStrictEqual(expectedResults); + }); + + it('should set buildResults buildPath after compilation is complete with ".mjs" extension', async () => { + const functionEntries: FunctionEntry[] = [ + { + entry: 'file1.ts', + func: { + events: [], + handler: 'file1.handler', + }, + functionAlias: 'func1', + }, + { + entry: 'file2.ts', + func: { + events: [], + handler: 'file2.handler', + }, + functionAlias: 'func2', + }, + ]; + + const buildOptions: Partial = { + concurrency: Infinity, + bundle: true, + target: 'node12', + external: [], + exclude: ['aws-sdk'], + nativeZip: false, + packager: 'npm', + installExtraArgs: [], + watch: {}, + keepOutputDirectory: false, + packagerOptions: {}, + platform: 'neutral', + outputFileExtension: '.mjs', + }; + + const expectedResults: FunctionBuildResult[] = [ + { + bundlePath: 'file1.mjs', + func: { events: [], handler: 'file1.handler' }, + functionAlias: 'func1', + }, + { + bundlePath: 'file2.mjs', + func: { events: [], handler: 'file2.handler' }, + functionAlias: 'func2', + }, + ]; + + const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); + + await bundle.call(plugin); + + expect(plugin.buildResults).toStrictEqual(expectedResults); + }); + + it('should error when trying to use ".cjs" extension', async () => { + const functionEntries: FunctionEntry[] = [ + { + entry: 'file1.ts', + func: { + events: [], + handler: 'file1.handler', + }, + functionAlias: 'func1', + }, + { + entry: 'file2.ts', + func: { + events: [], + handler: 'file2.handler', + }, + functionAlias: 'func2', + }, + ]; + + const buildOptions: Partial = { + concurrency: Infinity, + bundle: true, + target: 'node12', + external: [], + exclude: ['aws-sdk'], + nativeZip: false, + packager: 'npm', + installExtraArgs: [], + watch: {}, + keepOutputDirectory: false, + packagerOptions: {}, + platform: 'neutral', + outputFileExtension: '.cjs', + }; + + const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any }); + + const expectedError = + 'ERROR: platform "neutral" should not output a file with extension ".cjs".'; + + try { + await bundle.call(plugin); + } catch (error) { + expect(error).toHaveProperty('message', expectedError); + } + }); +}); diff --git a/src/types.ts b/src/types.ts index d8b1bc8f..5c7ee73e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,7 @@ export interface Configuration extends EsbuildOptions { disableIncremental?: boolean; outputWorkFolder?: string; outputBuildFolder?: string; + outputFileExtension: '.js' | '.cjs' | '.mjs'; } export interface FunctionEntry {