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

Issue:
Support `serverless run`
# This is a Feature Proposal

## Description

For feature proposals:
* What is the use case that should be solved. The more detail you describe this in the easier it is to understand for us.
Add support for `serverless run` by listening to the `before:run:run` hook.

* If there is additional config how would it look
Something similar to this:
https://github.com/serverless-heaven/serverless-webpack/blob/master/index.js#L130-L132

I recently added the same feature to serverless-plugin-typescript (for reference):
https://github.com/graphcool/serverless-plugin-typescript/blob/master/src/index.ts#L31-L37

Pull request description:
Support SLS run
<!--
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 #269 

<!--
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.
-->

Hooked `before:run:run`, use service packaging and start from the compiled service directory.
Added a watch mode that can be invoked with `serverless run --watch`.

## 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
-->

## 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/README.md b/README.md
index 5dc3aea..7073ddd 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ and much more!
 * Configuration possibilities range from zero-config to fully customizable
 * Support of `serverless package`, `serverless deploy` and `serverless deploy function`
 * Support of `serverless invoke local` and `serverless invoke local --watch`
+* Support of `serverless run` and `serverless run --watch`
 * Integrates with [`serverless-offline`][link-serverless-offline] to simulate local API Gateway endpoints
 * When enabled in your service configuration, functions are packaged and compiled
 individually, resulting in smaller Lambda packages that contain only the code and
@@ -339,6 +340,19 @@ All options that are supported by invoke local can be used as usual:
 
 > :exclamation: The old `webpack watch` command has been disabled.
 
+### Usage with serverless run (Serverless Event Gateway)
+
+The `serverless run` command is supported with the plugin. To test a local
+service with the Serverless Emulator, you can use the `serverless run`
+command as documented by Serverless. The command will compile the code before
+it uploads it into the event gateway.
+
+#### Serverless run with webpack watch mode
+
+You can enable source watch mode with `serverless run --watch`. The plugin will
+then watch for any source changes, recompile and redeploy the code to the event
+gateway. So you can just keep the event gateway running and test new code immediately.
+
 ### Usage with serverless-offline
 
 The plugin integrates very well with [serverless-offline][link-serverless-offline] to
diff --git a/index.js b/index.js
index 8733290..5b5e471 100644
--- a/index.js
+++ b/index.js
@@ -2,6 +2,7 @@
 
 const BbPromise = require('bluebird');
 const _ = require('lodash');
+const path = require('path');
 
 const validate = require('./lib/validate');
 const compile = require('./lib/compile');
@@ -9,6 +10,7 @@ const wpwatch = require('./lib/wpwatch');
 const cleanup = require('./lib/cleanup');
 const run = require('./lib/run');
 const prepareLocalInvoke = require('./lib/prepareLocalInvoke');
+const runPluginSupport = require('./lib/runPluginSupport');
 const prepareOfflineInvoke = require('./lib/prepareOfflineInvoke');
 const packExternalModules = require('./lib/packExternalModules');
 const packageModules = require('./lib/packageModules');
@@ -41,6 +43,7 @@ class ServerlessWebpack {
       packExternalModules,
       packageModules,
       prepareLocalInvoke,
+      runPluginSupport,
       prepareOfflineInvoke
     );
 
