Optimizing Typescript packages in Serverless Framework with esbuild
Tags: aws, serverless, typescript, esbuild
Published: 2023-05-12 12:44:54

I'm a big fan of [Serverless Framework](**https://www.serverless.com/framework**) as it takes care of a lot of things behind the scenes and offers many plugins. I do my work in [Typescript](https://www.typescriptlang.org/) and I was using [serverless-plugin-typescript](https://www.serverless.com/plugins/serverless-plugin-typescript) that takes care of transpiling into Javascript. Next to that, I was using a combination of [serverless-plugin-common-excludes](https://www.serverless.com/plugins/serverless-plugin-common-excludes) and a **huge** custom exclude list to reduce the size of the package (zip files) being uploaded to Lambda.

Doing a deploy would take around 2 minutes (on a Intel Core i5-13600K processor with 32GB RAM for who is interested) and in my project resulted in a 4MB package. While 4MB isn't a lot, it still contained a lot of code which wasn't actually used. The cold start time of the Lambda was around 4 seconds which bothered me a lot. While this seems to be caused by an [unresolved issue](https://github.com/aws/aws-lambda-base-images/issues/83) at AWS with the node 18 runtime, I felt like I shouldn't sit around and wait for a solution.

When looking for options to reduce the package size I stumbled upon the [serverless-esbuild](https://www.serverless.com/plugins/serverless-esbuild) plugin which uses [esbuild](https://esbuild.github.io/) which is supposed to be super fast _(spoiler alert: it is!)_. While the plugin works without configuration it felt like it was capable of more.

# Goals

I hoped to achieve the following things:

## Fast builds
While local invocation and tools like LocalStack are awesome, I prefer to just upload to AWS and run everything on the actual (dev) environment. Waiting for a deploy of several minutes can be really annoying so the faster it builds, the faster I can work.

## Small package size
Having a small package size has multiple advantages:

- Faster deployments
- Lower cold start time

## Proper stack traces
When something goes wrong unexpectedly and you go to CloudWatch it's very painful to only see unreadable stack traces because of bundled, minified code. This is where [source maps](https://web.dev/source-maps/) come in. Source maps can be used to transform bundled code back into their original file locations and line numbers.

The problem I faced with source maps is that once generated, they were huge (1 MB code, 8 MB source map) as it included _all_ the data from all external packages while usually the errors are within my own code. In case something went wrong, it would take _around 4 seconds_ to load and process the source map to generate the stack trace, that was unacceptable to me as Lambda is paid for by the millisecond. The slow processing of source maps seems to be an [issue](https://github.com/nodejs/node/issues/41541) from nodejs itself.

# Result
After fiddling around with configurations and reading many issues on Github I eventually ended up with the following configuration that seems to tick off the boxes for me:

## Configuration
_serverless.yaml_

```
plugins:
  - serverless-esbuild # enables esbuild plugin

package:
  individually: true # an optimized package per function

custom:
  esbuild:
    config: './esbuild.config.cjs' # external config file

environment:
  NODE_OPTIONS: '--enable-source-maps' # use source map if available
```

_esbuild.config.cjs_

```
const {Buffer} = require('node:buffer');
const fs = require('node:fs');
const path = require('node:path');

// inspired by https://github.com/evanw/esbuild/issues/1685
const excludeVendorFromSourceMap = (includes = []) => ({
    name: 'excludeVendorFromSourceMap',
    setup(build) {
        const emptySourceMap = '\n//# sourceMappingURL=data:application/json;base64,' + Buffer.from(JSON.stringify({
            version: 3,
            sources: [''],
            mappings: 'A',
        })).toString('base64');

        build.onLoad({filter: /node_modules/u}, async (args) => {
            if (/\.[mc]?js$/.test(args.path)
                && !new RegExp(includes.join('|'), 'u').test(args.path.split(path.sep).join(path.posix.sep))
            ) {
                return {
                    contents: `${await fs.promises.readFile(args.path, 'utf8')}${emptySourceMap}`,
                    loader: 'default',
                };
            }
        });
    },
});

module.exports = () => {
    return {
        format: 'esm',
        minify: true,
        sourcemap: true,
        sourcesContent: false,
        keepNames: false,
        outputFileExtension: '.mjs',
        plugins: [excludeVendorFromSourceMap(['@my-vendor', 'other/package'])],
        banner: {
            // https://github.com/evanw/esbuild/issues/1921
            js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
        }
    };
};
```

What this does:
- Targets the node version as configured in Serverless Framework (in my case: node 18)
- Package every function into a separate, optimized single file using tree shaking and minification
- Using ESM (instead of CJS) allowing things like top level await which can be very useful
- Create empty source maps for external packages _except_ for manually enabled ones to prevent having a giant source map
- Includes a "banner" to support packages that don't support ESM yet.
- Works on Windows and *nix machines

## The Good
- Extremely fast build process - it only takes miliseconds to transpile Typescript and package
- Really small package size
- Cold start went from around 4 seconds to 1.5 second, a massive improvement!
- Readable stack traces for my own source code and manually enabled vendor code

## The Less Good
- Even with a smaller source map it still takes node quite some time to process an error - this delay is around 100ms - 400ms for my use cases. Not super fast but way more acceptable than the 4 to 5 seconds without optimization obviously.
- If something unexpected now happens within vendor code, it won't show up in the stack trace unless it was configured to be included in the source maps.

# Verdict

While I was a bit skeptical at first to switch to esbuild, I'm quite happy with the end result. I hope this helps someone else to find a good configuration for their use case. If you have some tips or tricks that I've missed, please share them in the comments.