├── .github └── workflows │ ├── lint-pr.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .vscode └── settings.json ├── CHANGELOG.md ├── README.md ├── commitlint.config.js ├── jest.config.js ├── package.json ├── src ├── cli.ts ├── commands │ ├── __tests__ │ │ └── commands.test.ts │ ├── index.ts │ ├── list.ts │ ├── logs.ts │ ├── once.ts │ └── watch.ts ├── constructs │ ├── LogsLayerVersion.ts │ ├── NodeModulesLayer.ts │ ├── RealTimeLambdaLogsAPI.ts │ └── WatchableNodejsFunction.ts ├── consts.ts ├── index.ts ├── lambda-extension │ └── cdk-watch-lambda-wrapper │ │ ├── index.ts │ │ └── patchConsole.ts ├── lib │ ├── copyCdkAssetToWatchOutdir.ts │ ├── createCLILoggerForLambda.ts │ ├── filterManifestByPath.ts │ ├── initAwsSdk.ts │ ├── lambdaWatchOutdir.ts │ ├── readManifest.ts │ ├── resolveLambdaNamesFromManifest.ts │ ├── resolveStackNameForLambda.ts │ ├── runSynth.ts │ ├── tailLogsForLambdas │ │ ├── index.ts │ │ ├── logToString.ts │ │ ├── parseCloudwatchLog.ts │ │ ├── resolveLogEndpointDetailsFromLambdas.ts │ │ ├── tailCloudWatchLogsForLambda.ts │ │ └── tailRealTimeLogsForLambdas.ts │ ├── twisters.ts │ ├── updateLambdaFunctionCode.ts │ ├── writeManifest.ts │ └── zipDirectory.ts ├── types.d.ts └── websocketHandlers │ └── index.ts ├── tsconfig.json └── yarn.lock /.github/workflows/lint-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | pull_request_target: 10 | types: 11 | - opened 12 | - edited 13 | - synchronize 14 | 15 | jobs: 16 | main: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: amannn/action-semantic-pull-request@v3.1.0 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: [main, next] 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: checkout 10 | uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 # gives standard-version access to all previous commits 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '12.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - name: get yarn cache directory path 18 | id: yarn-cache-dir-path 19 | run: echo "::set-output name=dir::$(yarn cache dir)" 20 | 21 | - uses: actions/cache@v2 22 | id: yarn-cache 23 | with: 24 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 25 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 26 | restore-keys: | 27 | ${{ runner.os }}-yarn- 28 | 29 | - name: yarn install 30 | run: yarn install --frozen-lockfile 31 | 32 | - name: configure git 33 | run: | 34 | git config user.name ${GITHUB_ACTOR} 35 | git config user.email hello@planes.studio 36 | env: 37 | GITHUB_ACTOR: ${{ secrets.GITHUB_ACTOR }} 38 | 39 | # Only run on next branch 40 | - name: generate tag and release body (next) 41 | if: github.ref == 'refs/heads/next' 42 | run: | 43 | yarn standard-version -i RELEASE_BODY.md --skip.commit --prerelease next 44 | npm publish --tag next 45 | env: 46 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | 48 | # Only run on main branch 49 | - name: generate tag and release body (latest) 50 | if: github.ref == 'refs/heads/main' 51 | run: | 52 | yarn standard-version -i RELEASE_BODY.md --skip.commit 53 | npm publish 54 | env: 55 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 56 | 57 | - name: publish tag 58 | id: publish_tag 59 | run: | 60 | git add package.json 61 | git commit -m "chore: release" -n 62 | git push --follow-tags 63 | echo ::set-output name=tag_name::$(git describe HEAD --abbrev=0) 64 | 65 | - name: create release 66 | uses: actions/create-release@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | release_name: Release ${{ steps.publish_tag.outputs.tag_name }} 71 | tag_name: ${{ steps.publish_tag.outputs.tag_name }} 72 | body_path: RELEASE_BODY.md 73 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | pull_request_target: 5 | types: [synchronize] 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: checkout 11 | uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 # gives standard-version access to all previous commits 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '12.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - name: get yarn cache directory path 19 | id: yarn-cache-dir-path 20 | run: echo "::set-output name=dir::$(yarn cache dir)" 21 | 22 | - uses: actions/cache@v2 23 | id: yarn-cache 24 | with: 25 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - name: yarn install 31 | run: yarn install --frozen-lockfile 32 | 33 | - name: yarn lint 34 | run: yarn lint 35 | 36 | - name: yarn type-check 37 | run: yarn type-check 38 | 39 | - name: yarn test 40 | run: yarn test 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | package-lock.json 4 | lib 5 | !src/lib 6 | cdk-watch-logs-extension/**/*.js 7 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "OUTDIR", 4 | "cdkw", 5 | "esbuild" 6 | ] 7 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.0.12](https://github.com/teamplanes/cdk-watch/compare/v0.0.11...v0.0.12) (2021-02-11) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * set node target version on esbuild ([8b48a74](https://github.com/teamplanes/cdk-watch/commit/8b48a7499aa65466f25dea129f0a27e355a142f3)) 11 | 12 | ### [0.0.11](https://github.com/teamplanes/cdk-watch/compare/v0.0.10...v0.0.11) (2021-02-11) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * only print errors ([e564a01](https://github.com/teamplanes/cdk-watch/commit/e564a014efdee9bfa758b5ed6bc582d5d4aa98d0)) 18 | 19 | ### [0.0.10](https://github.com/teamplanes/cdk-watch/compare/v0.0.9...v0.0.10) (2021-02-11) 20 | 21 | 22 | ### Bug Fixes 23 | 24 | * force minification and treeShaking ([0d45e4b](https://github.com/teamplanes/cdk-watch/commit/0d45e4b2bc927d87ec5853b79f1594ad22d533ed)) 25 | 26 | ### [0.0.9](https://github.com/teamplanes/cdk-watch/compare/v0.0.8...v0.0.9) (2021-02-11) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * include bundling.nodeModules in esbuild externals ([9bbee6e](https://github.com/teamplanes/cdk-watch/commit/9bbee6e563bce00bf3dc874d1e7d9967e8196821)) 32 | 33 | ### [0.0.8](https://github.com/teamplanes/cdk-watch/compare/v0.0.7...v0.0.8) (2021-02-11) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * remove log ([1cc785d](https://github.com/teamplanes/cdk-watch/commit/1cc785dc3a20da6f461d4e618febfc831196c887)) 39 | * tsconfig should use absolute resolved path ([876b5e8](https://github.com/teamplanes/cdk-watch/commit/876b5e8095659a6776d2ed392fc420f3360c422d)) 40 | 41 | ### [0.0.7](https://github.com/teamplanes/cdk-watch/compare/v0.0.6...v0.0.7) (2021-02-11) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * attempt copy with dereference as a fix for "Cannot copy to a subdirectory of itself" ([3282ca8](https://github.com/teamplanes/cdk-watch/commit/3282ca82801146d8c4ad54e2032e9bfe1bf43bc0)) 47 | 48 | ### [0.0.6](https://github.com/teamplanes/cdk-watch/compare/v0.0.5...v0.0.6) (2021-02-11) 49 | 50 | 51 | ### Features 52 | 53 | * automatically infer the tsconfig path if none provided ([4cd3920](https://github.com/teamplanes/cdk-watch/commit/4cd39204a199a6c4bbef4d2c0b27da889cbc8ae4)) 54 | 55 | ### [0.0.5](https://github.com/teamplanes/cdk-watch/compare/v0.0.4...v0.0.5) (2021-02-11) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * clean out manifest before synthesizing again ([a193ac5](https://github.com/teamplanes/cdk-watch/commit/a193ac57f3f095f66612650300758aa588404e01)) 61 | 62 | ### [0.0.4](https://github.com/teamplanes/cdk-watch/compare/v0.0.3...v0.0.4) (2021-02-11) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * include props in main export and index.ts ([87223ae](https://github.com/teamplanes/cdk-watch/commit/87223ae004c191827e518c352c977325529d7dd2)) 68 | 69 | ### [0.0.3](https://github.com/teamplanes/cdk-watch/compare/v0.0.1...v0.0.3) (2021-02-11) 70 | 71 | 72 | ### Features 73 | 74 | * include props in main export ([0291c41](https://github.com/teamplanes/cdk-watch/commit/0291c41fb606f3814fcd51436ff1dea97406a953)) 75 | 76 | ### [0.0.2](https://github.com/teamplanes/cdk-watch/compare/v0.0.1...v0.0.2) (2021-02-11) 77 | 78 | ### 0.0.1 (2021-02-11) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * commitlint and husky ([1ee9c12](https://github.com/teamplanes/cdk-watch/commit/1ee9c12e3dc75c27c061eca7947031b516863bc6)) 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `cdk-watch` 👀 2 | 3 | > Run your CDK Stack's Lambda functions as if they were in a development environment. 4 | 5 | - As simple as `cdkw "MyApp/MyApi/**"` 6 | - Your code will be watched and built with the same esbuild config as when deploying your stack 7 | - Simply switch-out your existing `NodejsFunction` with the `WatchableNodejsFunction` construct 8 | - Opt-in to real-time logs, so no more digging through CloudWatch to find your Lambda's logs 9 | - Load your node modules as a separate lambda layer (allows for faster build/upload times) 10 | - Written in TypeScript 11 | - No extra infrastructure required, unless you are opting-in to real-time logs 12 | 13 | --- 14 | 15 |
16 | 17 |
18 | 19 | --- 20 | 21 | ## Getting Started 22 | 23 | #### 1. CDK Updates 24 | 25 | Simply switch to use the `WatchableNodejsFunction` rather than the `NodejsFunction`. 26 | ```patch 27 | - import {NodejsFunction} from '@aws-cdk/aws-lambda-nodejs'; 28 | + import {WatchableNodejsFunction} from 'cdk-watch'; 29 | 30 | // ... 31 | 32 | - const lambda = new NodejsFunction(this, 'Lambda', { 33 | + const lambda = new WatchableNodejsFunction(this, 'Lambda', { 34 | entry: path.resolve(__dirname, '../src/my-lambda.ts'), 35 | handler: 'handler', 36 | }); 37 | ``` 38 | 39 | #### 2. Run Your Stack 40 | 41 | ```sh 42 | # Run all Lambda functions 43 | $ yarn cdkw "**" 44 | 45 | # Run just your API Lambdas, for example 46 | $ yarn cdkw "MyStack/API/**" 47 | 48 | # Run without logs 49 | $ yarn cdkw "MyStack/API/**" --no-logs 50 | 51 | # Pass context to synth 52 | $ yarn cdkw "MyStack/API/**" -c foo=bar -c hello=world 53 | 54 | # If you are using npm 55 | $ npm run cdkw "**" 56 | ``` 57 | 58 | *Skip to the [command reference](#command-reference).* 59 | 60 | --- 61 | 62 | ## Real-time Logs 63 | 64 | `cdk-watch` provides real-time logs over web-sockets to make the development 65 | feedback-loop faster when debugging your lambdas. This is an additional feature 66 | that requires opt-in, and you have two options for achieving this. 67 | 1. **Turn on for all Lambdas:** To turn on real-time logging by default for all 68 | watchable lambdas in your stack you can set the context variable 69 | `cdk-watch:forceRealTimeLoggingEnabled` to `true`. 70 | 2. To set an individual Lambda to support real-time logging you can pass a prop 71 | to the `WatchableNodejsFunction`: `watchOptions.realTimeLoggingEnabled=true`. 72 | 73 | ### How does real-time logging work? 74 | 75 | When deploying your stack the `WatchableNodejsFunction` will include the 76 | necessary infrastructure to support WebSockets via API Gateway. It'll also 77 | assign a Lambda Layer Extension to wrap your lambda, the wrapper will patch the 78 | `console` and forward all logs to all API Gateway connections. If you have 79 | multiple lambdas in your stack it'll only create the require infrastructure 80 | once, and reuse it for all lambdas that need it. 81 | 82 | ## Node Modules Layer 83 | 84 | CDK-Watch allows you to install your node-modules as a stand alone layer. This 85 | means that when you deploy, `cdk-watch` will install your modules in a separate 86 | asset and install them as the lambda's layer. This is great for dev-performance 87 | as the upload bundle will be much smaller. You can configure this using the 88 | `bundling.nodeModulesLayer` property: 89 | 90 | ```ts 91 | bundling: { 92 | // Install only "knex" as a standalone layer 93 | nodeModulesLayer: {include: ['knex']} 94 | } 95 | ``` 96 | 97 | OR: 98 | 99 | ```ts 100 | bundling: { 101 | // Install every module found in your package.json except "knex" 102 | nodeModulesLayer: {exclude: ['knex']} 103 | } 104 | ``` 105 | 106 | ## How, what & why? 107 | 108 | ### Why would you want to do this? 109 | 110 | - Deploying via CDK is slow, it takes 1 minute to release an update to a 111 | zero-dependency, 1 line Lambda function 112 | - AWS provides a tonne of great services, therefore running your code and CDK 113 | Stack on AWS when developing makes sense rather than attempting to fully 114 | replicate the environment locally, or missing out on using some of it's services 115 | - Provided you keep your Lambda's small, an update via `cdk-watch` will take a 116 | couple of seconds 117 | 118 | ### How does it work? 119 | 120 | 1. `cdkw` will run `cdk synth` 121 | 2. At synthesis the `WatchableNodejsFunction` construct will write a manifest 122 | file including Lambda Logical IDs, their Stack names and their esbuild config 123 | 3. The CLI will now read the manifest and make API calls to fetch the Physical 124 | IDs of the lambdas 125 | 4. The CLI will start polling the logs of each Lambda 126 | 5. The CLI will now start esbuild in `watch` mode for each Lambda 127 | 6. When the code successfully rebuilds it Zips and uploads the output to Lambda 128 | directly by calling `updateFunctionCode` 129 | 130 | ### How is this different from [Serverless-Stack](https://github.com/serverless-stack/serverless-stack)? 131 | 132 | SST runs your Lambda functions locally by mocking the deployed lambda with your 133 | local environment. It comes with a number of performance benefits due to the 134 | fact that it doesn't need to upload your function code every time it change. 135 | That said, `cdk-watch` does not come with a slow feedback loop, you can expect 136 | 1s-5s from the moment you save a file to the moment its available on AWS. The 137 | upside of `cdk-watch` is we deploy no extra infrastructure to facilitate the 138 | development environment so there is less discrepancy between prod and pre-prod 139 | environments. 140 | 141 | --- 142 | 143 | ## Command Reference 144 | 145 | For more info on each command add a `--help` flag after any one of them. 146 | 147 | - **Watch:** `cdkw "**"` watch your Lambda where the CDK construct path matches 148 | the glob 149 | - **List:** `cdkw ls "**"` lists all your available `WatchableNodejsFunction` 150 | Constructs, a convenience command to help you get find paths you need 151 | - **Once:** `cdkw once "**"` updates your lambda's once, rather than in watch 152 | - **Logs:** `cdkw logs "**"` just tails logs for each of the matching lambdas 153 | 154 | --- 155 | 156 | ✈️ / [planes.studio](https://planes.studio) 157 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']}; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk-watch", 3 | "main": "lib/index.js", 4 | "files": [ 5 | "lib", 6 | "package.json" 7 | ], 8 | "bin": { 9 | "cdkw": "lib/cli.js" 10 | }, 11 | "version": "0.0.25-next.3", 12 | "scripts": { 13 | "type-check": "tsc -p tsconfig.json --noEmit", 14 | "build": "tsup src/index.ts src/cli.ts src/index.ts src/websocketHandlers/index.ts src/lambda-extension/cdk-watch-lambda-wrapper/index.ts --no-splitting -d lib --clean --dts=src/index.ts", 15 | "watch": "yarn build --watch", 16 | "lint": "eslint ./src --ext=.ts", 17 | "try": "node -r ts-node/register src/cli.ts", 18 | "postinstall": "husky install", 19 | "prepublishOnly": "yarn build && pinst --disable", 20 | "postpublish": "pinst --enable", 21 | "release": "standard-version", 22 | "test": "jest" 23 | }, 24 | "author": { 25 | "name": "Henry Kirkness", 26 | "email": "henry@planes.studio", 27 | "url": "https://planes.studio" 28 | }, 29 | "license": "MIT", 30 | "dependencies": { 31 | "archiver": "^5.2.0", 32 | "aws-sdk": "^2.840.0", 33 | "aws4": "^1.11.0", 34 | "chalk": "^4.1.0", 35 | "cli-truncate": "^2.1.0", 36 | "commander": "^7.0.0", 37 | "execa": "^5.0.0", 38 | "find-up": "^5.0.0", 39 | "fs-extra": "^9.1.0", 40 | "json5": "^2.2.0", 41 | "minimatch": "^3.0.4", 42 | "object-hash": "^2.1.1", 43 | "reconnecting-websocket": "^4.4.0", 44 | "stream-buffers": "^3.0.2", 45 | "twisters": "^1.1.0", 46 | "ws": "^7.4.4" 47 | }, 48 | "peerDependencies": { 49 | "@aws-cdk/aws-apigatewayv2": "^1.100.0", 50 | "@aws-cdk/aws-dynamodb": "^1.100.0", 51 | "@aws-cdk/aws-iam": "^1.100.0", 52 | "@aws-cdk/aws-lambda": "^1.100.0", 53 | "@aws-cdk/aws-lambda-nodejs": "^1.100.0", 54 | "@aws-cdk/aws-logs": "^1.100.0", 55 | "@aws-cdk/aws-s3-assets": "^1.100.0", 56 | "@aws-cdk/core": "^1.100.0", 57 | "@types/node": "^14.14.25", 58 | "esbuild": "^0.8.43" 59 | }, 60 | "devDependencies": { 61 | "@aws-cdk/aws-apigatewayv2": "^1.100.0", 62 | "@aws-cdk/aws-dynamodb": "^1.100.0", 63 | "@aws-cdk/aws-iam": "^1.100.0", 64 | "@aws-cdk/aws-lambda": "^1.100.0", 65 | "@aws-cdk/aws-lambda-nodejs": "^1.100.0", 66 | "@aws-cdk/aws-logs": "^1.100.0", 67 | "@aws-cdk/aws-s3-assets": "^1.100.0", 68 | "@aws-cdk/core": "^1.100.0", 69 | "@commitlint/cli": "^11.0.0", 70 | "@commitlint/config-conventional": "^11.0.0", 71 | "@types/archiver": "^5.1.0", 72 | "@types/aws-lambda": "^8.10.72", 73 | "@types/aws4": "^1.5.1", 74 | "@types/fs-extra": "^9.0.6", 75 | "@types/jest": "^26.0.20", 76 | "@types/minimatch": "^3.0.3", 77 | "@types/node": "^14.14.25", 78 | "@types/object-hash": "^2.1.0", 79 | "@types/stream-buffers": "^3.0.3", 80 | "@types/ws": "^7.4.0", 81 | "esbuild": "^0.8.43", 82 | "eslint": "7.2.0", 83 | "eslint-config-planes": "1.3.0", 84 | "husky": "^5.0.9", 85 | "jest": "^26.6.3", 86 | "pinst": "^2.1.4", 87 | "prettier-config-planes": "^1.0.1", 88 | "standard-version": "^9.1.0", 89 | "ts-jest": "^26.5.1", 90 | "ts-node": "^9.1.1", 91 | "tsup": "^4.8.19", 92 | "typescript": "~4.0.0" 93 | }, 94 | "prettier": "prettier-config-planes", 95 | "eslintConfig": { 96 | "extends": "planes/node" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as fs from 'fs-extra'; 3 | import * as path from 'path'; 4 | import {CdkWatchCommand} from './commands'; 5 | 6 | // NOTE: When this entry is built it's bundled into `/lib/cli.js`. So this is 7 | // relative to that path. 8 | const {version} = fs.readJSONSync(path.resolve(__dirname, '../package.json')); 9 | 10 | const program = new CdkWatchCommand(version); 11 | 12 | program.parseAsync(process.argv).catch((e) => { 13 | // eslint-disable-next-line no-console 14 | console.log(e); 15 | process.exit(1); 16 | }); 17 | -------------------------------------------------------------------------------- /src/commands/__tests__/commands.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first */ 2 | jest.mock('../list'); 3 | jest.mock('../logs'); 4 | jest.mock('../once'); 5 | jest.mock('../watch'); 6 | import {list} from '../list'; 7 | import {logs} from '../logs'; 8 | import {once} from '../once'; 9 | import {watch} from '../watch'; 10 | import {CdkWatchCommand} from '..'; 11 | 12 | const buildArgv = (cmd: string) => ['/path/to/node', 'cdkw', ...cmd.split(' ')]; 13 | 14 | describe('CLI commands', () => { 15 | beforeEach(() => { 16 | jest.resetAllMocks(); 17 | }); 18 | 19 | test('program runs watch as the default command', async () => { 20 | const program = new CdkWatchCommand('1'); 21 | await program.parseAsync(buildArgv('**')); 22 | expect(watch).toBeCalledWith( 23 | '**', 24 | expect.objectContaining({logs: true}), 25 | expect.anything(), 26 | ); 27 | expect(list).toBeCalledTimes(0); 28 | expect(logs).toBeCalledTimes(0); 29 | expect(once).toBeCalledTimes(0); 30 | }); 31 | 32 | test('program runs watch when command name is provided', async () => { 33 | const program = new CdkWatchCommand('1'); 34 | await program.parseAsync(buildArgv('watch My/Path')); 35 | expect(watch).toBeCalledWith( 36 | 'My/Path', 37 | expect.objectContaining({logs: true}), 38 | expect.anything(), 39 | ); 40 | expect(list).toBeCalledTimes(0); 41 | expect(logs).toBeCalledTimes(0); 42 | expect(once).toBeCalledTimes(0); 43 | }); 44 | 45 | test('program passes skip-initial boolean to watch function', async () => { 46 | const program = new CdkWatchCommand('1'); 47 | await program.parseAsync(buildArgv('watch My/Path --skip-initial')); 48 | expect(watch).toBeCalledWith( 49 | 'My/Path', 50 | expect.objectContaining({skipInitial: true}), 51 | expect.anything(), 52 | ); 53 | expect(list).toBeCalledTimes(0); 54 | expect(logs).toBeCalledTimes(0); 55 | expect(once).toBeCalledTimes(0); 56 | }); 57 | 58 | test('program passes skip-initial as false to watch function when not provided', async () => { 59 | const program = new CdkWatchCommand('1'); 60 | await program.parseAsync(buildArgv('watch My/Path')); 61 | expect(watch).toBeCalledWith( 62 | 'My/Path', 63 | expect.objectContaining({skipInitial: false}), 64 | expect.anything(), 65 | ); 66 | expect(list).toBeCalledTimes(0); 67 | expect(logs).toBeCalledTimes(0); 68 | expect(once).toBeCalledTimes(0); 69 | }); 70 | 71 | const otherCommands = {list, logs, once}; 72 | test.each` 73 | command 74 | ${'list'} 75 | ${'logs'} 76 | ${'once'} 77 | `( 78 | 'command runs correct function', 79 | async ({command}: {command: keyof typeof otherCommands}) => { 80 | const program = new CdkWatchCommand('1'); 81 | await program.parseAsync(buildArgv(`${command} My/Path`)); 82 | expect(otherCommands[command]).toBeCalledWith( 83 | 'My/Path', 84 | expect.anything(), 85 | expect.anything(), 86 | ); 87 | }, 88 | ); 89 | 90 | test.each` 91 | flag | expected 92 | ${''} | ${true} 93 | ${'--no-logs'} | ${false} 94 | `( 95 | 'logs are on by default but can be turned off', 96 | async ({flag, expected}) => { 97 | const program = new CdkWatchCommand('1'); 98 | await program.parseAsync(buildArgv(`watch My/Path ${flag}`)); 99 | expect(watch).toBeCalledWith( 100 | 'My/Path', 101 | expect.objectContaining({logs: expected}), 102 | expect.anything(), 103 | ); 104 | }, 105 | ); 106 | }); 107 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import {Command, Option} from 'commander'; 2 | import {list} from './list'; 3 | import {logs} from './logs'; 4 | import {once} from './once'; 5 | import {watch} from './watch'; 6 | 7 | class CdkWatchCommand extends Command { 8 | constructor(version: string) { 9 | super(); 10 | 11 | this.version(version); 12 | 13 | const profileOption = new Option( 14 | '-p, --profile ', 15 | 'pass the name of the AWS profile that you want to use', 16 | ); 17 | const logsOption = new Option( 18 | '--no-logs', 19 | "don't subscribe to CloudWatch logs for each of your lambdas", 20 | ); 21 | const forceCloudwatchLogsOption = new Option( 22 | '--force-cloudwatch', 23 | 'force polling cloudwatch streams rather than using real-time logs', 24 | ); 25 | const cdkContextOption = new Option( 26 | '-c, --context ', 27 | 'pass context to the cdk synth command', 28 | ); 29 | const cdkAppOption = new Option( 30 | '-a, --app ', 31 | 'pass the --app option to the underlying synth command', 32 | ); 33 | const skipInitialOption = new Option( 34 | '--skip-initial', 35 | 'prevent cdk from uploading the function code until a file has changed', 36 | ).default(false); 37 | 38 | this.command('watch', {isDefault: true}) 39 | .arguments('') 40 | .description( 41 | 'for each lambda matched by the path glob, watch the source-code and redeploy on change', 42 | ) 43 | .addHelpText( 44 | 'after', 45 | `\nExample: 46 | $ cdkw "**" 47 | $ cdkw "MyStack/API/**" 48 | $ cdkw "**" --profile=planes --no-logs\n`, 49 | ) 50 | .addOption(cdkContextOption) 51 | .addOption(skipInitialOption) 52 | .addOption(profileOption) 53 | .addOption(cdkAppOption) 54 | .addOption(logsOption) 55 | .addOption(forceCloudwatchLogsOption) 56 | .action(watch); 57 | 58 | this.command('logs') 59 | .arguments('') 60 | .description( 61 | 'for each lambda matched by the path glob, poll the associated log groups', 62 | ) 63 | .addOption(cdkContextOption) 64 | .addOption(profileOption) 65 | .addOption(forceCloudwatchLogsOption) 66 | .action(logs); 67 | 68 | this.command('once') 69 | .arguments('') 70 | .description( 71 | 'for each lambda matched by the path glob, build and deploy the source code once', 72 | ) 73 | .addOption(cdkContextOption) 74 | .addOption(profileOption) 75 | .action(once); 76 | 77 | this.command('list') 78 | .alias('ls') 79 | .arguments('') 80 | .description('list all lambdas matching the path glob') 81 | .addOption(cdkContextOption) 82 | .addOption(profileOption) 83 | .action(list); 84 | } 85 | } 86 | 87 | export {CdkWatchCommand}; 88 | -------------------------------------------------------------------------------- /src/commands/list.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import OS from 'os'; 3 | import {filterManifestByPath} from '../lib/filterManifestByPath'; 4 | import {readManifest} from '../lib/readManifest'; 5 | import {runSynth} from '../lib/runSynth'; 6 | 7 | export const list = async ( 8 | pathGlob: string | undefined, 9 | options: { 10 | context: string[]; 11 | app: string; 12 | profile: string; 13 | }, 14 | ): Promise => { 15 | await runSynth({ 16 | context: options.context || [], 17 | app: options.app, 18 | profile: options.profile, 19 | }); 20 | 21 | const manifest = readManifest(); 22 | if (!manifest) throw new Error('cdk-watch manifest file was not found'); 23 | const filteredManifest = pathGlob 24 | ? filterManifestByPath(pathGlob, manifest) 25 | : manifest; 26 | // eslint-disable-next-line no-console 27 | console.log( 28 | Object.keys(filteredManifest.lambdas) 29 | .map((key) => `- ${chalk.blue(key)}`) 30 | .join(OS.EOL), 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/commands/logs.ts: -------------------------------------------------------------------------------- 1 | import {filterManifestByPath} from '../lib/filterManifestByPath'; 2 | import {initAwsSdk} from '../lib/initAwsSdk'; 3 | import {readManifest} from '../lib/readManifest'; 4 | import {resolveLambdaNamesFromManifest} from '../lib/resolveLambdaNamesFromManifest'; 5 | import {runSynth} from '../lib/runSynth'; 6 | import {tailLogsForLambdas} from '../lib/tailLogsForLambdas'; 7 | 8 | export const logs = async ( 9 | pathGlob: string, 10 | options: { 11 | context: string[]; 12 | app: string; 13 | profile: string; 14 | forceCloudwatch: boolean; 15 | }, 16 | ): Promise => { 17 | await runSynth({ 18 | context: options.context || [], 19 | app: options.app, 20 | profile: options.profile, 21 | }); 22 | 23 | const manifest = readManifest(); 24 | if (!manifest) throw new Error('cdk-watch manifest file was not found'); 25 | initAwsSdk(manifest.region, options.profile); 26 | const filteredManifest = filterManifestByPath(pathGlob, manifest); 27 | 28 | const lambdaFunctions = await resolveLambdaNamesFromManifest( 29 | filteredManifest, 30 | ); 31 | await tailLogsForLambdas(lambdaFunctions, options.forceCloudwatch); 32 | }; 33 | -------------------------------------------------------------------------------- /src/commands/once.ts: -------------------------------------------------------------------------------- 1 | import {filterManifestByPath} from '../lib/filterManifestByPath'; 2 | import {initAwsSdk} from '../lib/initAwsSdk'; 3 | import {readManifest} from '../lib/readManifest'; 4 | import {resolveLambdaNamesFromManifest} from '../lib/resolveLambdaNamesFromManifest'; 5 | import {runSynth} from '../lib/runSynth'; 6 | import {updateLambdaFunctionCode} from '../lib/updateLambdaFunctionCode'; 7 | import {createCLILoggerForLambda} from '../lib/createCLILoggerForLambda'; 8 | import {twisters} from '../lib/twisters'; 9 | 10 | export const once = async ( 11 | pathGlob: string, 12 | options: { 13 | context: string[]; 14 | app: string; 15 | profile: string; 16 | logs: boolean; 17 | }, 18 | ): Promise => { 19 | await runSynth({ 20 | context: options.context || [], 21 | app: options.app, 22 | profile: options.profile, 23 | }); 24 | 25 | const manifest = readManifest(); 26 | if (!manifest) throw new Error('cdk-watch manifest file was not found'); 27 | initAwsSdk(manifest.region, options.profile); 28 | const filteredManifest = filterManifestByPath(pathGlob, manifest); 29 | 30 | const lambdaProgressText = 'resolving lambda configuration'; 31 | twisters.put('lambda', {text: lambdaProgressText}); 32 | resolveLambdaNamesFromManifest(filteredManifest) 33 | .then((result) => { 34 | twisters.put('lambda', { 35 | text: lambdaProgressText, 36 | active: false, 37 | }); 38 | return result; 39 | }) 40 | .then((lambdaDetails) => 41 | Promise.all( 42 | lambdaDetails.map( 43 | async ({functionName, lambdaCdkPath, lambdaManifest}) => { 44 | const {prefix} = createCLILoggerForLambda(lambdaCdkPath); 45 | const lambdaUploadText = 'uploading lambda function code'; 46 | twisters.put(lambdaCdkPath, { 47 | meta: {prefix}, 48 | text: lambdaUploadText, 49 | }); 50 | return updateLambdaFunctionCode( 51 | lambdaManifest.assetPath, 52 | functionName, 53 | ).then(() => 54 | twisters.put(lambdaCdkPath, { 55 | meta: {prefix}, 56 | active: false, 57 | text: lambdaUploadText, 58 | }), 59 | ); 60 | }, 61 | ), 62 | ), 63 | ) 64 | .catch((e) => { 65 | // eslint-disable-next-line no-console 66 | console.error(e); 67 | process.exit(1); 68 | }); 69 | }; 70 | -------------------------------------------------------------------------------- /src/commands/watch.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as esbuild from 'esbuild'; 3 | import chalk from 'chalk'; 4 | import {copyCdkAssetToWatchOutdir} from '../lib/copyCdkAssetToWatchOutdir'; 5 | import {filterManifestByPath} from '../lib/filterManifestByPath'; 6 | import {initAwsSdk} from '../lib/initAwsSdk'; 7 | import {readManifest} from '../lib/readManifest'; 8 | import {resolveLambdaNamesFromManifest} from '../lib/resolveLambdaNamesFromManifest'; 9 | import {runSynth} from '../lib/runSynth'; 10 | import {updateLambdaFunctionCode} from '../lib/updateLambdaFunctionCode'; 11 | import {createCLILoggerForLambda} from '../lib/createCLILoggerForLambda'; 12 | import {twisters} from '../lib/twisters'; 13 | import {tailLogsForLambdas} from '../lib/tailLogsForLambdas'; 14 | 15 | export const watch = async ( 16 | pathGlob: string, 17 | options: { 18 | context: string[]; 19 | app: string; 20 | profile: string; 21 | logs: boolean; 22 | skipInitial: boolean; 23 | forceCloudwatch?: boolean; 24 | }, 25 | ): Promise => { 26 | await runSynth({ 27 | context: options.context || [], 28 | app: options.app, 29 | profile: options.profile, 30 | }); 31 | 32 | const manifest = readManifest(); 33 | if (!manifest) throw new Error('cdk-watch manifest file was not found'); 34 | initAwsSdk(manifest.region, options.profile); 35 | const filteredManifest = filterManifestByPath(pathGlob, manifest); 36 | 37 | const lambdaProgressText = 'resolving lambda configuration'; 38 | twisters.put('lambda', {text: lambdaProgressText}); 39 | resolveLambdaNamesFromManifest(filteredManifest) 40 | .then((result) => { 41 | twisters.put('lambda', { 42 | text: lambdaProgressText, 43 | active: false, 44 | }); 45 | return result; 46 | }) 47 | .then(async (lambdaDetails) => { 48 | if (options.logs) { 49 | await tailLogsForLambdas(lambdaDetails, options.forceCloudwatch); 50 | } 51 | return Promise.all( 52 | lambdaDetails.map( 53 | async ({functionName, lambdaCdkPath, layers, lambdaManifest}) => { 54 | if ( 55 | lambdaManifest.nodeModulesLayerVersion && 56 | !layers.includes(lambdaManifest.nodeModulesLayerVersion) 57 | ) { 58 | // eslint-disable-next-line no-console 59 | console.warn( 60 | chalk.yellow( 61 | '[Warning]: Function modules layer is out of sync with published layer version, this can lead to runtime errors. To fix, do a full `cdk deploy`.', 62 | ), 63 | ); 64 | } 65 | 66 | const logger = createCLILoggerForLambda( 67 | lambdaCdkPath, 68 | lambdaDetails.length > 1, 69 | ); 70 | const watchOutdir = copyCdkAssetToWatchOutdir(lambdaManifest); 71 | 72 | const updateFunction = () => { 73 | const uploadingProgressText = 'uploading function code'; 74 | twisters.put(`${lambdaCdkPath}:uploading`, { 75 | meta: {prefix: logger.prefix}, 76 | text: uploadingProgressText, 77 | }); 78 | 79 | return updateLambdaFunctionCode(watchOutdir, functionName) 80 | .then(() => { 81 | twisters.put(`${lambdaCdkPath}:uploading`, { 82 | meta: {prefix: logger.prefix}, 83 | text: uploadingProgressText, 84 | active: false, 85 | }); 86 | }) 87 | .catch((e) => { 88 | twisters.put(`${lambdaCdkPath}:uploading`, { 89 | text: uploadingProgressText, 90 | meta: {error: e}, 91 | active: false, 92 | }); 93 | }); 94 | }; 95 | 96 | if (!options.skipInitial) { 97 | await updateFunction(); 98 | } 99 | 100 | logger.log('waiting for changes'); 101 | esbuild 102 | .build({ 103 | ...lambdaManifest.esbuildOptions, 104 | outfile: path.join(watchOutdir, 'index.js'), 105 | // Unless explicitly told not to, turn on treeShaking and minify to 106 | // improve upload times 107 | treeShaking: lambdaManifest.esbuildOptions.treeShaking ?? true, 108 | minify: lambdaManifest.esbuildOptions.minify ?? true, 109 | // Keep the console clean from build warnings, only print errors 110 | logLevel: lambdaManifest.esbuildOptions.logLevel ?? 'error', 111 | watch: { 112 | onRebuild: (error) => { 113 | if (error) { 114 | logger.error( 115 | `failed to rebuild lambda function code ${error.toString()}`, 116 | ); 117 | } else { 118 | updateFunction(); 119 | } 120 | }, 121 | }, 122 | }) 123 | .catch((e: Error) => { 124 | logger.error(`error building lambda: ${e.toString()}`); 125 | }); 126 | }, 127 | ), 128 | ); 129 | }) 130 | .catch((e) => { 131 | // eslint-disable-next-line no-console 132 | console.error(e); 133 | process.exit(1); 134 | }); 135 | }; 136 | -------------------------------------------------------------------------------- /src/constructs/LogsLayerVersion.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import {Code, LayerVersion} from '@aws-cdk/aws-lambda'; 3 | import {Construct, RemovalPolicy} from '@aws-cdk/core'; 4 | 5 | export class LogsLayerVersion extends LayerVersion { 6 | constructor(scope: Construct, id: string) { 7 | super(scope, id, { 8 | removalPolicy: RemovalPolicy.DESTROY, 9 | description: 10 | 'Catches Lambda Logs and sends them to API Gateway Connections', 11 | // NOTE: This file will be bundled into /lib/index.js, so this path must be relative to that 12 | code: Code.fromAsset(path.join(__dirname, 'lambda-extension')), 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/constructs/NodeModulesLayer.ts: -------------------------------------------------------------------------------- 1 | import {Code, LayerVersion} from '@aws-cdk/aws-lambda'; 2 | import { 3 | extractDependencies, 4 | findUp, 5 | LockFile, 6 | } from '@aws-cdk/aws-lambda-nodejs/lib/util'; 7 | import {Construct, RemovalPolicy} from '@aws-cdk/core'; 8 | import execa from 'execa'; 9 | import * as fs from 'fs-extra'; 10 | import * as path from 'path'; 11 | import objectHash from 'object-hash'; 12 | import {CDK_WATCH_OUTDIR} from '../consts'; 13 | 14 | interface NodeModulesLayerProps { 15 | depsLockFilePath?: string; 16 | pkgPath: string; 17 | nodeModules: string[]; 18 | skip?: boolean; 19 | } 20 | 21 | enum Installer { 22 | NPM = 'npm', 23 | YARN = 'yarn', 24 | } 25 | 26 | /** 27 | * Copied from cdk source: 28 | * https://github.com/aws/aws-cdk/blob/ca42461acd4f42a8bd7c0fb05788c7ea50834de2/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts#L88-L103 29 | */ 30 | const getDepsLock = (propsDepsLockFilePath?: string) => { 31 | let depsLockFilePath: string; 32 | if (propsDepsLockFilePath) { 33 | if (!fs.existsSync(propsDepsLockFilePath)) { 34 | throw new Error(`Lock file at ${propsDepsLockFilePath} doesn't exist`); 35 | } 36 | if (!fs.statSync(propsDepsLockFilePath).isFile()) { 37 | throw new Error('`depsLockFilePath` should point to a file'); 38 | } 39 | depsLockFilePath = path.resolve(propsDepsLockFilePath); 40 | } else { 41 | const lockFile = findUp(LockFile.YARN) ?? findUp(LockFile.NPM); 42 | if (!lockFile) { 43 | throw new Error( 44 | 'Cannot find a package lock file (`yarn.lock` or `package-lock.json`). Please specify it with `depsFileLockPath`.', 45 | ); 46 | } 47 | depsLockFilePath = lockFile; 48 | } 49 | 50 | return depsLockFilePath; 51 | }; 52 | 53 | export class NodeModulesLayer extends LayerVersion { 54 | public readonly layerVersion: string; 55 | 56 | constructor(scope: Construct, id: string, props: NodeModulesLayerProps) { 57 | const depsLockFilePath = getDepsLock(props.depsLockFilePath); 58 | 59 | const {pkgPath} = props; 60 | 61 | // Determine dependencies versions, lock file and installer 62 | const dependenciesPackageJson = { 63 | dependencies: extractDependencies(pkgPath, props.nodeModules), 64 | }; 65 | let installer = Installer.NPM; 66 | let lockFile = LockFile.NPM; 67 | if (depsLockFilePath.endsWith(LockFile.YARN)) { 68 | lockFile = LockFile.YARN; 69 | installer = Installer.YARN; 70 | } 71 | 72 | const layerBase = path.join( 73 | process.cwd(), 74 | 'cdk.out', 75 | CDK_WATCH_OUTDIR, 76 | 'node-module-layers', 77 | scope.node.addr, 78 | ); 79 | const outputDir = path.join(layerBase, 'nodejs'); 80 | 81 | fs.ensureDirSync(outputDir); 82 | fs.copyFileSync(depsLockFilePath, path.join(outputDir, lockFile)); 83 | fs.writeJsonSync( 84 | path.join(outputDir, 'package.json'), 85 | dependenciesPackageJson, 86 | ); 87 | const layerVersion = objectHash(dependenciesPackageJson); 88 | 89 | if (!props.skip) { 90 | // eslint-disable-next-line no-console 91 | console.log('Installing node_modules in layer'); 92 | execa.sync(installer, ['install'], { 93 | cwd: outputDir, 94 | stderr: 'inherit', 95 | stdout: 'ignore', 96 | stdin: 'ignore', 97 | }); 98 | } 99 | 100 | super(scope, id, { 101 | removalPolicy: RemovalPolicy.DESTROY, 102 | description: 'NodeJS Modules Packaged into a Layer by cdk-watch', 103 | code: Code.fromAsset(layerBase), 104 | layerVersionName: layerVersion, 105 | }); 106 | 107 | this.layerVersion = layerVersion; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/constructs/RealTimeLambdaLogsAPI.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-new */ 2 | import * as path from 'path'; 3 | import * as cdk from '@aws-cdk/core'; 4 | import * as dynamodb from '@aws-cdk/aws-dynamodb'; 5 | import * as apigwv2 from '@aws-cdk/aws-apigatewayv2'; 6 | import * as lambda from '@aws-cdk/aws-lambda'; 7 | import * as logs from '@aws-cdk/aws-logs'; 8 | import * as iam from '@aws-cdk/aws-iam'; 9 | import {Stack} from '@aws-cdk/core'; 10 | import {LogsLayerVersion} from './LogsLayerVersion'; 11 | 12 | export class RealTimeLambdaLogsAPI extends cdk.NestedStack { 13 | public readonly connectFn: lambda.Function; 14 | 15 | public readonly disconnectFn: lambda.Function; 16 | 17 | public readonly defaultFn: lambda.Function; 18 | 19 | /** role needed to send messages to websocket clients */ 20 | public readonly apigwRole: iam.Role; 21 | 22 | public readonly CDK_WATCH_CONNECTION_TABLE_NAME: string; 23 | 24 | public readonly CDK_WATCH_API_GATEWAY_MANAGEMENT_URL: string; 25 | 26 | private connectionTable: dynamodb.Table; 27 | 28 | public executeApigwPolicy: iam.PolicyStatement; 29 | 30 | public logsLayerVersion: LogsLayerVersion; 31 | 32 | public websocketApi: apigwv2.CfnApi; 33 | 34 | public lambdaDynamoConnectionPolicy: iam.PolicyStatement; 35 | 36 | constructor(scope: cdk.Construct, id: string) { 37 | super(scope, id); 38 | 39 | const stack = Stack.of(this); 40 | const routeSelectionKey = 'action'; 41 | // NOTE: This file will be bundled into /lib/index.js, so this path must be relative to that 42 | const websocketHandlerCodePath = path.join(__dirname, 'websocketHandlers'); 43 | 44 | this.logsLayerVersion = new LogsLayerVersion(this, 'LogsLayerVersion'); 45 | 46 | // table where websocket connections will be stored 47 | const websocketTable = new dynamodb.Table(this, 'connections', { 48 | partitionKey: { 49 | name: 'connectionId', 50 | type: dynamodb.AttributeType.STRING, 51 | }, 52 | billingMode: dynamodb.BillingMode.PROVISIONED, 53 | removalPolicy: cdk.RemovalPolicy.DESTROY, 54 | pointInTimeRecovery: true, 55 | writeCapacity: 5, 56 | readCapacity: 5, 57 | }); 58 | 59 | this.websocketApi = new apigwv2.CfnApi(this, 'LogsWebsocketApi', { 60 | protocolType: 'WEBSOCKET', 61 | routeSelectionExpression: `$request.body.${routeSelectionKey}`, 62 | name: `${id}LogsWebsocketApi`, 63 | }); 64 | 65 | const basePermissions = websocketTable.tableArn; 66 | const indexPermissions = `${basePermissions}/index/*`; 67 | this.lambdaDynamoConnectionPolicy = new iam.PolicyStatement({ 68 | actions: ['dynamodb:*'], 69 | resources: [basePermissions, indexPermissions], 70 | }); 71 | 72 | const connectLambdaRole = new iam.Role(this, 'connect-lambda-role', { 73 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 74 | }); 75 | connectLambdaRole.addToPolicy(this.lambdaDynamoConnectionPolicy); 76 | connectLambdaRole.addManagedPolicy( 77 | iam.ManagedPolicy.fromAwsManagedPolicyName( 78 | 'service-role/AWSLambdaBasicExecutionRole', 79 | ), 80 | ); 81 | 82 | const disconnectLambdaRole = new iam.Role(this, 'disconnect-lambda-role', { 83 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 84 | }); 85 | disconnectLambdaRole.addToPolicy(this.lambdaDynamoConnectionPolicy); 86 | disconnectLambdaRole.addManagedPolicy( 87 | iam.ManagedPolicy.fromAwsManagedPolicyName( 88 | 'service-role/AWSLambdaBasicExecutionRole', 89 | ), 90 | ); 91 | 92 | const messageLambdaRole = new iam.Role(this, 'message-lambda-role', { 93 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 94 | }); 95 | messageLambdaRole.addToPolicy(this.lambdaDynamoConnectionPolicy); 96 | messageLambdaRole.addManagedPolicy( 97 | iam.ManagedPolicy.fromAwsManagedPolicyName( 98 | 'service-role/AWSLambdaBasicExecutionRole', 99 | ), 100 | ); 101 | 102 | const resourceStr = this.createResourceStr( 103 | stack.account, 104 | stack.region, 105 | this.websocketApi.ref, 106 | ); 107 | 108 | this.executeApigwPolicy = new iam.PolicyStatement({ 109 | actions: ['execute-api:Invoke', 'execute-api:ManageConnections'], 110 | resources: [resourceStr], 111 | effect: iam.Effect.ALLOW, 112 | }); 113 | 114 | const lambdaProps = { 115 | code: lambda.Code.fromAsset(websocketHandlerCodePath), 116 | timeout: cdk.Duration.seconds(300), 117 | runtime: lambda.Runtime.NODEJS_12_X, 118 | logRetention: logs.RetentionDays.FIVE_DAYS, 119 | role: disconnectLambdaRole, 120 | environment: { 121 | CDK_WATCH_CONNECTION_TABLE_NAME: websocketTable.tableName, 122 | }, 123 | }; 124 | 125 | const connectLambda = new lambda.Function(this, 'ConnectLambda', { 126 | handler: 'index.onConnect', 127 | description: 'Connect a user.', 128 | ...lambdaProps, 129 | }); 130 | 131 | const disconnectLambda = new lambda.Function(this, 'DisconnectLambda', { 132 | handler: 'index.onDisconnect', 133 | description: 'Disconnect a user.', 134 | ...lambdaProps, 135 | }); 136 | 137 | const defaultLambda = new lambda.Function(this, 'DefaultLambda', { 138 | handler: 'index.onMessage', 139 | description: 'Default', 140 | ...lambdaProps, 141 | }); 142 | 143 | // access role for the socket api to access the socket lambda 144 | const policy = new iam.PolicyStatement({ 145 | effect: iam.Effect.ALLOW, 146 | resources: [ 147 | connectLambda.functionArn, 148 | disconnectLambda.functionArn, 149 | defaultLambda.functionArn, 150 | ], 151 | actions: ['lambda:InvokeFunction'], 152 | }); 153 | 154 | const role = new iam.Role(this, `LogsWebsocketIamRole`, { 155 | assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), 156 | }); 157 | role.addToPolicy(policy); 158 | 159 | // websocket api lambda integration 160 | const connectIntegration = new apigwv2.CfnIntegration( 161 | this, 162 | 'connect-lambda-integration', 163 | { 164 | apiId: this.websocketApi.ref, 165 | integrationType: 'AWS_PROXY', 166 | integrationUri: this.createIntegrationStr( 167 | stack.region, 168 | connectLambda.functionArn, 169 | ), 170 | credentialsArn: role.roleArn, 171 | }, 172 | ); 173 | 174 | const disconnectIntegration = new apigwv2.CfnIntegration( 175 | this, 176 | 'disconnect-lambda-integration', 177 | { 178 | apiId: this.websocketApi.ref, 179 | integrationType: 'AWS_PROXY', 180 | integrationUri: this.createIntegrationStr( 181 | stack.region, 182 | disconnectLambda.functionArn, 183 | ), 184 | credentialsArn: role.roleArn, 185 | }, 186 | ); 187 | 188 | const defaultIntegration = new apigwv2.CfnIntegration( 189 | this, 190 | 'default-lambda-integration', 191 | { 192 | apiId: this.websocketApi.ref, 193 | integrationType: 'AWS_PROXY', 194 | integrationUri: this.createIntegrationStr( 195 | stack.region, 196 | defaultLambda.functionArn, 197 | ), 198 | credentialsArn: role.roleArn, 199 | }, 200 | ); 201 | 202 | // Example route definition 203 | const connectRoute = new apigwv2.CfnRoute(this, 'connect-route', { 204 | apiId: this.websocketApi.ref, 205 | routeKey: '$connect', 206 | authorizationType: 'AWS_IAM', 207 | target: `integrations/${connectIntegration.ref}`, 208 | }); 209 | 210 | const disconnectRoute = new apigwv2.CfnRoute(this, 'disconnect-route', { 211 | apiId: this.websocketApi.ref, 212 | routeKey: '$disconnect', 213 | authorizationType: 'NONE', 214 | target: `integrations/${disconnectIntegration.ref}`, 215 | }); 216 | 217 | const defaultRoute = new apigwv2.CfnRoute(this, 'default-route', { 218 | apiId: this.websocketApi.ref, 219 | routeKey: '$default', 220 | authorizationType: 'NONE', 221 | target: `integrations/${defaultIntegration.ref}`, 222 | }); 223 | 224 | // allow other other tables to grant permissions to these lambdas 225 | this.connectFn = connectLambda; 226 | this.disconnectFn = disconnectLambda; 227 | this.defaultFn = defaultLambda; 228 | this.connectionTable = websocketTable; 229 | this.apigwRole = messageLambdaRole; 230 | 231 | // deployment 232 | const apigwWssDeployment = new apigwv2.CfnDeployment( 233 | this, 234 | 'apigw-deployment', 235 | {apiId: this.websocketApi.ref}, 236 | ); 237 | 238 | // stage 239 | const apiStage = new apigwv2.CfnStage(this, 'apigw-stage', { 240 | apiId: this.websocketApi.ref, 241 | autoDeploy: true, 242 | deploymentId: apigwWssDeployment.ref, 243 | stageName: 'v1', 244 | defaultRouteSettings: { 245 | throttlingBurstLimit: 500, 246 | throttlingRateLimit: 1000, 247 | }, 248 | }); 249 | 250 | // all routes are dependencies of the deployment 251 | const routes = new cdk.ConcreteDependable(); 252 | routes.add(connectRoute); 253 | routes.add(disconnectRoute); 254 | routes.add(defaultRoute); 255 | 256 | // add the dependency 257 | apigwWssDeployment.node.addDependency(routes); 258 | 259 | this.CDK_WATCH_CONNECTION_TABLE_NAME = websocketTable.tableName; 260 | this.CDK_WATCH_API_GATEWAY_MANAGEMENT_URL = this.createConnectionString( 261 | apiStage.stageName, 262 | stack.region, 263 | this.websocketApi.ref, 264 | ); 265 | } 266 | 267 | private createIntegrationStr = (region: string, fnArn: string): string => 268 | `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${fnArn}/invocations`; 269 | 270 | private createConnectionString = ( 271 | route: string, 272 | region: string, 273 | ref: string, 274 | ) => `https://${ref}.execute-api.${region}.amazonaws.com/${route}`; 275 | 276 | private createResourceStr = ( 277 | accountId: string, 278 | region: string, 279 | ref: string, 280 | ): string => `arn:aws:execute-api:${region}:${accountId}:${ref}/*`; 281 | 282 | public grantReadWrite = (lambdaFunction: lambda.Function): void => { 283 | this.connectionTable.grantReadWriteData(lambdaFunction); 284 | }; 285 | } 286 | -------------------------------------------------------------------------------- /src/constructs/WatchableNodejsFunction.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-new */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | import { 4 | BundlingOptions, 5 | NodejsFunction, 6 | NodejsFunctionProps, 7 | } from '@aws-cdk/aws-lambda-nodejs'; 8 | import {Runtime} from '@aws-cdk/aws-lambda'; 9 | import {Asset} from '@aws-cdk/aws-s3-assets'; 10 | import * as path from 'path'; 11 | import * as fs from 'fs-extra'; 12 | import findUp from 'find-up'; 13 | import * as cdk from '@aws-cdk/core'; 14 | import {BuildOptions, Loader} from 'esbuild'; 15 | import {CfnElement} from '@aws-cdk/core'; 16 | import minimatch from 'minimatch'; 17 | import {readManifest} from '../lib/readManifest'; 18 | import {writeManifest} from '../lib/writeManifest'; 19 | import {RealTimeLambdaLogsAPI} from './RealTimeLambdaLogsAPI'; 20 | import { 21 | CDK_WATCH_CONTEXT_LOGS_ENABLED, 22 | CDK_WATCH_CONTEXT_NODE_MODULES_DISABLED, 23 | } from '../consts'; 24 | import {NodeModulesLayer} from './NodeModulesLayer'; 25 | 26 | type NodeModulesSelectOption = 27 | | { 28 | // whitelist the modules you'd like to install in the layer 29 | include: string[]; 30 | } 31 | | { 32 | // include all but blacklist those you'd like to not include in the layer 33 | exclude: string[]; 34 | }; 35 | 36 | interface WatchableBundlingOptions extends BundlingOptions { 37 | /** 38 | * Similar to `bundling.nodeModules` however in this case your modules will be 39 | * bundled into a Lambda layer instead of being uploaded with your lambda 40 | * function code. This has upside when 'watching' your code as the only code 41 | * that needs to be uploaded each time is your core lambda code rather than 42 | * any modules, which are unlikely to change frequently. You can either select 43 | * the modules you want t =o include in the layer, or include all and select 44 | * the module's you'd like to exclude. Globs are accepted here. 45 | */ 46 | nodeModulesLayer?: NodeModulesSelectOption; 47 | } 48 | 49 | // NodeModulesLayer 50 | interface WatchableNodejsFunctionProps extends NodejsFunctionProps { 51 | /** 52 | * Bundling options. 53 | */ 54 | bundling?: WatchableBundlingOptions; 55 | /** 56 | * CDK Watch Options 57 | */ 58 | watchOptions?: { 59 | /** 60 | * Default: `false` 61 | * Set to true to enable this construct to create all the 62 | * required infrastructure for realtime logging 63 | */ 64 | realTimeLoggingEnabled?: boolean; 65 | }; 66 | } 67 | 68 | const getNodeModuleLayerDependencies = ( 69 | pkgJsonPath: string, 70 | selectOption: NodeModulesSelectOption, 71 | ) => { 72 | if ('include' in selectOption) { 73 | return selectOption.include; 74 | } 75 | 76 | const packageJson = fs.readJSONSync(pkgJsonPath); 77 | return Object.keys(packageJson.dependencies || {}).filter( 78 | (key) => !selectOption.exclude.some((pattern) => minimatch(key, pattern)), 79 | ); 80 | }; 81 | 82 | /** 83 | * `extends` NodejsFunction and behaves the same, however `entry` is a required 84 | * prop to prevent duplicating logic across NodejsFunction and 85 | * WatchableNodejsFunction to infer `entry`. 86 | */ 87 | class WatchableNodejsFunction extends NodejsFunction { 88 | public esbuildOptions: BuildOptions; 89 | 90 | public cdkWatchLogsApi?: RealTimeLambdaLogsAPI; 91 | 92 | public readonly local?: cdk.ILocalBundling; 93 | 94 | private readonly nodeModulesLayerVersion: string | undefined; 95 | 96 | constructor( 97 | scope: cdk.Construct, 98 | id: string, 99 | props: WatchableNodejsFunctionProps, 100 | ) { 101 | if (!props.entry) throw new Error('Expected props.entry'); 102 | const pkgPath = findUp.sync('package.json', { 103 | cwd: path.dirname(props.entry), 104 | }); 105 | if (!pkgPath) { 106 | throw new Error( 107 | 'Cannot find a `package.json` in this project. Using `nodeModules` requires a `package.json`.', 108 | ); 109 | } 110 | const nodeModulesLayerSelectOption = props.bundling?.nodeModulesLayer; 111 | 112 | let moduleNames: string[] | null = null; 113 | if (nodeModulesLayerSelectOption) { 114 | moduleNames = getNodeModuleLayerDependencies( 115 | pkgPath, 116 | nodeModulesLayerSelectOption, 117 | ); 118 | } 119 | const bundling: WatchableBundlingOptions = { 120 | ...props.bundling, 121 | externalModules: [ 122 | ...(moduleNames || []), 123 | ...(props.bundling?.externalModules || ['aws-sdk']), 124 | ], 125 | }; 126 | super(scope, id, { 127 | ...props, 128 | bundling, 129 | }); 130 | const shouldSkipInstall = 131 | scope.node.tryGetContext(CDK_WATCH_CONTEXT_NODE_MODULES_DISABLED) === '1'; 132 | if (moduleNames) { 133 | const nodeModulesLayer = new NodeModulesLayer(this, 'NodeModulesLayer', { 134 | nodeModules: moduleNames, 135 | pkgPath, 136 | depsLockFilePath: props.depsLockFilePath, 137 | skip: shouldSkipInstall, 138 | }); 139 | this.addLayers(nodeModulesLayer); 140 | this.nodeModulesLayerVersion = nodeModulesLayer.layerVersion; 141 | } 142 | 143 | const {entry} = props; 144 | if (!entry) throw new Error('`entry` must be provided'); 145 | const targetMatch = (props.runtime || Runtime.NODEJS_12_X).name.match( 146 | /nodejs(\d+)/, 147 | ); 148 | if (!targetMatch) { 149 | throw new Error('Cannot extract version from runtime.'); 150 | } 151 | const target = `node${targetMatch[1]}`; 152 | 153 | this.esbuildOptions = { 154 | target, 155 | bundle: true, 156 | entryPoints: [entry], 157 | platform: 'node', 158 | minify: bundling?.minify ?? false, 159 | sourcemap: bundling?.sourceMap, 160 | external: [ 161 | ...(bundling?.externalModules ?? ['aws-sdk']), 162 | ...(bundling?.nodeModules ?? []), 163 | ...(moduleNames ?? []), 164 | ], 165 | loader: bundling?.loader as {[ext: string]: Loader} | undefined, 166 | define: bundling?.define, 167 | logLevel: bundling?.logLevel, 168 | keepNames: bundling?.keepNames, 169 | tsconfig: bundling?.tsconfig 170 | ? path.resolve(entry, path.resolve(bundling?.tsconfig)) 171 | : findUp.sync('tsconfig.json', {cwd: path.dirname(entry)}), 172 | banner: bundling?.banner, 173 | footer: bundling?.footer, 174 | }; 175 | 176 | if ( 177 | scope.node.tryGetContext(CDK_WATCH_CONTEXT_LOGS_ENABLED) || 178 | props.watchOptions?.realTimeLoggingEnabled 179 | ) { 180 | const [rootStack] = this.parentStacks; 181 | const logsApiId = 'CDKWatchWebsocketLogsApi'; 182 | this.cdkWatchLogsApi = 183 | (rootStack.node.tryFindChild(logsApiId) as 184 | | undefined 185 | | RealTimeLambdaLogsAPI) || 186 | new RealTimeLambdaLogsAPI(rootStack, logsApiId); 187 | 188 | this.addEnvironment( 189 | 'AWS_LAMBDA_EXEC_WRAPPER', 190 | '/opt/cdk-watch-lambda-wrapper/index.js', 191 | ); 192 | this.addLayers(this.cdkWatchLogsApi.logsLayerVersion); 193 | this.addToRolePolicy(this.cdkWatchLogsApi.executeApigwPolicy); 194 | this.addToRolePolicy(this.cdkWatchLogsApi.lambdaDynamoConnectionPolicy); 195 | this.addEnvironment( 196 | 'CDK_WATCH_CONNECTION_TABLE_NAME', 197 | this.cdkWatchLogsApi.CDK_WATCH_CONNECTION_TABLE_NAME, 198 | ); 199 | this.addEnvironment( 200 | 'CDK_WATCH_API_GATEWAY_MANAGEMENT_URL', 201 | this.cdkWatchLogsApi.CDK_WATCH_API_GATEWAY_MANAGEMENT_URL, 202 | ); 203 | } 204 | } 205 | 206 | /** 207 | * Returns all the parents of this construct's stack (i.e. if this construct 208 | * is within a NestedStack etc etc). 209 | */ 210 | private get parentStacks() { 211 | const parents: cdk.Stack[] = [this.stack]; 212 | // Get all the nested stack parents into an array, the array will start with 213 | // the root stack all the way to the stack holding the lambda as the last 214 | // element in the array. 215 | while (parents[0].nestedStackParent) { 216 | parents.unshift(parents[0].nestedStackParent as cdk.Stack); 217 | } 218 | return parents; 219 | } 220 | 221 | /** 222 | * When this stack is synthesized, we output a manifest which gives the CLI 223 | * the info it needs to run the lambdas in watch mode. This will include the 224 | * logical IDs and the stack name (and logical IDs of nested stacks). 225 | */ 226 | public synthesize(session: cdk.ISynthesisSession): void { 227 | super.synthesize(session); 228 | 229 | const asset = this.node 230 | .findAll() 231 | .find((construct) => construct instanceof Asset) as Asset; 232 | 233 | if (!asset) { 234 | throw new Error( 235 | "WatchableNodejsFunction could not find an Asset in it's children", 236 | ); 237 | } 238 | 239 | const assetPath = path.join(session.outdir, asset.assetPath); 240 | const [rootStack, ...nestedStacks] = this.parentStacks; 241 | const cdkWatchManifest = readManifest() || { 242 | region: this.stack.region, 243 | lambdas: {}, 244 | }; 245 | 246 | if (cdk.Token.isUnresolved(this.stack.region)) { 247 | throw new Error( 248 | '`stack.region` is an unresolved token. `cdk-watch` requires a concrete region to be set.', 249 | ); 250 | } 251 | 252 | cdkWatchManifest.region = this.stack.region; 253 | 254 | cdkWatchManifest.lambdas = 255 | typeof cdkWatchManifest.lambdas === 'object' 256 | ? cdkWatchManifest.lambdas 257 | : {}; 258 | cdkWatchManifest.lambdas[this.node.path] = { 259 | assetPath, 260 | nodeModulesLayerVersion: this.nodeModulesLayerVersion, 261 | realTimeLogsStackLogicalId: this.cdkWatchLogsApi 262 | ? this.stack.getLogicalId( 263 | this.cdkWatchLogsApi.nestedStackResource as CfnElement, 264 | ) 265 | : undefined, 266 | realTimeLogsApiLogicalId: this.cdkWatchLogsApi?.websocketApi 267 | ? this.stack.getLogicalId(this.cdkWatchLogsApi.websocketApi) 268 | : undefined, 269 | esbuildOptions: this.esbuildOptions, 270 | lambdaLogicalId: this.stack.getLogicalId( 271 | this.node.defaultChild as cdk.CfnResource, 272 | ), 273 | rootStackName: rootStack.stackName, 274 | nestedStackLogicalIds: nestedStacks.map( 275 | (nestedStack) => 276 | nestedStack.nestedStackParent?.getLogicalId( 277 | nestedStack.nestedStackResource as cdk.CfnResource, 278 | ) as string, 279 | ), 280 | }; 281 | 282 | writeManifest(cdkWatchManifest); 283 | } 284 | } 285 | 286 | export {WatchableNodejsFunction, WatchableNodejsFunctionProps}; 287 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export const CDK_WATCH_MANIFEST_FILE_NAME = 'manifest.cdk-watch.json'; 2 | export const CDK_WATCH_OUTDIR = 'cdk-watch'; 3 | // Turns on realtime logs for the whole project (unless you specify 4 | // `realTimeLoggingEnabled: false`) on a per construct basis. i.e. this defaults 5 | // to tru rather than false. 6 | export const CDK_WATCH_CONTEXT_LOGS_ENABLED = 7 | 'cdk-watch:forceRealTimeLoggingEnabled'; 8 | // A flag to tell cdk to not install node-modules for the layer when 9 | // running synth. This is on by default when running any cdk-commands 10 | export const CDK_WATCH_CONTEXT_NODE_MODULES_DISABLED = 11 | 'cdk-watch:nodeModulesInstallDisabled'; 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | WatchableNodejsFunction, 3 | WatchableNodejsFunctionProps, 4 | } from './constructs/WatchableNodejsFunction'; 5 | -------------------------------------------------------------------------------- /src/lambda-extension/cdk-watch-lambda-wrapper/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-console */ 3 | /* eslint-disable no-underscore-dangle */ 4 | import * as AWS from 'aws-sdk'; 5 | import {Log, patchConsole} from './patchConsole'; 6 | 7 | const logs: Log[] = []; 8 | const file = process.argv.pop(); 9 | 10 | const ddb = new AWS.DynamoDB.DocumentClient({ 11 | apiVersion: '2012-08-10', 12 | region: process.env.AWS_REGION, 13 | }); 14 | const apigwManagementApi = new AWS.ApiGatewayManagementApi({ 15 | apiVersion: '2018-11-29', 16 | endpoint: process.env.CDK_WATCH_API_GATEWAY_MANAGEMENT_URL, 17 | }); 18 | 19 | const handlerFunctionName = 'cdkWatchWrappedHandler'; 20 | // eslint-disable-next-line no-underscore-dangle 21 | const originalHandlerName = process.env._HANDLER; 22 | 23 | const postToWS = async (postData: Log[]): Promise => { 24 | let connectionData; 25 | 26 | try { 27 | connectionData = await ddb 28 | .scan({ 29 | TableName: process.env.CDK_WATCH_CONNECTION_TABLE_NAME as string, 30 | ProjectionExpression: 'connectionId', 31 | }) 32 | .promise(); 33 | } catch (e) { 34 | console.error(`Failed to scan for connections`, e); 35 | return; 36 | } 37 | 38 | const postCalls = 39 | connectionData.Items && 40 | connectionData.Items.map(async ({connectionId}) => { 41 | try { 42 | await apigwManagementApi 43 | .postToConnection({ 44 | ConnectionId: connectionId, 45 | Data: JSON.stringify(postData, null, 2), 46 | }) 47 | .promise(); 48 | } catch (e) { 49 | if (e.statusCode === 410) { 50 | await ddb 51 | .delete({ 52 | TableName: process.env.CDK_WATCH_CONNECTION_TABLE_NAME as string, 53 | Key: {connectionId}, 54 | }) 55 | .promise() 56 | .catch((error) => 57 | console.log('Failed to delete connection', error), 58 | ); 59 | } else { 60 | console.log('Failed to send log', e); 61 | } 62 | } 63 | }); 64 | 65 | await Promise.all(postCalls || []); 66 | }; 67 | 68 | try { 69 | const handlerPath = `${process.env.LAMBDA_TASK_ROOT}/${originalHandlerName}`; 70 | const handlerArray = handlerPath.split('.'); 71 | const functionName = handlerArray.pop(); 72 | const handlerFile = handlerArray.join(''); 73 | process.env._HANDLER = `${handlerFile}.${handlerFunctionName}`; 74 | 75 | const handler = require(handlerFile); // eslint-disable-line 76 | const originalFunction = handler[functionName as string]; 77 | const wrappedHandler = async (...args: any[]) => { 78 | const sendLogs = async () => { 79 | const payload = [...logs]; 80 | logs.splice(0); 81 | if (!payload.length) return; 82 | await postToWS(payload).catch(console.error); 83 | }; 84 | const interval = setInterval(sendLogs, 100); 85 | try { 86 | const result = await originalFunction(...args); 87 | clearInterval(interval); 88 | await sendLogs(); 89 | return result; 90 | } catch (e) { 91 | console.error('Main handler threw error', e); 92 | clearInterval(interval); 93 | await sendLogs(); 94 | throw e; 95 | } 96 | }; 97 | 98 | Object.defineProperty(handler, handlerFunctionName, { 99 | get: () => wrappedHandler, 100 | enumerable: true, 101 | }); 102 | } catch (error) { 103 | console.log('Failed wrapping handler', error); 104 | } 105 | 106 | // eslint-disable-next-line import/no-dynamic-require 107 | module.exports = require(file as string); 108 | // Patching the logs is done following the same methodology as the lambda runtime: 109 | // https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/a850fd5adad5f32251350ce23ca2c8934b2fa542/src/utils/LogPatch.ts 110 | patchConsole(logs); 111 | -------------------------------------------------------------------------------- /src/lambda-extension/cdk-watch-lambda-wrapper/patchConsole.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | export type LogLevel = 3 | | 'info' 4 | | 'debug' 5 | | 'info' 6 | | 'warn' 7 | | 'error' 8 | | 'trace' 9 | | 'fatal'; 10 | 11 | export type Log = {level: LogLevel; log: any[]; date: number; lambda: string}; 12 | 13 | export const patchConsole = (logs: Log[]): void => { 14 | const {log, debug, info, warn, error, trace, fatal} = console as Console & { 15 | fatal: typeof console.log; 16 | }; 17 | 18 | console.log = (...params) => { 19 | logs.push({ 20 | lambda: process.env.AWS_LAMBDA_FUNCTION_NAME as string, 21 | level: 'info', 22 | date: Date.now(), 23 | log: params, 24 | }); 25 | log.apply(console, params); 26 | }; 27 | console.debug = (...params) => { 28 | logs.push({ 29 | lambda: process.env.AWS_LAMBDA_FUNCTION_NAME as string, 30 | level: 'debug', 31 | date: Date.now(), 32 | log: params, 33 | }); 34 | debug.apply(console, params); 35 | }; 36 | console.info = (...params) => { 37 | logs.push({ 38 | lambda: process.env.AWS_LAMBDA_FUNCTION_NAME as string, 39 | level: 'info', 40 | date: Date.now(), 41 | log: params, 42 | }); 43 | info.apply(console, params); 44 | }; 45 | console.warn = (...params) => { 46 | logs.push({ 47 | lambda: process.env.AWS_LAMBDA_FUNCTION_NAME as string, 48 | level: 'warn', 49 | date: Date.now(), 50 | log: params, 51 | }); 52 | warn.apply(console, params); 53 | }; 54 | console.error = (...params) => { 55 | logs.push({ 56 | lambda: process.env.AWS_LAMBDA_FUNCTION_NAME as string, 57 | level: 'error', 58 | date: Date.now(), 59 | log: params, 60 | }); 61 | error.apply(console, params); 62 | }; 63 | console.trace = (...params) => { 64 | logs.push({ 65 | lambda: process.env.AWS_LAMBDA_FUNCTION_NAME as string, 66 | level: 'trace', 67 | date: Date.now(), 68 | log: params, 69 | }); 70 | trace.apply(console, params); 71 | }; 72 | (console as any).fatal = (...params: any[]) => { 73 | logs.push({ 74 | lambda: process.env.AWS_LAMBDA_FUNCTION_NAME as string, 75 | level: 'fatal', 76 | date: Date.now(), 77 | log: params, 78 | }); 79 | fatal.apply(console, params); 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /src/lib/copyCdkAssetToWatchOutdir.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import {LambdaManifestType} from '../types.d'; 3 | import {lambdaWatchOutdir} from './lambdaWatchOutdir'; 4 | 5 | export const copyCdkAssetToWatchOutdir = ( 6 | lambdaManifest: LambdaManifestType, 7 | ): string => { 8 | const watchOutdir = lambdaWatchOutdir(lambdaManifest); 9 | fs.copySync(lambdaManifest.assetPath, watchOutdir, { 10 | errorOnExist: false, 11 | recursive: true, 12 | overwrite: true, 13 | dereference: true, 14 | }); 15 | return watchOutdir; 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/createCLILoggerForLambda.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import chalk from 'chalk'; 3 | import truncate from 'cli-truncate'; 4 | 5 | export const createCLILoggerForLambda = ( 6 | lambdaCdkPath: string, 7 | shouldPrefix = true, 8 | ): { 9 | prefix: string; 10 | log(...message: any[]): void; 11 | error(message: string | Error): void; 12 | } => { 13 | const functionName = truncate(lambdaCdkPath, 20, {position: 'start'}); 14 | const prefix = shouldPrefix ? `[${chalk.grey(functionName)}]` : ''; 15 | return { 16 | prefix, 17 | log(...message) { 18 | if (prefix) { 19 | console.log(prefix, ...message); 20 | } else { 21 | console.log(...message); 22 | } 23 | }, 24 | error(message) { 25 | const error = chalk.red( 26 | typeof message === 'string' ? message : message.toString(), 27 | ); 28 | if (prefix) { 29 | console.error(prefix, error); 30 | } else { 31 | console.error(error); 32 | } 33 | }, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/filterManifestByPath.ts: -------------------------------------------------------------------------------- 1 | import minimatch from 'minimatch'; 2 | import {CdkWatchManifest} from '../types.d'; 3 | 4 | export const filterManifestByPath = ( 5 | pathMatch: string, 6 | manifest: CdkWatchManifest, 7 | ): CdkWatchManifest => { 8 | const filtered = Object.keys(manifest.lambdas) 9 | .filter(minimatch.filter(pathMatch)) 10 | .reduce( 11 | (current, next) => ({ 12 | ...current, 13 | lambdas: {...current.lambdas, [next]: manifest.lambdas[next]}, 14 | }), 15 | {...manifest, lambdas: {}}, 16 | ); 17 | 18 | if (!Object.keys(filtered.lambdas).length) 19 | throw new Error(`No Lambdas found at "${pathMatch}"`); 20 | return filtered; 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/initAwsSdk.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from 'aws-sdk'; 2 | 3 | export const initAwsSdk = (region: string, profile?: string): void => { 4 | AWS.config.region = region; 5 | if (profile) { 6 | AWS.config.credentials = new AWS.SharedIniFileCredentials({ 7 | profile, 8 | }); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/lib/lambdaWatchOutdir.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import {CDK_WATCH_OUTDIR} from '../consts'; 3 | import {LambdaManifestType} from '../types.d'; 4 | 5 | export const lambdaWatchOutdir = (lambdaManifest: LambdaManifestType): string => 6 | path.join( 7 | 'cdk.out', 8 | CDK_WATCH_OUTDIR, 9 | path.relative('cdk.out', lambdaManifest.assetPath), 10 | ); 11 | -------------------------------------------------------------------------------- /src/lib/readManifest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs-extra'; 3 | import {CdkWatchManifest} from '../types.d'; 4 | import {CDK_WATCH_MANIFEST_FILE_NAME} from '../consts'; 5 | 6 | export const readManifest = (): CdkWatchManifest | undefined => { 7 | const manifestPath = path.join( 8 | process.cwd(), 9 | 'cdk.out', 10 | CDK_WATCH_MANIFEST_FILE_NAME, 11 | ); 12 | return fs.readJsonSync(manifestPath, {throws: false}); 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/resolveLambdaNamesFromManifest.ts: -------------------------------------------------------------------------------- 1 | import {CloudFormation, Lambda} from 'aws-sdk'; 2 | import {LambdaManifestType, CdkWatchManifest, LambdaDetail} from '../types.d'; 3 | import {resolveStackNameForLambda} from './resolveStackNameForLambda'; 4 | 5 | const resolveLambdaNameFromManifest = async ( 6 | lambdaManifest: LambdaManifestType, 7 | ): Promise<{ 8 | functionName: string; 9 | lambdaManifest: LambdaManifestType; 10 | layers: string[]; 11 | }> => { 12 | const cfn = new CloudFormation({maxRetries: 10}); 13 | const lambda = new Lambda({maxRetries: 10}); 14 | const lambdaStackName = await resolveStackNameForLambda(lambdaManifest); 15 | const {StackResourceDetail} = await cfn 16 | .describeStackResource({ 17 | StackName: lambdaStackName, 18 | LogicalResourceId: lambdaManifest.lambdaLogicalId, 19 | }) 20 | .promise(); 21 | 22 | if (!StackResourceDetail?.PhysicalResourceId) { 23 | throw new Error( 24 | `Could not find name for lambda with Logical ID ${lambdaManifest.lambdaLogicalId}`, 25 | ); 26 | } 27 | const functionName = StackResourceDetail?.PhysicalResourceId as string; 28 | const config = await lambda 29 | .getFunctionConfiguration({FunctionName: functionName}) 30 | .promise(); 31 | 32 | return { 33 | layers: 34 | config.Layers?.map((layer) => { 35 | const {6: name} = layer.Arn?.split(':') || ''; 36 | return name; 37 | }).filter(Boolean) || [], 38 | functionName, 39 | lambdaManifest, 40 | }; 41 | }; 42 | 43 | export const resolveLambdaNamesFromManifest = ( 44 | manifest: CdkWatchManifest, 45 | ): Promise => 46 | Promise.all( 47 | Object.keys(manifest.lambdas).map(async (lambdaCdkPath) => { 48 | const details = await resolveLambdaNameFromManifest( 49 | manifest.lambdas[lambdaCdkPath], 50 | ); 51 | return {lambdaCdkPath, ...details}; 52 | }), 53 | ); 54 | -------------------------------------------------------------------------------- /src/lib/resolveStackNameForLambda.ts: -------------------------------------------------------------------------------- 1 | import {CloudFormation} from 'aws-sdk'; 2 | import {LambdaManifestType} from '../types.d'; 3 | 4 | export const resolveStackNameForLambda = async ( 5 | lambdaManifest: LambdaManifestType, 6 | ): Promise => { 7 | const cfn = new CloudFormation({maxRetries: 10}); 8 | return lambdaManifest.nestedStackLogicalIds.reduce( 9 | (promise, nextNestedStack) => 10 | promise.then((stackName) => 11 | cfn 12 | .describeStackResource({ 13 | StackName: stackName, 14 | LogicalResourceId: nextNestedStack, 15 | }) 16 | .promise() 17 | .then( 18 | (result) => 19 | result.StackResourceDetail?.PhysicalResourceId as string, 20 | ), 21 | ), 22 | Promise.resolve(lambdaManifest.rootStackName), 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/lib/runSynth.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import {CDK_WATCH_CONTEXT_NODE_MODULES_DISABLED} from '../consts'; 3 | import {twisters} from './twisters'; 4 | import {writeManifest} from './writeManifest'; 5 | 6 | export const runSynth = async (options: { 7 | context: string[]; 8 | profile?: string; 9 | app?: string; 10 | }): Promise => { 11 | // Create a fresh manifest 12 | writeManifest({region: '', lambdas: {}}); 13 | 14 | const synthProgressText = 'synthesizing CDK app'; 15 | twisters.put('synth', {text: synthProgressText}); 16 | const command = [ 17 | 'synth', 18 | ...options.context.map((context) => `--context=${context}`), 19 | // If the user has defined CDK_WATCH_CONTEXT_NODE_MODULES_DISABLED then 20 | // don't set it to our default value 21 | ...(options.context.some((context) => 22 | context.includes(CDK_WATCH_CONTEXT_NODE_MODULES_DISABLED), 23 | ) 24 | ? [] 25 | : [`--context=${CDK_WATCH_CONTEXT_NODE_MODULES_DISABLED}=1`]), 26 | '--quiet', 27 | options.profile && `--profile=${options.profile}`, 28 | options.app && `--app=${options.app}`, 29 | ].filter(Boolean) as string[]; 30 | 31 | const result = await execa('cdk', command, { 32 | preferLocal: true, 33 | cleanup: true, 34 | reject: false, 35 | all: true, 36 | }); 37 | 38 | if (result.exitCode !== 0) { 39 | console.log(result.all); 40 | console.log(`\nSynth failed using the following command:`); 41 | console.log(['cdk', ...command].join(' ')); 42 | console.log(''); 43 | process.exit(result.exitCode); 44 | } 45 | 46 | twisters.put('synth', {active: false, text: synthProgressText}); 47 | }; 48 | -------------------------------------------------------------------------------- /src/lib/tailLogsForLambdas/index.ts: -------------------------------------------------------------------------------- 1 | import {LambdaDetail} from '../../types.d'; 2 | import {resolveLogEndpointDetailsFromLambdas} from './resolveLogEndpointDetailsFromLambdas'; 3 | import {tailCloudWatchLogsForLambda} from './tailCloudWatchLogsForLambda'; 4 | import {tailRealTimeLogsForLambdas} from './tailRealTimeLogsForLambdas'; 5 | import {createCLILoggerForLambda} from '../createCLILoggerForLambda'; 6 | 7 | const tailLogsForLambdas = async ( 8 | lambdaFunctions: LambdaDetail[], 9 | forceCloudwatch = false, 10 | ): Promise => { 11 | const realTimeEndpointsForLambdas = await resolveLogEndpointDetailsFromLambdas( 12 | lambdaFunctions, 13 | ); 14 | 15 | // All the lambdas that don't have real time logging setup 16 | const cloudwatchFunctions = (forceCloudwatch 17 | ? Object.keys(realTimeEndpointsForLambdas) 18 | : Object.keys(realTimeEndpointsForLambdas).filter( 19 | (key) => !realTimeEndpointsForLambdas[key], 20 | ) 21 | ).map((key) => { 22 | const found = lambdaFunctions.find((func) => func.lambdaCdkPath === key); 23 | if (!found) throw new Error('Lambda key not found'); // should never happen. 24 | return found; 25 | }); 26 | 27 | // Keyed by the endpoint, values are arrays of lambda details 28 | const realTimeLogsFunctionMap = forceCloudwatch 29 | ? {} 30 | : Object.keys(realTimeEndpointsForLambdas) 31 | .filter((key) => !!realTimeEndpointsForLambdas[key]) 32 | .reduce((current, nextKey) => { 33 | const endpoint = realTimeEndpointsForLambdas[nextKey] as string; 34 | return { 35 | ...current, 36 | [endpoint]: [ 37 | ...(current[endpoint] || []), 38 | lambdaFunctions.find( 39 | ({lambdaCdkPath}) => lambdaCdkPath === nextKey, 40 | ) as LambdaDetail, 41 | ], 42 | }; 43 | }, {} as Record); 44 | 45 | cloudwatchFunctions.forEach((lambda) => { 46 | const logger = createCLILoggerForLambda( 47 | lambda.lambdaCdkPath, 48 | lambdaFunctions.length > 1, 49 | ); 50 | tailCloudWatchLogsForLambda(lambda.functionName) 51 | .on('log', (log) => logger.log(log.toString())) 52 | .on('error', (log) => logger.error(log)); 53 | }); 54 | 55 | const loggers = Object.values(realTimeLogsFunctionMap) 56 | .flat() 57 | .reduce( 58 | (curr, detail) => ({ 59 | ...curr, 60 | [detail.functionName]: createCLILoggerForLambda( 61 | detail.lambdaCdkPath, 62 | lambdaFunctions.length > 1, 63 | ), 64 | }), 65 | {} as Record>, 66 | ); 67 | Object.keys(realTimeLogsFunctionMap).forEach((key) => { 68 | tailRealTimeLogsForLambdas( 69 | key, 70 | realTimeLogsFunctionMap[key].map(({functionName}) => functionName), 71 | ) 72 | .on('log', (log) => { 73 | if (loggers[log.lambda]) { 74 | loggers[log.lambda].log(...log.log); 75 | } else { 76 | // eslint-disable-next-line no-console 77 | console.log(...log.log); 78 | } 79 | }) 80 | .on('disconnect', () => { 81 | // eslint-disable-next-line no-console 82 | console.error(`Logs WebSocket Disconnected`); 83 | process.exit(1); 84 | }) 85 | .on('error', (error) => { 86 | // eslint-disable-next-line no-console 87 | console.error(`WebSocket Error`, error); 88 | process.exit(1); 89 | }); 90 | }); 91 | }; 92 | 93 | export {tailLogsForLambdas}; 94 | -------------------------------------------------------------------------------- /src/lib/tailLogsForLambdas/logToString.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import {inspect} from 'util'; 3 | import JSON5 from 'json5'; 4 | import {LambdaLog} from './parseCloudwatchLog'; 5 | 6 | const asJson = (msgParam: string) => { 7 | try { 8 | return JSON5.parse(msgParam) as Record; 9 | } catch { 10 | return null; 11 | } 12 | }; 13 | 14 | const logLevelColorMap = { 15 | emerg: chalk.bgRedBright, 16 | alert: chalk.bgRed, 17 | crit: chalk.bgRed, 18 | error: chalk.red, 19 | warning: chalk.yellow, 20 | warn: chalk.yellow, 21 | notice: chalk.blue, 22 | info: chalk.blue, 23 | debug: chalk.green, 24 | }; 25 | 26 | const colorFromLogLevel = (level: keyof typeof logLevelColorMap) => { 27 | const color = logLevelColorMap[level] || logLevelColorMap.info; 28 | return color(level); 29 | }; 30 | 31 | const isLogLevelObject = (log: Record) => { 32 | return ( 33 | log.level && 34 | log.message && 35 | (logLevelColorMap as Record)[log.level] 36 | ); 37 | }; 38 | 39 | const prettyJsonString = (jsonLog: Record) => { 40 | return inspect(jsonLog, {colors: true, depth: null}); 41 | }; 42 | 43 | const formatJsonLog = (log: string | Record) => { 44 | const jsonLog = (typeof log === 'object' ? log : asJson(log)) as Record< 45 | string, 46 | any 47 | >; 48 | if (!jsonLog) return log; 49 | if (isLogLevelObject(jsonLog)) { 50 | const logLevelMessageAsJsonOrString = 51 | asJson(jsonLog.message) || jsonLog.message; 52 | return [ 53 | `[${colorFromLogLevel(jsonLog.level)}]`, 54 | typeof logLevelMessageAsJsonOrString === 'object' 55 | ? prettyJsonString(logLevelMessageAsJsonOrString) 56 | : logLevelMessageAsJsonOrString, 57 | ].join(' '); 58 | } 59 | return prettyJsonString(jsonLog); 60 | }; 61 | 62 | export const logToString = (log: LambdaLog): string => { 63 | const time = log.info.timestamp 64 | .toLocaleTimeString() 65 | .split(':') 66 | .slice(0, 3) 67 | .map((num) => chalk.blue(num)) 68 | .join(':'); 69 | 70 | switch (log.event) { 71 | case 'START': 72 | case 'END': 73 | case 'REPORT': { 74 | const report = [ 75 | time, 76 | chalk.yellow(log.event), 77 | chalk.gray(log.info.requestId), 78 | ] 79 | .filter(Boolean) 80 | .join(' '); 81 | if (log.event === 'REPORT') { 82 | return [ 83 | report, 84 | [ 85 | log.info.duration, 86 | log.info.billedDuration, 87 | log.info.initDuration, 88 | log.info.maxMemoryUsed, 89 | ] 90 | .filter(Boolean) 91 | .join(' '), 92 | ].join(': '); 93 | } 94 | return report; 95 | } 96 | case 'JSON_LOG': 97 | case 'NATIVE_LOG': { 98 | return [time, formatJsonLog(log.message)].join(' '); 99 | } 100 | case 'UNKNOWN': 101 | return [time, log.message].join(' '); 102 | default: 103 | return [time, log.message].join(' '); 104 | } 105 | }; 106 | -------------------------------------------------------------------------------- /src/lib/tailLogsForLambdas/parseCloudwatchLog.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import {logToString} from './logToString'; 3 | 4 | const asJson = (msgParam: string) => { 5 | try { 6 | return JSON.parse(msgParam) as Record; 7 | } catch { 8 | return null; 9 | } 10 | }; 11 | 12 | const isDate = (str: string) => { 13 | return !Number.isNaN(new Date(str).getTime()); 14 | }; 15 | 16 | const KNOWN_NATIVE_LOG_LEVELS = ['ERROR', 'INFO', 'WARN']; 17 | const KNOWN_ERROR_MESSAGES = [ 18 | 'Unknown application error occurredError', 19 | 'Process exited before completing request', 20 | ]; 21 | 22 | const reportMessageToObject = ( 23 | message: string, 24 | prefix: string, 25 | ): Record => { 26 | if (prefix === 'START') { 27 | // Start comes through a little different without the use of `\t`. So using 28 | // this ugly hack to extract only the request id for now! 29 | const [RequestId] = message 30 | .replace(`${prefix} RequestId: `, '') 31 | .split(' Version: '); 32 | return {RequestId}; 33 | } 34 | return Object.fromEntries( 35 | message 36 | .replace(`${prefix} `, '') 37 | .split('\t') 38 | .map((part) => { 39 | return part.split(': '); 40 | }), 41 | ); 42 | }; 43 | 44 | const parseStartEvent = (message: string, timestamp: Date) => { 45 | const objectified = reportMessageToObject(message, 'START'); 46 | return { 47 | raw: message, 48 | event: 'START', 49 | message, 50 | info: {requestId: objectified.RequestId, timestamp}, 51 | toString(): string { 52 | return logToString(this); 53 | }, 54 | } as const; 55 | }; 56 | 57 | const parseEndEvent = (message: string, timestamp: Date) => { 58 | const objectified = reportMessageToObject(message, 'END'); 59 | return { 60 | raw: message, 61 | event: 'END', 62 | message, 63 | info: {requestId: objectified.RequestId, timestamp}, 64 | toString(): string { 65 | return logToString(this); 66 | }, 67 | } as const; 68 | }; 69 | 70 | const parseReportEvent = (message: string, timestamp: Date) => { 71 | const objectified = reportMessageToObject(message, 'REPORT'); 72 | 73 | return { 74 | raw: message, 75 | event: 'REPORT', 76 | message, 77 | info: { 78 | requestId: objectified.RequestId, 79 | duration: objectified.Duration, 80 | billedDuration: objectified['Billed Duration'], 81 | memorySize: objectified['Memory Size'], 82 | maxMemoryUsed: objectified['Max Memory Used'], 83 | initDuration: objectified['Init Duration'], 84 | timestamp, 85 | }, 86 | toString(): string { 87 | return logToString(this); 88 | }, 89 | } as const; 90 | }; 91 | 92 | // TODO: Fix inferred type, use class. 93 | const parseCloudWatchLog = (log: string, timestamp: Date) => { 94 | const msg = log.replace(os.EOL, ''); 95 | 96 | if (msg.startsWith('START')) { 97 | return parseStartEvent(msg, timestamp); 98 | } 99 | 100 | if (msg.startsWith('END')) { 101 | return parseEndEvent(msg, timestamp); 102 | } 103 | 104 | if (msg.startsWith('REPORT')) { 105 | return parseReportEvent(msg, timestamp); 106 | } 107 | 108 | if (KNOWN_ERROR_MESSAGES.includes(msg.trim())) { 109 | return { 110 | raw: msg, 111 | event: 'ERROR', 112 | message: msg.trim(), 113 | info: { 114 | requestId: undefined, 115 | level: 'ERROR', 116 | timestamp, 117 | }, 118 | toString(): string { 119 | return logToString(this); 120 | }, 121 | } as const; 122 | } 123 | 124 | const jsonMessage = asJson(msg); 125 | if (jsonMessage) { 126 | return { 127 | raw: msg, 128 | event: 'JSON_LOG', 129 | message: jsonMessage, 130 | info: {timestamp}, 131 | toString(): string { 132 | return logToString(this); 133 | }, 134 | } as const; 135 | } 136 | 137 | const splitMessage = msg.split('\t'); 138 | 139 | if (splitMessage.length < 3) { 140 | return { 141 | raw: msg, 142 | event: 'UNKNOWN', 143 | message: msg, 144 | info: {timestamp}, 145 | toString(): string { 146 | return logToString(this); 147 | }, 148 | } as const; 149 | } 150 | 151 | let date = ''; 152 | let reqId = ''; 153 | let level = ''; 154 | let text = ''; 155 | let textParts = []; 156 | if (isDate(splitMessage[0])) { 157 | if (KNOWN_NATIVE_LOG_LEVELS.includes(splitMessage[2])) { 158 | [date, reqId, level, ...textParts] = splitMessage; 159 | text = textParts.join(`\t`); 160 | } else { 161 | [date, reqId, ...textParts] = splitMessage; 162 | text = textParts.join(`\t`); 163 | } 164 | } else if (isDate(splitMessage[1])) { 165 | [level, date, reqId, ...textParts] = splitMessage; 166 | text = textParts.join(`\t`); 167 | } else { 168 | return { 169 | raw: msg, 170 | event: 'UNKNOWN', 171 | message: msg, 172 | info: {timestamp}, 173 | toString(): string { 174 | return logToString(this); 175 | }, 176 | } as const; 177 | } 178 | return { 179 | raw: msg, 180 | event: 'NATIVE_LOG', 181 | message: text, 182 | info: { 183 | timestamp: new Date(date), 184 | requestId: reqId === 'undefined' ? undefined : reqId, 185 | level, 186 | }, 187 | toString(): string { 188 | return logToString(this); 189 | }, 190 | } as const; 191 | }; 192 | 193 | export type LambdaLog = ReturnType; 194 | 195 | export {parseCloudWatchLog}; 196 | -------------------------------------------------------------------------------- /src/lib/tailLogsForLambdas/resolveLogEndpointDetailsFromLambdas.ts: -------------------------------------------------------------------------------- 1 | import {ApiGatewayV2, CloudFormation} from 'aws-sdk'; 2 | import {LambdaDetail} from '../../types.d'; 3 | 4 | // @todo: optimise - potentially by memoizing calls for resources 5 | export const resolveLogEndpointDetailsFromLambdas = async ( 6 | lambdas: LambdaDetail[], 7 | ): Promise<{[lambdaPath: string]: string | undefined}> => { 8 | const cfn = new CloudFormation({maxRetries: 10}); 9 | const apigw = new ApiGatewayV2({maxRetries: 10}); 10 | return Promise.all( 11 | lambdas.map(async (lambda) => { 12 | if ( 13 | !lambda.lambdaManifest.realTimeLogsStackLogicalId || 14 | !lambda.lambdaManifest.realTimeLogsApiLogicalId 15 | ) 16 | return [lambda.lambdaCdkPath, undefined]; 17 | const logsStackResource = await cfn 18 | .describeStackResource({ 19 | StackName: lambda.lambdaManifest.rootStackName, 20 | LogicalResourceId: lambda.lambdaManifest.realTimeLogsStackLogicalId, 21 | }) 22 | .promise(); 23 | 24 | if (!logsStackResource.StackResourceDetail?.PhysicalResourceId) { 25 | throw new Error( 26 | 'Could not find resource for real-time logs api, make sure your stack is up-to-date', 27 | ); 28 | } 29 | const logsApiResource = await cfn 30 | .describeStackResource({ 31 | StackName: logsStackResource.StackResourceDetail?.PhysicalResourceId, 32 | LogicalResourceId: lambda.lambdaManifest.realTimeLogsApiLogicalId, 33 | }) 34 | .promise(); 35 | 36 | if (!logsApiResource.StackResourceDetail?.PhysicalResourceId) { 37 | throw new Error( 38 | 'Could not find resource for real-time logs api, make sure your stack is up-to-date', 39 | ); 40 | } 41 | 42 | const res = await apigw 43 | .getApi({ 44 | ApiId: logsApiResource.StackResourceDetail.PhysicalResourceId, 45 | }) 46 | .promise(); 47 | 48 | return [lambda.lambdaCdkPath, `${res.ApiEndpoint}/v1`]; 49 | }), 50 | ).then(Object.fromEntries); 51 | }; 52 | -------------------------------------------------------------------------------- /src/lib/tailLogsForLambdas/tailCloudWatchLogsForLambda.ts: -------------------------------------------------------------------------------- 1 | import {AWSError, CloudWatchLogs} from 'aws-sdk'; 2 | import EventEmitter from 'events'; 3 | import {parseCloudWatchLog} from './parseCloudwatchLog'; 4 | 5 | interface LogEventEmitter extends EventEmitter { 6 | on( 7 | event: 'log', 8 | cb: (log: ReturnType) => void, 9 | ): this; 10 | on(event: 'error', cb: (error: Error) => void): this; 11 | } 12 | 13 | export const tailCloudWatchLogsForLambda = ( 14 | lambdaName: string, 15 | ): LogEventEmitter => { 16 | const logGroupName = `/aws/lambda/${lambdaName}`; 17 | const cloudWatchLogs = new CloudWatchLogs(); 18 | let startTime = Date.now(); 19 | const emitter = new EventEmitter(); 20 | 21 | const getNextLogs = async () => { 22 | const {logStreams} = await cloudWatchLogs 23 | .describeLogStreams({ 24 | logGroupName, 25 | descending: true, 26 | limit: 10, 27 | orderBy: 'LastEventTime', 28 | }) 29 | .promise(); 30 | 31 | const logStreamNames = ( 32 | logStreams?.map(({logStreamName}) => logStreamName) || [] 33 | ).filter(Boolean) as string[]; 34 | 35 | const {events} = await cloudWatchLogs 36 | .filterLogEvents({ 37 | logGroupName, 38 | logStreamNames, 39 | startTime, 40 | interleaved: true, 41 | limit: 50, 42 | }) 43 | .promise(); 44 | 45 | if (events?.length) { 46 | events.forEach((log) => { 47 | if (log.message) { 48 | emitter.emit( 49 | 'log', 50 | parseCloudWatchLog( 51 | log.message, 52 | log.timestamp ? new Date(log.timestamp) : new Date(), 53 | ), 54 | ); 55 | } 56 | }); 57 | 58 | startTime = (events[events.length - 1]?.timestamp || Date.now()) + 1; 59 | } 60 | }; 61 | 62 | let hasReportedResourceNotFoundException = false; 63 | const poll = () => { 64 | getNextLogs().catch((error: Error | AWSError) => { 65 | if ( 66 | error.name === 'ResourceNotFoundException' && 67 | !hasReportedResourceNotFoundException 68 | ) { 69 | hasReportedResourceNotFoundException = true; 70 | // eslint-disable-next-line no-param-reassign 71 | error.message = `Lambda Log Group not found, this could mean it has not yet been invoked.`; 72 | emitter.emit('error', error); 73 | } 74 | }); 75 | }; 76 | 77 | setInterval(poll, 1000); 78 | setImmediate(poll); 79 | 80 | return emitter; 81 | }; 82 | -------------------------------------------------------------------------------- /src/lib/tailLogsForLambdas/tailRealTimeLogsForLambdas.ts: -------------------------------------------------------------------------------- 1 | import * as aws4 from 'aws4'; 2 | import * as AWS from 'aws-sdk'; 3 | import WebSocket from 'ws'; 4 | import ReconnectingWebSocket from 'reconnecting-websocket'; 5 | import {URL} from 'url'; 6 | import EventEmitter from 'events'; 7 | import {Log} from '../../lambda-extension/cdk-watch-lambda-wrapper/patchConsole'; 8 | 9 | interface LogEventEmitter extends EventEmitter { 10 | on(event: 'log', cb: (log: Log) => void): this; 11 | on(event: 'disconnect', cb: () => void): this; 12 | on(event: 'connect', cb: () => void): this; 13 | on(event: 'error', cb: (error: Error) => void): this; 14 | } 15 | 16 | const tailRealTimeLogsForLambdas = ( 17 | endpoint: string, 18 | lambdas: string[], 19 | ): LogEventEmitter => { 20 | const emitter = new EventEmitter(); 21 | 22 | const url = new URL(endpoint); 23 | url.searchParams.append('lambdas', lambdas.join(',')); 24 | const signedUrl = aws4.sign( 25 | { 26 | hostname: url.hostname, 27 | path: url.pathname + url.search, 28 | method: 'GET', 29 | }, 30 | AWS.config.credentials, 31 | ); 32 | 33 | class ReconnectWebSocket extends WebSocket { 34 | constructor( 35 | address: string, 36 | protocols?: string | string[], 37 | options?: WebSocket.ClientOptions, 38 | ) { 39 | super(address, protocols, {...options, headers: signedUrl.headers}); 40 | } 41 | } 42 | 43 | const socket = new ReconnectingWebSocket( 44 | `wss://${signedUrl.hostname}${signedUrl.path}`, 45 | [], 46 | { 47 | WebSocket: ReconnectWebSocket, 48 | }, 49 | ); 50 | 51 | socket.onopen = () => { 52 | emitter.emit('connect'); 53 | setInterval(() => { 54 | socket.send('ping'); 55 | }, 60 * 1000); 56 | setImmediate(() => { 57 | socket.send('ping'); 58 | }); 59 | }; 60 | 61 | socket.onclose = () => { 62 | emitter.emit('disconnect'); 63 | }; 64 | 65 | socket.onerror = (error) => { 66 | emitter.emit('error', error); 67 | }; 68 | 69 | socket.onmessage = ({data}) => { 70 | const logs: Log[] = JSON.parse(data); 71 | if (Array.isArray(logs)) { 72 | logs.forEach((log) => { 73 | emitter.emit('log', log); 74 | }); 75 | } 76 | }; 77 | 78 | return emitter; 79 | }; 80 | 81 | export {tailRealTimeLogsForLambdas}; 82 | -------------------------------------------------------------------------------- /src/lib/twisters.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import Twisters from 'twisters'; 3 | 4 | export const twisters = new Twisters<{prefix?: string; error?: Error}>({ 5 | pinActive: true, 6 | messageDefaults: { 7 | render: (message, frame) => { 8 | const {active, text, meta} = message; 9 | const prefix = meta?.prefix ? `${meta?.prefix} ` : ''; 10 | const completion = meta?.error 11 | ? `error: ${chalk.red(meta.error.toString())}` 12 | : 'done'; 13 | return active && frame 14 | ? `${prefix}${text}... ${frame}` 15 | : `${prefix}${text}... ${completion}`; 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/lib/updateLambdaFunctionCode.ts: -------------------------------------------------------------------------------- 1 | import {AWSError, Lambda} from 'aws-sdk'; 2 | import {PromiseResult} from 'aws-sdk/lib/request'; 3 | import {zipDirectory} from './zipDirectory'; 4 | 5 | export const updateLambdaFunctionCode = async ( 6 | watchOutdir: string, 7 | functionName: string, 8 | ): Promise> => { 9 | const lambda = new Lambda({maxRetries: 10}); 10 | return zipDirectory(watchOutdir).then((zip) => { 11 | return lambda 12 | .updateFunctionCode({ 13 | FunctionName: functionName as string, 14 | ZipFile: zip, 15 | }) 16 | .promise(); 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/writeManifest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs-extra'; 3 | import {CdkWatchManifest} from '../types.d'; 4 | import {CDK_WATCH_MANIFEST_FILE_NAME} from '../consts'; 5 | 6 | export const writeManifest = (manifest: CdkWatchManifest): void => { 7 | const cdkOut = path.join(process.cwd(), 'cdk.out'); 8 | const manifestPath = path.join(cdkOut, CDK_WATCH_MANIFEST_FILE_NAME); 9 | fs.ensureDirSync(cdkOut); 10 | fs.writeJsonSync(manifestPath, manifest); 11 | }; 12 | -------------------------------------------------------------------------------- /src/lib/zipDirectory.ts: -------------------------------------------------------------------------------- 1 | import {WritableStreamBuffer} from 'stream-buffers'; 2 | import archiver from 'archiver'; 3 | 4 | export const zipDirectory = (pathToDir: string): Promise => 5 | new Promise((res, rej) => { 6 | const output = new WritableStreamBuffer(); 7 | const archive = archiver('zip', { 8 | zlib: {level: 9}, 9 | }); 10 | archive.on('error', rej); 11 | output.on('error', rej); 12 | output.on('finish', () => { 13 | const contents = output.getContents(); 14 | if (contents) res(contents); 15 | else rej(new Error('No buffer contents')); 16 | }); 17 | archive.directory(pathToDir, false); 18 | archive.pipe(output); 19 | archive.finalize(); 20 | }); 21 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | export interface LambdaManifestType { 4 | assetPath: string; 5 | esbuildOptions: esbuild.BuildOptions; 6 | lambdaLogicalId: string; 7 | rootStackName: string; 8 | nestedStackLogicalIds: string[]; 9 | realTimeLogsApiLogicalId: string | undefined; 10 | realTimeLogsStackLogicalId: string | undefined; 11 | nodeModulesLayerVersion: string | undefined; 12 | } 13 | 14 | export interface LambdaMap { 15 | [lambdaCdkPath: string]: LambdaManifestType; 16 | } 17 | 18 | export interface CdkWatchManifest { 19 | region: string; 20 | lambdas: LambdaMap; 21 | } 22 | 23 | export interface LambdaDetail { 24 | functionName: string; 25 | lambdaCdkPath: string; 26 | lambdaManifest: LambdaManifestType; 27 | layers: string[]; 28 | } 29 | -------------------------------------------------------------------------------- /src/websocketHandlers/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | /** 4 | * These handlers are called by the realtime logs API Gateway (not by the 5 | * library itself). 6 | */ 7 | 8 | import * as AWS from 'aws-sdk'; 9 | 10 | const dynamoDb = new AWS.DynamoDB.DocumentClient({ 11 | apiVersion: '2012-08-10', 12 | region: process.env.AWS_REGION, 13 | }); 14 | 15 | export const onConnect = async ( 16 | event: AWSLambda.APIGatewayProxyWithLambdaAuthorizerEvent, 17 | ): Promise => { 18 | const lambdaIds = event.queryStringParameters?.lambdas?.split(',') ?? []; 19 | 20 | if (!lambdaIds.length) 21 | return { 22 | statusCode: 400, 23 | body: 'You must provide at least one lambda parameter', 24 | }; 25 | 26 | const putParams = lambdaIds.map((lambdaId: string) => ({ 27 | TableName: process.env.CDK_WATCH_CONNECTION_TABLE_NAME as string, 28 | Item: { 29 | connectionId: event.requestContext.connectionId, 30 | lambdaId, 31 | }, 32 | })); 33 | 34 | try { 35 | await Promise.all( 36 | putParams.map((putParam) => dynamoDb.put(putParam).promise()), 37 | ); 38 | } catch (err) { 39 | console.error(err); 40 | return {statusCode: 500, body: `Failed to connect.`}; 41 | } 42 | 43 | return {statusCode: 200, body: 'Connected.'}; 44 | }; 45 | 46 | export const onDisconnect = async ( 47 | event: AWSLambda.APIGatewayProxyWithLambdaAuthorizerEvent, 48 | ): Promise => { 49 | try { 50 | await dynamoDb 51 | .delete({ 52 | TableName: process.env.CDK_WATCH_CONNECTION_TABLE_NAME as string, 53 | Key: { 54 | connectionId: event.requestContext.connectionId, 55 | }, 56 | }) 57 | .promise(); 58 | } catch (err) { 59 | console.error(err); 60 | return {statusCode: 500, body: `Failed to disconnect.`}; 61 | } 62 | 63 | return {statusCode: 200, body: 'Disconnected.'}; 64 | }; 65 | 66 | export const onMessage = async ( 67 | event: AWSLambda.APIGatewayProxyWithLambdaAuthorizerEvent, 68 | ): Promise => { 69 | if (event.body === 'ping') { 70 | return {statusCode: 200, body: 'pong'}; 71 | } 72 | return {statusCode: 422, body: 'wrong'}; 73 | }; 74 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": ["ES2020"], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./lib", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | }, 70 | } 71 | --------------------------------------------------------------------------------