├── .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 |
--------------------------------------------------------------------------------