Repository: serverless/serverless (JavaScript)
Date: 2017-06-06 11:26:11+00:00
Task type: Feature

Issue:
DX: Zip only production relevant node_modules to decrease bundle size
# This is a Feature Proposal

## Description
At the moment everything inside `node_modules` is being zipped and uploaded to s3.
If we would create a plugin for zipping the service (to not only be dependent on node.js), we could leverage npm (or at least their `ls` algorithm) to find a list of only the production dependencies (even with npm@3 and their flat directory structure).
[`npm ls --production --parseable`](https://docs.npmjs.com/cli/ls#prod--production) creates exactly this - a list of all dependencies and their transient dependencies, that are declared through the package.json's dependencies (excluding devDependencies).




Issue discussion:
I wrote a plugin for this, called serverless-plugin-include-dependencies - give it a try?
Awesome @dougmoscrop! 🎉 
Thanks for sharing!

BTW here's the link https://github.com/dougmoscrop/serverless-plugin-include-dependencies
@dougmoscrop cool, I guess you'll want to add it to the [list of plugins](https://github.com/serverless/serverless#plugins-v10).
If you're using later versions of npm (I've only tested this with 4.5.0) then it's pretty easy to reduce the size of your deployment pages. Create a `package.json` with your development dependencies in the project root folder and a `package.json` with your runtime dependencies in your service folder (the one created by Serverless).

This keeps all of your development dependencies and the packages they depend on out of the zip file as only the `node_modules` from the services folder are included in the package. I have a more complete write up about this [here](http://www.goingserverless.com/blog/keeping-dev-dependencies-out-of-your-serverless-package).
That's neat! (sad that it only works with newer `npm` versions)

Thanks for posting @buggy 👍 
There are a number of ways we can include only prod dependancies and exclude dev dependancies

See http://stackoverflow.com/questions/36447801/grunt-and-npm-package-all-production-dependencies

This works. 

```javascript
/**
 * Returns an array of the node dependencies needed for production.
 * See https://docs.npmjs.com/cli/ls for info on the 'npm ls' command.
*/
var getProdDependencies = function(callback) {
  require('child_process').exec('npm ls --prod=true --parseable=true', undefined,
      function(err, stdout, stderr) {
        var array = stdout.split('\n');
        var nodeModuleNames = [];

        array.forEach(function(line) {
          var index = line.indexOf('node_modules');
          if (index > -1) {
            nodeModuleNames.push(line.substr(index + 13));
          }
        });

        callback(nodeModuleNames);
      });
};
```

Are there any potential risks/downsides to implementing something like this?
@DavidWells great research!

we should only keep in mind to support both `yarn` and `npm`.
@pmuens I believe `yarn` uses npm under the hood (or at least package.json) and yarns future is in question https://twitter.com/kentcdodds/status/860516174957690880

The alternative solution would be recursively parsing the package.json prod deps ourselves and zipped up only the prod deps. 

Or potentially leveraging @dougmoscrop handy work =) https://github.com/dougmoscrop/serverless-plugin-include-dependencies/blob/master/get-dependency-list.js 
@DavidWells yes, I'm also not 100% sure if supporting both (`npm` and `yarn`) is super important here.

I came up with that because of this LOC where `npm` is explicitly used:

```javascript
require('child_process').exec('npm ls --prod=true --parseable=true', undefined, function(err, stdout, stderr)
```

AFAIK we have some people relying on yarn.

Another question we've should discuss is a way how we can extend this later on to support other runtimes such as Python or JVM. Maybe we can define an interface for this so that it can be easily extended for other runtimes later down the road.

Something to keep in my although this might be out of the scope for now.
> Another question we've should discuss is a way how we can extend this later on to support other runtimes such as Python or JVM. Maybe we can define an interface for this so that it can be easily extended for other runtimes later down the road.

I think we can do this for `node` to start. Either with the `npm` command or manually ourselves via recursively parsing `prod` dependancies. I will investigate
@DavidWells for what it's worth, this can be accomplished with `npm prune --production`. That might be easier than trying to traverse the dev dependences manually.
Also note, there was some discussion around an equivalent command for `yarn` in https://github.com/yarnpkg/yarn/issues/696. For now, it appears that `yarn install --production` has the same behavior.
Thanks for the tip @marshall007 👍 That sounds good.

I'm still in favor of the more general solution without `npm` or `yarn`. But yeah, parsing the dependencies might get pretty complex.

Can't we just read the `package.json` `dependencies` and then cherry pick the corresponding node modules in the `node_modules` directory and include them / copy them over into the zip? 🤔 
@pmuens yeah we can start with root `package.json` and recursively traverse down to each depedancy's `package.json` copying the required prod dependancies folders over

I believe this is how npm is actually doing it with that `npm ls --prod=true --parseable=true` command

https://github.com/npm/npm/blob/6f09d6d2d915cdccfee6f423d7f14135be89e9f9/lib/ls.js

Solution patch:
diff --git a/docs/providers/aws/guide/packaging.md b/docs/providers/aws/guide/packaging.md
index 1dabdf63430..fa9878c209b 100644
--- a/docs/providers/aws/guide/packaging.md
+++ b/docs/providers/aws/guide/packaging.md
@@ -130,3 +130,9 @@ functions:
     package:
       individually: true
 ```
+
+### Development dependencies
+
+Serverless will auto-detect and exclude development dependencies based on the runtime your service is using.
+
+This ensures that only the production relevant packages and modules are included in your zip file. Doing this drastically reduces the overall size of the deployment package which will be uploaded to the cloud provider.
diff --git a/docs/providers/azure/guide/packaging.md b/docs/providers/azure/guide/packaging.md
index 7fe9f6bec77..040d0327226 100644
--- a/docs/providers/azure/guide/packaging.md
+++ b/docs/providers/azure/guide/packaging.md
@@ -151,3 +151,9 @@ functions:
     package:
       individually: true
 ```
