I've been wanting to switch from the Serverless Framework to SAM for a long time now. While the Serverless Framework has been an excellent tool I only use AWS and they have good tooling/support for SAM that will only get better. Until now the major roadblock has been the lack of TypeScript support.
Over the last few day I've spent a lot of time reading the documentation for NPM, Webpack, TypeScript, SAM; looking at the SAM source code and messaging with Heitor Lessa who was also trying to solve the same problem. Between the two of us we've managed to solve the problem in slightly different ways.
This article describes my solution.
My number one requirement was to have something that worked with SAM build/package/deploy. TypeScript support for the Serverless Framework uses the serverless-webpack plugin so my first thought was writing a Node.js/Webpack builder for SAM.
Step 1: Building the app with Webpack
My first problem was building the app with Webpack. To do this I removed the existing devDependencies
from the package.json
in my functions folder (hello-world
) then I added all of the packages I would need to compile a project using Webpack to the devDependencies
.
npm install @babel/core @babel/preset-env @types/aws-lambda babel-loader ts-loader typescript webpack webpack-cli webpack-node-externals --save-dev
Knowing that I wanted source map support I also added a dependency for
npm install source-map-support
With that done I added a webpack.config.js
to build the project.
const nodeExternals = require("webpack-node-externals");
module.exports = {
devtool: "source-map",
resolve: {
extensions: [".js", ".ts"],
},
output: {
libraryTarget: "commonjs2",
},
target: "node",
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
},
},
},
{
test: /\.ts?$/,
loader: "ts-loader",
},
],
},
mode: "development",
};
This will use Babel to build .js
files and TypeScript for .ts
files.
You may have noticed that I'm only using Webpack to compile my code and that all of the NPM dependencies remain in the node_modules
folder because the webpack-node-externals
plugin is declaring them all as external. There were a few things that went into this decision and it's the single biggest difference between the two solutions that Heitor and I currently have.
The main reason I'm doing this is that compatibility with the sam build/package/deploy process was high on my wish list. When you run sam build
it performs an npm pack
to move the files into a new build folder then it runs npm install
because npm pack
doesn't copy the node_modules
folder. This is very unforunate because it undoes all of the advantages of using tree shaking in Webpack by adding all of the dependencies back into the deployment package. It would have been much better if SAM told people to use bundledDependencies
for any dependencies you want to include in the deployment package as npm pack
does copy those. This would have removed the need to perform an npm install
.
A secondary reason for doing this is that not all NPM packages are compatible with Webpack.
I also added a tsconfig.json
to the hello-world
folder with the configuration for TypeScript.
{
"compilerOptions": {
"target": "es2015",
"module": "commonjs",
"allowJs": true,
"checkJs": true,
"sourceMap": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Next I created a src
folder inside the hello-world
folder then moved the existing app.js
into it, converting it to a TypeScript file along the way.
With that done I could now compile my code into a build
folder by running Webpack from inside the hello-world
folder.
npx webpack-cli src/app.ts -c webpack.config.js -o build/app.js
After confirm it worked I added two scripts to my package.json
.
"watch": "webpack-cli src/app.ts -c webpack.config.js -o build/app.js -w",
"webpack": "webpack-cli src/app.ts -c webpack.config.js -o build/app.js",
Now go to your template.yaml
file and change the Handler
for your function from app.lambdaHandler
to build/app.lambdaHandler
.
Step 2: What about tests?
Jest is my preferred test framework and I know it works with Webpack. The first step was to add a few more devDependencies
to my package.json
.
npm install @types/jest jest ts-jest --save-dev
Then I added a jest.config.js
into the hello-world
folder.
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};
Finally I moved the old test
folder into src
, renaming it to __tests__
, and converted the file in it to TypeScript using Jest.
After confirming the tests worked when I ran npx jest
I updated the "test" script in my package.json
to execute jest
.
Step 3: Making it work with SAM build
SAM uses the npm pack
command to build the package. Running Webpack to build the project should have been as simple as renaming the "webpack" script to "prepack" so that NPM runs it before performing the pack. While this approach works if you're running npm pack
manually it fails when you run sam build
. At some point I'll investigate this problem further but for now I've added a build.sh
script into the root of the project which executes npm run-script webpack
in any folder immediately below the project root if it contains both a package.json
and webpack.config.js
to build all of my functions using Webpack.
#!/bin/sh
ROOT_DIR=$PWD
for dir in *; do
if [ -d $ROOT_DIR/$dir ] && [ -f $ROOT_DIR/$dir/package.json ] && [ -f $ROOT_DIR/$dir/webpack.config.js ]
then
echo $dir
cd $ROOT_DIR/$dir
npm run-script webpack
fi
done
To keep the source, tests and config files out of the final Lambda package I also modified the .npmignore
in the hello-world
folder to exclude those
src/*
jest.config.js
tsconfig.json
webpack.config.js
Finally I could now build the project using
./build.sh && sam build
Step 4: Debugging in VS Code
I've previously written about debugging Node.js Lambda with AWS SAM local and VS Code which covers how to use the VS Code debugger. Instead of repeating how to use the VS Code debugger I'm going to focus on the differences you need to know for TypeScript.
If you didn't include source-map-support
as a dependency in step 1 you'll need to add it. You'll also need to include it at the top of your handler file.
import "source-map-support/register";
Note: I had to increase the memory for my Lambda after doing this or my Lambda would fail without reporting an error when something went wrong due to running out of memory when generating new stack traces.
The only difference in my launch.json
is the addition of the sourceMapPathOverrides
.
{
"version": "0.2.0",
"configurations": [
{
"name": "hello-world",
"type": "node",
"request": "attach",
"address": "localhost",
"port": 5858,
"localRoot": "${workspaceRoot}/hello-world",
"remoteRoot": "/var/task",
"protocol": "inspector",
"stopOnEntry": false,
"sourceMapPathOverrides": {
"webpack:///./~/*": "${workspaceRoot}/hello-world/node_modules/*",
"webpack:///./*": "${workspaceRoot}/hello-world/*",
"webpack:///*": "*"
}
}
]
}
Adding that allows setting breakpoints in the .ts
file.
What next?
If you followed the article you should have
SAM + TypeScript + VS Code debugging working.
SAM build/package/deploy almost working exactly the same as plain Node (remember to run
build.sh
).You can debug using both
sam invoke
andsam start-api
.If you run
npm run-script watch
inside your function folder it will automatically recompile when you make changes to the source and the API reloading will work too.
There are some areas I would like to improve over the coming weeks.
The development dependencies are duplicated in each function which is inefficient.
You need to run
npm run-script watch
for every function you want to rebuild automatically.
Beyond that I've created an issue to add a Webpack Lambda builder for SAM. Hopefully with a dedicated builder the remaining issues can be fixed including full support for tree shaking.
The full source for this as available on GitHub. I want to give a big thanks to Heitor Lessa for his help.
If you want updates to this then follow me on Twitter and join the mailing list.