@@ -102,7 +105,22 @@ class ServerlessWebpack {
       'after:invoke:local:invoke': () => BbPromise.bind(this)
         .then(() => {
           if (this.options.watch && !this.isWatching) {
-            return this.watch();
+            return this.watch('invoke:local');
+          }
+          return BbPromise.resolve();
+        }),
+
+      'before:run:run': () => BbPromise.bind(this)
+        .then(() => _.set(this.serverless, 'service.package.individually', false))
+        .then(() => this.serverless.pluginManager.spawn('webpack:validate'))
+        .then(() => this.serverless.pluginManager.spawn('webpack:compile'))
+        .then(this.packExternalModules)
+        .then(this.prepareRun),
+
+      'after:run:run': () => BbPromise.bind(this)
+        .then(() => {
+          if (this.options.watch && !this.isWatching) {
+            return this.watch(this.watchRun.bind(this));
           }
           return BbPromise.resolve();
         }),
diff --git a/lib/run.js b/lib/run.js
index db6a045..4e90583 100644
--- a/lib/run.js
+++ b/lib/run.js
@@ -5,9 +5,13 @@ const BbPromise = require('bluebird');
 const webpack = require('webpack');
 
 module.exports = {
-  watch() {
+  watch(command) {
     const functionName = this.options.function;
-    this.serverless.cli.log(`Watch function ${functionName}...`);
+    if (functionName) {
+      this.serverless.cli.log(`Watch function ${functionName}...`);
+    } else {
+      this.serverless.cli.log('Watch service...');
+    }
 
     const compiler = webpack(this.webpackConfig);
     const watchOptions = {};
@@ -34,7 +38,11 @@ module.exports = {
         }
 
         this.serverless.cli.log('Sources changed.');
-        return this.serverless.pluginManager.spawn('invoke:local');
+        if (_.isFunction(command)) {
+          return command();
+        }
+        this.options.verbose && this.serverless.cli.log(`Invoke ${command}`);
+        return this.serverless.pluginManager.spawn(command);
       })
       .then(() => this.serverless.cli.log('Waiting for changes ...'));
     });
diff --git a/lib/runPluginSupport.js b/lib/runPluginSupport.js
new file mode 100644
index 0000000..8f1584c
--- /dev/null
+++ b/lib/runPluginSupport.js
@@ -0,0 +1,60 @@
+'use strict';
+
+const BbPromise = require('bluebird');
+const path = require('path');
+
+module.exports = {
+  prepareRun() {
+    this.originalServicePath = this.serverless.config.servicePath;
+    this.originalWebpackOutputPath = this.webpackOutputPath;
+
+    this.serverless.config.servicePath = path.join(this.webpackOutputPath, 'service');
+
+    // Set service path as CWD to allow accessing bundled files correctly
+    process.chdir(this.serverless.config.servicePath);
+
+    // Prevent a respawn to delete our output directory
+    this.keepOutputDirectory = true;
+
+    return BbPromise.resolve();
+  },
+
+  watchRun() {
+    // Redeploy functions to the event gateway
+    // We have to use the internal functions here, because the run plugin
+    // does not offer any detailed hooks that could be overridden to do
+    // a deploy only. Running the whole run command will lead to an error
+    // because the functions are already registered in the event gateway
+    const deployFunctionsToLocalEmulator = require(
+      path.join(
+        this.serverless.config.serverlessPath,
+        'plugins',
+        'run',
+        'utils',
+        'deployFunctionsToLocalEmulator'
+      )
+    );
+    const getLocalRootUrl = require(
+      path.join(
+        this.serverless.config.serverlessPath,
+        'plugins',
+        'run',
+        'utils',
+        'getLocalRootUrl'
+      )
+    );
+
+    // Reset configuration
+    this.serverless.config.servicePath = this.originalServicePath;
+    this.webpackOutputPath = this.originalWebpackOutputPath;
+    this.webpackConfig.output.path = this.webpackOutputPath;
+    process.chdir(this.serverless.config.servicePath);
+
+    return this.hooks['before:run:run']()
+    .then(() => deployFunctionsToLocalEmulator(
+      this.serverless.service,
+      this.serverless.config.servicePath,
+      getLocalRootUrl(this.options.lport)
+    ));
+  }
+};
diff --git a/lib/validate.js b/lib/validate.js
index f3a10d0..ec97159 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -139,7 +139,10 @@ module.exports = {
       this.webpackConfig.output.path = path.join(this.serverless.config.servicePath, this.options.out);
     }
 
-    fse.removeSync(this.webpackConfig.output.path);
+    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;
 
     // In case of individual packaging we have to create a separate config for each function