+
+### Development dependencies
+
+Serverless will auto-detect and exclude development dependencies based on the runtime your service is using.
+
+This ensures that only the production relevant packages and modules are included in your zip file. Doing this drastically reduces the overall size of the deployment package which will be uploaded to the cloud provider.
diff --git a/docs/providers/google/guide/packaging.md b/docs/providers/google/guide/packaging.md
index 8d2c301bfe3..5f3ab133fce 100644
--- a/docs/providers/google/guide/packaging.md
+++ b/docs/providers/google/guide/packaging.md
@@ -123,3 +123,9 @@ functions:
     package:
       individually: true
 ```
+
+### Development dependencies
+
+Serverless will auto-detect and exclude development dependencies based on the runtime your service is using.
+
+This ensures that only the production relevant packages and modules are included in your zip file. Doing this drastically reduces the overall size of the deployment package which will be uploaded to the cloud provider.
diff --git a/docs/providers/openwhisk/guide/packaging.md b/docs/providers/openwhisk/guide/packaging.md
index eaf440b24b8..63e9bf50478 100644
--- a/docs/providers/openwhisk/guide/packaging.md
+++ b/docs/providers/openwhisk/guide/packaging.md
@@ -117,3 +117,9 @@ functions:
       exclude:
         - some-file.js
 ```
