diff --git a/lib/classes/Utils.js b/lib/classes/Utils.js index 7d2481dbc0d..17660555123 100644 --- a/lib/classes/Utils.js +++ b/lib/classes/Utils.js @@ -120,6 +120,8 @@ class Utils { servicePath = process.cwd(); } else if (fileExistsSync(path.join(process.cwd(), 'serverless.js'))) { servicePath = process.cwd(); + } else if (fileExistsSync(path.join(process.cwd(), 'serverless.ts'))) { + servicePath = process.cwd(); } return servicePath; diff --git a/lib/classes/Utils.test.js b/lib/classes/Utils.test.js index 26cc7f109ab..72774b68e3e 100644 --- a/lib/classes/Utils.test.js +++ b/lib/classes/Utils.test.js @@ -313,6 +313,18 @@ describe('Utils', () => { expect(servicePath).to.not.equal(null); }); + it('should detect if the CWD is a service directory when using Serverless .ts files', () => { + const tmpDirPath = getTmpDirPath(); + const tmpFilePath = path.join(tmpDirPath, 'serverless.ts'); + + serverless.utils.writeFileSync(tmpFilePath, 'foo'); + process.chdir(tmpDirPath); + + const servicePath = serverless.utils.findServicePath(); + + expect(servicePath).to.not.equal(null); + }); + it('should detect if the CWD is not a service directory', () => { // just use the root of the tmpdir because findServicePath will // also check parent directories (and may find matching tmp dirs diff --git a/lib/utils/getServerlessConfigFile.js b/lib/utils/getServerlessConfigFile.js index dbb4873cef8..0e723451203 100644 --- a/lib/utils/getServerlessConfigFile.js +++ b/lib/utils/getServerlessConfigFile.js @@ -3,8 +3,11 @@ const _ = require('lodash'); const BbPromise = require('bluebird'); const path = require('path'); +const resolveModulePath = require('ncjsm/resolve'); +const spawn = require('child-process-ext/spawn'); const fileExists = require('./fs/fileExists'); const readFile = require('./fs/readFile'); +const ServerlessError = require('../classes/Error').ServerlessError; const getConfigFilePath = (servicePath, options = {}) => { if (options.config) { @@ -18,12 +21,14 @@ const getConfigFilePath = (servicePath, options = {}) => { const ymlPath = path.join(servicePath, 'serverless.yml'); const yamlPath = path.join(servicePath, 'serverless.yaml'); const jsPath = path.join(servicePath, 'serverless.js'); + const tsPath = path.join(servicePath, 'serverless.ts'); return BbPromise.props({ json: fileExists(jsonPath), yml: fileExists(ymlPath), yaml: fileExists(yamlPath), js: fileExists(jsPath), + ts: fileExists(tsPath), }).then(exists => { if (exists.yaml) { return yamlPath; @@ -33,6 +38,8 @@ const getConfigFilePath = (servicePath, options = {}) => { return jsonPath; } else if (exists.js) { return jsPath; + } else if (exists.ts) { + return tsPath; } return null; @@ -46,28 +53,59 @@ const getServerlessConfigFilePath = serverless => { ); }; -const handleJsConfigFile = jsConfigFile => +const resolveTsNode = serviceDir => { + const resolveModuleRealPath = (...args) => + resolveModulePath(...args).then(({ realPath }) => realPath); + + const ifNotFoundContinueWith = cb => error => { + if (error.code !== 'MODULE_NOT_FOUND') throw error; + return cb(); + }; + + const resolveAsServerlessPeerDependency = () => resolveModuleRealPath(__dirname, 'ts-node'); + const resolveAsServiceDependency = () => resolveModuleRealPath(serviceDir, 'ts-node'); + const resolveAsGlobalInstallation = () => + spawn('npm', ['root', '-g']).then(({ stdoutBuffer }) => + require.resolve(`${String(stdoutBuffer).trim()}/ts-node`) + ); + const throwTsNodeError = () => { + throw new ServerlessError( + 'Ensure "ts-node" dependency when working with TypeScript configuration files', + 'TS_NODE_NOT_FOUND' + ); + }; + + return resolveAsServerlessPeerDependency() + .catch(ifNotFoundContinueWith(resolveAsServiceDependency)) + .catch(ifNotFoundContinueWith(resolveAsGlobalInstallation)) + .catch(ifNotFoundContinueWith(throwTsNodeError)); +}; + +const handleJsOrTsConfigFile = configFile => BbPromise.try(() => { - // use require to load serverless.js - // eslint-disable-next-line global-require - const configExport = require(jsConfigFile); - // In case of a promise result, first resolve it. - return configExport; + if (configFile.endsWith('.ts')) { + return resolveTsNode(path.dirname(configFile)).then(tsNodePath => { + require(tsNodePath).register(); + return require(configFile); + }); + } + return require(configFile); }).then(config => { if (_.isPlainObject(config)) { return config; } - throw new Error('serverless.js must export plain object'); + throw new Error(`${path.basename(configFile)} must export plain object`); }); const getServerlessConfigFile = _.memoize( serverless => getServerlessConfigFilePath(serverless).then(configFilePath => { if (configFilePath !== null) { - const isJSConfigFile = _.last(_.split(configFilePath, '.')) === 'js'; + const fileExtension = path.extname(configFilePath); + const isJSOrTsConfigFile = fileExtension === '.js' || fileExtension === '.ts'; - if (isJSConfigFile) { - return handleJsConfigFile(configFilePath); + if (isJSOrTsConfigFile) { + return handleJsOrTsConfigFile(configFilePath); } return readFile(configFilePath).then(result => result || {}); diff --git a/lib/utils/getServerlessConfigFile.test.js b/lib/utils/getServerlessConfigFile.test.js index 758c0398466..1774072d448 100644 --- a/lib/utils/getServerlessConfigFile.test.js +++ b/lib/utils/getServerlessConfigFile.test.js @@ -2,13 +2,17 @@ const path = require('path'); const chai = require('chai'); +const sinon = require('sinon'); const writeFileSync = require('./fs/writeFileSync'); const serverlessConfigFileUtils = require('./getServerlessConfigFile'); const { getTmpDirPath } = require('../../tests/utils/fs'); +const fixtures = require('../../tests/fixtures'); +const runServerless = require('../../tests/utils/run-serverless'); const getServerlessConfigFile = serverlessConfigFileUtils.getServerlessConfigFile; chai.use(require('chai-as-promised')); +chai.use(require('sinon-chai')); const expect = chai.expect; @@ -168,4 +172,40 @@ describe('#getServerlessConfigFile()', () => { } ); }); + + describe('serverless.ts', () => { + const tsNodePath = 'node_modules/ts-node.js'; + + after(() => fixtures.cleanup({ extraPaths: [tsNodePath] })); + + afterEach(() => { + sinon.restore(); + }); + + it('should throw TS_NODE_NOT_FOUND error when ts-node is not found', () => { + return runServerless({ + cwd: fixtures.map.serverlessTs, + cliArgs: ['package'], + shouldStubSpawn: true, + }).catch(error => { + return expect(error.code).to.equal('TS_NODE_NOT_FOUND'); + }); + }); + + it('should package serverless.ts files when ts-node is found', () => { + const tsNodeFullPath = `${fixtures.map.serverlessTs}/${tsNodePath}`; + + writeFileSync(tsNodeFullPath, 'module.exports.register = () => null;'); + const registerSpy = sinon.spy(require(tsNodeFullPath), 'register'); + + return runServerless({ + cwd: fixtures.map.serverlessTs, + cliArgs: ['package'], + }).then(serverless => { + expect(registerSpy).to.have.callCount(1); + expect(serverless.service.service).to.equal('serverless-ts-service'); + return expect(serverless.service.provider.name).to.equal('aws'); + }); + }); + }); }); diff --git a/tests/fixtures/serverlessTs/serverless.ts b/tests/fixtures/serverlessTs/serverless.ts new file mode 100644 index 00000000000..0583ba64995 --- /dev/null +++ b/tests/fixtures/serverlessTs/serverless.ts @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = { + service: 'serverless-ts-service', + provider: { + name: 'aws' + } +};