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

Issue:
Support optional use of Yarn for packaging (instead of NPM)
Hey! 👋 

As discussed [here](https://github.com/trek10inc/serverless-secrets/issues/26#issuecomment-346095775) with @HyperBrain, it'd be great if we could optionally use Yarn for packaging files, instead of NPM. 🙂 

In particular, since `serverless-webpack` completely relies on NPM and uses it for packaging files, I'm having issues using `serverless-webpack` with [Yarn Workspaces](https://yarnpkg.com/blog/2017/08/02/introducing-workspaces/). 

Currently, the bundled file is very huge (4 MB when used with `Yarn Workspaces`) compared to the same project when not used with `Yarn Workspaces` (25 kB). `serverless-webpack` bundles all the dependencies in one file even when using `webpack-node-externals` and `webpackIncludeModules`. 

Thanks! 😃 

Pull request description:
Yarn support
<!--
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 #286 

<!--
Briefly describe the feature if no issue exists for this PR. If possible only
submit PRs for existing issues. If the PR is trivial (like doc changes or simple
code fixes) it can be submitted without a related issue, but as soon as it adds
or changes functionality, a related issue should be present.
-->

## How did you implement it:

<!--
If this is a nontrivial change please briefly describe your implementation so its easy for us to understand and review your code.
-->

Yarn support is provided by an additional packager class that uses the yarn executable instead of npm.
The packager is selected by the `webpack: packager` setting introduced in serverless-webpack version 5.0.0.

The implementation honors any existing yarn.lock file and provides the same reproducibility as the locked npm packages. It even should behave better, because `--frozen-lockfile` is used with the `yarn install` command. Additionally, some special packager options are supported in `webpack: packagerOptions`:
```yaml
custom:
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules: true
    packager: yarn  # has to be set explicitly as it defaults to npm
    packagerOptions:
      flat: true   # Will set --flat on install (defaults to false)
      ignoreScripts: true  # Do not execute package.json hook scripts on install (defaults to false)
```

## How can we verify it:

<!--
Add any applicable config, commands, screenshots or other resources
to make it easy for us to verify this works. The easier you make it for us
to review a PR, the faster we can review and merge it.

Examples:
* Step by step description, how to verify
* Screenshots - Showing the difference between your output and the master
* Other - Anything else that comes to mind to help us evaluate
-->

Use Yarn in your project - preferably with a yarn.lock file - and set the packager accordingly in `serverless.yml`. A `serverless package` or `serverless deploy` should do as before, but use yarn and the yarn.lock to create the packages.
You can verify the installation by inspecting the generated zip files in `.serverless` (best to be done with `serverless package`.

With Yarn it is not needed to copy the modules explicitly into the function dependencies. For npm copy+prune was faster than install, but yarn is fast as hell here!
**With that optimization, the yarn based package/deployment should take less than half of the time that npm does !!! Of course this heavily depends on the machine where it is executed. With a very large file system cache and big amounts of memory, the difference might be rather small.**

## Your help needed 😄 

There are still some uncertainties, especially, if transient dependencies are handled correctly (i.e. if you bundle a first level dependency, its second level dependencies must become first level dependencies). With npm this works as expected and the behavior with yarn should be exactly the same.
Second, we should try this with the most common installation flavors of yarn, from a simple project up to yarn workspaces and shared modules.

## Todos:

Unit tests and proper documentation in the README are yet missing. Working on that.

- [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/.appveyor.yml b/.appveyor.yml
index 4645a17..e4eeff6 100644
--- a/.appveyor.yml
+++ b/.appveyor.yml
@@ -1,6 +1,5 @@
 environment:
   matrix:
-    - NODEJS_VERSION: "4"
     - NODEJS_VERSION: "6"
 
 install:
diff --git a/.travis.yml b/.travis.yml
index 4405c79..b51098d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,6 +2,7 @@ language: node_js
 
 matrix:
   include:
+    - node_js: '4.8.7'
     - node_js: '6.10.1'
 
 sudo: false
diff --git a/README.md b/README.md
index e4d0880..b68b2e9 100644
--- a/README.md
+++ b/README.md
@@ -246,11 +246,43 @@ custom:
 ```
 > Note that only relative path is supported at the moment.
 
-#### Usage with yarn
+#### Packagers
 
-Note that if auto-packing is enabled, the plugin will call `npm install`. If you are using yarn your `yarn.lock` file will be not be honored, which might lead to unexpected results as your dependencies will most likely not match (or be missing, as npm does not install packages in the same way as yarn).
+You can select the packager that will be used to package your external modules.
+The packager can be set with the packager configuration. Currently it can be 'npm'
+or 'yarn' and defaults to using npm when not set.
 
-Yarn support is planned [#286][link-286]. Until then we recommend using npm when using auto-packing.
+```yaml
+# serverless.yml
+custom:
+  webpack:
+    packager: 'yarn'      # Defaults to npm
+    packagerOptions: {}   # Optional, depending on the selected packager
+```
+
+You should select the packager, that you use to develop your projects, because only
+then locked versions will be handled correctly, i.e. the plugin uses the generated 
+(and usually committed) package lock file that is created by your favorite packager.
+
+Each packager might support specific options that can be set in the `packagerOptions`
+configuration setting. For details see below.
+
+##### NPM
+
+By default, the plugin uses NPM to package the external modules. However, if you use npm,
+you should use any version `<5.5 >=5.7.1` as the versions in-between have some nasty bugs.
+
+Right now there are no `packagerOptions` that can be set with NPM.
+
+#### Yarn
+
+Using yarn will switch the whole packaging pipeline to use yarn, so does it use a `yarn.lock` file.
+
+The yarn packager supports the following `packagerOptions`:
+
+| Option        | Type | Default | Description |
+|---------------|------|---------|-------------|
+| ignoreScripts | bool | true    | Do not execute package.json hook scripts on install |
 
 #### Forced inclusion
 
diff --git a/lib/Configuration.js b/lib/Configuration.js
index 70f5098..07e3eeb 100644
--- a/lib/Configuration.js
+++ b/lib/Configuration.js
@@ -12,6 +12,7 @@ const DefaultConfig = {
   webpackConfig: 'webpack.config.js',
   includeModules: false,
   packager: 'npm',
+  packagerOptions: {},
   packExternalModulesMaxBuffer: 200 * 1024,
   config: null
 };
@@ -62,6 +63,10 @@ class Configuration {
     return this._config.packager;
   }
 
+  get packagerOptions() {
+    return this._config.packagerOptions;
+  }
+
   get config() {
     return this._config.config;
   }
diff --git a/lib/packExternalModules.js b/lib/packExternalModules.js
index 5f7133b..daff219 100644
--- a/lib/packExternalModules.js
+++ b/lib/packExternalModules.js
@@ -236,12 +236,13 @@ module.exports = {
   
         // (1.a.2) Copy package-lock.json if it exists, to prevent unwanted upgrades
         const packageLockPath = path.join(path.dirname(packageJsonPath), packager.lockfileName);
+        let hasPackageLock = false;
         return BbPromise.fromCallback(cb => fse.pathExists(packageLockPath, cb))
         .then(exists => {
           if (exists) {
             this.serverless.cli.log('Package lock found - Using locked versions');
             try {
-              const packageLockJson = this.serverless.utils.readFileSync(packageLockPath);
+              let packageLockJson = this.serverless.utils.readFileSync(packageLockPath);
               /**
                * We should not be modifying 'package-lock.json'
                * because this file should be treat as internal to npm.
@@ -250,8 +251,12 @@ module.exports = {
                * removed as soon as https://github.com/npm/npm/issues/19183 gets fixed.
                */
               packager.rebaseLockfile(relPath, packageLockJson);
+              if (_.isObject(packageLockJson)) {
+                packageLockJson = JSON.stringify(packageLockJson, null, 2);
+              }
   
-              this.serverless.utils.writeFileSync(path.join(compositeModulePath, packager.lockfileName), JSON.stringify(packageLockJson, null, 2));
+              this.serverless.utils.writeFileSync(path.join(compositeModulePath, packager.lockfileName), packageLockJson);
+              hasPackageLock = true;
             } catch(err) {
               this.serverless.cli.log(`Warning: Could not read lock file: ${err.message}`);
             }
@@ -261,7 +266,7 @@ module.exports = {
         .then(() => {
           const start = _.now();
           this.serverless.cli.log('Packing external modules: ' + compositeModules.join(', '));
-          return packager.install(compositeModulePath, maxExecBufferSize)
+          return packager.install(compositeModulePath, maxExecBufferSize, this.configuration.packagerOptions)
           .then(() => this.options.verbose && this.serverless.cli.log(`Package took [${_.now() - start} ms]`))
           .return(stats.stats);
         })
@@ -290,12 +295,22 @@ module.exports = {
           }
   
           const startCopy = _.now();
-          return BbPromise.fromCallback(callback => fse.copy(path.join(compositeModulePath, 'node_modules'), path.join(modulePath, 'node_modules'), callback))
+          return BbPromise.try(() => {
+            // Only copy dependency modules if demanded by packager
+            if (packager.mustCopyModules) {
+              return BbPromise.fromCallback(callback => fse.copy(path.join(compositeModulePath, 'node_modules'), path.join(modulePath, 'node_modules'), callback));
+            }
+            return BbPromise.resolve();
+          })
+          .then(() => hasPackageLock ?
+            BbPromise.fromCallback(callback => fse.copy(path.join(compositeModulePath, packager.lockfileName), path.join(modulePath, packager.lockfileName), callback)) :
+            BbPromise.resolve()
+          )
           .tap(() => this.options.verbose && this.serverless.cli.log(`Copy modules: ${modulePath} [${_.now() - startCopy} ms]`))
           .then(() => {
             // Prune extraneous packages - removes not needed ones
             const startPrune = _.now();
-            return packager.prune(modulePath, maxExecBufferSize)
+            return packager.prune(modulePath, maxExecBufferSize, this.configuration.packagerOptions)
             .tap(() => this.options.verbose && this.serverless.cli.log(`Prune: ${modulePath} [${_.now() - startPrune} ms]`));
           });
         })
diff --git a/lib/packagers/index.js b/lib/packagers/index.js
index 790782f..ffd1001 100644
--- a/lib/packagers/index.js
+++ b/lib/packagers/index.js
@@ -17,9 +17,11 @@
 
 const _ = require('lodash');
 const npm = require('./npm');
+const yarn = require('./yarn');
 
 const registeredPackagers = {
-  npm: npm
+  npm: npm,
+  yarn: yarn
 };
 
 /**
diff --git a/lib/packagers/npm.js b/lib/packagers/npm.js
index da5a7ca..56c31dc 100644
--- a/lib/packagers/npm.js
+++ b/lib/packagers/npm.js
@@ -12,6 +12,10 @@ class NPM {
     return 'package-lock.json';
   }
 
+  static get mustCopyModules() {  // eslint-disable-line lodash/prefer-constant
+    return true;
+  }
+
   static getProdDependencies(cwd, depth, maxExecBufferSize) {
     // Get first level dependency graph
     const command = `npm ls -prod -json -depth=${depth || 1}`;  // Only prod dependencies
diff --git a/lib/packagers/yarn.js b/lib/packagers/yarn.js
new file mode 100644
index 0000000..fcfe5cd
--- /dev/null
+++ b/lib/packagers/yarn.js
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * Yarn packager.
+ * 
+ * Yarn specific packagerOptions (default):
+ *   flat (false) - Use --flat with install
+ *   ignoreScripts (false) - Do not execute scripts during install
+ */
+
+const _ = require('lodash');
+const BbPromise = require('bluebird');
+const childProcess = require('child_process');
+
+class Yarn {
+  static get lockfileName() {  // eslint-disable-line lodash/prefer-constant
+    return 'yarn.lock';
+  }
+
+  static get mustCopyModules() {  // eslint-disable-line lodash/prefer-constant
+    return false;
+  }
+
+  static getProdDependencies(cwd, depth, maxExecBufferSize) {
+    const command = `yarn list --depth=${depth || 1} --json --production`;  // Only prod dependencies
+
+    // If we need to ignore some errors add them here
+    const ignoredYarnErrors = [];
+
+    return BbPromise.fromCallback(cb => {
+      childProcess.exec(command, {
+        cwd: cwd,
+        maxBuffer: maxExecBufferSize,
+        encoding: 'utf8'
+      }, (err, stdout, stderr) => {
+        if (err) {
+          // Only exit with an error if we have critical npm errors for 2nd level inside
+          const errors = _.split(stderr, '\n');
+          const failed = _.reduce(errors, (failed, error) => {
+            if (failed) {
+              return true;
+            }
+            return !_.isEmpty(error) && !_.some(ignoredYarnErrors, ignoredError => _.startsWith(error, `error ${ignoredError.yarnError}`));
+          }, false);
+
+          if (failed) {
+            return cb(err);
+          }
+        }
+        return cb(null, stdout);
+      });
+    })
+    .then(depJson => BbPromise.try(() => JSON.parse(depJson)))
+    .then(parsedTree => {
+      const convertTrees = trees => _.reduce(trees, (__, tree) => {
+        const splitModule = _.split(tree.name, '@');
+        // If we have a scoped module we have to re-add the @
+        if (_.startsWith(tree.name, '@')) {
+          splitModule.splice(0, 1);
+          splitModule[0] = '@' + splitModule[0];
+        }
+        __[_.first(splitModule)] = {
+          version: _.join(_.tail(splitModule), '@'),
+          dependencies: convertTrees(tree.children)
+        };
+        return __;
+      }, {});
+
+      const trees = _.get(parsedTree, 'data.trees', []);
+      const result = {
+        problems: [],
+        dependencies: convertTrees(trees) 
+      };
+      return result;
+    });
+  }
+
+  // TODO: Check if we need that for Yarn
+  static rebaseLockfile(/* pathToPackageRoot, lockfile */) {
+  }
+
+  static install(cwd, maxExecBufferSize, packagerOptions) {
+    let command = 'yarn install --frozen-lockfile --non-interactive';
+    // Convert supported packagerOptions
+    if (packagerOptions.ignoreScripts) {
+      command += ' --ignore-scripts';
+    }
+    return BbPromise.fromCallback(cb => {
+      childProcess.exec(command, {
+        cwd: cwd,
+        maxBuffer: maxExecBufferSize,
+        encoding: 'utf8'
+      }, cb);
+    })
+    .return();
+  }
+
+  // "Yarn install" prunes automatically
+  static prune(cwd, maxExecBufferSize, packagerOptions) {
+    return Yarn.install(cwd, maxExecBufferSize, packagerOptions);
+  }
+}
+
+module.exports = Yarn;


Test patch:
diff --git a/lib/Configuration.test.js b/lib/Configuration.test.js
index 34600e2..71e9f94 100644
--- a/lib/Configuration.test.js
+++ b/lib/Configuration.test.js
@@ -17,6 +17,7 @@ describe('Configuration', () => {
         webpackConfig: 'webpack.config.js',
         includeModules: false,
         packager: 'npm',
+        packagerOptions: {},
         packExternalModulesMaxBuffer: 200 * 1024,
         config: null
       };
@@ -71,6 +72,7 @@ describe('Configuration', () => {
         webpackConfig: 'myWebpackFile.js',
         includeModules: { forceInclude: ['mod1'] },
         packager: 'npm',
+        packagerOptions: {},
         packExternalModulesMaxBuffer: 200 * 1024,
         config: null
       });
@@ -90,6 +92,7 @@ describe('Configuration', () => {
         webpackConfig: 'myWebpackFile.js',
         includeModules: { forceInclude: ['mod1'] },
         packager: 'npm',
+        packagerOptions: {},
         packExternalModulesMaxBuffer: 200 * 1024,
         config: null
       });
@@ -108,6 +111,7 @@ describe('Configuration', () => {
         webpackConfig: 'myWebpackFile.js',
         includeModules: { forceInclude: ['mod1'] },
         packager: 'npm',
+        packagerOptions: {},
         packExternalModulesMaxBuffer: 200 * 1024,
         config: null
       });
diff --git a/lib/packagers/index.test.js b/lib/packagers/index.test.js
index 988b37b..bb027c1 100644
--- a/lib/packagers/index.test.js
+++ b/lib/packagers/index.test.js
@@ -17,6 +17,7 @@ describe('packagers factory', () => {
   let sandbox;
   let serverless;
   let npmMock;
+  let yarnMock;
   let baseModule;
   let module;
 
@@ -25,9 +26,13 @@ describe('packagers factory', () => {
     npmMock = {
       hello: 'I am NPM'
     };
+    yarnMock = {
+      hello: 'I am Yarn'
+    };
     mockery.enable({ useCleanCache: true });
     mockery.registerAllowables([ './index', 'lodash' ]);
     mockery.registerMock('./npm', npmMock);
+    mockery.registerMock('./yarn', yarnMock);
     baseModule = require('./index');
     Object.freeze(baseModule);
   });
diff --git a/lib/packagers/npm.test.js b/lib/packagers/npm.test.js
index 481f134..6ad18ef 100644
--- a/lib/packagers/npm.test.js
+++ b/lib/packagers/npm.test.js
@@ -1,6 +1,6 @@
 'use strict';
 /**
- * Unit tests for packagers/index
+ * Unit tests for packagers/npm
  */
 
 const _ = require('lodash');
@@ -50,6 +50,10 @@ describe('npm', () => {
     expect(npmModule.lockfileName).to.equal('package-lock.json');
   });
 
+  it('requires to copy modules', () => {
+    expect(npmModule.mustCopyModules).to.be.true;
+  });
+
   describe('install', () => {
     it('should use npm install', () => {
       childProcessMock.exec.yields(null, 'installed successfully', '');
diff --git a/lib/packagers/yarn.test.js b/lib/packagers/yarn.test.js
new file mode 100644
index 0000000..b8f39b5
--- /dev/null
+++ b/lib/packagers/yarn.test.js
@@ -0,0 +1,200 @@
+'use strict';
+/**
+ * Unit tests for packagers/yarn
+ */
+
+const BbPromise = require('bluebird');
+const chai = require('chai');
+const sinon = require('sinon');
+const mockery = require('mockery');
+
+// Mocks
+const childProcessMockFactory = require('../../tests/mocks/child_process.mock');
+
+chai.use(require('chai-as-promised'));
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('yarn', () => {
+  let sandbox;
+  let yarnModule;
+
+  // Mocks
+  let childProcessMock;
+
+  before(() => {
+    sandbox = sinon.sandbox.create();
+    sandbox.usingPromise(BbPromise.Promise);
+
+    childProcessMock = childProcessMockFactory.create(sandbox);
+
+    mockery.enable({ useCleanCache: true, warnOnUnregistered: false });
+    mockery.registerMock('child_process', childProcessMock);
+    yarnModule = require('./yarn');
+  });
+
+  after(() => {
+    mockery.disable();
+    mockery.deregisterAll();
+
+    sandbox.restore();
+  });
+
+  afterEach(() => {
+    sandbox.reset();
+  });
+
+  it('should return "yarn.lock" as lockfile name', () => {
+    expect(yarnModule.lockfileName).to.equal('yarn.lock');
+  });
+
+  it('does not require to copy modules', () => {
+    expect(yarnModule.mustCopyModules).to.be.false;
+  });
+
+  describe('getProdDependencies', () => {
+    it('should use yarn list', () => {
+      childProcessMock.exec.yields(null, '{}', '');
+      return expect(yarnModule.getProdDependencies('myPath', 1, 2000)).to.be.fulfilled
+      .then(result => {
+        expect(result).to.be.an('object');
+        expect(childProcessMock.exec).to.have.been.calledOnce;
+        expect(childProcessMock.exec).to.have.been.calledWithExactly(
+          'yarn list --depth=1 --json --production',
+          {
+            cwd: 'myPath',
+            encoding: 'utf8',
+            maxBuffer: 2000
+          },
+          sinon.match.any
+        );
+        return null;
+      });
+    });
+
+    it('should transform yarn trees to npm dependencies', () => {
+      const testYarnResult = `{"type":"tree","data":{"type":"list","trees":[
+        {"name":"archiver@2.1.1","children":[],"hint":null,"color":"bold",
+        "depth":0},{"name":"bluebird@3.5.1","children":[],"hint":null,"color":
+        "bold","depth":0},{"name":"fs-extra@4.0.3","children":[],"hint":null,
+        "color":"bold","depth":0},{"name":"mkdirp@0.5.1","children":[{"name":
+        "minimist@0.0.8","children":[],"hint":null,"color":"bold","depth":0}],
+        "hint":null,"color":null,"depth":0},{"name":"@sls/webpack@1.0.0", 
+        "children":[],"hint":null,"color":"bold","depth":0}]}}`;
+      const expectedResult = {
+        problems: [],
+        dependencies: {
+          archiver: {
+            version: '2.1.1',
+            dependencies: {}
+          },
+          bluebird: {
+            version: '3.5.1',
+            dependencies: {}
+          },
+          'fs-extra': {
+            version: '4.0.3',
+            dependencies: {}
+          },
+          mkdirp: {
+            version: '0.5.1',
+            dependencies: {
+              minimist: {
+                version: '0.0.8',
+                dependencies: {}
+              }
+            }
+          },
+          '@sls/webpack': {
+            version: '1.0.0',
+            dependencies: {}
+          },
+        }
+      };
+      childProcessMock.exec.yields(null, testYarnResult, '');
+      return expect(yarnModule.getProdDependencies('myPath', 1, 2000)).to.be.fulfilled
+      .then(result => {
+        expect(result).to.deep.equal(expectedResult);
+        return null;
+      });
+    });
+
+    it('should reject on critical yarn errors', () => {
+      childProcessMock.exec.yields(new Error('Exited with code 1'), '', 'Yarn failed.\nerror Could not find module.');
+      return expect(yarnModule.getProdDependencies('myPath', 1, 2000)).to.be.rejectedWith('Exited with code 1');
+    });
+
+  });
+
+  describe('rebaseLockfile', () => {
+    it('should return the original lockfile', () => {
+      const testContent = 'eugfogfoigqwoeifgoqwhhacvaisvciuviwefvc';
+      const testContent2 = 'eugfogfoigqwoeifgoqwhhacvaisvciuviwefvc';
+      yarnModule.rebaseLockfile('.', testContent);
+      expect(testContent).to.equal(testContent2);
+    });
+  });
+
+  describe('install', () => {
+    it('should use yarn install', () => {
+      childProcessMock.exec.yields(null, 'installed successfully', '');
+      return expect(yarnModule.install('myPath', 2000, {})).to.be.fulfilled
+      .then(result => {
+        expect(result).to.be.undefined;
+        expect(childProcessMock.exec).to.have.been.calledOnce;
+        expect(childProcessMock.exec).to.have.been.calledWithExactly(
+          'yarn install --frozen-lockfile --non-interactive',
+          {
+            cwd: 'myPath',
+            encoding: 'utf8',
+            maxBuffer: 2000
+          },
+          sinon.match.any
+        );
+        return null;
+      });
+    });
+
+    it('should use ignoreScripts option', () => {
+      childProcessMock.exec.yields(null, 'installed successfully', '');
+      return expect(yarnModule.install('myPath', 2000, { ignoreScripts: true })).to.be.fulfilled
+      .then(result => {
+        expect(result).to.be.undefined;
+        expect(childProcessMock.exec).to.have.been.calledOnce;
+        expect(childProcessMock.exec).to.have.been.calledWithExactly(
+          'yarn install --frozen-lockfile --non-interactive --ignore-scripts',
+          {
+            cwd: 'myPath',
+            encoding: 'utf8',
+            maxBuffer: 2000
+          },
+          sinon.match.any
+        );
+        return null;
+      });
+    });
+  });
+
+  describe('prune', () => {
+    let installStub;
+
+    before(() => {
+      installStub = sandbox.stub(yarnModule, 'install').returns(BbPromise.resolve());
+    });
+
+    after(() => {
+      installStub.restore();
+    });
+
+    it('should call install', () => {
+      return expect(yarnModule.prune('myPath', 2000, {})).to.be.fulfilled
+      .then(() => {
+        expect(installStub).to.have.been.calledOnce;
+        expect(installStub).to.have.been.calledWithExactly('myPath', 2000, {});
+        return null;
+      });
+    });
+  });
+
+});
\ No newline at end of file
diff --git a/tests/packExternalModules.test.js b/tests/packExternalModules.test.js
index ed433cd..292a5eb 100644
--- a/tests/packExternalModules.test.js
+++ b/tests/packExternalModules.test.js
@@ -33,6 +33,7 @@ const packagerMockFactory = {
   create(sandbox) {
     const packagerMock = {
       lockfileName: 'mocked-lock.json',
+      mustCopyModules: true,
       rebaseLockfile: sandbox.stub(),
       getProdDependencies: sandbox.stub(),
       install: sandbox.stub(),
@@ -351,8 +352,8 @@ describe('packExternalModules', () => {
         expect(writeFileSyncStub).to.have.been.calledThrice,
         expect(writeFileSyncStub.firstCall.args[1]).to.equal(JSON.stringify(expectedCompositePackageJSON, null, 2)),
         expect(writeFileSyncStub.thirdCall.args[1]).to.equal(JSON.stringify(expectedPackageJSON, null, 2)),
-        // The modules should have been copied
-        expect(fsExtraMock.copy).to.have.been.calledOnce,
+        // The modules and the lock file should have been copied
+        expect(fsExtraMock.copy).to.have.been.calledTwice,
         // Lock file rebase should have been called
         expect(packagerMock.rebaseLockfile).to.have.been.calledOnce,
         expect(packagerMock.rebaseLockfile).to.have.been.calledWith(sinon.match.any, sinon.match(fakePackageLockJSON)),
@@ -670,8 +671,8 @@ describe('packExternalModules', () => {
         expect(writeFileSyncStub.firstCall.args[1]).to.equal(JSON.stringify(expectedCompositePackageJSON, null, 2)),
         expect(writeFileSyncStub.secondCall.args[1]).to.equal(JSON.stringify({ info: 'lockfile' }, null, 2)),
         expect(writeFileSyncStub.thirdCall.args[1]).to.equal(JSON.stringify(expectedPackageJSON, null, 2)),
-        // The modules should have been copied
-        expect(fsExtraMock.copy).to.have.been.calledOnce,
+        // The modules and the lock file should have been copied
+        expect(fsExtraMock.copy).to.have.been.calledTwice,
         // npm ls and npm prune should have been called
         expect(packagerMock.getProdDependencies).to.have.been.calledOnce,
         expect(packagerMock.install).to.have.been.calledOnce,
@@ -722,6 +723,52 @@ describe('packExternalModules', () => {
       ]));
     });
 
+    it('should skip module copy if demanded by packager', () => {
+      const expectedCompositePackageJSON = {
+        name: 'test-service',
+        version: '1.0.0',
+        description: 'Packaged externals for test-service',
+        private: true,
+        dependencies: {
+          '@scoped/vendor': '1.0.0',
+          uuid: '^5.4.1',
+          bluebird: '^3.4.0'
+        }
+      };
+      const expectedPackageJSON = {
+        dependencies: {
+          '@scoped/vendor': '1.0.0',
+          uuid: '^5.4.1',
+          bluebird: '^3.4.0'
+        }
+      };
+
+      module.webpackOutputPath = 'outputPath';
+      fsExtraMock.pathExists.yields(null, false);
+      fsExtraMock.copy.onFirstCall().yields();
+      packagerMock.getProdDependencies.returns(BbPromise.resolve({}));
+      packagerMock.install.returns(BbPromise.resolve());
+      packagerMock.prune.returns(BbPromise.resolve());
+      packagerMock.mustCopyModules = false;
+      module.compileStats = stats;
+      return expect(module.packExternalModules()).to.be.fulfilled
+      .then(() => BbPromise.all([
+        // The module package JSON and the composite one should have been stored
+        expect(writeFileSyncStub).to.have.been.calledTwice,
+        expect(writeFileSyncStub.firstCall.args[1]).to.equal(JSON.stringify(expectedCompositePackageJSON, null, 2)),
+        expect(writeFileSyncStub.secondCall.args[1]).to.equal(JSON.stringify(expectedPackageJSON, null, 2)),
+        // The modules should not have been copied
+        expect(fsExtraMock.copy).to.not.have.been.called,
+        // npm ls and npm prune should have been called
+        expect(packagerMock.getProdDependencies).to.have.been.calledOnce,
+        expect(packagerMock.install).to.have.been.calledOnce,
+        expect(packagerMock.prune).to.have.been.calledOnce,
+      ]))
+      .finally(() => {
+        packagerMock.mustCopyModules = true;
+      });
+    });
+
     describe('peer dependencies', () => {
       before(() => {
         const peerDepPackageJson = require('./data/package-peerdeps.json');