aws-sdk-js-v3 icon indicating copy to clipboard operation
aws-sdk-js-v3 copied to clipboard

Client packages can't be tree-shaken properly

Open spaceemotion opened this issue 11 months ago • 5 comments

Checkboxes for prior research

Describe the bug

I noticed that our lambda bundle contains pretty much all S3 and DynamoDB commands, even though I recently refactored to use the smaller, non-aggregated clients instead.

Image

Image

Regression Issue

  • [x] Select this option if this issue appears to be a regression.

SDK version number

@aws-sdk/client-s3 3.749.0, @aws-sdk/client-dynamodb 3.749.0

Which JavaScript Runtime is this issue in?

Node.js

Details of the browser/Node.js/ReactNative version

v22.2.0

Reproduction Steps

Our setup: esbuild, using the following config:

{
  bundle: true,
  treeShaking: true,
  minify: true,
  sourcemap: true,

  legalComments: "none",

  platform: "node",
  target: "es2020",
  mainFields: ['module', 'main'],

  conditions: [
    'wintercg',
    'import',
    'module',
    'node',
  ],

  define: {
    'process.env.NODE_ENV': '"production"',
  },

  loader: {
    ".node": "file",
  },

  alias: {
    'lodash': 'lodash-es',
  },
}

Observed Behavior

From what I can tell, the tree shaking stops working because the "S3.js" and "DynamoDB.js" files include a list of all commands to support on the clients. Even though we're not importing those files/clients (and use the raw S3Client and DynamoDBClient instead), the file likely still gets evaluated:

https://github.com/aws/aws-sdk-js-v3/blob/5f1be938f93fdb47c9c2673b4f8883a61f98aca8/clients/client-dynamodb/src/DynamoDB.ts#L1153

https://github.com/aws/aws-sdk-js-v3/blob/5f1be938f93fdb47c9c2673b4f8883a61f98aca8/clients/client-s3/src/S3.ts#L2096

There already has been an issue up on StackOverflow about this:

https://stackoverflow.com/q/79169593/1397894

Here is an analysis by esbuild:

Imported file src/services/dynamoDbClient.ts contains:
  import "@aws-sdk/client-dynamodb";

Imported file ../../node_modules/.pnpm/@[email protected]/node_modules/@aws-sdk/client-dynamodb/dist-es/index.js contains:
  import "./commands";

Imported file ../../node_modules/.pnpm/@[email protected]/node_modules/@aws-sdk/client-dynamodb/dist-es/commands/index.js contains:
  import "./DescribeKinesisStreamingDestinationCommand";

So imported file ../../node_modules/.pnpm/@[email protected]/node_modules/@aws-sdk/client-dynamodb/dist-es/commands/DescribeKinesisStreamingDestinationCommand.js is included in the bundle.

Expected Behavior

Only the commands used should be included in the bundle.

Possible Solution

When importing the S3 client directly, via using the path directly, the issue goes away. However, this is impractical for the DynamoDB client, as we'd need to do the same for every command import that we use.

Additional Information/Context

This issue is based on taking the recommendations mentioned in https://github.com/aws/aws-sdk-js-v3/issues/3542 already into account.

spaceemotion avatar Feb 16 '25 17:02 spaceemotion

After a bit more digging, I wrote a custom esbuild plugin that only tries to include the commands we actually need. However, there are still a couple files that don't seem to be necessarily included in full?

{
  name: 'exclude-aggregated clients',
  setup(build) {
    build.onLoad({ filter: /@aws-sdk\/client-dynamodb\/dist-es\/DynamoDB\.js$/ }, () => {
      return {
        contents: 'export default {};',
        loader: 'js',
      };
    });

    build.onLoad({ filter: /@aws-sdk\/client-dynamodb\/dist-es\/commands\/index\.js$/ }, () => {
      const commands = [
        'BatchGetItemCommand',
        'BatchWriteItemCommand',
        'GetItemCommand',
        'PutItemCommand',
        'UpdateItemCommand',
        'DeleteItemCommand',
        'QueryCommand',
      ];

      return {
        contents: commands.map((command) => `export * from './${command}';`).join('\n'),
        loader: 'js',
      };
    });

    build.onLoad({ filter: /@aws-sdk\/client-s3\/dist-es\/S3\.js$/ }, () => {
      return {
        contents: 'export default {};',
        loader: 'js',
      };
    });
  },
}

There's a large AWS_json file that gets included by various commands, but for some reason it's not just pulling in the ones needed (the import sits at 33kb)

Imported file src/services/dynamoDbClient.ts contains:
  import "@aws-sdk/client-dynamodb/dist-es/DynamoDBClient.js";

Imported file ../../node_modules/.pnpm/@[email protected]/node_modules/@aws-sdk/client-dynamodb/dist-es/DynamoDBClient.js contains:
  import "./commands/DescribeEndpointsCommand";

Imported file ../../node_modules/.pnpm/@[email protected]/node_modules/@aws-sdk/client-dynamodb/dist-es/commands/DescribeEndpointsCommand.js contains:
  import "../protocols/Aws_json1_0";

So imported file ../../node_modules/.pnpm/@[email protected]/node_modules/@aws-sdk/client-dynamodb/dist-es/protocols/Aws_json1_0.js is included in the bundle.

spaceemotion avatar Feb 16 '25 18:02 spaceemotion

I also asked over at the esbuild project and got a reply that this is indeed something that AWS has to fix: https://github.com/evanw/esbuild/issues/1698#issuecomment-2661644387

spaceemotion avatar Feb 16 '25 22:02 spaceemotion

When I run this I only get one Command in the bundle

// index.ts
export { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
	npx esbuild --bundle index.ts --outfile=dist/bundle.js --format=esm \
		--main-fields=module,main --platform=node  \
		--external:@aws-sdk/client-sts \
		--external:@aws-sdk/client-sso* \
		--external:@aws-sdk/credential-provider-* \
		--external:@aws-sdk/token-providers \
		--external:fast-xml-parser

esbuild 0.21.4

kuhe avatar Feb 19 '25 17:02 kuhe

@kuhe Thanks for the test! Your code does work as expected (well, it does pull in one extra command, but that's fine). Is there a reason why you made those specific packages external? From the AWS blogs and such I read that the AWS SDK should be included in the bundle - or are there exceptions for specific packages?

I think I have found the culprit of my issue though, and it looks like I caught a weird case: In the lambda code there's an async import, which itself pulls in only one exported function (+ client) from the dynamodb service file I have.

As soon as I added that async boundary to my test file, the tree shaking stopped working. Once I import the files like normal, only the necessary bits get included. I'll ask Evan if that's expected behavior.


Edit: Here's a test case that pulls everything in:

// test.ts
export const migrations = {
  'add-users': import('./migrations/20250101-add-users.ts'),
};
// 20250101-add-users.ts
import { QueryCommand } from '@aws-sdk/client-dynamodb';

export default function migrate() {
  new QueryCommand();
}

spaceemotion avatar Feb 19 '25 17:02 spaceemotion

I externalized some packages because they are part of the AWS SDK default credential provider chain that may be unnecessary in Lambda. That part is optional and shouldn't affect the tree-shaking.

kuhe avatar Feb 19 '25 20:02 kuhe

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs and link to relevant comments in this thread.

github-actions[bot] avatar Nov 04 '25 00:11 github-actions[bot]