AWS SAM and Typescript: building functions and layers
This post is to address some issues when working with SAM using Typescript and Lambda Layers.
Recently, esbuild
support in AWS SAM CLI was made
generally available,
this makes it very easy and fast to build Typescript functions. Some of the pros
of using esbuild are:
- very fast transpiling
- very quick to setup
But there are also a few cons:
- esbuild does not have type checking (can be solved with tests)
- still not supported in Layers, they must to be built in a different way
- less control on build settings, must rely on what is exposed by SAM and its defaults
If you decide to use this new feature you will need to be careful as you will be building different parts of your code in different ways (different compilers or defaults), the following is one of the errors that prompted this post:
"errorType": "Runtime.UserCodeSyntaxError",
"errorMessage": "SyntaxError: Unexpected token 'export'",
The main takeaway is that currently, by using esbuild with SAM for λ functions, there is no way to change the format option, which is forced to be “cjs”. When building layers, for which at the moment we must use a different build system through a Makefile, we should always remember to transpile our code to use “commonjs” modules.
This post will provide an example of a working configuration to avoid some of the common pitfalls.
Setting up the project
SAM init
We will start from the official AWS SAM Typescript template
sam init --runtime nodejs16.x --app-template hello-world-typescript --name sam-typescript-functions-layers --package-type Zip --dependency-manager npm --no-interactive
Refactoring
We plan on having multiple λ functions, so let’s create a new top-level folder
named functions/
and move the hello-world/
folder there. We plan to achieve
a folder structure similar to the following:
sam-typescript-functions-layers/
├── functions/
│ └── hello-world/
└── layers/
└── commmons/
Adding a Typescript Layer
All of our λ functions will need a way to build a response object, we can use a Layer to share the following code as a module:
export const responseBuilder = (
payload: any,
statusCode: number = 200,
headers: any = { 'Content-Type': 'application/json' },
) => {
return {
statusCode,
headers,
body: JSON.stringify({ payload }),
};
};
We will create a Typescript Layer with the following folder structure:
layers/commons/
├── index.ts
├── Makefile
├── package.json
└── tsconfig.json
-
Put the
responseBuilder
code shown before into a new file namedindex.ts
-
Add the following configuration files:
// package.json { "description": "Lambda layer with common utils", "main": "index.ts", "name": "layer-commons", "version": "1.0.0", "devDependencies": { "@types/node": "^16.18.23", "typescript": "^5.0.3" }, "scripts": { "build": "node_modules/typescript/bin/tsc" } }
we added Typescript and Type Definitions for Node.js as dependencies, and a build script to use tsc as the transpiler.
// tsconfig.json { "compilerOptions": { "strict": true, "target": "es2021", "preserveConstEnums": true, "resolveJsonModule": true, "noEmit": false, "sourceMap": false, "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./commons" }, "exclude": ["node_modules", "**/*.test.ts"] }
Important:
"module"
should be set either to"node16"
or"commonjs"
, otherwise it will not be compatible with the code of our Lambdas built with esbuild."noEmit"
also needs to be set tofalse
. -
Create a
Makefile
to be used by SAM for transpiling the Typescript code and placing it insidenode_modules
:.PHONY: build-LayerCommons build-LayerCommons: npm install npm run build mkdir -p "$(ARTIFACTS_DIR)/nodejs/node_modules" cp package.json package-lock.json "$(ARTIFACTS_DIR)/nodejs/" # for runtime deps npm install --production --prefix "$(ARTIFACTS_DIR)/nodejs/" # for runtime deps rm "$(ARTIFACTS_DIR)/nodejs/package.json" # for runtime deps cp -r commons "$(ARTIFACTS_DIR)/nodejs/node_modules"
this will put the transpiled code inside the
/nodejs/node_modules/
folder of the layer alongside with the needed dependencies, so we will then be able to import the layer code in our functions as a Node.js module. -
Add the Layer inside
Resources:
in the globaltemplate.yaml
file# Inside Resources LayerCommons: Type: AWS::Serverless::LayerVersion Properties: ContentUri: layers/commons/ CompatibleRuntimes: - nodejs16.x RetentionPolicy: Delete Metadata: BuildMethod: makefile
Using the Layer inside a Lambda function
-
Reference the new layer in the λ functions that are using it:
# Inside HelloWorldFunction.Properties Layers: - !Ref LayerCommons
And then declare the js module inside the Layer as external, so it will be omitted from the final bundle of the λ function build:
# Inside HelloWorldFunction.Metadata.BuildProperties External: - commons
-
We can now import this module in our
.js
code with:import { responseBuilder } from 'commons';
We are now able to use the code from the Layer in our Lambda functions.
Further documentation:
Here are a few links worth reading about the topics of this post:
- Building Node.js Lambda functions with esbuild [AWS docs]
- Including library dependencies in a layer [AWS docs]
- Building Layers [AWS docs]
- esbuild Format option [esbuild docs]
- esbuild (and TypeScript) Beta Support Feedback [Github issue]
- SAM official Typescript template (no layers) [Github repo]