Test patch:
diff --git a/tests/all.js b/tests/all.js
index b816bd6..b68fc27 100644
--- a/tests/all.js
+++ b/tests/all.js
@@ -8,4 +8,5 @@ describe('serverless-webpack', () => {
   require('./run.test');
   require('./cleanup.test');
   require('./wpwatch.test');
+  require('./runPluginSupport.test');
 });
diff --git a/tests/run.test.js b/tests/run.test.js
index 833e2a9..2de0b88 100644
--- a/tests/run.test.js
+++ b/tests/run.test.js
@@ -80,8 +80,9 @@ describe('run', () => {
       module.isWatching = false;
       const watch = module.watch.bind(module);
       webpackMock.compilerMock.watch = sandbox.stub().yields(null, {});
+      _.set(module, 'options.function', 'myFunction');
 
-      watch();
+      watch('invoke:local');
       expect(spawnStub).to.not.have.been.called;
       expect(module.isWatching).to.be.true;
     });
@@ -91,12 +92,36 @@ describe('run', () => {
       const watch = module.watch.bind(module);
       webpackMock.compilerMock.watch = sandbox.stub().yields(null, {});
 
-      watch();
+      watch('invoke:local');
       expect(spawnStub).to.have.been.calledOnce;
       expect(spawnStub).to.have.been.calledWith('invoke:local');
       expect(module.isWatching).to.be.true;
     });
 
