Caching Site Assets with AWS CDK & S3 - snippet
Tags: webdev, aws, performance, typescript
Published: 2023-02-01 19:30:50

> Getting caching right yields huge performance benefits, saves bandwidth and reduces server costs... [more](https://jakearchibald.com/2016/caching-best-practices/)

```ts
import { join } from 'path'
import type { Construct } from 'constructs'
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'

type CacheControlMaxAge = '0' | '31536000'
type SiteBucketDeploymentProps = Omit<s3deploy.BucketDeploymentProps, 'sources'> & {
  sitePaths: string[]
  longCacheFileExtensions: string[]
}
type CachedBucketDeploymentProps = SiteBucketDeploymentProps & { maxAge: CacheControlMaxAge }
class CachedBucketDeployment extends s3deploy.BucketDeployment {
  constructor(scope: Construct, id: string, props: CachedBucketDeploymentProps) {
    const { sitePaths, longCacheFileExtensions, maxAge } = props
    const longCacheFiles = `.{${longCacheFileExtensions.join(',')}}`
    const assetOptions = {
      exclude: [
        ...(maxAge === '31536000'
          ? ['**/*.*', '!**/*' + longCacheFiles]
          : ['**/*' + longCacheFiles]),
      ],
    }
    const cacheControl = `max-age=${maxAge},public,${
      maxAge === '31536000' ? 'immutable' : 'must-revalidate'
    }`

    super(scope, id, {
      ...props,
      sources: [s3deploy.Source.asset(join(__dirname, ...sitePaths), assetOptions)],
      cacheControl: [s3deploy.CacheControl.fromString(cacheControl)],
      prune: false,
    })
  }
}
/**
 * Creates multiple bucket deployments for the same destination bucket
 * in order to set different Cache-Control headers (rule of thumb:
 * either a very long `maxAge` or `maxAge: 0`) for different types of files.
 *
 * `.css`, `.js` files are usually distributed with a hash such as `[name].[contenthash].js`
 * so a `max-age=31536000,public,immutable` Cache-Control header will be set.
 *
 * `.html` files are usually expected to be invalidated every time since their URLs
 * cannot be versioned and their content must be able to change so a
 * `public,max-age=0,must-revalidate` header will be set for all the file extensions
 * that are not found inside `longCacheFileExtensions`.
 *
 * @example
 * new SiteBucketDeployment(this, 'SiteDeployment', {
 *   longCacheFileExtensions: ['js', 'css'],
 *   sitePaths: ['..', '..', 'dist'],
 * })
 *
 */
export default class SiteBucketDeployment {
  private readonly _maxAgePatterns: CacheControlMaxAge[] = ['0', '31536000']
  private readonly _constructIdSuffix: Record<CacheControlMaxAge, string> = {
    '0': 'NoCache',
    '31536000': 'LongCache',
  }

  private _createCachedBucketDeployment(
    scope: Construct,
    id: string,
    props: CachedBucketDeploymentProps,
    cleanupDeployment: s3deploy.BucketDeployment
  ) {
    new CachedBucketDeployment(scope, id + this._constructIdSuffix[props.maxAge], {
      ...props,
      maxAge: props.maxAge,
    }).node.addDependency(cleanupDeployment)
  }

  constructor(scope: Construct, id: string, props: SiteBucketDeploymentProps) {
    /**
     * Initial deployment for cleaning up the content from previous deploys.
     * Since `prune: false` is used for the actual deployments, in some cases the
     * unnecessary files (i.e some frameworks generate different static/<hash> directories
     * on every new build, etc.) won't be deleted otherwise.
     *
     * @see https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html#prune
     */
    const cleanupDeployment = new s3deploy.BucketDeployment(scope, id + 'Cleanup', {
      ...props,
      sources: [s3deploy.Source.asset(join(__dirname, ...props.sitePaths))],
      prune: true,
    })

    this._maxAgePatterns.forEach((maxAge) => {
      this._createCachedBucketDeployment(scope, id, { ...props, maxAge }, cleanupDeployment)
    })
  }
}
```

[View Gist](https://gist.github.com/htmnk/507ece0ecf98d1dd0cb4aa94f53a291b#file-sitebucketdeployment-ts)

When using AWS CDK we can set the [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) headers with [S3 object metadata](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html) on our `BucketDeployment` construct (check out my article on how to deploy a site to AWS with CDK below if you need an introduction to CDK).

{% embed https://dev.to/htmnk/deploy-a-static-site-to-aws-s3-and-cloudfront-using-aws-cdk-21d4 %}



**Rule of thumb**

- Set the `max-age=31536000,public,immutable` directives for all assets that have been processed with `[contenthash]` substitutions. Many frameworks typically produce files with names such as `[name].[contenthash].js`, etc. when building the app (see [Webpack Caching](https://webpack.js.org/guides/caching/)). These are usually all the `.js`, `.css`, and statically imported images. These directives can also be used for other files, but we need to ensure that we version the path to the files ourselves by changing the file name to revalidate. Otherwise, our users may have stale files loaded until they perform a hard refresh.
- Alternatively, set `public,max-age=0,must-revalidate` (or `max-age=0,no-cache,no-store` - see [difference](https://stackoverflow.com/questions/18148884/difference-between-no-cache-and-must-revalidate-for-cache-control)) directives. These are typically all the document `.html` files. Since these URLs cannot be versioned and the content changes frequently, we don't want to cache them at all.

**Custom Props**

- `sitePaths` - The site build folder that is being deployed. Uses [path.join](https://nodejs.org/api/path.html#pathjoinpaths) to join and normalize the resulting path.
- `longCacheFileExtensions` - Sets `max-age=31536000` to all the files with the specified extension located inside the `sitePaths` directory. Otherwise, it sets `max-age=0` to all the files that are not found inside the array.

You can play around with the [asset bundling exclude filters](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html#exclude-and-include-filters) and exclude/include directories or files instead of file extensions as I did above. The [s3deploy.BucketDeployment](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html#aws-s3-deployment-construct-library) can be then replaced with the `SiteBucketDeployment` construct

```ts
new SiteBucketDeployment(this, 'BlogBucketDeployment', {
  longCacheFileExtensions: ['js', 'css'],
  // relative to where the `SiteBucketDeployment.ts` file is located
  sitePaths: ['..', '..', 'dist'],
  destinationBucket: bucket,
  distributionPaths: ['/*'],
})
```

[View Gist](https://gist.github.com/htmnk/507ece0ecf98d1dd0cb4aa94f53a291b#file-cdk-stack-ts)

:bulb: The same results could also be achieved through the AWS Console manually if you are not using CDK, see [editing object metadata in the Amazon S3 console](https://docs.aws.amazon.com/AmazonS3/latest/userguide/add-object-metadata.html)

1.  Open the Amazon S3 console and your bucket.
2.  Select the check box to the left of a file/directory.
3.  On the Actions menu, choose Edit actions, and choose Edit metadata.
4.  Choose Add metadata.
5.  For metadata Type, select System-defined.
6.  Select `Cache-Control` for the key and add `max-age=31536000,public,immutable` as the value.
7.  When you are done, hit Save Changes and Amazon S3 should edit all the selected files recursively, you can verify it by opening a specific file and scrolling down to the metadata section.