diff --git a/README.md b/README.md index 155b14356..b13f58905 100644 --- a/README.md +++ b/README.md @@ -658,6 +658,12 @@ Options are: - `--out` or `-o` (optional) The output directory. Defaults to `.webpack`. +You may find this option useful in CI environments where you want to build the package once but deploy the same artifact to many environments. To use existing output, specify the `--no-build` flag. + +```bash +$ serverless deploy --no-build --out dist +``` + ### Simulate API Gateway locally :exclamation: The serve command has been removed. See above how to achieve the diff --git a/index.js b/index.js index 6841c68dc..d8dc58403 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ const prepareOfflineInvoke = require('./lib/prepareOfflineInvoke'); const prepareStepOfflineInvoke = require('./lib/prepareStepOfflineInvoke'); const packExternalModules = require('./lib/packExternalModules'); const packageModules = require('./lib/packageModules'); +const compileStats = require('./lib/compileStats'); const lib = require('./lib'); class ServerlessWebpack { @@ -47,7 +48,8 @@ class ServerlessWebpack { prepareLocalInvoke, runPluginSupport, prepareOfflineInvoke, - prepareStepOfflineInvoke + prepareStepOfflineInvoke, + compileStats ); this.commands = { @@ -86,8 +88,15 @@ class ServerlessWebpack { this.hooks = { 'before:package:createDeploymentArtifacts': () => BbPromise.bind(this) - .then(() => this.serverless.pluginManager.spawn('webpack:validate')) - .then(() => this.serverless.pluginManager.spawn('webpack:compile')) + .then(() => { + // --no-build override + if (this.options.build === false) { + this.skipCompile = true; + } + + return this.serverless.pluginManager.spawn('webpack:validate'); + }) + .then(() => (this.skipCompile ? BbPromise.resolve() : this.serverless.pluginManager.spawn('webpack:compile'))) .then(() => this.serverless.pluginManager.spawn('webpack:package')), 'after:package:createDeploymentArtifacts': () => BbPromise.bind(this).then(this.cleanup), diff --git a/index.test.js b/index.test.js index c2448c118..b19c9d9ff 100644 --- a/index.test.js +++ b/index.test.js @@ -130,6 +130,8 @@ describe('ServerlessWebpack', () => { beforeEach(() => { ServerlessWebpack.lib.webpack.isLocal = false; + slsw.options.build = true; + slsw.skipCompile = false; }); after(() => { @@ -154,6 +156,20 @@ describe('ServerlessWebpack', () => { return null; }); }); + + it('should skip compile if requested', () => { + slsw.options.build = false; + return expect(slsw.hooks['before:package:createDeploymentArtifacts']()).to.be.fulfilled.then(() => { + expect(slsw.serverless.pluginManager.spawn).to.have.been.calledTwice; + expect(slsw.serverless.pluginManager.spawn.firstCall).to.have.been.calledWithExactly( + 'webpack:validate' + ); + expect(slsw.serverless.pluginManager.spawn.secondCall).to.have.been.calledWithExactly( + 'webpack:package' + ); + return null; + }); + }); } }, { diff --git a/lib/cleanup.js b/lib/cleanup.js index 63bfcc796..7bd0bbcfc 100644 --- a/lib/cleanup.js +++ b/lib/cleanup.js @@ -8,7 +8,7 @@ module.exports = { const webpackOutputPath = this.webpackOutputPath; const keepOutputDirectory = this.configuration.keepOutputDirectory; - if (!keepOutputDirectory) { + if (!keepOutputDirectory && !this.skipCompile) { this.options.verbose && this.serverless.cli.log(`Remove ${webpackOutputPath}`); if (this.serverless.utils.dirExistsSync(webpackOutputPath)) { fse.removeSync(webpackOutputPath); diff --git a/lib/compile.js b/lib/compile.js index e5c2363cf..c157b77fe 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -36,11 +36,12 @@ module.exports = { throw new Error('Webpack compilation error, see above'); } - compileOutputPaths.push(compileStats.compilation.compiler.outputPath); + compileOutputPaths.push(compileStats.outputPath); }); this.compileOutputPaths = compileOutputPaths; - this.compileStats = stats; + + this.saveCompileStats(stats); return BbPromise.resolve(); }); diff --git a/lib/compileStats.js b/lib/compileStats.js new file mode 100644 index 000000000..27ad70556 --- /dev/null +++ b/lib/compileStats.js @@ -0,0 +1,46 @@ +const path = require('path'); +const fs = require('fs'); +const _ = require('lodash'); + +const statsFileName = 'stats.json'; + +function loadStatsFromFile(webpackOutputPath) { + const statsFile = getStatsFilePath(webpackOutputPath); + const data = fs.readFileSync(statsFile); + const stats = JSON.parse(data); + + if (!stats.stats || !stats.stats.length) { + throw new this.serverless.classes.Error('Packaging: No stats information found'); + } + + const mappedStats = _.map(stats.stats, s => + _.assign({}, s, { outputPath: path.resolve(webpackOutputPath, s.outputPath) }) + ); + + return { stats: mappedStats }; +} + +const getStatsFilePath = webpackOutputPath => path.join(webpackOutputPath, statsFileName); + +module.exports = { + getCompileStats() { + const stats = this.stats || loadStatsFromFile.call(this, this.webpackOutputPath); + + return stats; + }, + saveCompileStats(stats) { + const statsJson = _.invokeMap(stats.stats, 'toJson'); + + this.stats = { stats: statsJson }; + + const normalisedStats = _.map(statsJson, s => { + return _.assign({}, s, { outputPath: path.relative(this.webpackOutputPath, s.outputPath) }); + }); + + const statsFile = getStatsFilePath(this.webpackOutputPath); + + fs.writeFileSync(statsFile, JSON.stringify({ stats: normalisedStats }, null, 2)); + + return; + } +}; diff --git a/lib/compileStats.test.js b/lib/compileStats.test.js new file mode 100644 index 000000000..32156051e --- /dev/null +++ b/lib/compileStats.test.js @@ -0,0 +1,130 @@ +'use strict'; + +const _ = require('lodash'); +const BbPromise = require('bluebird'); +const chai = require('chai'); +const sinon = require('sinon'); +const path = require('path'); +const Serverless = require('serverless'); + +// Mocks +const fsMockFactory = require('../tests/mocks/fs.mock'); +const mockery = require('mockery'); + +chai.use(require('chai-as-promised')); +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('compileStats', () => { + let baseModule; + let module; + let sandbox; + let serverless; + let fsMock; + + before(() => { + sandbox = sinon.createSandbox(); + sandbox.usingPromise(BbPromise.Promise); + + fsMock = fsMockFactory.create(sandbox); + + mockery.enable({ warnOnUnregistered: false }); + mockery.registerMock('fs', fsMock); + + baseModule = require('./compileStats'); + Object.freeze(baseModule); + }); + + beforeEach(() => { + serverless = new Serverless(); + serverless.cli = { + log: sandbox.stub() + }; + module = _.assign( + { + serverless, + options: {} + }, + baseModule + ); + }); + + afterEach(() => { + fsMock.writeFileSync.reset(); + fsMock.readFileSync.reset(); + mockery.disable(); + mockery.deregisterAll(); + sandbox.restore(); + }); + + describe('getCompileStats', () => { + it('should return this.stats if available', () => { + const stats = { stats: [{}] }; + module.stats = stats; + + const result = module.getCompileStats(); + + expect(result).to.equal(stats); + }); + + it('should load stats from file if this.stats is not present', () => { + const webpackOutputPath = '.webpack'; + + const statsFile = { stats: [{ outputPath: 'service/path' }] }; + const mappedFile = { stats: [{ outputPath: path.resolve(webpackOutputPath, 'service', 'path') }] }; + module.webpackOutputPath = webpackOutputPath; + + const fullStatsPath = path.join(webpackOutputPath, 'stats.json'); + + fsMock.readFileSync.withArgs(fullStatsPath).returns(JSON.stringify(statsFile)); + + const stats = module.getCompileStats(); + + expect(fsMock.readFileSync).to.be.calledWith(fullStatsPath); + expect(stats).to.deep.equal(mappedFile); + }); + + it('should fail if compile stats are not loaded', () => { + const webpackOutputPath = '.webpack'; + + const statsFile = { stats: [] }; + + module.webpackOutputPath = webpackOutputPath; + + const fullStatsPath = path.join(webpackOutputPath, 'stats.json'); + + fsMock.readFileSync.withArgs(fullStatsPath).returns(JSON.stringify(statsFile)); + + expect(() => module.getCompileStats()).to.throw(/Packaging: No stats information found/); + }); + }); + + describe('saveCompileStats', () => { + it('should set this.stats', () => { + const webpackOutputPath = '.webpack'; + module.webpackOutputPath = webpackOutputPath; + + const stats = { stats: [{ toJson: () => ({ outputPath: '.webpack/service/path' }) }] }; + + module.saveCompileStats(stats); + + expect(module.stats).to.deep.equal({ stats: [{ outputPath: '.webpack/service/path' }] }); + }); + + it('should write stats to a file', () => { + const webpackOutputPath = '/tmp/.webpack'; + module.webpackOutputPath = webpackOutputPath; + + const stats = { stats: [{ toJson: () => ({ outputPath: '/tmp/.webpack/service/path' }) }] }; + + const fullStatsPath = path.join(webpackOutputPath, 'stats.json'); + + const fileContent = JSON.stringify({ stats: [{ outputPath: path.join('service', 'path') }] }, null, 2); + + module.saveCompileStats(stats); + + expect(fsMock.writeFileSync).to.be.calledWith(fullStatsPath, fileContent); + }); + }); +}); diff --git a/lib/packExternalModules.js b/lib/packExternalModules.js index 3e787a874..4761d9614 100644 --- a/lib/packExternalModules.js +++ b/lib/packExternalModules.js @@ -127,14 +127,18 @@ function getProdModules(externalModules, packagePath, dependencyGraph, forceExcl if (!_.includes(ignoredDevDependencies, module.external)) { // Runtime dependency found in devDependencies but not forcefully excluded this.serverless.cli.log( - `ERROR: Runtime dependency '${module.external}' found in devDependencies. Move it to dependencies or use forceExclude to explicitly exclude it.` + `ERROR: Runtime dependency '${ + module.external + }' found in devDependencies. Move it to dependencies or use forceExclude to explicitly exclude it.` ); throw new this.serverless.classes.Error(`Serverless-webpack dependency error: ${module.external}.`); } this.options.verbose && this.serverless.cli.log( - `INFO: Runtime dependency '${module.external}' found in devDependencies. It has been excluded automatically.` + `INFO: Runtime dependency '${ + module.external + }' found in devDependencies. It has been excluded automatically.` ); } } diff --git a/lib/packageModules.js b/lib/packageModules.js index 422af3893..e64b267c8 100644 --- a/lib/packageModules.js +++ b/lib/packageModules.js @@ -71,12 +71,13 @@ function zip(directory, name) { module.exports = { packageModules() { - const stats = this.compileStats; + const stats = this.getCompileStats(); return BbPromise.mapSeries(stats.stats, (compileStats, index) => { const entryFunction = _.get(this.entryFunctions, index, {}); const filename = `${entryFunction.funcName || this.serverless.service.getServiceObject().name}.zip`; - const modulePath = compileStats.compilation.compiler.outputPath; + + const modulePath = compileStats.outputPath; const startZip = _.now(); return zip diff --git a/tests/compile.test.js b/tests/compile.test.js index c0bcb672a..cebcb1d03 100644 --- a/tests/compile.test.js +++ b/tests/compile.test.js @@ -65,6 +65,8 @@ describe('compile', () => { it('should compile with webpack from a context configuration', () => { const testWebpackConfig = 'testconfig'; module.webpackConfig = testWebpackConfig; + module.saveCompileStats = sandbox.stub(); + return expect(module.compile()).to.be.fulfilled.then(() => { expect(webpackMock).to.have.been.calledWith(testWebpackConfig); expect(webpackMock.compilerMock.run).to.have.been.calledOnce; @@ -94,6 +96,8 @@ describe('compile', () => { ]; module.webpackConfig = testWebpackConfig; module.multiCompile = true; + module.saveCompileStats = sandbox.stub(); + webpackMock.compilerMock.run.reset(); webpackMock.compilerMock.run.yields(null, multiStats); return expect(module.compile()).to.be.fulfilled.then(() => { @@ -117,6 +121,9 @@ describe('compile', () => { toString: sandbox.stub().returns('testStats') }; + const mockSaveCompileStats = sandbox.stub(); + module.saveCompileStats = mockSaveCompileStats; + module.webpackConfig = testWebpackConfig; webpackMock.compilerMock.run.reset(); webpackMock.compilerMock.run.yields(null, mockStats); @@ -124,6 +131,7 @@ describe('compile', () => { .to.be.fulfilled.then(() => { expect(webpackMock).to.have.been.calledWith(testWebpackConfig); expect(mockStats.toString.firstCall.args).to.eql([testWebpackConfig.stats]); + expect(mockSaveCompileStats).to.have.been.calledWith({ stats: [mockStats] }); module.webpackConfig = [testWebpackConfig]; return expect(module.compile()).to.be.fulfilled; }) diff --git a/tests/packageModules.test.js b/tests/packageModules.test.js index 22464a158..4679b3159 100644 --- a/tests/packageModules.test.js +++ b/tests/packageModules.test.js @@ -85,10 +85,12 @@ describe('packageModules', () => { }); describe('packageModules()', () => { - it('should do nothing if no compile stats are available', () => { - module.compileStats = { stats: [] }; + it('should do nothing if no stats are available', () => { + module.getCompileStats = sandbox.stub().returns({ stats: [] }); + return expect(module.packageModules()).to.be.fulfilled.then(() => BbPromise.all([ + expect(module.getCompileStats).to.have.been.called, expect(archiverMock.create).to.not.have.been.called, expect(writeFileDirStub).to.not.have.been.called, expect(fsMock.createWriteStream).to.not.have.been.called, @@ -109,11 +111,7 @@ describe('packageModules', () => { const stats = { stats: [ { - compilation: { - compiler: { - outputPath: '/my/Service/Path/.webpack/service' - } - } + outputPath: '/my/Service/Path/.webpack/service' } ] }; @@ -141,10 +139,12 @@ describe('packageModules', () => { fsMock._streamMock.on.withArgs('open').yields(); fsMock._streamMock.on.withArgs('close').yields(); fsMock._statMock.isDirectory.returns(false); + fsMock.readFileSync.returns('[]'); const expectedArtifactPath = path.join('.serverless', 'test-service.zip'); - module.compileStats = stats; + module.getCompileStats = sandbox.stub().returns(stats); + return expect(module.packageModules()).to.be.fulfilled.then(() => BbPromise.all([ expect(func1) @@ -179,11 +179,7 @@ describe('packageModules', () => { const stats = { stats: [ { - compilation: { - compiler: { - outputPath: '/my/Service/Path/.webpack/service' - } - } + outputPath: '/my/Service/Path/.webpack/service' } ] }; @@ -213,7 +209,8 @@ describe('packageModules', () => { const expectedArtifactPath = path.join('.serverless', 'test-service.zip'); - module.compileStats = stats; + module.getCompileStats = sandbox.stub().returns(stats); + return expect(module.packageModules()).to.be.fulfilled.then(() => expect(serverless.service) .to.have.a.nested.property('package.artifact') @@ -227,11 +224,7 @@ describe('packageModules', () => { const stats = { stats: [ { - compilation: { - compiler: { - outputPath: '/my/Service/Path/.webpack/service' - } - } + outputPath: '/my/Service/Path/.webpack/service' } ] }; @@ -261,7 +254,8 @@ describe('packageModules', () => { const expectedArtifactPath = path.join('.serverless', 'test-service.zip'); - module.compileStats = stats; + module.getCompileStats = sandbox.stub().returns(stats); + return BbPromise.each([ '1.18.1', '2.17.0', '10.15.3' ], version => { getVersionStub.returns(version); return expect(module.packageModules()).to.be.fulfilled.then(() => @@ -331,7 +325,8 @@ describe('packageModules', () => { fsMock._streamMock.on.withArgs('close').yields(); fsMock._statMock.isDirectory.returns(false); - module.compileStats = stats; + module.getCompileStats = sandbox.stub().returns(stats); + return expect(module.packageModules()).to.be.rejectedWith('Packaging: No files found'); }); }); @@ -341,18 +336,10 @@ describe('packageModules', () => { const stats = { stats: [ { - compilation: { - compiler: { - outputPath: '/my/Service/Path/.webpack/func1' - } - } + outputPath: '/my/Service/Path/.webpack/func1' }, { - compilation: { - compiler: { - outputPath: '/my/Service/Path/.webpack/func2' - } - } + outputPath: '/my/Service/Path/.webpack/func2' } ] }; @@ -401,7 +388,8 @@ describe('packageModules', () => { fsMock._streamMock.on.withArgs('close').yields(); fsMock._statMock.isDirectory.returns(false); - module.compileStats = stats; + module.getCompileStats = sandbox.stub().returns(stats); + return expect(module.packageModules()).to.be.fulfilled.then(() => BbPromise.all([ expect(func1)