+
+### Development dependencies
+
+Serverless will auto-detect and exclude development dependencies based on the runtime your service is using.
+
+This ensures that only the production relevant packages and modules are included in your zip file. Doing this drastically reduces the overall size of the deployment package which will be uploaded to the cloud provider.
diff --git a/lib/plugins/package/lib/packageService.js b/lib/plugins/package/lib/packageService.js
index 3609583644f..f6d3681b705 100644
--- a/lib/plugins/package/lib/packageService.js
+++ b/lib/plugins/package/lib/packageService.js
@@ -55,7 +55,7 @@ module.exports = {
     const include = this.getIncludes();
     const zipFileName = `${this.serverless.service.service}.zip`;
 
-    return this.zipDirectory(exclude, include, zipFileName).then(filePath => {
+    return this.zipService(exclude, include, zipFileName).then(filePath => {
       // only set the default artifact for backward-compatibility
       // when no explicit artifact is defined
       if (!this.serverless.service.package.artifact) {
@@ -74,7 +74,7 @@ module.exports = {
     const include = this.getIncludes(funcPackageConfig.include);
     const zipFileName = `${functionName}.zip`;
 
-    return this.zipDirectory(exclude, include, zipFileName).then(artifactPath => {
+    return this.zipService(exclude, include, zipFileName).then(artifactPath => {
       functionObject.artifact = artifactPath;
       return artifactPath;
     });
diff --git a/lib/plugins/package/lib/zipService.js b/lib/plugins/package/lib/zipService.js
index 25f009f034a..6ad97efe9e1 100644
--- a/lib/plugins/package/lib/zipService.js
+++ b/lib/plugins/package/lib/zipService.js
@@ -1,16 +1,44 @@
 'use strict';
 
+/* eslint-disable no-use-before-define */
+/* eslint-disable no-param-reassign */
+
+const childProcess = require('child_process');
 const archiver = require('archiver');
 const BbPromise = require('bluebird');
 const path = require('path');
 const fs = require('fs');
 const globby = require('globby');
+const _ = require('lodash');
 
 module.exports = {
-  zipDirectory(exclude, include, zipFileName) {
+  zipService(exclude, include, zipFileName) {
+    const params = {
+      exclude,
+      include,
+      zipFileName,
+    };
+
+    return BbPromise.bind(this)
+      .then(() => BbPromise.resolve(params))
+      .then(this.excludeDevDependencies)
+      .then(this.zip);
+  },
+
+  excludeDevDependencies(params) {
+    const servicePath = this.serverless.config.servicePath;
+    const exAndInNode = excludeNodeDevDependencies(servicePath);
+
+    params.exclude = _.union(params.exclude, exAndInNode.exclude);
+    params.include = _.union(params.include, exAndInNode.include);
+
+    return BbPromise.resolve(params);
+  },
+
+  zip(params) {
     const patterns = ['**'];
 
-    exclude.forEach((pattern) => {
+    params.exclude.forEach((pattern) => {
       if (pattern.charAt(0) !== '!') {
         patterns.push(`!${pattern}`);
       } else {
@@ -20,7 +48,7 @@ module.exports = {
 
     // push the include globs to the end of the array
     // (files and folders will be re-added again even if they were excluded beforehand)
-    include.forEach((pattern) => {
+    params.include.forEach((pattern) => {
       patterns.push(pattern);
     });
 
@@ -28,7 +56,7 @@ module.exports = {
     // Create artifact in temp path and move it to the package path (if any) later
     const artifactFilePath = path.join(this.serverless.config.servicePath,
       '.serverless',
-      zipFileName
+      params.zipFileName
     );
     this.serverless.utils.writeFileDir(artifactFilePath);
 
@@ -75,3 +103,71 @@ module.exports = {
     });
   },
 };
+
+function excludeNodeDevDependencies(servicePath) {
+  const cwd = process.cwd();
+  let exclude = [];
+  let include = [];
+
+  try {
+    const packageJsonFilePaths = globby.sync([
+      '**/package.json',
+      // TODO add glob for node_modules filtering
+    ], {
+      cwd: servicePath,
+      dot: true,
+      silent: true,
+      follow: true,
+      nosort: true,
+    });
+
+    // filter out non node_modules file paths
+    const relevantFilePaths = _.filter(packageJsonFilePaths, (filePath) => {
+      const isNodeModulesDir = !!filePath.match(/node_modules/);
+      return !isNodeModulesDir;
+    });
+
+    _.forEach(relevantFilePaths, (relevantFilePath) => {
+      // the path where the package.json file lives
+      const fullPath = path.join(servicePath, relevantFilePath);
+      const rootDirPath = fullPath.replace(path.join(path.sep, 'package.json'), '');
+
+      process.chdir(rootDirPath);
+
+      // TODO replace with package-manager independent directory traversal?!
+      const prodDependencies = childProcess
+        .execSync('npm ls --prod=true --parseable=true --silent')
+        .toString().trim();
+
+      const prodDependencyPaths = prodDependencies.match(/(node_modules\/.*)/g);
+
+      let pathToDep = '';
+      // if the package.json file is not in the root of the service path
+      if (rootDirPath !== servicePath) {
+        // the path without the servicePath prepended
+        const relativeFilePath = rootDirPath.replace(path.join(servicePath, path.sep), '');
+        pathToDep = relativeFilePath ? `${relativeFilePath}/` : '';
+      }
+
+      const includePatterns = _.map(prodDependencyPaths, (depPath) =>
+        `${pathToDep}${depPath}/**`);
+
+      if (includePatterns.length) {
+        // at first exclude the whole node_modules directory
+        // after that re-include the production relevant modules
+        exclude = _.union(exclude, [`${pathToDep}node_modules/**`]);
+        include = _.union(include, includePatterns);
+      }
+    });
+  } catch (e) {
+    // npm is not installed
+  } finally {
+    // make sure to always chdir back to the cwd, no matter what
+    process.chdir(cwd);
+  }
+
+  return {
+    exclude,
+    include,
+  };
+}


Test patch:
diff --git a/lib/plugins/package/lib/packageService.test.js b/lib/plugins/package/lib/packageService.test.js
index 00c4a592f96..4fae5d2cecf 100644
--- a/lib/plugins/package/lib/packageService.test.js
+++ b/lib/plugins/package/lib/packageService.test.js
@@ -168,21 +168,21 @@ describe('#packageService()', () => {
     const artifactFilePath = '/some/fake/path/test-artifact.zip';
     let getExcludesStub;
     let getIncludesStub;
-    let zipDirectoryStub;
+    let zipServiceStub;
 
     beforeEach(() => {
       getExcludesStub = sinon
         .stub(packagePlugin, 'getExcludes').returns(exclude);
       getIncludesStub = sinon
         .stub(packagePlugin, 'getIncludes').returns(include);
-      zipDirectoryStub = sinon
-        .stub(packagePlugin, 'zipDirectory').resolves(artifactFilePath);
+      zipServiceStub = sinon
+        .stub(packagePlugin, 'zipService').resolves(artifactFilePath);
     });
 
     afterEach(() => {
       packagePlugin.getExcludes.restore();
       packagePlugin.getIncludes.restore();
-      packagePlugin.zipDirectory.restore();
+      packagePlugin.zipService.restore();
     });
 
     it('should call zipService with settings', () => {
@@ -195,8 +195,8 @@ describe('#packageService()', () => {
       .then(() => BbPromise.all([
         expect(getExcludesStub).to.be.calledOnce,
         expect(getIncludesStub).to.be.calledOnce,
-        expect(zipDirectoryStub).to.be.calledOnce,
-        expect(zipDirectoryStub).to.have.been.calledWithExactly(
+        expect(zipServiceStub).to.be.calledOnce,
+        expect(zipServiceStub).to.have.been.calledWithExactly(
           exclude,
           include,
           zipFileName
@@ -211,21 +211,21 @@ describe('#packageService()', () => {
     const artifactFilePath = '/some/fake/path/test-artifact.zip';
     let getExcludesStub;
     let getIncludesStub;
-    let zipDirectoryStub;
+    let zipServiceStub;
 
     beforeEach(() => {
       getExcludesStub = sinon
         .stub(packagePlugin, 'getExcludes').returns(exclude);
       getIncludesStub = sinon
         .stub(packagePlugin, 'getIncludes').returns(include);
-      zipDirectoryStub = sinon
-        .stub(packagePlugin, 'zipDirectory').resolves(artifactFilePath);
+      zipServiceStub = sinon
+        .stub(packagePlugin, 'zipService').resolves(artifactFilePath);
     });
 
     afterEach(() => {
       packagePlugin.getExcludes.restore();
       packagePlugin.getIncludes.restore();
-      packagePlugin.zipDirectory.restore();
+      packagePlugin.zipService.restore();
     });
 
     it('should call zipService with settings', () => {
@@ -243,8 +243,8 @@ describe('#packageService()', () => {
         expect(getExcludesStub).to.be.calledOnce,
         expect(getIncludesStub).to.be.calledOnce,
 
-        expect(zipDirectoryStub).to.be.calledOnce,
-        expect(zipDirectoryStub).to.have.been.calledWithExactly(
+        expect(zipServiceStub).to.be.calledOnce,
+        expect(zipServiceStub).to.have.been.calledWithExactly(
           exclude,
           include,
           zipFileName
diff --git a/lib/plugins/package/lib/zipService.test.js b/lib/plugins/package/lib/zipService.test.js
index 80e08ef53b4..ae7df33310a 100644
--- a/lib/plugins/package/lib/zipService.test.js
+++ b/lib/plugins/package/lib/zipService.test.js
@@ -1,344 +1,502 @@
 'use strict';
 
+/* eslint-disable no-unused-expressions */
+
 const chai = require('chai');
 const fs = require('fs');
 const os = require('os');
 const path = require('path');
 const JsZip = require('jszip');
+const globby = require('globby');
 const _ = require('lodash');
+const childProcess = require('child_process');
+const sinon = require('sinon');
 const Package = require('../package');
 const Serverless = require('../../../Serverless');
-const ServerlessError = require('../../../classes/Error').ServerlessError;
 const testUtils = require('../../../../tests/utils');
 
 // Configure chai
 chai.use(require('chai-as-promised'));
+chai.use(require('sinon-chai'));
 const expect = require('chai').expect;
 
-describe('#zipService()', () => {
+describe('zipService', () => {
+  let tmpDirPath;
   let serverless;
-  let packageService;
-  let zip;
-
-  const testDirectory = {
-    // root
-    '.': {
-      'event.json': 'some content',
-      'handler.js': 'some content',
-      'file-1': 'some content',
-      'file-2': 'some content',
-    },
-    // bin
-    bin: {
-      'binary-777': {
-        content: 'some content',
-        permissions: 777,
-      },
-      'binary-444': {
-        content: 'some content',
-        permissions: 444,
-      },
-    },
-    // lib
-    lib: {
-      'file-1.js': 'some content',
-    },
-    'lib/directory-1': {
-      'file-1.js': 'some content',
-    },
-    // node_modules
-    'node_modules/directory-1': {
-      'file-1': 'some content',
-      'file-2': 'some content',
-    },
-    'node_modules/directory-2': {
-      'file-1': 'some content',
-      'file-2': 'some content',
-    },
-  };
-
-  function getTestArtifactFileName(testName) {
-    return `test-${testName}-${(new Date()).getTime().toString()}.zip`;
-  }
+  let packagePlugin;
+  let params;
 
   beforeEach(() => {
+    tmpDirPath = testUtils.getTmpDirPath();
     serverless = new Serverless();
-    zip = new JsZip();
-    packageService = new Package(serverless, {});
-    packageService.serverless.cli = new serverless.classes.CLI();
+    serverless.service.service = 'first-service';
+    serverless.config.servicePath = tmpDirPath;
+    packagePlugin = new Package(serverless, {});
+    packagePlugin.serverless.cli = new serverless.classes.CLI();
+    params = {
+      include: [],
+      exclude: [],
+      zipFileName: 'my-service.zip',
+    };
+  });
 
-    // create a mock service in a temporary directory
-    const tmpDirPath = testUtils.getTmpDirPath();
+  describe('#zipService()', () => {
+    let excludeDevDependenciesStub;
+    let zipStub;
 
-    Object.keys(testDirectory).forEach(dirName => {
-      const dirPath = path.join(tmpDirPath, dirName);
-      const files = testDirectory[dirName];
+    beforeEach(() => {
+      excludeDevDependenciesStub = sinon.stub(packagePlugin, 'excludeDevDependencies').resolves();
+      zipStub = sinon.stub(packagePlugin, 'zip').resolves();
+    });
 
-      Object.keys(files).forEach(fileName => {
-        const filePath = path.join(dirPath, fileName);
-        const fileValue = files[fileName];
-        const file = _.isObject(fileValue) ? fileValue : { content: fileValue };
+    afterEach(() => {
+      packagePlugin.excludeDevDependencies.restore();
+      packagePlugin.zip.restore();
+    });
 
-        if (!file.content) {
-          throw new Error('File content is required');
-        }
+    it('should run promise chain in order', () => {
+      const exclude = params.exclude;
+      const include = params.include;
+      const zipFileName = params.zipFileName;
+
+      return expect(packagePlugin.zipService(exclude, include, zipFileName)).to.be
+        .fulfilled.then(() => {
+          expect(excludeDevDependenciesStub).to.have.been.calledOnce;
+          expect(zipStub).to.have.been.calledOnce;
+        });
+    });
+  });
 
-        serverless.utils.writeFileSync(filePath, file.content);
+  describe('#excludeDevDependencies()', () => {
+    describe('when dealing with Node.js runtimes', () => {
+      let globbySyncStub;
+      let processChdirStub;
+      let execSyncStub;
 
-        if (file.permissions) {
-          fs.chmodSync(filePath, file.permissions);
-        }
+      beforeEach(() => {
+        globbySyncStub = sinon.stub(globby, 'sync');
+        processChdirStub = sinon.stub(process, 'chdir').returns();
+        execSyncStub = sinon.stub(childProcess, 'execSync');
       });
-    });
-    // set the service name
-    serverless.service.service = 'first-service';
 
-    // set the servicePath
-    serverless.config.servicePath = tmpDirPath;
-  });
+      afterEach(() => {
+        process.chdir.restore();
+        globby.sync.restore();
+        childProcess.execSync.restore();
+      });
+
+      it('should do nothing if no packages are used', () => {
+        const filePaths = [];
+
+        globbySyncStub.returns(filePaths);
+
+        return expect(packagePlugin.excludeDevDependencies(params)).to.be
+          .fulfilled.then((updatedParams) => {
+            expect(globbySyncStub).to.have.been.calledOnce;
+            expect(processChdirStub).to.have.been.calledOnce;
+            expect(execSyncStub).to.not.have.been.called;
+            expect(globbySyncStub).to.have.been
+              .calledWithExactly(['**/package.json'], {
+                cwd: packagePlugin.serverless.config.servicePath,
+                dot: true,
+                silent: true,
+                follow: true,
+                nosort: true,
+              });
+            expect(updatedParams.exclude).to
+              .deep.equal([]);
+            expect(updatedParams.include).to
+              .deep.equal([]);
+            expect(updatedParams.zipFileName).to.equal(params.zipFileName);
+          });
+      });
 
-  it('should zip a whole service (without include / exclude usage)', () => {
-    const exclude = [];
-    const include = [];
-    const zipFileName = getTestArtifactFileName('whole-service');
-
-    return expect(packageService.zipDirectory(exclude, include, zipFileName))
-      .to.eventually.be.equal(path.join(serverless.config.servicePath, '.serverless', zipFileName))
-    .then(artifact => {
-      const data = fs.readFileSync(artifact);
-      return expect(zip.loadAsync(data)).to.be.fulfilled;
-    })
-    .then(unzippedData => {
-      const unzippedFileData = unzippedData.files;
-
-      expect(Object.keys(unzippedFileData)
-       .filter(file => !unzippedFileData[file].dir))
-       .to.be.lengthOf(13);
-
-      // root directory
-      expect(unzippedFileData['event.json'].name)
-        .to.equal('event.json');
-      expect(unzippedFileData['handler.js'].name)
-        .to.equal('handler.js');
-      expect(unzippedFileData['file-1'].name)
-        .to.equal('file-1');
-      expect(unzippedFileData['file-2'].name)
-        .to.equal('file-2');
-
-      // bin directory
-      expect(unzippedFileData['bin/binary-777'].name)
-        .to.equal('bin/binary-777');
-      expect(unzippedFileData['bin/binary-444'].name)
-        .to.equal('bin/binary-444');
-
-      // lib directory
-      expect(unzippedFileData['lib/file-1.js'].name)
-        .to.equal('lib/file-1.js');
-      expect(unzippedFileData['lib/directory-1/file-1.js'].name)
-        .to.equal('lib/directory-1/file-1.js');
-
-      // node_modules directory
-      expect(unzippedFileData['node_modules/directory-1/file-1'].name)
-        .to.equal('node_modules/directory-1/file-1');
-      expect(unzippedFileData['node_modules/directory-1/file-2'].name)
-        .to.equal('node_modules/directory-1/file-2');
-      expect(unzippedFileData['node_modules/directory-2/file-1'].name)
-        .to.equal('node_modules/directory-2/file-1');
-      expect(unzippedFileData['node_modules/directory-2/file-2'].name)
-        .to.equal('node_modules/directory-2/file-2');
+      it('should exclude dev dependencies in the services root directory', () => {
+        const filePaths = ['package.json', 'node_modules'];
+
+        globbySyncStub.returns(filePaths);
+        execSyncStub.returns('node_modules/module-1\nnode_modules/module-2');
+
+        return expect(packagePlugin.excludeDevDependencies(params)).to.be
+          .fulfilled.then((updatedParams) => {
+            expect(globbySyncStub).to.have.been.calledOnce;
+            expect(processChdirStub).to.have.been.calledTwice;
+            expect(execSyncStub).to.have.been.calledOnce;
+            expect(globbySyncStub).to.have.been
+              .calledWithExactly(['**/package.json'], {
+                cwd: packagePlugin.serverless.config.servicePath,
+                dot: true,
+                silent: true,
+                follow: true,
+                nosort: true,
+              });
+            expect(execSyncStub).to.have.been
+              .calledWithExactly('npm ls --prod=true --parseable=true --silent');
+            expect(updatedParams.exclude).to
+              .deep.equal(['node_modules/**']);
+            expect(updatedParams.include).to
+              .deep.equal(['node_modules/module-1/**', 'node_modules/module-2/**']);
+            expect(updatedParams.zipFileName).to.equal(params.zipFileName);
+          });
+      });
+
+      it('should exclude dev dependencies in deeply nested services directories', () => {
+        const filePaths = [
+          // root of the service
+          'package.json', 'node_modules',
+          // nested-dir
+          path.join('1st', 'package.json'),
+          path.join('1st', 'node_modules'),
+          // nested-dir which is nested
+          path.join('1st', '2nd', 'package.json'),
+          path.join('1st', '2nd', 'node_modules'),
+        ];
+
+        globbySyncStub.returns(filePaths);
+        execSyncStub.onCall(0).returns('node_modules/module-1\nnode_modules/module-2');
+        execSyncStub.onCall(1)
+          .returns('1st/node_modules/module-1\n1st/node_modules/module-2');
+        execSyncStub.onCall(2)
+          .returns('1st/2nd/node_modules/module-1\n1st/2nd/node_modules/module-2');
+
+        return expect(packagePlugin.excludeDevDependencies(params)).to.be
+          .fulfilled.then((updatedParams) => {
+            expect(globbySyncStub).to.have.been.calledOnce;
+            expect(processChdirStub.callCount).to.equal(4);
+            expect(execSyncStub.callCount).to.equal(3);
+            expect(globbySyncStub).to.have.been
+              .calledWithExactly(['**/package.json'], {
+                cwd: packagePlugin.serverless.config.servicePath,
+                dot: true,
+                silent: true,
+                follow: true,
+                nosort: true,
+              });
+            expect(execSyncStub).to.have.been
+              .calledWithExactly('npm ls --prod=true --parseable=true --silent');
+            expect(updatedParams.exclude).to
+              .deep.equal([
+                'node_modules/**',
+                '1st/node_modules/**',
+                '1st/2nd/node_modules/**',
+              ]);
+            expect(updatedParams.include).to
+              .deep.equal([
+                'node_modules/module-1/**',
+                'node_modules/module-2/**',
+                '1st/node_modules/module-1/**',
+                '1st/node_modules/module-2/**',
+                '1st/2nd/node_modules/module-1/**',
+                '1st/2nd/node_modules/module-2/**',
+              ]);
+            expect(updatedParams.zipFileName).to.equal(params.zipFileName);
+          });
+      });
     });
   });
 
-  it('should keep file permissions', () => {
-    const exclude = [];
-    const include = [];
-    const zipFileName = getTestArtifactFileName('file-permissions');
-
-    return expect(packageService.zipDirectory(exclude, include, zipFileName))
-      .to.eventually.be.equal(path.join(serverless.config.servicePath, '.serverless', zipFileName))
-    .then(artifact => {
-      const data = fs.readFileSync(artifact);
-      return expect(zip.loadAsync(data)).to.be.fulfilled;
-    }).then(unzippedData => {
-      const unzippedFileData = unzippedData.files;
-
-      if (os.platform() === 'win32') {
-        // chmod does not work right on windows. this is better than nothing?
-        expect(unzippedFileData['bin/binary-777'].unixPermissions)
-          .to.not.equal(unzippedFileData['bin/binary-444'].unixPermissions);
-      } else {
-        // binary file is set with chmod of 777
-        expect(unzippedFileData['bin/binary-777'].unixPermissions)
-          .to.equal(Math.pow(2, 15) + 777);
-
-        // read only file is set with chmod of 444
-        expect(unzippedFileData['bin/binary-444'].unixPermissions)
-          .to.equal(Math.pow(2, 15) + 444);
-      }
+  describe('#zip()', () => {
+    let zip;
+
+    const testDirectory = {
+      // root
+      '.': {
+        'event.json': 'some content',
+        'handler.js': 'some content',
+        'file-1': 'some content',
+        'file-2': 'some content',
+      },
+      // bin
+      bin: {
+        'binary-777': {
+          content: 'some content',
+          permissions: 777,
+        },
+        'binary-444': {
+          content: 'some content',
+          permissions: 444,
+        },
+      },
+      // lib
+      lib: {
+        'file-1.js': 'some content',
+      },
+      'lib/directory-1': {
+        'file-1.js': 'some content',
+      },
+      // node_modules
+      'node_modules/directory-1': {
+        'file-1': 'some content',
+        'file-2': 'some content',
+      },
+      'node_modules/directory-2': {
+        'file-1': 'some content',
+        'file-2': 'some content',
+      },
+    };
+
+    function getTestArtifactFileName(testName) {
+      return `test-${testName}-${(new Date()).getTime().toString()}.zip`;
+    }
+
+    beforeEach(() => {
+      zip = new JsZip();
+
+      Object.keys(testDirectory).forEach(dirName => {
+        const dirPath = path.join(tmpDirPath, dirName);
+        const files = testDirectory[dirName];
+
+        Object.keys(files).forEach(fileName => {
+          const filePath = path.join(dirPath, fileName);
+          const fileValue = files[fileName];
+          const file = _.isObject(fileValue) ? fileValue : { content: fileValue };
+
+          if (!file.content) {
+            throw new Error('File content is required');
+          }
+
+          serverless.utils.writeFileSync(filePath, file.content);
+
+          if (file.permissions) {
+            fs.chmodSync(filePath, file.permissions);
+          }
+        });
+      });
     });
-  });
 
-  it('should exclude with globs', () => {
-    const exclude = [
-      'event.json',
-      'lib/**',
-      'node_modules/directory-1/**',
-    ];
-    const include = [];
-
-    const zipFileName = getTestArtifactFileName('exclude-with-globs');
-
-    return expect(packageService.zipDirectory(exclude, include, zipFileName))
-      .to.eventually.be.equal(path.join(serverless.config.servicePath, '.serverless', zipFileName))
-    .then(artifact => {
-      const data = fs.readFileSync(artifact);
-      return expect(zip.loadAsync(data)).to.be.fulfilled;
-    }).then(unzippedData => {
-      const unzippedFileData = unzippedData.files;
-
-      expect(Object.keys(unzippedFileData)
-        .filter(file => !unzippedFileData[file].dir))
-        .to.be.lengthOf(8);
-
-      // root directory
-      expect(unzippedFileData['handler.js'].name)
-        .to.equal('handler.js');
-      expect(unzippedFileData['file-1'].name)
-        .to.equal('file-1');
-      expect(unzippedFileData['file-2'].name)
-        .to.equal('file-2');
-
-      // bin directory
-      expect(unzippedFileData['bin/binary-777'].name)
-        .to.equal('bin/binary-777');
-      expect(unzippedFileData['bin/binary-444'].name)
-        .to.equal('bin/binary-444');
-
-      // node_modules directory
-      expect(unzippedFileData['node_modules/directory-2/file-1'].name)
-        .to.equal('node_modules/directory-2/file-1');
-      expect(unzippedFileData['node_modules/directory-2/file-2'].name)
-        .to.equal('node_modules/directory-2/file-2');
+    it('should zip a whole service (without include / exclude usage)', () => {
+      params.zipFileName = getTestArtifactFileName('whole-service');
+
+      return expect(packagePlugin.zip(params)).to.eventually.be
+        .equal(path.join(serverless.config.servicePath, '.serverless', params.zipFileName))
+      .then(artifact => {
+        const data = fs.readFileSync(artifact);
+        return expect(zip.loadAsync(data)).to.be.fulfilled;
+      })
+      .then(unzippedData => {
+        const unzippedFileData = unzippedData.files;
+
+        expect(Object.keys(unzippedFileData)
+         .filter(file => !unzippedFileData[file].dir))
+         .to.be.lengthOf(13);
+
+        // root directory
+        expect(unzippedFileData['event.json'].name)
+          .to.equal('event.json');
+        expect(unzippedFileData['handler.js'].name)
+          .to.equal('handler.js');
+        expect(unzippedFileData['file-1'].name)
+          .to.equal('file-1');
+        expect(unzippedFileData['file-2'].name)
+          .to.equal('file-2');
+
+        // bin directory
+        expect(unzippedFileData['bin/binary-777'].name)
+          .to.equal('bin/binary-777');
+        expect(unzippedFileData['bin/binary-444'].name)
+          .to.equal('bin/binary-444');
+
+        // lib directory
+        expect(unzippedFileData['lib/file-1.js'].name)
+          .to.equal('lib/file-1.js');
+        expect(unzippedFileData['lib/directory-1/file-1.js'].name)
+          .to.equal('lib/directory-1/file-1.js');
+
+        // node_modules directory
+        expect(unzippedFileData['node_modules/directory-1/file-1'].name)
+          .to.equal('node_modules/directory-1/file-1');
+        expect(unzippedFileData['node_modules/directory-1/file-2'].name)
+          .to.equal('node_modules/directory-1/file-2');
+        expect(unzippedFileData['node_modules/directory-2/file-1'].name)
+          .to.equal('node_modules/directory-2/file-1');
+        expect(unzippedFileData['node_modules/directory-2/file-2'].name)
+          .to.equal('node_modules/directory-2/file-2');
+      });
     });
-  });
 
-  it('should re-include files using ! glob pattern', () => {
-    const exclude = [
-      'event.json',
-      'lib/**',
-      'node_modules/directory-1/**',
-
-      '!event.json', // re-include
-      '!lib/**', // re-include
-    ];
-    const include = [];
-
-    const zipFileName = getTestArtifactFileName('re-include-with-globs');
-
-    return expect(packageService.zipDirectory(exclude, include, zipFileName))
-      .to.eventually.be.equal(path.join(serverless.config.servicePath, '.serverless', zipFileName))
-    .then(artifact => {
-      const data = fs.readFileSync(artifact);
-      return expect(zip.loadAsync(data)).to.be.fulfilled;
-    }).then(unzippedData => {
-      const unzippedFileData = unzippedData.files;
-
-      expect(Object.keys(unzippedFileData)
-        .filter(file => !unzippedFileData[file].dir))
-        .to.be.lengthOf(11);
-
-      // root directory
-      expect(unzippedFileData['event.json'].name)
-        .to.equal('event.json');
-      expect(unzippedFileData['handler.js'].name)
-        .to.equal('handler.js');
-      expect(unzippedFileData['file-1'].name)
-        .to.equal('file-1');
-      expect(unzippedFileData['file-2'].name)
-        .to.equal('file-2');
-
-      // bin directory
-      expect(unzippedFileData['bin/binary-777'].name)
-        .to.equal('bin/binary-777');
-      expect(unzippedFileData['bin/binary-444'].name)
-        .to.equal('bin/binary-444');
-
-      // lib directory
-      expect(unzippedFileData['lib/file-1.js'].name)
-        .to.equal('lib/file-1.js');
-      expect(unzippedFileData['lib/directory-1/file-1.js'].name)
-        .to.equal('lib/directory-1/file-1.js');
-
-      // node_modules directory
-      expect(unzippedFileData['node_modules/directory-2/file-1'].name)
-        .to.equal('node_modules/directory-2/file-1');
-      expect(unzippedFileData['node_modules/directory-2/file-2'].name)
-        .to.equal('node_modules/directory-2/file-2');
+    it('should keep file permissions', () => {
+      params.zipFileName = getTestArtifactFileName('file-permissions');
+
+      return expect(packagePlugin.zip(params)).to.eventually.be
+        .equal(path.join(serverless.config.servicePath, '.serverless', params.zipFileName))
+      .then(artifact => {
+        const data = fs.readFileSync(artifact);
+        return expect(zip.loadAsync(data)).to.be.fulfilled;
+      }).then(unzippedData => {
+        const unzippedFileData = unzippedData.files;
+
+        if (os.platform() === 'win32') {
+          // chmod does not work right on windows. this is better than nothing?
+          expect(unzippedFileData['bin/binary-777'].unixPermissions)
+            .to.not.equal(unzippedFileData['bin/binary-444'].unixPermissions);
+        } else {
+          // binary file is set with chmod of 777
+          expect(unzippedFileData['bin/binary-777'].unixPermissions)
+            .to.equal(Math.pow(2, 15) + 777);
+
+          // read only file is set with chmod of 444
+          expect(unzippedFileData['bin/binary-444'].unixPermissions)
+            .to.equal(Math.pow(2, 15) + 444);
+        }
+      });
     });
-  });
 
-  it('should re-include files using include config', () => {
-    const exclude = [
-      'event.json',
-      'lib/**',
-      'node_modules/directory-1/**',
-    ];
-    const include = [
-      'event.json',
-      'lib/**',
-    ];
-
-    const zipFileName = getTestArtifactFileName('re-include-with-include');
-
-    return expect(packageService.zipDirectory(exclude, include, zipFileName))
-      .to.eventually.be.equal(path.join(serverless.config.servicePath, '.serverless', zipFileName))
-    .then(artifact => {
-      const data = fs.readFileSync(artifact);
-      return expect(zip.loadAsync(data)).to.be.fulfilled;
-    }).then(unzippedData => {
-      const unzippedFileData = unzippedData.files;
-
-      expect(Object.keys(unzippedFileData)
-        .filter(file => !unzippedFileData[file].dir))
-        .to.be.lengthOf(11);
-
-      // root directory
-      expect(unzippedFileData['event.json'].name)
-        .to.equal('event.json');
-      expect(unzippedFileData['handler.js'].name)
-        .to.equal('handler.js');
-      expect(unzippedFileData['file-1'].name)
-        .to.equal('file-1');
-      expect(unzippedFileData['file-2'].name)
-        .to.equal('file-2');
-
-      // bin directory
-      expect(unzippedFileData['bin/binary-777'].name)
-        .to.equal('bin/binary-777');
-      expect(unzippedFileData['bin/binary-444'].name)
-        .to.equal('bin/binary-444');
-
-      // lib directory
-      expect(unzippedFileData['lib/file-1.js'].name)
-        .to.equal('lib/file-1.js');
-      expect(unzippedFileData['lib/directory-1/file-1.js'].name)
-        .to.equal('lib/directory-1/file-1.js');
-
-      // node_modules directory
-      expect(unzippedFileData['node_modules/directory-2/file-1'].name)
-        .to.equal('node_modules/directory-2/file-1');
-      expect(unzippedFileData['node_modules/directory-2/file-2'].name)
-        .to.equal('node_modules/directory-2/file-2');
+    it('should exclude with globs', () => {
+      params.zipFileName = getTestArtifactFileName('exclude-with-globs');
+      params.exclude = [
+        'event.json',
+        'lib/**',
+        'node_modules/directory-1/**',
+      ];
+
+      return expect(packagePlugin.zip(params)).to.eventually.be
+        .equal(path.join(serverless.config.servicePath, '.serverless', params.zipFileName))
+      .then(artifact => {
+        const data = fs.readFileSync(artifact);
+        return expect(zip.loadAsync(data)).to.be.fulfilled;
+      }).then(unzippedData => {
+        const unzippedFileData = unzippedData.files;
+
+        expect(Object.keys(unzippedFileData)
+          .filter(file => !unzippedFileData[file].dir))
+          .to.be.lengthOf(8);
+
+        // root directory
+        expect(unzippedFileData['handler.js'].name)
+          .to.equal('handler.js');
+        expect(unzippedFileData['file-1'].name)
+          .to.equal('file-1');
+        expect(unzippedFileData['file-2'].name)
+          .to.equal('file-2');
+
+        // bin directory
+        expect(unzippedFileData['bin/binary-777'].name)
+          .to.equal('bin/binary-777');
+        expect(unzippedFileData['bin/binary-444'].name)
+          .to.equal('bin/binary-444');
+
+        // node_modules directory
+        expect(unzippedFileData['node_modules/directory-2/file-1'].name)
+          .to.equal('node_modules/directory-2/file-1');
+        expect(unzippedFileData['node_modules/directory-2/file-2'].name)
+          .to.equal('node_modules/directory-2/file-2');
+      });
     });
-  });
 
-  it('should throw an error when no file are matched', () => {
-    const exclude = ['**/**'];
-    const include = [];
+    it('should re-include files using ! glob pattern', () => {
+      params.zipFileName = getTestArtifactFileName('re-include-with-globs');
+      params.exclude = [
+        'event.json',
+        'lib/**',
+        'node_modules/directory-1/**',
+
+        '!event.json', // re-include
+        '!lib/**', // re-include
+      ];
+
+      return expect(packagePlugin.zip(params)).to.eventually.be
+        .equal(path.join(serverless.config.servicePath, '.serverless', params.zipFileName))
+      .then(artifact => {
+        const data = fs.readFileSync(artifact);
+        return expect(zip.loadAsync(data)).to.be.fulfilled;
+      }).then(unzippedData => {
+        const unzippedFileData = unzippedData.files;
+
+        expect(Object.keys(unzippedFileData)
+          .filter(file => !unzippedFileData[file].dir))
+          .to.be.lengthOf(11);
+
+        // root directory
+        expect(unzippedFileData['event.json'].name)
+          .to.equal('event.json');
+        expect(unzippedFileData['handler.js'].name)
+          .to.equal('handler.js');
+        expect(unzippedFileData['file-1'].name)
+          .to.equal('file-1');
+        expect(unzippedFileData['file-2'].name)
+          .to.equal('file-2');
+
+        // bin directory
+        expect(unzippedFileData['bin/binary-777'].name)
+          .to.equal('bin/binary-777');
+        expect(unzippedFileData['bin/binary-444'].name)
+          .to.equal('bin/binary-444');
+
+        // lib directory
+        expect(unzippedFileData['lib/file-1.js'].name)
+          .to.equal('lib/file-1.js');
+        expect(unzippedFileData['lib/directory-1/file-1.js'].name)
+          .to.equal('lib/directory-1/file-1.js');
+
+        // node_modules directory
+        expect(unzippedFileData['node_modules/directory-2/file-1'].name)
+          .to.equal('node_modules/directory-2/file-1');
+        expect(unzippedFileData['node_modules/directory-2/file-2'].name)
+          .to.equal('node_modules/directory-2/file-2');
+      });
+    });
+
+    it('should re-include files using include config', () => {
+      params.zipFileName = getTestArtifactFileName('re-include-with-include');
+      params.exclude = [
+        'event.json',
+        'lib/**',
+        'node_modules/directory-1/**',
+      ];
+      params.include = [
+        'event.json',
+        'lib/**',
+      ];
+
+      return expect(packagePlugin.zip(params)).to.eventually.be
+        .equal(path.join(serverless.config.servicePath, '.serverless', params.zipFileName))
+      .then(artifact => {
+        const data = fs.readFileSync(artifact);
+        return expect(zip.loadAsync(data)).to.be.fulfilled;
+      }).then(unzippedData => {
+        const unzippedFileData = unzippedData.files;
+
+        expect(Object.keys(unzippedFileData)
+          .filter(file => !unzippedFileData[file].dir))
+          .to.be.lengthOf(11);
+
+        // root directory
+        expect(unzippedFileData['event.json'].name)
+          .to.equal('event.json');
+        expect(unzippedFileData['handler.js'].name)
+          .to.equal('handler.js');
+        expect(unzippedFileData['file-1'].name)
+          .to.equal('file-1');
+        expect(unzippedFileData['file-2'].name)
+          .to.equal('file-2');
+
+        // bin directory
+        expect(unzippedFileData['bin/binary-777'].name)
+          .to.equal('bin/binary-777');
+        expect(unzippedFileData['bin/binary-444'].name)
+          .to.equal('bin/binary-444');
+
+        // lib directory
+        expect(unzippedFileData['lib/file-1.js'].name)
+          .to.equal('lib/file-1.js');
+        expect(unzippedFileData['lib/directory-1/file-1.js'].name)
+          .to.equal('lib/directory-1/file-1.js');
+
+        // node_modules directory
+        expect(unzippedFileData['node_modules/directory-2/file-1'].name)
+          .to.equal('node_modules/directory-2/file-1');
+        expect(unzippedFileData['node_modules/directory-2/file-2'].name)
+          .to.equal('node_modules/directory-2/file-2');
+      });
+    });
 
-    const zipFileName = getTestArtifactFileName('empty');
+    it('should throw an error if no files are matched', () => {
+      params.exclude = ['**/**'];
+      params.include = [];
+      params.zipFileName = getTestArtifactFileName('empty');
 
-    return expect(packageService.zipDirectory(exclude, include, zipFileName))
-      .to.be.rejectedWith(ServerlessError, 'file matches include / exclude');
+      return expect(packagePlugin.zip(params)).to.be
+        .rejectedWith(Error, 'file matches include / exclude');
+    });
   });
 });