├── .gitignore ├── LICENSE ├── README.md ├── ServerlessRunWatch.js ├── package.json └── test └── ServerlessRunWatchTest.js /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.swp 2 | 3 | .nyc_output/ 4 | package-lock.json 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 AJ Stuyvenberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serverless Run Watch 2 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 3 | 4 | This [Serverless](https://github.com/serverless/serverless) plugin provides a fast iterate -> test loop in your CLI. 5 | 6 | Logs delivered from CloudWatch to your terminal. 7 | 8 | Deployments skip CloudFormation, and use direct `updateFunction` and `updateFunctionConfiguration` API calls which take only a few seconds. 9 | 10 | Supports all runtimes 11 | 12 | ![sls-run-watch-reduced](https://user-images.githubusercontent.com/1598537/213583355-1c08619f-da92-454d-b431-3df21d40ed09.gif) 13 | 14 | ## Documentation 15 | - [Installation](#installation) 16 | - [Command line options](#command-line-options) 17 | - [Usage](#usage) 18 | - [Serverless Framework Support](#serverless-framework-support) 19 | 20 | ## Installation 21 | `serverless plugin install -n serverless-run-watch` 22 | 23 | then run 24 | 25 | `serverless run-watch --function ` 26 | 27 | ## Command Line Options 28 | 29 | #### function (required) 30 | Specify the name of the function to deploy and tail logs. 31 | 32 | #### disable-logs 33 | Skip streaming logs, only redeploy your function when a change is detected 34 | 35 | #### config 36 | This is shared with `serverless`, and honors a config file named something other than `serverless.yml`. 37 | 38 | #### stage 39 | This is shared with `serverless` 40 | 41 | #### region 42 | This is shared with `serverless` 43 | 44 | #### watch-glob 45 | Customize the path or paths (comma-separated) to watch. Supports glob/regex, or a direct file. Useful if you have a large project but only want to redeploy if one (or a few) files change. Also useful if my regex is missing a file you use. 46 | 47 | ## Usage 48 | `sls run-watch --function ` 49 | 50 | This plugin grew from a hacky script which combines two commands built into the framework: `serverless logs` and `serverless deploy function`, along with `chokidar`, a library based on `fs` events. 51 | 52 | It is useful only when changing function code, or function configuration changes like architecture, timeout, memory, or environment variables. Other changes (adding new functions, adding IAM permissions, new events, or provisioning additional resources) requires a full CloudFormation deployment. 53 | 54 | ## Serverless Framework Support 55 | Initially tested with v3. Likely supports other versions, but the CLI might not look as nice. 56 | 57 | ## License 58 | 59 | MIT 60 | 61 | ## Contributing 62 | Feel free to raise a PR 63 | 64 | -------------------------------------------------------------------------------- /ServerlessRunWatch.js: -------------------------------------------------------------------------------- 1 | const childProcess = require("node:child_process"); 2 | const chokidar = require("chokidar"); 3 | 4 | const slsLogs = require("@serverless/utils/log"); 5 | const mainProgress = slsLogs.progress.get("main"); 6 | const chokidarConfig = { 7 | interval: 500, 8 | awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 }, 9 | }; 10 | 11 | /* 12 | * Watches your code for changes 13 | * Runs fast deploys with 'serverless deploy function' 14 | * Tails logs 15 | * 16 | */ 17 | class ServerlessRunWatch { 18 | constructor(serverless, cliOptions) { 19 | this.cliOptions = cliOptions; 20 | this.serverless = serverless; 21 | this.serviceDir = serverless.config.serviceDir; 22 | this.watchPaths = []; 23 | 24 | this.slsRegex = /serverless.(yml|yaml|json|ts)$/; 25 | this.spawnSlsOptions = [] 26 | 27 | this.hooks = { 28 | "run-watch:start": this.start.bind(this), 29 | initialize: this.init.bind(this), 30 | }; 31 | 32 | this.commands = { 33 | "run-watch": { 34 | lifecycleEvents: ["start"], 35 | options: { 36 | function: { 37 | type: "string", 38 | usage: "Specify a function", 39 | required: true, 40 | shortcut: "f", 41 | }, 42 | config: { 43 | type: "string", 44 | usage: "Path to serverless config file", 45 | shortcut: "c", 46 | }, 47 | "disable-logs": { 48 | type: "boolean", 49 | usage: "Skip tailing logs", 50 | }, 51 | "watch-glob": { 52 | type: "string", 53 | usage: "Comma separated list of files or globs to watch", 54 | }, 55 | }, 56 | usage: 57 | "Watch and redeploy your Lambda function when code is changed. Uses direct function updates for speed", 58 | }, 59 | }; 60 | } 61 | 62 | async init() { 63 | if (!this.cliOptions["disable-logs"]) { 64 | let plugins = this.serverless.cli.loadedPlugins; 65 | for (let plugin of plugins) { 66 | if (plugin.constructor.name === "AwsLogs") { 67 | plugin.options["tail"] = true; 68 | } 69 | } 70 | } 71 | this.spawnSlsOptions = Object.entries(this.cliOptions).reduce((accum, [k, v]) => { 72 | if (['config', 'stage', 'region', 'verbose', 'force'].includes(k)) { 73 | accum.push(`--${k}`, v) 74 | } 75 | return accum 76 | }, []) 77 | if (this.cliOptions["watch-glob"]) { 78 | this.watchPaths.push(...this.cliOptions["watch-glob"].split(",")); 79 | } else { 80 | this.watchPaths.push(`${this.serviceDir}/**/*.(js|mjs|py|ts|go|java|rb)`); 81 | } 82 | if (this.cliOptions.config) { 83 | this.watchPaths.push(this.cliOptions.config); 84 | } else { 85 | this.watchPaths.push(`${this.serviceDir}/serverless.(yml|yaml|json|ts)`); 86 | } 87 | } 88 | 89 | async tailLogs() { 90 | await this.serverless.pluginManager.spawn("logs"); 91 | } 92 | 93 | spawnServerless() { 94 | const cmdArray = [ 95 | "deploy", 96 | "function", 97 | "--function", 98 | this.cliOptions.function, 99 | ...this.spawnSlsOptions 100 | ]; 101 | return childProcess.execFileSync("serverless", cmdArray, { stdio: "inherit" }); 102 | } 103 | 104 | async delay(time) { 105 | return new Promise((res) => setTimeout(res, time)); 106 | } 107 | 108 | async setCli() { 109 | if (this.cliOptions["disable-logs"]) { 110 | mainProgress.notice("Waiting for file change", { isMainEvent: true }); 111 | await this.delay(5000000); 112 | } else { 113 | mainProgress.notice("Waiting for logs", { isMainEvent: true }); 114 | await this.tailLogs(); 115 | } 116 | } 117 | 118 | serverlessFileChanged(event) { 119 | if (this.cliOptions.config) { 120 | return event.includes(this.cliOptions.config); 121 | } else { 122 | return event.match(this.slsRegex) !== null; 123 | } 124 | } 125 | 126 | async processEvent(event) { 127 | // Serverless won't reload itself 128 | // so if the file changes, we have to shell-out 129 | // to a new serverless instance 130 | if (this.serverlessFileChanged(event)) { 131 | this.spawnServerless(); 132 | } else { 133 | await this.serverless.pluginManager.spawn("deploy:function"); 134 | } 135 | await this.setCli(); 136 | } 137 | 138 | async start() { 139 | chokidar 140 | .watch(this.watchPaths, chokidarConfig) 141 | .on("change", async (event) => { 142 | await this.processEvent(event); 143 | }); 144 | await this.setCli(); 145 | } 146 | } 147 | 148 | module.exports = ServerlessRunWatch; 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-run-watch", 3 | "version": "0.4.1", 4 | "description": "Cloud-local developer experience for Serverless. Single-digit second deploys, and logs streamed from CloudWatch straight to your terminal", 5 | "main": "ServerlessRunWatch.js", 6 | "scripts": { 7 | "test": "nyc mocha" 8 | }, 9 | "keywords": [ 10 | "serverless", 11 | "lambda", 12 | "aws" 13 | ], 14 | "author": "AJ Stuyvenberg", 15 | "license": "MIT", 16 | "dependencies": { 17 | "@serverless/utils": "^6.8.2", 18 | "chokidar": "^3.5.3" 19 | }, 20 | "devDependencies": { 21 | "chai": "^4.3.7", 22 | "mocha": "^10.2.0", 23 | "nyc": "^15.1.0", 24 | "sinon": "^15.0.1" 25 | }, 26 | "homepage":"https://github.com/astuyve/serverless-run-watch", 27 | "repository" : { 28 | "type": "git", 29 | "url": "https://github.com/astuyve/serverless-run-watch" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/ServerlessRunWatchTest.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { expect } = require('chai') 3 | const sinon = require('sinon'); 4 | 5 | const childProcess = require("node:child_process") 6 | const chokidar = require('chokidar') 7 | 8 | const ServerlessRunWatch = require('../ServerlessRunWatch.js') 9 | 10 | class AwsLogs { 11 | constructor() { 12 | this.options = {} 13 | } 14 | } 15 | 16 | let slsStub, logsPlugin, pluginManagerSpawn, execFileStub 17 | 18 | describe('ServerlessRunWatch', () => { 19 | beforeEach(() => { 20 | logsPlugin = new AwsLogs 21 | pluginManagerSpawn = sinon.spy() 22 | execFileStub = sinon.stub(childProcess, 'execFileSync').returns() 23 | 24 | slsStub = { 25 | cli: { 26 | loadedPlugins: [ 27 | logsPlugin 28 | ] 29 | }, 30 | config: { 31 | serviceDir: './test' 32 | }, 33 | pluginManager: { 34 | spawn: pluginManagerSpawn 35 | } 36 | } 37 | }) 38 | 39 | afterEach(() => { 40 | sinon.restore() 41 | }) 42 | 43 | describe('init', () => { 44 | it('sets default watch paths', async () => { 45 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello' }) 46 | await runWatch.init() 47 | expect(runWatch.watchPaths).to.deep.equal([ 48 | "./test/**/*.(js|mjs|py|ts|go|java|rb)", 49 | "./test/serverless.(yml|yaml|json|ts)" 50 | ]) 51 | }) 52 | 53 | it('honors serverless config option', async () => { 54 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello', config: './test/custom.yml' }) 55 | await runWatch.init() 56 | expect(runWatch.watchPaths).to.deep.equal([ 57 | "./test/**/*.(js|mjs|py|ts|go|java|rb)", 58 | "./test/custom.yml" 59 | ]) 60 | }) 61 | 62 | it('takes a watch-glob option', async () => { 63 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello', config: './test/custom.yml', 'watch-glob': './test/**/*.myFile' }) 64 | await runWatch.init() 65 | expect(runWatch.watchPaths).to.deep.equal([ 66 | "./test/**/*.myFile", 67 | "./test/custom.yml" 68 | ]) 69 | }) 70 | 71 | it('sets tail on logs plugin by default', async () => { 72 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello' }) 73 | await runWatch.init() 74 | expect(logsPlugin.options.tail).to.be.true 75 | }) 76 | 77 | it('skips tail when disable-logs is set', async () => { 78 | const cliOptions = { 79 | function: 'hello', 80 | 'disable-logs': true 81 | } 82 | 83 | const runWatch = new ServerlessRunWatch(slsStub, cliOptions) 84 | await runWatch.init() 85 | expect(logsPlugin.options.tail).to.be.undefined 86 | }) 87 | 88 | }) 89 | 90 | describe('serverlessFileChanged', () => { 91 | it('defaults to regex', () => { 92 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello' }) 93 | expect(runWatch.serverlessFileChanged('/some/path/serverless.yml')).to.be.true 94 | }) 95 | 96 | it('honors config', () => { 97 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello', config: 'myfile.yml' }) 98 | expect(runWatch.serverlessFileChanged('myfile.yml')).to.be.true 99 | }) 100 | 101 | it('doesn\'t trigger for swapfiles ', () => { 102 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello' }) 103 | expect(runWatch.serverlessFileChanged('serverless.yml.swp')).to.be.false 104 | }) 105 | }) 106 | 107 | describe('runServerless', () => { 108 | it('passes hello', async () => { 109 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello' }) 110 | await runWatch.init() 111 | runWatch.spawnServerless() 112 | 113 | sinon.assert.calledWith(execFileStub,'serverless', ['deploy', 'function', '--function', 'hello'], {stdio: 'inherit'}) 114 | }) 115 | 116 | it('honors config', async () => { 117 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello', config: 'foo.yml' }) 118 | await runWatch.init() 119 | runWatch.spawnServerless() 120 | 121 | sinon.assert.calledWith(execFileStub,'serverless', ['deploy', 'function', '--function', 'hello', '--config', 'foo.yml'], {stdio: 'inherit'}) 122 | }) 123 | 124 | it('passes stage and region', async () => { 125 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello', stage: 'foobar', region: 'us-west-2' }) 126 | await runWatch.init() 127 | runWatch.spawnServerless() 128 | 129 | sinon.assert.calledWith(execFileStub,'serverless', ['deploy', 'function', '--function', 'hello', '--stage', 'foobar', '--region', 'us-west-2'], {stdio: 'inherit'}) 130 | }) 131 | }) 132 | 133 | describe('tailLogs', () => { 134 | it('tails logs...', async () => { 135 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello' }) 136 | await runWatch.init() 137 | runWatch.tailLogs() 138 | sinon.assert.calledWith(pluginManagerSpawn, 'logs') 139 | }) 140 | }) 141 | 142 | describe('processEvent', () => { 143 | it('calls spawn serverless if serverless changed', async () => { 144 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello' }) 145 | await runWatch.init() 146 | await runWatch.processEvent('./test/serverless.yml') 147 | sinon.assert.calledWith(execFileStub,'serverless', ['deploy', 'function', '--function', 'hello'], {stdio: 'inherit'}) 148 | }) 149 | 150 | it('calls serverless plugin manager\'s spawn if any other files changed', async () => { 151 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello' }) 152 | await runWatch.init() 153 | await runWatch.processEvent('./test/myFunction.js') 154 | sinon.assert.calledWith(pluginManagerSpawn, 'deploy:function') 155 | }) 156 | 157 | it('optionally skips tailing logs', async () => { 158 | const runWatch = new ServerlessRunWatch(slsStub, { function: 'hello', 'disable-logs': true }) 159 | const tailLogsSpy = sinon.spy(runWatch, 'tailLogs') 160 | const delayStub = sinon.stub(runWatch, 'delay').resolves() 161 | await runWatch.init() 162 | await runWatch.processEvent('./test/myFunction.js') 163 | sinon.assert.calledWith(pluginManagerSpawn, 'deploy:function') 164 | }) 165 | }) 166 | }) 167 | --------------------------------------------------------------------------------