Repository: serverless-heaven/serverless-webpack (js)

Issue:
Asynchronous configuration object support for Webpack
# This is a  Feature Proposal

## Description
Webpack 2+ support async configuration object by exporting a promise as configuration object.
This is a feature request to support async configuration object. This feature helps to define env variables and constants inside code after an async operation inside `webpack.config.js`. for example defining `AccountId` of current user inside `lambda@edge` which does not support defining normal process environment variables.

Similar or dependent issue(s):

## Additional Data

* ***Serverless-Webpack Version***: 5.1.5
* ***Webpack version***: 4.11.1
* ***Serverless Framework Version***: 1.27.2
* ***Operating System***: MacOS High Sierra (10.13.5)
* ***Stack Trace***:


Pull request description:
Added support for async webpack config object
<!--
1. Do not remove any section of the template. If something is not applicable leave it empty but leave it in the PR
2. Please follow the template, otherwise we'll have to ask you to update it and it will take longer until your PR is merged
-->

## What did you implement:

Closes #412

Webpack 2+ support async configuration object by exporting a promise as configuration object.
This PR added support for async configuration object.

## How did you implement it:

` Line 114 :  this.webpackConfig = require(webpackConfigFilePath);`
Return an object or a promise depending on user's webpack config.
All the validation code after this logic moved in to an arrow function called `processConfig`. 
First argument of this function is `_config` and it will overwrite the variable `this.webpackConfig`.
After defining the function, an if condition added to check whether the config object is a promise or a normal object. If it is a promise `processConfig` will call with resolved value otherwise immediately call `processConfig`. In both case `processConfig` will get webpack config object as it's first argument.

## How can we verify it:

Wrap webpack config in a Promise

Example:
```
const path = require('path');
const webpack = require('webpack')
const AWS = require('aws-sdk');
const slsw = require('serverless-webpack');

const outDir = '.build/.webpack';

module.exports = new Promise(function (resolve) {
    slsw.lib.serverless.providers.aws.getAccountId().then(accountId => {
        resolve({
            entry: slsw.lib.entries,
            target: 'node',
            mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
            plugins: [
                new webpack.DefinePlugin({
                    AWS_ACCOUNT_ID: `${accountId}`,
                }),
            ],
            output: {
                libraryTarget: 'commonjs',
                path: path.resolve(__dirname, outDir),
                filename: '[name].js',
                devtoolModuleFilenameTemplate: function (info) {
                    return "file:///" + encodeURI(info.absoluteResourcePath);
                },
            },
        });
    })
});
```


## Todos:

- [x] Write tests
- [x] Write documentation
- [x] Fix linting errors
- [x] Make sure code coverage hasn't dropped
- [x] Provide verification config / commands / resources
- [x] Enable "Allow edits from maintainers" for this PR
- [x] Update the messages below

***Is this ready for review?:*** YES
***Is it a breaking change?:*** NO


Patch:
diff --git a/.gitignore b/.gitignore
index 8f127e3..e701029 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ coverage
 
 .idea
 /.nyc_output
+.history
diff --git a/README.md b/README.md
index 8400ace..28e0e13 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ dependencies needed to run the function. This allows the plugin to fully utilize
 WebPack's [Tree-Shaking][link-webpack-tree] optimization.
 * Webpack version 3 and 4 support
 * Support NPM and Yarn for packaging
+* Support asynchronous webpack configuration
 
 ## Recent improvements and important changes
 
@@ -33,6 +34,7 @@ WebPack's [Tree-Shaking][link-webpack-tree] optimization.
 * Support Webpack 4
 * Drop Webpack 2 support
 * Cleaned up configuration. You should now use a `custom.webpack` object to configure everything relevant for the plugin. The old configuration still works but will be removed in the next major release. For details see below.
+* Added support for asynchronous webpack configuration
 
 For the complete release notes see the end of this document.
 
