DEV Community

Cover image for Optimizing Typescript packages in Serverless Framework with esbuild
Robert Slootjes
Robert Slootjes

Posted on • Edited on

Optimizing Typescript packages in Serverless Framework with esbuild

Update December 16, 2024
This article applies to Serverless Framework v3 which is no longer maintained. You can seamlessly switch to the open source fork which is a drop-in replacement.


I'm a big fan of Serverless Framework as it takes care of a lot of things behind the scenes and offers many plugins. I do my work in Typescript and I was using serverless-plugin-typescript that takes care of transpiling into Javascript. Next to that, I was using a combination of 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 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 plugin which uses esbuild 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 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 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
Enter fullscreen mode Exit fullscreen mode

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);",
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (3)

Collapse
 
perpil profile image
perpil

I independently came across essentially the same config, but I noticed it wasn't doing tree shaking on the aws sdk v3 when I looked at the metadata using the esbuild bundle size analyzer. To enable tree shaking I needed the following additional configuration:

mainFields: ['module','main'],
treeShaking: true
Enter fullscreen mode Exit fullscreen mode

I found this missing piece reading this GitHub Issue

Looking at the source map, I found it included the json files from the node_modules. From this GitHub Issue issue I added this to my excludeVendorFromSourceMap plugin. I found that if I didn't used the js loader for the .json files in resources, I encountered errors with the xray sdk like this: ✖ Error: Error in sampling file. Invalid attribute for default: default. Valid attributes for default are "fixed_target" and "rate". So I needed to use the copy loader for those files.

build.onLoad({ filter: /node_modules.*\.json$/ }, (args) => {
      var json = fs.readFileSync(args.path, 'utf8');
      const sourceMapExcludeSuffix =
      '\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIiJdLCJtYXBwaW5ncyI6IkEifQ==';
      var js = `export default  ${json}${sourceMapExcludeSuffix}`;
      return args.path.includes('/resources')
        ? { contents: json, loader: 'copy' }
        : { contents: js, loader: 'js' };
    });
Enter fullscreen mode Exit fullscreen mode
Collapse
 
landfelix profile image
landfelix

For me the excludeVendorFromSourceMap plugin did not reduce the size of the source map. After a bit of fiddling around I found the reason: my includes array is empty, causing it to match against an empty regex that matches anything. I have modified the code to check for that case:

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');
        const includePattern = new RegExp(includes.join('|'), 'u');
        const fileIsIncluded = includes.length === 0
            ? () => false
            : (filepath) => includePattern.test(filepath.split(path.sep).join(path.posix.sep))

        build.onLoad({ filter: /node_modules/u }, async (args) => {
            if (fileIsIncluded(args.path)) {
                return;
            }

            if (/\.[mc]?js$/.test(args.path)) {
                return {
                    contents: `${await fs.promises.readFile(args.path, 'utf8')}${emptySourceMap}`,
                    loader: 'default',
                };
            }
        });
    },
});
Enter fullscreen mode Exit fullscreen mode
Collapse
 
caspergeek profile image
Eranda K.

Great insights.
Thanks for sharing.