Tips for exposing SPAs and APIs with Cloudfront + S3 + API Gateway
Tags: aws, serverless
Published: 2022-06-07 13:30:10

This is a common pattern, but I would like to share some of the practices that I have arrived at.

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33070/b35f713b-1eb6-dcaa-59a3-01107fb26fef.png)


## Conclusion first
- SPA: Change path with extension to /index.html using Cloudfront Functions
- API: Point /api to API Gateway with Behavior

## Background
Until now, when publishing SPA applications such as Vue or Angular with Cloudfront + S3
S3 returns 403 or 404 by making a request to a path that does not exist.
Therefore, we had to create a custom error page on the Cloudfront side and configure it to redirect to /index.html.

### Common problem 1 (SPA path issue)
Even if there is a 404 that should be caught, it is redirected without care.
For example, it is hard to notice if there is a CSS or JS upload error.

### Common Problem 2 (Cloudfront problem of assigning specific paths)
I want to assign /api paths to API Gateway.
This method seems to be good from a security point of view, as it eliminates the need to configure CORS.

## SPA path problem
Using `Cloudfront Functions`, I can distribute requests like `/users/xxx` to `/index.html` in a good way without waiting for error pages.

- `Cloudfront Functions` will be the ones you code to handle events at the edge.
- There is a `lambda@edge` that is similar, but you can read more about the differences [here](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/edge-functions.html) for the difference.
- It is better to understand that `lambda@edge` has limited functionality and can only perform simple processing.


Also, if you want to try Cloudfront Functions quickly, you can try this.
[Cloudfront Functions tutorial](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/functions-tutorial.html)

### terraform example

#### Configuring Cloudfront Functions
Define resources like this. (Parts not directly related to AWS Provider settings, etc., are omitted.)
See [documentation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_function) for a description of the parameters.

- Write the actual process in `code`. Here, it is read from a separate file.
- You can make it public by setting `publish = true`.
```cloudfront_functions.tf
resource "aws_cloudfront_function" "spa_redirect" {
  name = "${var.product_name}-${var.env}-spa-redirect"
  runtime = "cloudfront-js-1.0"
  comment = "${var.product_name}-${var.env}-spa-redirect"
  publish = true
  code = file("cloudfront_functions/spa-redirect.js")
}
```

#### function part
It is written in a very old fashioned way, but for some reason it needs to be ``ECMAScript 5.1 compliant``.
I would be very grateful if you could improve on this.
- The presence or absence of extensions is determined by the presence or absence of a dot. `if(request.uri.indexOf(".")) === -1)`
```cloudfront_functions/spa-redirect.js
var index = '/index.html';
function handler(event) {
    var request = event.request;
    // if extension not found (access not real file)
    if(request.uri.indexOf(".")) === -1) {
        request.uri = index;
    }
    return request;
}
```

#### Configuring Cloudfront Functions to adapt to Cloudfront
This is a long list, but all you need to focus on is the `function_association` at the bottom.
- You can set it for each behavior!
- The event_type is at the time of request, so we'll write `viewer-request`.
- For more information on event_type, please refer to [here](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html) for more information about event_type.
Cloudfront Functions supports only `viewer-request` and `viewer-response`.
```cloudfront.tf
resource "aws_cloudfront_distribution" "front" {
  origin {
    domain_name = aws_s3_bucket.front.bucket_regional_domain_name
    origin_id = local.s3_origin_id

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.origin.cloudfront_access_identity_path
    }
  }
  enabled = true
  is_ipv6_enabled = true
  comment = "${var.product_name}-${var.env}"
  default_root_object = "index.html"
  aliases = ["test.${data.terraform_remote_state.network.outputs.domain}"]

  default_cache_behavior {
    allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]]
    cached_methods = ["GET", "HEAD"].
    target_origin_id = local.s3_origin_id

    forwarded_values {
      query_string = true

      cookies {
        forward = "none"
      }
    }

    function_association {
      event_type = "viewer-request"
      function_arn = aws_cloudfront_function.spa_redirect.arn
    }

    viewer_protocol_policy = "allow-all"
    min_ttl = 0
    default_ttl = 0
    max_ttl = 0

  }
... Omitted.
}
```

## I want to separate /api
As mentioned above, you can use cloudfront's behavior to separate them.
Many people create API Gateways using the serverless framework or CDK.
It is convenient to get values from Cloudformation.

Point.
- Get the endpoint and stage from the cloudformation in the `domain_name` of `origin`.
- Specify `api/*` in `path_pattern` of `behavior`.
- Since `/api` is not needed to access the APIGateway, it is removed by lambda@edge (this could also be replaced by cloudfront functions).

```cloudfront.tf 
resource "aws_cloudfront_distribution" "front" {
...
  origin {
    custom_origin_config {
      http_port = "80"
      https_port = "443"
      origin_protocol_policy = "https-only"
      origin_ssl_protocols = ["TLSv1.2"]
    }
    # Extract xxx.execute-api.ap-northeast-1.amazonaws.com and v1 from ServiceEndpoint
    domain_name = split("/", data.aws_cloudformation_stack.api_test.outputs["ServiceEndpoint"])[2])
    origin_id = var.product_name
  }
...
  ordered_cache_behavior {
    path_pattern = "api/*"
    allowed_methods = ["HEAD", "DELETE", "POST", "GET", "OPTIONS", "PUT", "PATCH"].
    cached_methods = ["GET", "HEAD"].
    target_origin_id = var.product_name

    forwarded_values {
      headers = ["Authorization"].
      query_string = true
      cookies {
        forward = "none"
      }
    }

    min_ttl = 0
    default_ttl = 10
    max_ttl = 10
    viewer_protocol_policy = "https-only"
    lambda_function_association {
      event_type = "origin-request"
      lambda_arn = aws_lambda_function.redirect_trim_context.qualified_arn
    }

  }
...
}
```

That's all, obvious! Some of you may think it is, but we hope it will help you if you have any doubts when configuring your infrastructure.

Reference.
[SPA routing process with AWS CloudFront Functions](https://qiita.com/seapolis/items/0afd49d24b12749d93ab)