+    it('should not call given handler function on first run', () => {
+      module.isWatching = false;
+      const watch = module.watch.bind(module);
+      const watchHandler = sandbox.stub().returns(BbPromise.resolve());
+      webpackMock.compilerMock.watch = sandbox.stub().yields(null, {});
+
+      watch(watchHandler);
+      expect(spawnStub).to.not.have.been.called;
+      expect(watchHandler).to.not.have.been.called;
+      expect(module.isWatching).to.be.true;
+    });
+
+    it('should call given handler function on subsequent runs', () => {
+      module.isWatching = true;
+      const watch = module.watch.bind(module);
+      const watchHandler = sandbox.stub().returns(BbPromise.resolve());
+      webpackMock.compilerMock.watch = sandbox.stub().yields(null, {});
+
+      watch(watchHandler);
+      expect(spawnStub).to.have.not.been.called;
+      expect(watchHandler).to.have.been.calledOnce;
+      expect(module.isWatching).to.be.true;
+    });
+
     it('should reset the service path', () => {
       module.isWatching = true;
       module.originalServicePath = 'originalPath';
diff --git a/tests/runPluginSupport.test.js b/tests/runPluginSupport.test.js
new file mode 100644
index 0000000..afe46f8
--- /dev/null
+++ b/tests/runPluginSupport.test.js
@@ -0,0 +1,116 @@
+'use strict';
+
+const BbPromise = require('bluebird');
+const _ = require('lodash');
+const chai = require('chai');
+const sinon = require('sinon');
+const mockery = require('mockery');
+const Serverless = require('serverless');
+const path = require('path');
+
+chai.use(require('chai-as-promised'));
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('runPluginSupport', () => {
+  let sandbox;
+  let baseModule;
+  let serverless;
+  let module;
+  let chdirStub;
+  let getLocalRootUrlStub;
+  let deployFunctionsToLocalEmulatorStub;
+
+  before(() => {
+    sandbox = sinon.createSandbox();
+    sandbox.usingPromise(BbPromise.Promise);
+
+    const pluginRunUtils = path.join(
+      '.',
+      'plugins',
+      'run',
+      'utils'
+    );
+
+    deployFunctionsToLocalEmulatorStub = sandbox.stub().resolves();
+    getLocalRootUrlStub = sandbox.stub();
+
+    mockery.enable({ warnOnUnregistered: false });
+    mockery.registerMock(path.join(pluginRunUtils, 'deployFunctionsToLocalEmulator'), deployFunctionsToLocalEmulatorStub);
+    mockery.registerMock(path.join(pluginRunUtils, 'getLocalRootUrl'), getLocalRootUrlStub);
+    baseModule = require('../lib/runPluginSupport');
+    Object.freeze(baseModule);
+  });
+
+  after(() => {
+    mockery.disable();
+    mockery.deregisterAll();
+  });
+
+  beforeEach(() => {
+    serverless = new Serverless();
+    serverless.cli = {
+      log: sandbox.stub(),
+      consoleLog: sandbox.stub()
+    };
+
+    module = _.assign({
+      serverless,
+      options: {},
+    }, baseModule);
+
+    _.set(serverless, 'config.serverlessPath', '.');
+
+    chdirStub = sandbox.stub(process, 'chdir');
+  });
+
+  afterEach(() => {
+    chdirStub.reset();
+    sandbox.restore();
+  });
+
+  describe('prepareRun', () => {
+    it('should prepare environment and save original values', () => {
+      const prepareRun = module.prepareRun.bind(module);
+      const servicePath = path.join('my', 'servicePath');
+      const webpackOutputPath = path.join('webpack', 'output', 'path');
+
+      _.set(serverless, 'config.servicePath', servicePath);
+      _.set(module, 'webpackOutputPath', webpackOutputPath);
+      _.unset(module, 'keepOutputDirectory');
+
+      return expect(prepareRun()).to.be.fulfilled
+      .then(() => BbPromise.join(
+        expect(module.originalServicePath).to.equal(servicePath),
+        expect(module.originalWebpackOutputPath).to.equal(webpackOutputPath),
+        expect(module.keepOutputDirectory).to.be.true,
+        expect(serverless.config.servicePath).to.equal(path.join(webpackOutputPath, 'service')),
+        expect(chdirStub).to.have.been.calledWith(serverless.config.servicePath)
+      ));
+    });
+  });
+
+  describe('watchRun', () => {
+    beforeEach(() => {
+      _.set(module, 'webpackConfig.output.path', 'outputPath');
+    });
+
+    it('should invoke hook and deploy functions', () => {
+      const watchRun = module.watchRun.bind(module);
+      const service = {
+        name: 'testService',
+        functions: {}
+      };
+      _.set(module, 'hooks[before:run:run]', sandbox.stub().resolves());
+      _.set(serverless, 'service', service);
+
+      return expect(watchRun()).to.be.fulfilled
+      .then(() => BbPromise.join(
+        expect(deployFunctionsToLocalEmulatorStub).to.have.been.calledOnce,
+        expect(getLocalRootUrlStub).to.have.been.calledOnce,
+        expect(deployFunctionsToLocalEmulatorStub).to.have.been.calledWith(service)
+      ));
+    });
+  });
+});
diff --git a/tests/validate.test.js b/tests/validate.test.js
index f660317..3ce2488 100644
--- a/tests/validate.test.js
+++ b/tests/validate.test.js
@@ -51,6 +51,7 @@ describe('validate', () => {
   });
 
   afterEach(() => {
+    fsExtraMock.removeSync.reset();
     sandbox.restore();
   });
 
@@ -87,6 +88,22 @@ describe('validate', () => {
       .then(() => expect(fsExtraMock.removeSync).to.have.been.calledWith(testOutPath));
   });
 
+  it('should keep the output path if requested', () => {
+    const testOutPath = 'test';
+    const testConfig = {
+      entry: 'test',
+      context: 'testcontext',
+      output: {
+        path: testOutPath,
+      },
+    };
+    _.set(module, 'keepOutputDirectory', true);
+    module.serverless.service.custom.webpack = testConfig;
+    return module
+      .validate()
+      .then(() => expect(fsExtraMock.removeSync).to.not.have.been.called);
+  });
+
   it('should override the output path if `out` option is specified', () => {
     const testConfig = {
       entry: 'test',