@@ -87,6 +89,57 @@ module.exports = {
 };
 ```
 
+Alternatively the Webpack configuration can export an asynchronous object (e.g. a promise or async function) which will be awaited by the plugin and resolves to the final configuration object. This is useful if the confguration depends on asynchronous functions, for example, defining the AccountId of the current aws user inside AWS lambda@edge which does not support defining normal process environment variables.
+
+A basic Webpack promise configuration might look like this:
+```js
+// Version if the local Node.js version supports async/await
+// webpack.config.js
+
+const webpack = require('webpack')
+const slsw = require('serverless-webpack');
+
+module.exports = async () => {
+  const accountId = await slsw.lib.serverless.providers.aws.getAccountId();
+  return {
+    entry: './handler.js',
+    target: 'node',
+    plugins: [
+      new webpack.DefinePlugin({
+        AWS_ACCOUNT_ID: `${accountId}`,
+      }),
+    ],
+    module: {
+      loaders: [ ... ]
+    }
+  };
+}();
+```
+```js
+// Version with promises
+// webpack.config.js
+
+const webpack = require('webpack')
+const slsw = require('serverless-webpack');
+const BbPromise = require('bluebird');
+
+module.exports = BbPromise.try(() => {
+  return slsw.lib.serverless.providers.aws.getAccountId()
+  .then(accountId => ({
+    entry: './handler.js',
+    target: 'node',
+    plugins: [
+      new webpack.DefinePlugin({
+        AWS_ACCOUNT_ID: `${accountId}`,
+      }),
+    ],
+    module: {
+      loaders: [ ... ]
+    }
+  }));
+};
+```
+
 ### serverless-webpack lib export helper
 
 serverless-webpack exposes a lib object, that can be used in your webpack.config.js
@@ -665,6 +718,9 @@ me to take it over and continue working on the project. That helped to revive it
 
 ## Release Notes
 
+* 5.2.0
+  * Added support for asynchronous webpack configuration
+
 * 5.1.5
   * Re-publish of 5.1.4 without yarn.lock
 
diff --git a/lib/validate.js b/lib/validate.js
index badb035..b9c8130 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -107,8 +107,8 @@ module.exports = {
     if (_.isString(this.webpackConfig)) {
       const webpackConfigFilePath = path.join(this.serverless.config.servicePath, this.webpackConfig);
       if (!this.serverless.utils.fileExistsSync(webpackConfigFilePath)) {
-        throw new this.serverless.classes
-          .Error('The webpack plugin could not find the configuration file at: ' + webpackConfigFilePath);
+        return BbPromise.reject(new this.serverless.classes
+          .Error('The webpack plugin could not find the configuration file at: ' + webpackConfigFilePath));
       }
       try {
         this.webpackConfig = require(webpackConfigFilePath);
@@ -117,104 +117,115 @@ module.exports = {
         return BbPromise.reject(err);
       }
     }
+    
+    // Intermediate function to handle async webpack config
+    const processConfig = _config => {
+      this.webpackConfig = _config;
+      // Default context
+      if (!this.webpackConfig.context) {
+        this.webpackConfig.context = this.serverless.config.servicePath;
+      }
 
-    // Default context
-    if (!this.webpackConfig.context) {
-      this.webpackConfig.context = this.serverless.config.servicePath;
-    }
-
-    // Default target
-    if (!this.webpackConfig.target) {
-      this.webpackConfig.target = 'node';
-    }
-
-    // Default output
-    if (!this.webpackConfig.output || _.isEmpty(this.webpackConfig.output)) {
-      const outputPath = path.join(this.serverless.config.servicePath, '.webpack');
-      this.webpackConfig.output = {
-        libraryTarget: 'commonjs',
-        path: outputPath,
-        filename: '[name].js',
-      };
-    }
-
-    // Custom output path
-    if (this.options.out) {
-      this.webpackConfig.output.path = path.join(this.serverless.config.servicePath, this.options.out);
-    }
+      // Default target
+      if (!this.webpackConfig.target) {
+        this.webpackConfig.target = 'node';
+      }
 
-    if (this.skipCompile) {
-      this.serverless.cli.log('Skipping build and using existing compiled output');
-      if (!fse.pathExistsSync(this.webpackConfig.output.path)) {
-        return BbPromise.reject(new this.serverless.classes
-          .Error('No compiled output found'));
+      // Default output
+      if (!this.webpackConfig.output || _.isEmpty(this.webpackConfig.output)) {
+        const outputPath = path.join(this.serverless.config.servicePath, '.webpack');
+        this.webpackConfig.output = {
+          libraryTarget: 'commonjs',
+          path: outputPath,
+          filename: '[name].js',
+        };
       }
-      this.keepOutputDirectory = true;
-    }
 
-    if (!this.keepOutputDirectory) {
-      this.options.verbose && this.serverless.cli.log(`Removing ${this.webpackConfig.output.path}`);
-      fse.removeSync(this.webpackConfig.output.path);
-    }
-    this.webpackOutputPath = this.webpackConfig.output.path;
+      // Custom output path
+      if (this.options.out) {
+        this.webpackConfig.output.path = path.join(this.serverless.config.servicePath, this.options.out);
+      }
 
-    // In case of individual packaging we have to create a separate config for each function
-    if (_.has(this.serverless, 'service.package') && this.serverless.service.package.individually) {
-      this.options.verbose && this.serverless.cli.log('Using multi-compile (individual packaging)');
-      this.multiCompile = true;
+      if (this.skipCompile) {
+        this.serverless.cli.log('Skipping build and using existing compiled output');
+        if (!fse.pathExistsSync(this.webpackConfig.output.path)) {
+          return BbPromise.reject(new this.serverless.classes
+            .Error('No compiled output found'));
+        }
+        this.keepOutputDirectory = true;
+      }
 
-      if (this.webpackConfig.entry && !_.isEqual(this.webpackConfig.entry, entries)) {
-        return BbPromise.reject(new this.serverless.classes
-          .Error('Webpack entry must be automatically resolved when package.individually is set to true. ' +
-            'In webpack.config.js, remove the entry declaration or set entry to slsw.lib.entries.'));
+      if (!this.keepOutputDirectory) {
+        this.options.verbose && this.serverless.cli.log(`Removing ${this.webpackConfig.output.path}`);
+        fse.removeSync(this.webpackConfig.output.path);
       }
+      this.webpackOutputPath = this.webpackConfig.output.path;
 
-      // Lookup associated Serverless functions
-      const allEntryFunctions = _.map(
-        this.serverless.service.getAllFunctions(),
-        funcName => {
-          const func = this.serverless.service.getFunction(funcName);
-          const handler = func.handler;
-          const handlerFile = path.relative('.', getHandlerFile(handler));
-          return {
-            handlerFile,
-            funcName,
-            func
-          };
+      // In case of individual packaging we have to create a separate config for each function
+      if (_.has(this.serverless, 'service.package') && this.serverless.service.package.individually) {
+        this.options.verbose && this.serverless.cli.log('Using multi-compile (individual packaging)');
+        this.multiCompile = true;
+
+        if (this.webpackConfig.entry && !_.isEqual(this.webpackConfig.entry, entries)) {
+          return BbPromise.reject(new this.serverless.classes
+            .Error('Webpack entry must be automatically resolved when package.individually is set to true. ' +
+              'In webpack.config.js, remove the entry declaration or set entry to slsw.lib.entries.'));
         }
-      );
 
-      this.entryFunctions = _.flatMap(entries, (value, key) => {
-        const entry = path.relative('.', value);
-        const entryFile = _.replace(entry, new RegExp(`${path.extname(entry)}$`), '');
+        // Lookup associated Serverless functions
+        const allEntryFunctions = _.map(
+          this.serverless.service.getAllFunctions(),
+          funcName => {
+            const func = this.serverless.service.getFunction(funcName);
+            const handler = func.handler;
+            const handlerFile = path.relative('.', getHandlerFile(handler));
+            return {
+              handlerFile,
+              funcName,
+              func
+            };
+          }
+        );
+
+        this.entryFunctions = _.flatMap(entries, (value, key) => {
+          const entry = path.relative('.', value);
+          const entryFile = _.replace(entry, new RegExp(`${path.extname(entry)}$`), '');
+
+          const entryFuncs = _.filter(allEntryFunctions, [ 'handlerFile', entryFile ]);
+          if (_.isEmpty(entryFuncs)) {
+            // We have to make sure that for each entry there is an entry function item.
+            entryFuncs.push({});
+          }
+          _.forEach(entryFuncs, entryFunc => {
+            entryFunc.entry = {
+              key,
+              value
+            };
+          });
+          return entryFuncs;
+        });
 
-        const entryFuncs = _.filter(allEntryFunctions, [ 'handlerFile', entryFile ]);
-        if (_.isEmpty(entryFuncs)) {
-          // We have to make sure that for each entry there is an entry function item.
-          entryFuncs.push({});
-        }
-        _.forEach(entryFuncs, entryFunc => {
-          entryFunc.entry = {
-            key,
-            value
+        this.webpackConfig = _.map(this.entryFunctions, entryFunc => {
+          const config = _.cloneDeep(this.webpackConfig);
+          config.entry = {
+            [entryFunc.entry.key]: entryFunc.entry.value
           };
+          const compileName = entryFunc.funcName || _.camelCase(entryFunc.entry.key);
+          config.output.path = path.join(config.output.path, compileName);
+          return config;
         });
-        return entryFuncs;
-      });
+      } else {
+        this.webpackConfig.output.path = path.join(this.webpackConfig.output.path, 'service');
+      }
 
-      this.webpackConfig = _.map(this.entryFunctions, entryFunc => {
-        const config = _.cloneDeep(this.webpackConfig);
-        config.entry = {
-          [entryFunc.entry.key]: entryFunc.entry.value
-        };
-        const compileName = entryFunc.funcName || _.camelCase(entryFunc.entry.key);
-        config.output.path = path.join(config.output.path, compileName);
-        return config;
-      });
+      return BbPromise.resolve();
+    };
+    
+    // Webpack config can be a Promise, If it's a Promise wait for resolved config object.
+    if (this.webpackConfig && _.isFunction(this.webpackConfig.then)) {
+      return BbPromise.resolve(this.webpackConfig.then(config => processConfig(config)));
     } else {
-      this.webpackConfig.output.path = path.join(this.webpackConfig.output.path, 'service');
+      return processConfig(this.webpackConfig);
     }
-
-    return BbPromise.resolve();
   },
 };


Test patch:
diff --git a/tests/validate.test.js b/tests/validate.test.js
index eb1d015..11e9f28 100644
--- a/tests/validate.test.js
+++ b/tests/validate.test.js
@@ -1,6 +1,7 @@
 'use strict';
 
 const _ = require('lodash');
+const BbPromise = require('bluebird');
 const chai = require('chai');
 const sinon = require('sinon');
 const mockery = require('mockery');
@@ -232,14 +233,80 @@ describe('validate', () => {
         entry: 'testentry',
       };
       mockery.registerMock(requiredPath, loadedConfig);
-      return module
-        .validate()
-        .then(() => {
-          expect(serverless.utils.fileExistsSync).to.have.been.calledWith(requiredPath);
-          expect(module.webpackConfig).to.eql(loadedConfig);
-          mockery.deregisterMock(requiredPath);
-          return null;
-        });
+      return expect(module.validate()).to.fulfilled
+      .then(() => {
+        expect(serverless.utils.fileExistsSync).to.have.been.calledWith(requiredPath);
+        expect(module.webpackConfig).to.eql(loadedConfig);
+        return null;
+      })
+      .finally(() => {
+        mockery.deregisterMock(requiredPath);
+      });
+    });
+
+    it('should load a async webpack config from file if `custom.webpack` is a string', () => {
+      const testConfig = 'testconfig';
+      const testServicePath = 'testpath';
+      const requiredPath = path.join(testServicePath, testConfig);
+      module.serverless.config.servicePath = testServicePath;
+      module.serverless.service.custom.webpack = testConfig;
+      serverless.utils.fileExistsSync = sinon.stub().returns(true);
+      const loadedConfig = {
+        entry: 'testentry',
+      };
+      const loadedConfigPromise = BbPromise.resolve(loadedConfig);
+      mockery.registerMock(requiredPath, loadedConfigPromise);
+      return expect(module.validate()).to.be.fulfilled
+      .then(() => {
+        expect(serverless.utils.fileExistsSync).to.have.been.calledWith(requiredPath);
+        expect(module.webpackConfig).to.deep.equal(loadedConfig);
+        return null;
+      })
+      .finally(() => {
+        mockery.deregisterMock(requiredPath);
+      });
+    });
+
+    it('should catch errors while loading a async webpack config from file if `custom.webpack` is a string', () => {
+      const testConfig = 'testconfig';
+      const testServicePath = 'testpath';
+      const requiredPath = path.join(testServicePath, testConfig);
+      module.serverless.config.servicePath = testServicePath;
+      module.serverless.service.custom.webpack = testConfig;
+      serverless.utils.fileExistsSync = sinon.stub().returns(true);
+      const loadedConfigPromise = BbPromise.reject('config failed to load');
+      mockery.registerMock(requiredPath, loadedConfigPromise);
+      return expect(module.validate()).to.be.rejectedWith('config failed to load')
+      .then(() => {
+        expect(serverless.utils.fileExistsSync).to.have.been.calledWith(requiredPath);
+        return null;
+      })
+      .finally(() => {
+        mockery.deregisterMock(requiredPath);
+      });
+    });
+
+    it('should load a wrong thenable webpack config as normal object from file if `custom.webpack` is a string', () => {
+      const testConfig = 'testconfig';
+      const testServicePath = 'testpath';
+      const requiredPath = path.join(testServicePath, testConfig);
+      module.serverless.config.servicePath = testServicePath;
+      module.serverless.service.custom.webpack = testConfig;
+      serverless.utils.fileExistsSync = sinon.stub().returns(true);
+      const loadedConfig = {
+        then: 'I am not a Promise member',
+        entry: 'testentry',
+      };
+      mockery.registerMock(requiredPath, loadedConfig);
+      return expect(module.validate()).to.be.fulfilled
+      .then(() => {
+        expect(serverless.utils.fileExistsSync).to.have.been.calledWith(requiredPath);
+        expect(module.webpackConfig).to.deep.equal(loadedConfig);
+        return null;
+      })
+      .finally(() => {
+        mockery.deregisterMock(requiredPath);
+      });
     });
 
     it('should throw if providing an invalid file', () => {
@@ -248,7 +315,7 @@ describe('validate', () => {
       module.serverless.config.servicePath = testServicePath;
       module.serverless.service.custom.webpack = testConfig;
       serverless.utils.fileExistsSync = sinon.stub().returns(false);
-      expect(module.validate.bind(module)).to.throw(/could not find/);
+      return expect(module.validate()).to.be.rejectedWith(/could not find/);
     });
 
     it('should load a default file if no custom config is provided', () => {
@@ -261,14 +328,15 @@ describe('validate', () => {
         entry: 'testentry',
       };
       mockery.registerMock(requiredPath, loadedConfig);
-      return module
-        .validate()
-        .then(() => {
-          expect(serverless.utils.fileExistsSync).to.have.been.calledWith(requiredPath);
-          expect(module.webpackConfig).to.eql(loadedConfig);
-          mockery.deregisterMock(requiredPath);
-          return null;
-        });
+      return expect(module.validate()).to.be.fulfilled
+      .then(() => {
+        expect(serverless.utils.fileExistsSync).to.have.been.calledWith(requiredPath);
+        expect(module.webpackConfig).to.eql(loadedConfig);
+        return null;
+      })
+      .finally(() => {
+        mockery.deregisterMock(requiredPath);
+      });
     });
 
     it('should fail when importing a broken configuration file', () => {