Serverless Framework with Typescript and Source Maps

Time to read: 9 minutes

The serverless framework is the original serverless deployment tool. It was my first introduction to serverless and a solid build tool. Lately I have been doing more terraform and aws-cdk to deploy mostly python and typescript.

In this instance, I would like to look at how to get setup with a typescript deployment to AWS Lambda using serverless framework.

Setup

Run the following to get a serverless project

# Choose node js
npx serverless
# Add a package json
npm init --force
# Add dev dependencies
yarn add --dev serverless serverless-esbuild typescript @types/node @types/aws-lambda
# Setup the tsconfig
npx tsc --init

Next we can update the serverless.yml to include the plugin, and custom configuration for serverless-esbuild

# serverless.yml
service: esbuild-example

frameworkVersion: '2'

provider:
  name: aws
  runtime: nodejs12.x
  lambdaHashingVersion: 20201221
  environment:
    # From Node 12.x this option is available,
    # so support-source-maps package is not needed
    NODE_OPTIONS: "--enable-source-maps"

functions:
  hello:
    handler: handler.hello

plugins:
  # Plugin will register into the
  # serverless framework lifecycle hooks
  - serverless-esbuild

custom:
  # esbuild options go here
  esbuild:
    bundle: true
    minify: false
    sourcemap: external

Change the handler file from handler.js to handler.ts. Now we can deploy the package with this configuration:

yarn sls deploy

Now we can look at the deployed code in the lambda handler and see that there is a source map available:

Code with two panels, one showing source code, the other showing source map

Typescript Code

So source maps would be useful, for example if we tried to perform a DynamoDB put request, and throw an error because we don't have permissions.

So we can add some code, and look at the debugging experience, with, and without source maps.

Adding some DynamoDB code; we can take the aws-sdk v3 for a spin:

yarn add  @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/util-dynamodb

The behavior of TCP Keep-alive is different in the AWS-SDK V3 for JS. Now, the default behavior is to keep connections alive (docs)

The default Node.js HTTP/HTTPS agent creates a new TCP connection for every new request. To avoid the cost of establishing a new connection, the SDK for JavaScript reuses TCP connections.

Previously this behavior was opt in with AWS_NODEJS_CONNECTION_REUSE_ENABLED=1.

Then in our handler we can put the client and document client together like:

import { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({})
const DocumentClient = DynamoDBDocumentClient.from(client)

export const hello: APIGatewayProxyHandlerV2 = async (event) => {
  try {
    await DocumentClient.send(new PutCommand({
      TableName: 'NoTable',
      Item: {
        pk: 'not',
        sk: 'real'
      }
    }))
  } catch (e) {
    // We can log our stack here to check that we get what is expected
    console.error(e)
  }

  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'Go Serverless v2.0! Your function executed successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};

Logs Without Source Maps

Now we can look at our logs and see how our source maps help, the first snippet here has the source maps disabled by removing the custom.esbuild.sourcemap attribute from the serverless.yml file

ERROR	AccessDeniedException: User: arn:aws:sts::322567890963:assumed-role/esbuild-example-dev-eu-west-1-lambdaRole/esbuild-example-dev-hello is not authorized to perform: dynamodb:PutItem on resource: arn:aws:dynamodb:eu-west-1:322567890963:table/NoTable
    at mR (/var/task/handler.js:1:121223)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
    at async /var/task/handler.js:1:217049
    at async /var/task/handler.js:18:2644
    at async F_.retry (/var/task/handler.js:18:31612)
    at async /var/task/handler.js:18:53241
    at async /var/task/handler.js:18:189508
    at async Runtime.dH [as handler] (/var/task/handler.js:18:198204) {
  __type: 'com.amazon.coral.service#AccessDeniedException',
  '$fault': 'client',
  '$metadata': {
    httpStatusCode: 400,
    requestId: 'GAQAJ7NHOELF7P35KI2NQ434D7VV4KQNSO5AEMVJF66Q9ASUAAJG',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  }
}

Logs with Source Maps

And with the source maps enabled with custom.esbuild.sourcemap: external the error log looks like below:

ERROR	AccessDeniedException: User: arn:aws:sts::322567890963:assumed-role/esbuild-example-dev-eu-west-1-lambdaRole/esbuild-example-dev-hello is not authorized to perform: dynamodb:PutItem on resource: arn:aws:dynamodb:eu-west-1:322567890963:table/NoTable
    at mR (/var/task/handler.js:1:121223)
        -> /node_modules/@aws-sdk/client-dynamodb/protocols/Aws_json1_0.ts:3625:39
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
    at async /var/task/handler.js:1:217049
        -> /node_modules/@aws-sdk/middleware-serde/src/deserializerMiddleware.ts:18:20
    at async /var/task/handler.js:18:2644
        -> /node_modules/@aws-sdk/middleware-signing/src/middleware.ts:26:22
    at async F_.retry (/var/task/handler.js:18:31612)
        -> /node_modules/@aws-sdk/middleware-retry/src/StandardRetryStrategy.ts:83:38
    at async /var/task/handler.js:18:53241
        -> /node_modules/@aws-sdk/middleware-logger/src/loggerMiddleware.ts:22:22
    at async /var/task/handler.js:18:189508
        -> /node_modules/@aws-sdk/lib-dynamodb/src/commands/PutCommand.ts:72:20
    at async Runtime.dH [as handler] (/var/task/handler.js:18:198204)
        -> /handler.ts:10:5 {
  __type: 'com.amazon.coral.service#AccessDeniedException',
  '$fault': 'client',
  '$metadata': {
    httpStatusCode: 400,
    requestId: '26DDD46C5137EV4SIK21F3EHCNVV4KQNSO5AEMVJF66Q9ASUAAJG',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  }
}

The second error log is much better, and also, the line numbers correspond to the source typescript code, not the transpiled and minified javascript; making it more easy to debug.

/handler.ts:10:5 ->
  await DocumentClient.send(new PutCommand({

Wrap-Up

Serverless plugins continue to make it easy to use best practices when writing code for lambda. I was impressed with how easy esbuild was to integrate.

My own opinion is that configuring babel/webpack/parcel and others is undifferentiated heavy lifting by the definition Jeff Bezos used in 2006:

all of the hard IT work that companies do that doesn’t add value to the mission of the company

So its good to be able to avoid that (when possible), especially since the best code, is code never written 👍