diff --git a/README.md b/README.md index c5fdf925..dbcea723 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ Serverless plugin for zero-config Typescript support ## Install ```sh -yarn add --dev serverless-plugin-typescript +yarn add --dev serverless-plugin-typescript typescript +# or +npm install -D serverless-plugin-typescript typescript ``` Add the following plugin to your `serverless.yml`: @@ -38,6 +40,7 @@ The default `tsconfig.json` file used by the plugin looks like this: "preserveConstEnums": true, "strictNullChecks": true, "sourceMap": true, + "allowJs": true, "target": "es5", "outDir": ".build", "moduleResolution": "node", diff --git a/package.json b/package.json index 73ded396..0599b0ea 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,10 @@ "@types/fs-extra": "5.0.5", "@types/jest": "24.0.12", "@types/lodash": "4.14.123", + "@types/node": "12.6.2", "jest": "24.5.0", "mock-fs": "4.9.0", - "rimraf": "^2.6.3", + "rimraf": "2.6.3", "ts-jest": "24.0.2", "tslint": "5.14.0", "typescript": "^3.4.1" diff --git a/src/Serverless.d.ts b/src/Serverless.d.ts new file mode 100644 index 00000000..ec7d7049 --- /dev/null +++ b/src/Serverless.d.ts @@ -0,0 +1,46 @@ +declare namespace Serverless { + interface Instance { + cli: { + log(str: string): void + } + + config: { + servicePath: string + } + + service: { + provider: { + name: string + } + functions: { + [key: string]: Serverless.Function + } + package: Serverless.Package + getAllFunctions(): string[] + } + + pluginManager: PluginManager + } + + interface Options { + function?: string + watch?: boolean + extraServicePath?: string + } + + interface Function { + handler: string + package: Serverless.Package + } + + interface Package { + include: string[] + exclude: string[] + artifact?: string + individually?: boolean + } + + interface PluginManager { + spawn(command: string): Promise + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 27e1769b..10fcb5e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,50 +1,64 @@ import * as path from 'path' import * as fs from 'fs-extra' - import * as _ from 'lodash' import * as globby from 'globby' -import { ServerlessOptions, ServerlessInstance, ServerlessFunction } from './types' import * as typescript from './typescript' - import { watchFiles } from './watchFiles' -// Folders -const serverlessFolder = '.serverless' -const buildFolder = '.build' +const SERVERLESS_FOLDER = '.serverless' +const BUILD_FOLDER = '.build' export class TypeScriptPlugin { - private originalServicePath: string private isWatching: boolean - serverless: ServerlessInstance - options: ServerlessOptions - commands: { [key: string]: any } + serverless: Serverless.Instance + options: Serverless.Options hooks: { [key: string]: Function } - constructor(serverless: ServerlessInstance, options: ServerlessOptions) { + constructor(serverless: Serverless.Instance, options: Serverless.Options) { this.serverless = serverless this.options = options this.hooks = { 'before:run:run': async () => { await this.compileTs() + await this.copyExtras() + await this.copyDependencies() }, 'before:offline:start': async () => { await this.compileTs() + await this.copyExtras() + await this.copyDependencies() this.watchAll() }, 'before:offline:start:init': async () => { await this.compileTs() + await this.copyExtras() + await this.copyDependencies() this.watchAll() }, - 'before:package:createDeploymentArtifacts': this.compileTs.bind(this), - 'after:package:createDeploymentArtifacts': this.cleanup.bind(this), - 'before:deploy:function:packageFunction': this.compileTs.bind(this), - 'after:deploy:function:packageFunction': this.cleanup.bind(this), + 'before:package:createDeploymentArtifacts': async () => { + await this.compileTs() + await this.copyExtras() + await this.copyDependencies(true) + }, + 'after:package:createDeploymentArtifacts': async () => { + await this.cleanup() + }, + 'before:deploy:function:packageFunction': async () => { + await this.compileTs() + await this.copyExtras() + await this.copyDependencies(true) + }, + 'after:deploy:function:packageFunction': async () => { + await this.cleanup() + }, 'before:invoke:local:invoke': async () => { const emitedFiles = await this.compileTs() + await this.copyExtras() + await this.copyDependencies() if (this.isWatching) { emitedFiles.forEach(filename => { const module = require.resolve(path.resolve(this.originalServicePath, filename)) @@ -55,31 +69,42 @@ export class TypeScriptPlugin { 'after:invoke:local:invoke': () => { if (this.options.watch) { this.watchFunction() - this.serverless.cli.log('Waiting for changes ...') + this.serverless.cli.log('Waiting for changes...') } } } } get functions() { - return this.options.function - ? { [this.options.function] : this.serverless.service.functions[this.options.function] } - : this.serverless.service.functions + const { options } = this + const { service } = this.serverless + + if (options.function) { + return { + [options.function]: service.functions[this.options.function] + } + } + + return service.functions } get rootFileNames() { - return typescript.extractFileNames(this.originalServicePath, this.serverless.service.provider.name, this.functions) + return typescript.extractFileNames( + this.originalServicePath, + this.serverless.service.provider.name, + this.functions + ) } prepare() { // exclude serverless-plugin-typescript - const functions = this.functions - for (const fnName in functions) { - const fn = functions[fnName] + for (const fnName in this.functions) { + const fn = this.functions[fnName] fn.package = fn.package || { exclude: [], include: [], } + // Add plugin to excluded packages or an empty array if exclude is undefined fn.package.exclude = _.uniq([...fn.package.exclude || [], 'node_modules/serverless-plugin-typescript']) } @@ -106,9 +131,7 @@ export class TypeScriptPlugin { this.serverless.cli.log(`Watching typescript files...`) this.isWatching = true - watchFiles(this.rootFileNames, this.originalServicePath, () => { - this.compileTs() - }) + watchFiles(this.rootFileNames, this.originalServicePath, this.compileTs) } async compileTs(): Promise { @@ -119,7 +142,7 @@ export class TypeScriptPlugin { // Save original service path and functions this.originalServicePath = this.serverless.config.servicePath // Fake service path so that serverless will know what to zip - this.serverless.config.servicePath = path.join(this.originalServicePath, buildFolder) + this.serverless.config.servicePath = path.join(this.originalServicePath, BUILD_FOLDER) } const tsconfig = typescript.getTypescriptConfig( @@ -127,34 +150,23 @@ export class TypeScriptPlugin { this.isWatching ? null : this.serverless.cli ) - tsconfig.outDir = buildFolder + tsconfig.outDir = BUILD_FOLDER const emitedFiles = await typescript.run(this.rootFileNames, tsconfig) - await this.copyExtras() this.serverless.cli.log('Typescript compiled.') return emitedFiles } + /** Link or copy extras such as node_modules or package.include definitions */ async copyExtras() { - const outPkgPath = path.resolve(path.join(buildFolder, 'package.json')) - const outModulesPath = path.resolve(path.join(buildFolder, 'node_modules')) - - // Link or copy node_modules and package.json to .build so Serverless can - // exlcude devDeps during packaging - if (!fs.existsSync(outModulesPath)) { - await this.linkOrCopy(path.resolve('node_modules'), outModulesPath, 'junction') - } - - if (!fs.existsSync(outPkgPath)) { - await this.linkOrCopy(path.resolve('package.json'), outPkgPath, 'file') - } + const { service } = this.serverless // include any "extras" from the "include" section - if (this.serverless.service.package.include && this.serverless.service.package.include.length > 0) { - const files = await globby(this.serverless.service.package.include) + if (service.package.include && service.package.include.length > 0) { + const files = await globby(service.package.include) for (const filename of files) { - const destFileName = path.resolve(path.join(buildFolder, filename)) + const destFileName = path.resolve(path.join(BUILD_FOLDER, filename)) const dirname = path.dirname(destFileName) if (!fs.existsSync(dirname)) { @@ -162,45 +174,81 @@ export class TypeScriptPlugin { } if (!fs.existsSync(destFileName)) { - fs.copySync(path.resolve(filename), path.resolve(path.join(buildFolder, filename))) + fs.copySync(path.resolve(filename), path.resolve(path.join(BUILD_FOLDER, filename))) } } } } + /** + * Copy the `node_modules` folder and `package.json` files to the output + * directory. + * @param isPackaging Provided if serverless is packaging the service for deployment + */ + async copyDependencies(isPackaging = false) { + const outPkgPath = path.resolve(path.join(BUILD_FOLDER, 'package.json')) + const outModulesPath = path.resolve(path.join(BUILD_FOLDER, 'node_modules')) + + // copy development dependencies during packaging + if (isPackaging) { + if (fs.existsSync(outModulesPath)) { + fs.unlinkSync(outModulesPath) + } + + fs.copySync( + path.resolve('node_modules'), + path.resolve(path.join(BUILD_FOLDER, 'node_modules')) + ) + } else { + if (!fs.existsSync(outModulesPath)) { + await this.linkOrCopy(path.resolve('node_modules'), outModulesPath, 'junction') + } + } + + // copy/link package.json + if (!fs.existsSync(outPkgPath)) { + await this.linkOrCopy(path.resolve('package.json'), outPkgPath, 'file') + } + } + + /** + * Move built code to the serverless folder, taking into account individual + * packaging preferences. + */ async moveArtifacts(): Promise { + const { service } = this.serverless + await fs.copy( - path.join(this.originalServicePath, buildFolder, serverlessFolder), - path.join(this.originalServicePath, serverlessFolder) + path.join(this.originalServicePath, BUILD_FOLDER, SERVERLESS_FOLDER), + path.join(this.originalServicePath, SERVERLESS_FOLDER) ) if (this.options.function) { - const fn = this.serverless.service.functions[this.options.function] - const basename = path.basename(fn.package.artifact) - fn.package.artifact = path.join( + const fn = service.functions[this.options.function] + fn.package.artifact = path.join( this.originalServicePath, - serverlessFolder, + SERVERLESS_FOLDER, path.basename(fn.package.artifact) ) return } - if (this.serverless.service.package.individually) { - const functionNames = this.serverless.service.getAllFunctions() + if (service.package.individually) { + const functionNames = service.getAllFunctions() functionNames.forEach(name => { - this.serverless.service.functions[name].package.artifact = path.join( + service.functions[name].package.artifact = path.join( this.originalServicePath, - serverlessFolder, - path.basename(this.serverless.service.functions[name].package.artifact) + SERVERLESS_FOLDER, + path.basename(service.functions[name].package.artifact) ) }) return } - this.serverless.service.package.artifact = path.join( + service.package.artifact = path.join( this.originalServicePath, - serverlessFolder, - path.basename(this.serverless.service.package.artifact) + SERVERLESS_FOLDER, + path.basename(service.package.artifact) ) } @@ -209,18 +257,14 @@ export class TypeScriptPlugin { // Restore service path this.serverless.config.servicePath = this.originalServicePath // Remove temp build folder - fs.removeSync(path.join(this.originalServicePath, buildFolder)) + fs.removeSync(path.join(this.originalServicePath, BUILD_FOLDER)) } /** * Attempt to symlink a given path or directory and copy if it fails with an * `EPERM` error. */ - private async linkOrCopy( - srcPath: string, - dstPath: string, - type?: 'dir' | 'junction' | 'file' - ): Promise { + private async linkOrCopy(srcPath: string, dstPath: string, type?: fs.FsSymlinkType): Promise { return fs.symlink(srcPath, dstPath, type) .catch(error => { if (error.code === 'EPERM' && error.errno === -4048) { diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 4ad23338..00000000 --- a/src/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface ServerlessInstance { - cli: { - log(str: string) - } - config: { - servicePath: string - } - service: { - provider: { - name: string - } - functions: { [key: string]: ServerlessFunction } - package: ServerlessPackage - getAllFunctions: () => string[] - } - pluginManager: PluginManager -} - -export interface ServerlessOptions { - function?: string - watch?: boolean - extraServicePath?: string -} - -export interface ServerlessFunction { - handler: string - package: ServerlessPackage -} - -export interface ServerlessPackage { - include: string[] - exclude: string[] - artifact?: string - individually?: boolean -} - -export interface PluginManager { - spawn(command: string): Promise -} diff --git a/src/typescript.ts b/src/typescript.ts index 02ac9585..22f7354a 100644 --- a/src/typescript.ts +++ b/src/typescript.ts @@ -1,7 +1,6 @@ import * as ts from 'typescript' import * as fs from 'fs-extra' import * as _ from 'lodash' -import { ServerlessFunction } from './types' import * as path from 'path' export function makeDefaultTypescriptConfig() { @@ -19,8 +18,7 @@ export function makeDefaultTypescriptConfig() { return defaultTypescriptConfig } -export function extractFileNames(cwd: string, provider: string, functions?: { [key: string]: ServerlessFunction }): string[] { - +export function extractFileNames(cwd: string, provider: string, functions?: { [key: string]: Serverless.Function }): string[] { // The Google provider will use the entrypoint not from the definition of the // handler function, but instead from the package.json:main field, or via a // index.js file. This check reads the current package.json in the same way diff --git a/src/watchFiles.ts b/src/watchFiles.ts index 8b8322c5..a231534a 100644 --- a/src/watchFiles.ts +++ b/src/watchFiles.ts @@ -1,13 +1,7 @@ import * as typescript from './typescript' -import * as ts from 'typescript' import { watchFile, unwatchFile, Stats} from 'fs' -import { ServerlessOptions, ServerlessInstance, ServerlessFunction } from './types' -export function watchFiles( - rootFileNames: string[], - originalServicePath: string, - cb: () => void -) { +export function watchFiles(rootFileNames: string[], originalServicePath: string, cb: () => void) { const tsConfig = typescript.getTypescriptConfig(originalServicePath) let watchedFiles = typescript.getSourceFiles(rootFileNames, tsConfig) @@ -18,7 +12,7 @@ export function watchFiles( function watchCallback(curr: Stats, prev: Stats) { // Check timestamp if (+curr.mtime <= +prev.mtime) { - return + return } cb() diff --git a/tests/typescript.extractFileName.test.ts b/tests/typescript.extractFileName.test.ts index 2c2cfbe5..82fac1d8 100644 --- a/tests/typescript.extractFileName.test.ts +++ b/tests/typescript.extractFileName.test.ts @@ -1,8 +1,7 @@ import {extractFileNames} from '../src/typescript' -import {ServerlessFunction} from '../src/types' import * as path from 'path' -const functions: { [key: string]: ServerlessFunction } = { +const functions: { [key: string]: Serverless.Function } = { hello: { handler: 'tests/assets/hello.handler', package: { diff --git a/tslint.json b/tslint.json index 5c031f84..c5574c66 100644 --- a/tslint.json +++ b/tslint.json @@ -19,7 +19,8 @@ "ban-types": false, "interface-name": false, "forin": false, - "no-empty-interface": false + "no-empty-interface": false, + "no-namespace": [true, "allow-declarations"] }, "rulesDirectory": [] } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 5a8bd145..735f988f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -375,6 +375,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-11.12.2.tgz#d7f302e74b10e9801d52852137f652d9ee235da8" integrity sha512-c82MtnqWB/CqqK7/zit74Ob8H1dBdV7bK+BcErwtXbe0+nUGkgzq5NTDmRW/pAv2lFtmeNmW95b0zK2hxpeklg== +"@types/node@12.6.2": + version "12.6.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.2.tgz#a5ccec6abb6060d5f20d256fb03ed743e9774999" + integrity sha512-gojym4tX0FWeV2gsW4Xmzo5wxGjXGm550oVUII7f7G5o4BV6c7DBdiG1RRQd+y1bvqRyYtPfMK85UM95vsapqQ== + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -2974,7 +2979,7 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3: +rimraf@2.6.3, rimraf@^2.5.4, rimraf@^2.6.2: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==