diff --git a/README.md b/README.md index 1a313643..31c7775a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,21 @@ constructs: type: database/dynamodb-single-table ``` +### [Server-side website](docs/server-side-website.md) + +Deploy server-side rendered websites, for example Laravel or Symfony apps. + +```yaml +constructs: + website: + type: server-side-website + assets: + '/css/*': public/css + '/js/*': public/js +``` + +[Read more...](docs/server-side-website.md) + More constructs are coming soon! Got suggestions? [Open and upvote drafts](https://github.com/getlift/lift/discussions/categories/constructs). ## Ejecting diff --git a/docs/img/server-side-website.png b/docs/img/server-side-website.png new file mode 100644 index 00000000..1bb99541 Binary files /dev/null and b/docs/img/server-side-website.png differ diff --git a/docs/server-side-website.md b/docs/server-side-website.md new file mode 100644 index 00000000..328f38fb --- /dev/null +++ b/docs/server-side-website.md @@ -0,0 +1,335 @@ +# Server-side website + +The `server-side-website` construct deploys websites where the HTML is rendered "server-side", i.e. on AWS Lambda. + +This is usually done with backend frameworks like Laravel/Symfony (PHP), Ruby on Rails, Django/Flask (Python), Express (Node), etc. + +To build a SPA or static website, use the [Static Website construct](static-website.md) instead. + +## Quick start + +```yaml +service: my-app +provider: + name: aws + +functions: + home: + handler: home.handler + events: + - httpApi: 'GET /' + # ... + +constructs: + website: + type: server-side-website + assets: + '/css/*': public/css + '/js/*': public/js + +plugins: + - serverless-lift +``` + +On `serverless deploy`, the example above will set up a website that serves both: + +- `https:///*` -> the website through API Gateway + Lambda +- `https:///css/*` and `https:///js/*` -> assets through S3 + +_Note: **the first deployment takes 5 minutes**._ + +The website is served over HTTPS and cached all over the world via the CloudFront CDN. + +## How it works + +![](img/server-side-website.png) + +On the first `serverless deploy`, Lift creates: + +- an [S3](https://aws.amazon.com/s3/) bucket to serve static assets (CSS, JS, etc.) +- a [CloudFront CDN](https://aws.amazon.com/cloudfront/) + +CloudFront serves the website over HTTPS with caching at the edge. It also provides an "HTTP to HTTPS" redirection which is not supported by API Gateway. For websites, this is problematic because it means someone typing `website.com` in a browser will get a blank page: API Gateway will not even redirect this to HTTPS. + +Finally, CloudFront also acts as a router: + +- URLs that points to static assets are served by S3 +- all the other URLs are served by API Gateway + +The construct uses the API Gateway configured in functions defined in `serverless.yml`. + +Additionally, every time `serverless deploy` runs, Lift: + +- uploads the static assets to the S3 bucket +- invalidates the CloudFront CDN cache + +_Note: the S3 bucket is public and entirely managed by Lift. Do not store or upload files to the bucket, they will be removed by Lift on the next deployment. Instead, create a separate bucket to store any extra file._ + +### CloudFront configuration + +CloudFront is configured to cache static assets by default, but not cache dynamic content by default. It will forward cookies, query strings and most headers to the backend running on Lambda. + +## Website routes + +To define website routes, create Lambda functions in `functions:` [with `httpApi` events](https://www.serverless.com/framework/docs/providers/aws/events/http-api/): + +```yaml +# serverless.yml +# ... + +functions: + home: + handler: home.handler + events: + - httpApi: 'GET /' + search: + handler: search.handler + events: + - httpApi: 'GET /search' + # ... + +constructs: + website: + type: server-side-website + # ... +``` + +Check out [the official documentation](https://www.serverless.com/framework/docs/providers/aws/events/http-api/) on how to set up HTTP events. + +When using backend frameworks that provide a routing feature, another option is to define a single Lambda function that captures all the HTTP routes: + +```yaml +# serverless.yml +# ... + +functions: + backend: + handler: index.handler + events: + - httpApi: '*' + +constructs: + website: + type: server-side-website + # ... +``` + +## Variables + +The `server-side-website` construct exposes the following variables: + +- `url`: the URL of the deployed website (either the CloudFront URL or the first custom domain, if configured) + +For example: + +```yaml +constructs: + website: + type: server-side-website + # ... + +functions: + backend: + # ... + environment: + WEBSITE_URL: ${construct:website.url} +``` + +_How it works: the `${construct:website.url}` variable will automatically be replaced with a CloudFormation reference._ + +- `cname`: the CloudFront domain to point custom domains to, for example `d1111abcdef8.cloudfront.net` + +This can be used to configure a custom domain with Route53, for example: + +```yaml +constructs: + website: + type: server-side-website + # ... +resources: + Resources: + Route53Record: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: ZXXXXXXXXXXXXXXXXXXJ # Your HostedZoneId + Name: app.mydomain + Type: A + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 # Cloudfront Route53 HostedZoneId. This does not change. + DNSName: ${construct:website.cname} +``` + +## Commands + +`serverless deploy` deploys everything configured in `serverless.yml` and uploads assets. + +When iterating, it is possible to skip the CloudFormation deployment and directly publish changes via: + +- `serverless deploy function -f ` to deploy a single Lambda function +- `serverless :assets:upload` to upload assets to S3 (the CloudFront cache will be cleared as well) + +## Configuration reference + +### Assets + +```yaml +constructs: + website: + # ... + assets: + '/assets/*': dist/ +``` + +The `assets` section lets users define routing for static assets (like JavaScript, CSS, images, etc.). + +- The key defines **the URL pattern**. +- The value defines **the local path to upload**. + +Assets can be either whole directories, or single files: + +```yaml +constructs: + website: + # ... + assets: + # Directories: routes must end with `/*` + '/css/*': dist/css + '/images/*': assets/animations + # Files: + '/favicon.ico': public/favicon.ico +``` + +With the example above: + +- `https:///*` -> Lambda +- `https:///css/*` -> serves the files uploaded from the local `dist/css` directory +- `https:///images/*` -> serves the files uploaded from the local `assets/animations` directory +- `https:///favicon.ico` -> serves the file uploaded from `public/favicon.ico` + +### API Gateway + +API Gateway provides 2 versions of APIs: + +- v1: REST API +- v2: HTTP API, the fastest and cheapest + +By default, the `server-side-website` construct supports v2 HTTP APIs. + +If your Lambda functions uses `http` events (v1 REST API) instead of `httpApi` events (v2 HTTP API), use the `apiGateway: "rest"` option: + +```yaml +constructs: + website: + type: server-side-website + apiGateway: 'rest' # either "rest" (v1) or "http" (v2, the default) + +functions: + v1: + handler: foo.handler + events: + - http: 'GET /' # REST API (v1) + v2: + handler: bar.handler + events: + - httpApi: 'GET /' # HTTP API (v2) +``` + +### Custom domain + +```yaml +constructs: + website: + # ... + domain: mywebsite.com + # ARN of an ACM certificate for the domain, registered in us-east-1 + certificate: arn:aws:acm:us-east-1:123456615250:certificate/0a28e63d-d3a9-4578-9f8b-14347bfe8123 +``` + +The configuration above will activate the custom domain `mywebsite.com` on CloudFront, using the provided HTTPS certificate. + +After running `serverless deploy` (or `serverless info`), you should see the following output in the terminal: + +``` +website: + url: https://mywebsite.com + cname: s13hocjp.cloudfront.net +``` + +Create a CNAME DNS entry that points your domain to the `xxx.cloudfront.net` domain. After a few minutes/hours, the domain should be available. + +#### HTTPS certificate + +To create the HTTPS certificate: + +- Open [the ACM Console](https://console.aws.amazon.com/acm/home?region=us-east-1#/wizard/) in the `us-east-1` region (CDN certificates _must be_ in us-east-1, regardless of where your application is hosted) +- Click "_Request a new certificate_", add your domain name and click "Next" +- Choose a domain validation method: + - Domain validation will require you to add CNAME entries to your DNS configuration + - Email validation will require you to click a link in an email sent to `admin@your-domain.com` + +After the certificate is created and validated, you should see the ARN of the certificate. + +#### Multiple domains + +It is possible to set up multiple domains: + +```yaml +constructs: + website: + # ... + domain: + - mywebsite.com + - app.mywebsite.com +``` + +Usually, we can retrieve which domain a user is visiting via the `Host` HTTP header. This doesn't work with API Gateway (`Host` contains the API Gateway domain). + +The `server-side-website` construct offers a workaround: the `X-Forwarded-Host` header is automatically populated via CloudFront Functions. Code running on Lambda will be able to access the original `Host` header via this `X-Forwarded-Host` header. + +### Error pages + +```yaml +constructs: + website: + # ... + errorPage: error500.html +``` + +In case a web page throws an error, API Gateway's default `Internal Error` blank page shows up. This can be overridden by providing an HTML error page. + +Applications are of course free to catch errors and display custom error pages. However, sometimes even error pages and frameworks fail completely: this is where the API Gateway error page shows up. + +### Forwarded headers + +By default, CloudFront is configured to forward the following HTTP headers to the backend running on Lambda: + +- `Accept` +- `Accept-Language` +- `Authorization` +- `Content-Type` +- `Origin` +- `Referer` +- `User-Agent` +- `X-Forwarded-Host` +- `X-Requested-With` + +Why only this list? Because CloudFront + API Gateway requires us to define explicitly the list of headers to forward. It isn't possible to forward _all_ headers. + +To access more headers from the client (or [from CloudFront](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-cloudfront-headers.html)), you can redefine the list of headers forwarded by CloudFront in `forwardedHeaders`: + +```yaml +constructs: + website: + # ... + forwardedHeaders: + - Accept + - Accept-Language + # ... + - X-Custom-Header +``` + +CloudFront accepts maximum 10 headers. + +### More options + +Looking for more options in the construct configuration? [Open a GitHub issue](https://github.com/getlift/lift/issues/new). diff --git a/src/classes/aws.ts b/src/classes/aws.ts index 6e206202..6f693cd3 100644 --- a/src/classes/aws.ts +++ b/src/classes/aws.ts @@ -1,3 +1,11 @@ +import type { + DeleteObjectsOutput, + DeleteObjectsRequest, + ListObjectsV2Output, + ListObjectsV2Request, +} from "aws-sdk/clients/s3"; +import type { CreateInvalidationRequest, CreateInvalidationResult } from "aws-sdk/clients/cloudfront"; +import type { AwsProvider } from "@lift/providers"; import type { Provider as LegacyAwsProvider } from "../types/serverless"; // This is defined as a separate function to allow mocking in tests @@ -9,3 +17,34 @@ export async function awsRequest( ): Promise { return await provider.request(service, method, params); } + +export async function emptyBucket(aws: AwsProvider, bucketName: string): Promise { + const data = await aws.request("S3", "listObjectsV2", { + Bucket: bucketName, + }); + if (data.Contents === undefined) { + return; + } + const keys = data.Contents.map((item) => item.Key).filter((key): key is string => key !== undefined); + await aws.request("S3", "deleteObjects", { + Bucket: bucketName, + Delete: { + Objects: keys.map((key) => ({ Key: key })), + }, + }); +} + +export async function invalidateCloudFrontCache(aws: AwsProvider, distributionId: string): Promise { + await aws.request("CloudFront", "createInvalidation", { + DistributionId: distributionId, + InvalidationBatch: { + // This should be a unique ID: we use a timestamp + CallerReference: Date.now().toString(), + Paths: { + // Invalidate everything + Items: ["/*"], + Quantity: 1, + }, + }, + }); +} diff --git a/src/constructs/aws/ServerSideWebsite.ts b/src/constructs/aws/ServerSideWebsite.ts new file mode 100644 index 00000000..8afde05f --- /dev/null +++ b/src/constructs/aws/ServerSideWebsite.ts @@ -0,0 +1,440 @@ +import { Bucket } from "@aws-cdk/aws-s3"; +import { + AllowedMethods, + CacheHeaderBehavior, + CachePolicy, + Distribution, + FunctionEventType, + HttpVersion, + OriginAccessIdentity, + OriginProtocolPolicy, + OriginRequestCookieBehavior, + OriginRequestHeaderBehavior, + OriginRequestPolicy, + OriginRequestQueryStringBehavior, + ViewerProtocolPolicy, +} from "@aws-cdk/aws-cloudfront"; +import type { Construct } from "@aws-cdk/core"; +import { CfnOutput, Duration, Fn, RemovalPolicy } from "@aws-cdk/core"; +import type { FromSchema } from "json-schema-to-ts"; +import chalk from "chalk"; +import { HttpOrigin, S3Origin } from "@aws-cdk/aws-cloudfront-origins"; +import * as acm from "@aws-cdk/aws-certificatemanager"; +import type { BehaviorOptions, ErrorResponse } from "@aws-cdk/aws-cloudfront/lib/distribution"; +import * as path from "path"; +import * as fs from "fs"; +import { flatten } from "lodash"; +import * as cloudfront from "@aws-cdk/aws-cloudfront"; +import { AwsConstruct } from "@lift/constructs/abstracts"; +import type { ConstructCommands } from "@lift/constructs"; +import type { AwsProvider } from "@lift/providers"; +import { log } from "../../utils/logger"; +import { s3Put, s3Sync } from "../../utils/s3-sync"; +import { emptyBucket, invalidateCloudFrontCache } from "../../classes/aws"; +import ServerlessError from "../../utils/error"; + +const SCHEMA = { + type: "object", + properties: { + type: { const: "server-side-website" }, + apiGateway: { enum: ["http", "rest"] }, + assets: { + type: "object", + additionalProperties: { type: "string" }, + propertyNames: { + pattern: "^/.*$", + }, + minProperties: 1, + }, + errorPage: { type: "string" }, + domain: { + anyOf: [ + { type: "string" }, + { + type: "array", + items: { type: "string" }, + }, + ], + }, + certificate: { type: "string" }, + forwardedHeaders: { type: "array", items: { type: "string" } }, + }, + additionalProperties: false, +} as const; + +type Configuration = FromSchema; + +export class ServerSideWebsite extends AwsConstruct { + public static type = "server-side-website"; + public static schema = SCHEMA; + public static commands: ConstructCommands = { + "assets:upload": { + usage: "Upload assets directly to S3 without going through a CloudFormation deployment.", + handler: ServerSideWebsite.prototype.uploadAssets, + }, + }; + + private readonly distribution: Distribution; + private readonly bucketNameOutput: CfnOutput; + private readonly domainOutput: CfnOutput; + private readonly cnameOutput: CfnOutput; + private readonly distributionIdOutput: CfnOutput; + + constructor( + scope: Construct, + private readonly id: string, + readonly configuration: Configuration, + private readonly provider: AwsProvider + ) { + super(scope, id); + + if (configuration.domain !== undefined && configuration.certificate === undefined) { + throw new Error( + `Invalid configuration in 'constructs.${id}.certificate': if a domain is configured, then a certificate ARN must be configured as well.` + ); + } + if (configuration.errorPage !== undefined && !configuration.errorPage.endsWith(".html")) { + throw new Error( + `Invalid configuration in 'constructs.${id}.errorPage': the custom error page must be a static HTML file. '${configuration.errorPage}' does not end with '.html'.` + ); + } + + const bucket = new Bucket(this, "Assets", { + // Assets are compiled artifacts, we can clear them on serverless remove + removalPolicy: RemovalPolicy.DESTROY, + }); + const cloudFrontOAI = new OriginAccessIdentity(this, "OriginAccessIdentity", { + comment: `Identity that represents CloudFront for the ${id} website.`, + }); + bucket.grantRead(cloudFrontOAI); + + /** + * We create custom "Origin Policy" and "Cache Policy" for the backend. + * "All URL query strings, HTTP headers, and cookies that you include in the cache key (using a cache policy) are automatically included in origin requests. Use the origin request policy to specify the information that you want to include in origin requests, but not include in the cache key." + * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-origin-requests.html + */ + const backendOriginPolicy = new OriginRequestPolicy(this, "BackendOriginPolicy", { + originRequestPolicyName: `${this.provider.stackName}-${id}`, + comment: `Origin request policy for the ${id} website.`, + cookieBehavior: OriginRequestCookieBehavior.all(), + queryStringBehavior: OriginRequestQueryStringBehavior.all(), + headerBehavior: this.headersToForward(), + }); + const backendCachePolicy = new CachePolicy(this, "BackendCachePolicy", { + cachePolicyName: `${this.provider.stackName}-${id}`, + comment: `Cache policy for the ${id} website.`, + // For the backend we disable all caching by default + defaultTtl: Duration.seconds(0), + // Authorization is an exception and must be whitelisted in the Cache Policy + // This is the reason why we don't use the managed `CachePolicy.CACHING_DISABLED` + headerBehavior: CacheHeaderBehavior.allowList("Authorization"), + }); + const s3Origin = new S3Origin(bucket, { + originAccessIdentity: cloudFrontOAI, + }); + + const apiId = + configuration.apiGateway === "rest" + ? this.provider.naming.getRestApiLogicalId() + : this.provider.naming.getHttpApiLogicalId(); + const apiGatewayDomain = Fn.join(".", [Fn.ref(apiId), `execute-api.${this.provider.region}.amazonaws.com`]); + + // Cast the domains to an array + const domains = configuration.domain !== undefined ? flatten([configuration.domain]) : undefined; + const certificate = + configuration.certificate !== undefined + ? acm.Certificate.fromCertificateArn(this, "Certificate", configuration.certificate) + : undefined; + + this.distribution = new Distribution(this, "CDN", { + comment: `${provider.stackName} ${id} website CDN`, + defaultBehavior: { + // Origins are where CloudFront fetches content + origin: new HttpOrigin(apiGatewayDomain, { + // API Gateway only supports HTTPS + protocolPolicy: OriginProtocolPolicy.HTTPS_ONLY, + }), + // For a backend app we all all methods + allowedMethods: AllowedMethods.ALLOW_ALL, + cachePolicy: backendCachePolicy, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + // Forward all values (query strings, headers, and cookies) to the backend app + // See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policies-list + originRequestPolicy: backendOriginPolicy, + functionAssociations: [ + { + function: this.createRequestFunction(), + eventType: FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + // All the assets paths are created in there + additionalBehaviors: this.createCacheBehaviors(s3Origin), + errorResponses: this.createErrorResponses(), + // Enable http2 transfer for better performances + httpVersion: HttpVersion.HTTP2, + certificate: certificate, + domainNames: domains, + }); + + // CloudFormation outputs + this.bucketNameOutput = new CfnOutput(this, "AssetsBucketName", { + description: "Name of the bucket that stores the website assets.", + value: bucket.bucketName, + }); + let websiteDomain = this.getMainCustomDomain(); + if (websiteDomain === undefined) { + // Fallback on the CloudFront domain + websiteDomain = this.distribution.distributionDomainName; + } + this.domainOutput = new CfnOutput(this, "Domain", { + description: "Website domain name.", + value: websiteDomain, + }); + this.cnameOutput = new CfnOutput(this, "CloudFrontCName", { + description: "CloudFront CNAME.", + value: this.distribution.distributionDomainName, + }); + this.distributionIdOutput = new CfnOutput(this, "DistributionId", { + description: "ID of the CloudFront distribution.", + value: this.distribution.distributionId, + }); + } + + outputs(): Record Promise> { + return { + url: () => this.getUrl(), + cname: () => this.getCName(), + }; + } + + variables(): Record { + const domain = this.getMainCustomDomain() ?? this.distribution.distributionDomainName; + + return { + url: Fn.join("", ["https://", domain]), + cname: this.distribution.distributionDomainName, + }; + } + + async postDeploy(): Promise { + await this.uploadAssets(); + } + + async uploadAssets(): Promise { + log(`Deploying the assets for the '${this.id}' website`); + + const bucketName = await this.getBucketName(); + if (bucketName === undefined) { + throw new Error( + `Could not find the bucket in which to deploy the '${this.id}' website: did you forget to run 'serverless deploy' first?` + ); + } + + let invalidate = false; + for (const [pattern, filePath] of Object.entries(this.getAssetPatterns())) { + if (!fs.existsSync(filePath)) { + throw new Error(`Error in 'constructs.${this.id}': the file or directory '${filePath}' does not exist`); + } + let s3PathPrefix: string = path.dirname(pattern); + if (s3PathPrefix.startsWith("/")) { + s3PathPrefix = s3PathPrefix.slice(1); + } + + if (fs.lstatSync(filePath).isDirectory()) { + // Directory + log(`Uploading '${filePath}' to 's3://${bucketName}/${s3PathPrefix}'`); + const { hasChanges } = await s3Sync({ + aws: this.provider, + localPath: filePath, + targetPathPrefix: s3PathPrefix, + bucketName, + }); + invalidate = invalidate || hasChanges; + } else { + // File + const targetKey = path.join(s3PathPrefix, path.basename(filePath)); + log(`Uploading '${filePath}' to 's3://${bucketName}/${targetKey}'`); + await s3Put(this.provider, bucketName, targetKey, fs.readFileSync(filePath)); + invalidate = true; + } + } + if (invalidate) { + await this.clearCDNCache(); + } + + const domain = await this.getDomain(); + if (domain !== undefined) { + log(`Deployed ${chalk.green(`https://${domain}`)}`); + } + } + + private async clearCDNCache(): Promise { + const distributionId = await this.getDistributionId(); + if (distributionId === undefined) { + return; + } + await invalidateCloudFrontCache(this.provider, distributionId); + } + + async preRemove(): Promise { + const bucketName = await this.getBucketName(); + if (bucketName === undefined) { + // No bucket found => nothing to delete! + return; + } + + log( + `Emptying S3 bucket '${bucketName}' for the '${this.id}' website, else CloudFormation will fail (it cannot delete a non-empty bucket)` + ); + await emptyBucket(this.provider, bucketName); + } + + async getUrl(): Promise { + const domain = await this.getDomain(); + if (domain === undefined) { + return undefined; + } + + return `https://${domain}`; + } + + async getBucketName(): Promise { + return this.provider.getStackOutput(this.bucketNameOutput); + } + + async getDomain(): Promise { + return this.provider.getStackOutput(this.domainOutput); + } + + async getCName(): Promise { + return this.provider.getStackOutput(this.cnameOutput); + } + + async getDistributionId(): Promise { + return this.provider.getStackOutput(this.distributionIdOutput); + } + + getMainCustomDomain(): string | undefined { + if (this.configuration.domain === undefined) { + return undefined; + } + + // In case of multiple domains, we take the first one + return typeof this.configuration.domain === "string" ? this.configuration.domain : this.configuration.domain[0]; + } + + private headersToForward(): OriginRequestHeaderBehavior { + let additionalHeadersToForward = this.configuration.forwardedHeaders ?? []; + if (additionalHeadersToForward.includes("Host")) { + throw new ServerlessError( + `Invalid value in 'constructs.${this.id}.forwardedHeaders': the 'Host' header cannot be forwarded (this is an API Gateway limitation). Use the 'X-Forwarded-Host' header in your code instead (it contains the value of the original 'Host' header).`, + "LIFT_INVALID_CONSTRUCT_CONFIGURATION" + ); + } + // `Authorization` cannot be forwarded via this setting (we automatically forward it anyway so we remove it from the list) + additionalHeadersToForward = additionalHeadersToForward.filter((header: string) => header !== "Authorization"); + if (additionalHeadersToForward.length > 0) { + if (additionalHeadersToForward.length > 10) { + throw new ServerlessError( + `Invalid value in 'constructs.${this.id}.forwardedHeaders': ${additionalHeadersToForward.length} headers are configured but only 10 headers can be forwarded (this is an CloudFront limitation).`, + "LIFT_INVALID_CONSTRUCT_CONFIGURATION" + ); + } + + // Custom list + return OriginRequestHeaderBehavior.allowList(...additionalHeadersToForward); + } + + /** + * We forward everything except: + * - `Host` because it messes up API Gateway (that uses the Host to identify which API Gateway to invoke) + * - `Authorization` because it must be configured on the cache policy + * (see https://aws.amazon.com/premiumsupport/knowledge-center/cloudfront-authorization-header/?nc1=h_ls) + */ + return OriginRequestHeaderBehavior.allowList( + "Accept", + "Accept-Language", + "Content-Type", + "Origin", + "Referer", + "User-Agent", + "X-Requested-With", + // This header is set by our CloudFront Function + "X-Forwarded-Host" + ); + } + + private createCacheBehaviors(s3Origin: S3Origin): Record { + const behaviors: Record = {}; + for (const pattern of Object.keys(this.getAssetPatterns())) { + behaviors[pattern] = { + // Origins are where CloudFront fetches content + origin: s3Origin, + allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS, + // Use the "Managed-CachingOptimized" policy + // See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-policies-list + cachePolicy: CachePolicy.CACHING_OPTIMIZED, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + }; + } + + return behaviors; + } + + private createRequestFunction(): cloudfront.Function { + /** + * CloudFront function that forwards the real `Host` header into `X-Forwarded-Host` + * + * CloudFront does not forward the original `Host` header. We use this + * to forward the website domain name to the backend app via the `X-Forwarded-Host` header. + * Learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host + */ + const code = `function handler(event) { + var request = event.request; + request.headers["x-forwarded-host"] = request.headers["host"]; + return request; +}`; + + return new cloudfront.Function(this, "RequestFunction", { + functionName: `${this.provider.stackName}-${this.provider.region}-${this.id}-request`, + code: cloudfront.FunctionCode.fromInline(code), + }); + } + + private createErrorResponses(): ErrorResponse[] { + let responsePagePath = undefined; + if (this.configuration.errorPage !== undefined) { + responsePagePath = `/${this.getErrorPageFileName()}`; + } + + return [ + { + httpStatus: 500, + // Disable caching of error responses + ttl: Duration.seconds(0), + responsePagePath, + }, + { + httpStatus: 504, + // Disable caching of error responses + ttl: Duration.seconds(0), + responsePagePath, + }, + ]; + } + + private getAssetPatterns(): Record { + const assetPatterns = this.configuration.assets ?? {}; + // If a custom error page is provided, we upload it to S3 + if (this.configuration.errorPage !== undefined) { + assetPatterns[`/${this.getErrorPageFileName()}`] = this.configuration.errorPage; + } + + return assetPatterns; + } + + private getErrorPageFileName(): string { + return this.configuration.errorPage !== undefined ? path.basename(this.configuration.errorPage) : ""; + } +} diff --git a/src/constructs/aws/StaticWebsite.ts b/src/constructs/aws/StaticWebsite.ts index 8df0d25d..026acd5c 100644 --- a/src/constructs/aws/StaticWebsite.ts +++ b/src/constructs/aws/StaticWebsite.ts @@ -12,14 +12,7 @@ import * as cloudfront from "@aws-cdk/aws-cloudfront"; import type { Construct as CdkConstruct } from "@aws-cdk/core"; import { CfnOutput, Duration, RemovalPolicy } from "@aws-cdk/core"; import type { FromSchema } from "json-schema-to-ts"; -import type { - DeleteObjectsOutput, - DeleteObjectsRequest, - ListObjectsV2Output, - ListObjectsV2Request, -} from "aws-sdk/clients/s3"; import chalk from "chalk"; -import type { CreateInvalidationRequest, CreateInvalidationResult } from "aws-sdk/clients/cloudfront"; import { S3Origin } from "@aws-cdk/aws-cloudfront-origins"; import * as acm from "@aws-cdk/aws-certificatemanager"; import { flatten } from "lodash"; @@ -30,6 +23,7 @@ import type { ConstructCommands } from "@lift/constructs"; import { log } from "../../utils/logger"; import { s3Sync } from "../../utils/s3-sync"; import ServerlessError from "../../utils/error"; +import { emptyBucket, invalidateCloudFrontCache } from "../../classes/aws"; const STATIC_WEBSITE_DEFINITION = { type: "object", @@ -203,22 +197,7 @@ export class StaticWebsite extends AwsConstruct { if (distributionId === undefined) { return; } - await this.provider.request( - "CloudFront", - "createInvalidation", - { - DistributionId: distributionId, - InvalidationBatch: { - // This should be a unique ID: we use a timestamp - CallerReference: Date.now().toString(), - Paths: { - // Invalidate everything - Items: ["/*"], - Quantity: 1, - }, - }, - } - ); + await invalidateCloudFrontCache(this.provider, distributionId); } async preRemove(): Promise { @@ -231,19 +210,7 @@ export class StaticWebsite extends AwsConstruct { log( `Emptying S3 bucket '${bucketName}' for the '${this.id}' static website, else CloudFormation will fail (it cannot delete a non-empty bucket)` ); - const data = await this.provider.request("S3", "listObjectsV2", { - Bucket: bucketName, - }); - if (data.Contents === undefined) { - return; - } - const keys = data.Contents.map((item) => item.Key).filter((key): key is string => key !== undefined); - await this.provider.request("S3", "deleteObjects", { - Bucket: bucketName, - Delete: { - Objects: keys.map((key) => ({ Key: key })), - }, - }); + await emptyBucket(this.provider, bucketName); } async getUrl(): Promise { diff --git a/src/constructs/aws/index.ts b/src/constructs/aws/index.ts index fe84d689..9f69b3eb 100644 --- a/src/constructs/aws/index.ts +++ b/src/constructs/aws/index.ts @@ -4,3 +4,4 @@ export { StaticWebsite } from "./StaticWebsite"; export { Storage } from "./Storage"; export { Vpc } from "./Vpc"; export { Webhook } from "./Webhook"; +export { ServerSideWebsite } from "./ServerSideWebsite"; diff --git a/src/providers/AwsProvider.ts b/src/providers/AwsProvider.ts index b6d5603a..bc02d444 100644 --- a/src/providers/AwsProvider.ts +++ b/src/providers/AwsProvider.ts @@ -4,7 +4,15 @@ import { get, merge } from "lodash"; import type { AwsCfInstruction, AwsLambdaVpcConfig } from "@serverless/typescript"; import type { ProviderInterface } from "@lift/providers"; import type { ConstructInterface, StaticConstructInterface } from "@lift/constructs"; -import { DatabaseDynamoDBSingleTable, Queue, StaticWebsite, Storage, Vpc, Webhook } from "@lift/constructs/aws"; +import { + DatabaseDynamoDBSingleTable, + Queue, + ServerSideWebsite, + StaticWebsite, + Storage, + Vpc, + Webhook, +} from "@lift/constructs/aws"; import { getStackOutput } from "../CloudFormation"; import type { CloudformationTemplate, Provider as LegacyAwsProvider, Serverless } from "../types/serverless"; import { awsRequest } from "../classes/aws"; @@ -50,7 +58,12 @@ export class AwsProvider implements ProviderInterface { public readonly region: string; public readonly stackName: string; private readonly legacyProvider: LegacyAwsProvider; - public naming: { getStackName: () => string; getLambdaLogicalId: (functionName: string) => string }; + public naming: { + getStackName: () => string; + getLambdaLogicalId: (functionName: string) => string; + getRestApiLogicalId: () => string; + getHttpApiLogicalId: () => string; + }; constructor(private readonly serverless: Serverless) { this.stackName = serverless.getProvider("aws").naming.getStackName(); @@ -152,4 +165,12 @@ export class AwsProvider implements ProviderInterface { * If they use TypeScript, `registerConstructs()` will validate that the construct class * implements both static fields (type, schema, create(), …) and non-static fields (outputs(), references(), …). */ -AwsProvider.registerConstructs(Storage, Queue, Webhook, StaticWebsite, Vpc, DatabaseDynamoDBSingleTable); +AwsProvider.registerConstructs( + Storage, + Queue, + Webhook, + StaticWebsite, + Vpc, + DatabaseDynamoDBSingleTable, + ServerSideWebsite +); diff --git a/src/types/serverless.ts b/src/types/serverless.ts index bd7e144e..ae6a7a38 100644 --- a/src/types/serverless.ts +++ b/src/types/serverless.ts @@ -24,6 +24,8 @@ export type Provider = { naming: { getStackName: () => string; getLambdaLogicalId: (functionName: string) => string; + getRestApiLogicalId: () => string; + getHttpApiLogicalId: () => string; getCompiledTemplateFileName: () => string; }; getRegion: () => string; diff --git a/src/utils/s3-sync.ts b/src/utils/s3-sync.ts index 1677370c..8aa55f4e 100644 --- a/src/utils/s3-sync.ts +++ b/src/utils/s3-sync.ts @@ -29,26 +29,29 @@ type S3Objects = Record; export async function s3Sync({ aws, localPath, + targetPathPrefix, bucketName, }: { aws: AwsProvider; localPath: string; + targetPathPrefix?: string; bucketName: string; }): Promise<{ hasChanges: boolean }> { let hasChanges = false; const filesToUpload: string[] = await listFilesRecursively(localPath); - const existingS3Objects = await s3ListAll(aws, bucketName); + const existingS3Objects = await s3ListAll(aws, bucketName, targetPathPrefix); // Upload files by chunks let skippedFiles = 0; for (const batch of chunk(filesToUpload, 2)) { await Promise.all( batch.map(async (file) => { + const targetKey = targetPathPrefix !== undefined ? path.join(targetPathPrefix, file) : file; const fileContent = fs.readFileSync(path.join(localPath, file)); // Check that the file isn't already uploaded - if (file in existingS3Objects) { - const existingObject = existingS3Objects[file]; + if (targetKey in existingS3Objects) { + const existingObject = existingS3Objects[targetKey]; const etag = computeS3ETag(fileContent); if (etag === existingObject.ETag) { skippedFiles++; @@ -58,7 +61,7 @@ export async function s3Sync({ } console.log(`Uploading ${file}`); - await s3Put(aws, bucketName, file, fileContent); + await s3Put(aws, bucketName, targetKey, fileContent); hasChanges = true; }) ); @@ -67,10 +70,13 @@ export async function s3Sync({ console.log(chalk.gray(`Skipped uploading ${skippedFiles} unchanged files`)); } - const objectsToDelete = findObjectsToDelete(Object.keys(existingS3Objects), filesToUpload); - if (objectsToDelete.length > 0) { - objectsToDelete.map((key) => console.log(`Deleting ${key}`)); - await s3Delete(aws, bucketName, objectsToDelete); + const targetKeys = filesToUpload.map((file) => + targetPathPrefix !== undefined ? path.join(targetPathPrefix, file) : file + ); + const keysToDelete = findKeysToDelete(Object.keys(existingS3Objects), targetKeys); + if (keysToDelete.length > 0) { + keysToDelete.map((key) => console.log(`Deleting ${key}`)); + await s3Delete(aws, bucketName, keysToDelete); hasChanges = true; } @@ -99,13 +105,14 @@ async function listFilesRecursively(directory: string): Promise { return flatten(files); } -async function s3ListAll(aws: AwsProvider, bucketName: string): Promise { +async function s3ListAll(aws: AwsProvider, bucketName: string, pathPrefix?: string): Promise { let result; let continuationToken = undefined; const objects: Record = {}; do { result = await aws.request("S3", "listObjectsV2", { Bucket: bucketName, + Prefix: pathPrefix, MaxKeys: 1000, ContinuationToken: continuationToken, }); @@ -121,12 +128,12 @@ async function s3ListAll(aws: AwsProvider, bucketName: string): Promise target.indexOf(key) === -1); } -async function s3Put(aws: AwsProvider, bucket: string, key: string, fileContent: Buffer): Promise { +export async function s3Put(aws: AwsProvider, bucket: string, key: string, fileContent: Buffer): Promise { let contentType = lookup(key); if (contentType === false) { contentType = "application/octet-stream"; diff --git a/test/fixtures/serverSideWebsite/error.html b/test/fixtures/serverSideWebsite/error.html new file mode 100644 index 00000000..968e2866 --- /dev/null +++ b/test/fixtures/serverSideWebsite/error.html @@ -0,0 +1 @@ +Error page! diff --git a/test/fixtures/serverSideWebsite/handler.js b/test/fixtures/serverSideWebsite/handler.js new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/serverSideWebsite/public/logo.png b/test/fixtures/serverSideWebsite/public/logo.png new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/serverSideWebsite/public/scripts.js b/test/fixtures/serverSideWebsite/public/scripts.js new file mode 100644 index 00000000..c66d3904 --- /dev/null +++ b/test/fixtures/serverSideWebsite/public/scripts.js @@ -0,0 +1,3 @@ +function main() {} + +main(); diff --git a/test/fixtures/serverSideWebsite/public/styles.css b/test/fixtures/serverSideWebsite/public/styles.css new file mode 100644 index 00000000..8e1a1017 --- /dev/null +++ b/test/fixtures/serverSideWebsite/public/styles.css @@ -0,0 +1,3 @@ +body { + background: rosybrown; +} diff --git a/test/fixtures/serverSideWebsite/serverless.yml b/test/fixtures/serverSideWebsite/serverless.yml new file mode 100644 index 00000000..872bdc1b --- /dev/null +++ b/test/fixtures/serverSideWebsite/serverless.yml @@ -0,0 +1,16 @@ +service: website +configValidationMode: error + +provider: + name: aws + +functions: + http: + handler: handler.handler + +constructs: + backend: + type: server-side-website + assets: + "/assets/*": public + errorPage: error.html diff --git a/test/unit/serverSideWebsite.test.ts b/test/unit/serverSideWebsite.test.ts new file mode 100644 index 00000000..209f56e6 --- /dev/null +++ b/test/unit/serverSideWebsite.test.ts @@ -0,0 +1,614 @@ +import * as sinon from "sinon"; +import * as fs from "fs"; +import * as path from "path"; +import { baseConfig, pluginConfigExt, runServerless } from "../utils/runServerless"; +import * as CloudFormationHelpers from "../../src/CloudFormation"; +import { computeS3ETag } from "../../src/utils/s3-sync"; +import { mockAws } from "../utils/mockAws"; + +describe("server-side website", () => { + afterEach(() => { + sinon.restore(); + }); + + it("should create all required resources", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + assets: { + "/assets/*": "public", + }, + }, + }, + }), + }); + const bucketLogicalId = computeLogicalId("backend", "Assets"); + const bucketPolicyLogicalId = computeLogicalId("backend", "Assets", "Policy"); + const originAccessIdentityLogicalId = computeLogicalId("backend", "OriginAccessIdentity"); + const cfDistributionLogicalId = computeLogicalId("backend", "CDN"); + const cfOriginId1 = computeLogicalId("backend", "CDN", "Origin1"); + const cfOriginId2 = computeLogicalId("backend", "CDN", "Origin2"); + const originPolicyId = computeLogicalId("backend", "BackendOriginPolicy"); + const cachePolicyId = computeLogicalId("backend", "BackendCachePolicy"); + const requestFunction = computeLogicalId("backend", "RequestFunction"); + expect(Object.keys(cfTemplate.Resources)).toStrictEqual([ + "ServerlessDeploymentBucket", + "ServerlessDeploymentBucketPolicy", + bucketLogicalId, + bucketPolicyLogicalId, + originAccessIdentityLogicalId, + originPolicyId, + cachePolicyId, + requestFunction, + cfDistributionLogicalId, + ]); + expect(cfTemplate.Resources[bucketLogicalId]).toMatchObject({ + Type: "AWS::S3::Bucket", + UpdateReplacePolicy: "Delete", + DeletionPolicy: "Delete", + }); + expect(cfTemplate.Resources[bucketPolicyLogicalId]).toMatchObject({ + Properties: { + Bucket: { Ref: bucketLogicalId }, + PolicyDocument: { + Statement: [ + { + Action: ["s3:GetObject*", "s3:GetBucket*", "s3:List*"], + Effect: "Allow", + Principal: { + CanonicalUser: { "Fn::GetAtt": [originAccessIdentityLogicalId, "S3CanonicalUserId"] }, + }, + Resource: [ + { "Fn::GetAtt": [bucketLogicalId, "Arn"] }, + { "Fn::Join": ["", [{ "Fn::GetAtt": [bucketLogicalId, "Arn"] }, "/*"]] }, + ], + }, + ], + Version: "2012-10-17", + }, + }, + }); + expect(cfTemplate.Resources[originAccessIdentityLogicalId]).toMatchObject({ + Type: "AWS::CloudFront::CloudFrontOriginAccessIdentity", + Properties: { + CloudFrontOriginAccessIdentityConfig: { + Comment: "Identity that represents CloudFront for the backend website.", + }, + }, + }); + expect(cfTemplate.Resources[cfDistributionLogicalId]).toStrictEqual({ + Type: "AWS::CloudFront::Distribution", + Properties: { + DistributionConfig: { + Comment: "app-dev backend website CDN", + CustomErrorResponses: [ + { ErrorCachingMinTTL: 0, ErrorCode: 500 }, + { ErrorCachingMinTTL: 0, ErrorCode: 504 }, + ], + DefaultCacheBehavior: { + AllowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"], + Compress: true, + CachePolicyId: { Ref: cachePolicyId }, + OriginRequestPolicyId: { Ref: originPolicyId }, + TargetOriginId: cfOriginId1, + ViewerProtocolPolicy: "redirect-to-https", + FunctionAssociations: [ + { + EventType: "viewer-request", + FunctionARN: { + "Fn::GetAtt": [requestFunction, "FunctionARN"], + }, + }, + ], + }, + CacheBehaviors: [ + { + AllowedMethods: ["GET", "HEAD", "OPTIONS"], + CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", + Compress: true, + PathPattern: "/assets/*", + TargetOriginId: cfOriginId2, + ViewerProtocolPolicy: "redirect-to-https", + }, + ], + Enabled: true, + HttpVersion: "http2", + IPV6Enabled: true, + Origins: [ + { + Id: cfOriginId1, + CustomOriginConfig: { + OriginProtocolPolicy: "https-only", + OriginSSLProtocols: ["TLSv1.2"], + }, + DomainName: { + "Fn::Join": [".", [{ Ref: "HttpApi" }, "execute-api.us-east-1.amazonaws.com"]], + }, + }, + { + DomainName: { "Fn::GetAtt": [bucketLogicalId, "RegionalDomainName"] }, + Id: cfOriginId2, + S3OriginConfig: { + OriginAccessIdentity: { + "Fn::Join": [ + "", + ["origin-access-identity/cloudfront/", { Ref: originAccessIdentityLogicalId }], + ], + }, + }, + }, + ], + }, + }, + }); + expect(cfTemplate.Resources[originPolicyId]).toStrictEqual({ + Type: "AWS::CloudFront::OriginRequestPolicy", + Properties: { + OriginRequestPolicyConfig: { + Name: "app-dev-backend", + Comment: "Origin request policy for the backend website.", + CookiesConfig: { CookieBehavior: "all" }, + QueryStringsConfig: { QueryStringBehavior: "all" }, + HeadersConfig: { + HeaderBehavior: "whitelist", + Headers: [ + "Accept", + "Accept-Language", + "Content-Type", + "Origin", + "Referer", + "User-Agent", + "X-Requested-With", + "X-Forwarded-Host", + ], + }, + }, + }, + }); + expect(cfTemplate.Resources[cachePolicyId]).toStrictEqual({ + Type: "AWS::CloudFront::CachePolicy", + Properties: { + CachePolicyConfig: { + Comment: "Cache policy for the backend website.", + DefaultTTL: 0, + MaxTTL: 31536000, + MinTTL: 0, + Name: "app-dev-backend", + ParametersInCacheKeyAndForwardedToOrigin: { + CookiesConfig: { CookieBehavior: "none" }, + QueryStringsConfig: { QueryStringBehavior: "none" }, + HeadersConfig: { + HeaderBehavior: "whitelist", + Headers: ["Authorization"], + }, + EnableAcceptEncodingBrotli: false, + EnableAcceptEncodingGzip: false, + }, + }, + }, + }); + expect(cfTemplate.Outputs).toMatchObject({ + [computeLogicalId("backend", "AssetsBucketName")]: { + Description: "Name of the bucket that stores the website assets.", + Value: { Ref: bucketLogicalId }, + }, + [computeLogicalId("backend", "Domain")]: { + Description: "Website domain name.", + Value: { "Fn::GetAtt": [cfDistributionLogicalId, "DomainName"] }, + }, + [computeLogicalId("backend", "CloudFrontCName")]: { + Description: "CloudFront CNAME.", + Value: { "Fn::GetAtt": [cfDistributionLogicalId, "DomainName"] }, + }, + [computeLogicalId("backend", "DistributionId")]: { + Description: "ID of the CloudFront distribution.", + Value: { Ref: cfDistributionLogicalId }, + }, + }); + }); + + it("assets should be optional", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + }, + }, + }), + }); + const bucketLogicalId = computeLogicalId("backend", "Assets"); + const bucketPolicyLogicalId = computeLogicalId("backend", "Assets", "Policy"); + const originAccessIdentityLogicalId = computeLogicalId("backend", "OriginAccessIdentity"); + const cfDistributionLogicalId = computeLogicalId("backend", "CDN"); + const cfOriginId1 = computeLogicalId("backend", "CDN", "Origin1"); + const originPolicyId = computeLogicalId("backend", "BackendOriginPolicy"); + const cachePolicyId = computeLogicalId("backend", "BackendCachePolicy"); + const requestFunction = computeLogicalId("backend", "RequestFunction"); + expect(Object.keys(cfTemplate.Resources)).toStrictEqual([ + "ServerlessDeploymentBucket", + "ServerlessDeploymentBucketPolicy", + bucketLogicalId, + bucketPolicyLogicalId, + originAccessIdentityLogicalId, + originPolicyId, + cachePolicyId, + requestFunction, + cfDistributionLogicalId, + ]); + expect(cfTemplate.Resources[cfDistributionLogicalId]).toStrictEqual({ + Type: "AWS::CloudFront::Distribution", + Properties: { + DistributionConfig: { + Comment: "app-dev backend website CDN", + CustomErrorResponses: [ + { ErrorCachingMinTTL: 0, ErrorCode: 500 }, + { ErrorCachingMinTTL: 0, ErrorCode: 504 }, + ], + DefaultCacheBehavior: { + AllowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"], + Compress: true, + CachePolicyId: { Ref: cachePolicyId }, + OriginRequestPolicyId: { Ref: originPolicyId }, + TargetOriginId: cfOriginId1, + ViewerProtocolPolicy: "redirect-to-https", + FunctionAssociations: [ + { + EventType: "viewer-request", + FunctionARN: { "Fn::GetAtt": [requestFunction, "FunctionARN"] }, + }, + ], + }, + Enabled: true, + HttpVersion: "http2", + IPV6Enabled: true, + Origins: [ + { + Id: cfOriginId1, + CustomOriginConfig: { + OriginProtocolPolicy: "https-only", + OriginSSLProtocols: ["TLSv1.2"], + }, + DomainName: { + "Fn::Join": [".", [{ Ref: "HttpApi" }, "execute-api.us-east-1.amazonaws.com"]], + }, + }, + ], + }, + }, + }); + }); + + it("should support REST APIs", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + apiGateway: "rest", + }, + }, + }), + }); + expect(cfTemplate.Resources[computeLogicalId("backend", "CDN")]).toMatchObject({ + Properties: { + DistributionConfig: { + Origins: [ + { + CustomOriginConfig: { + OriginProtocolPolicy: "https-only", + OriginSSLProtocols: ["TLSv1.2"], + }, + DomainName: { + "Fn::Join": [ + ".", + [{ Ref: "ApiGatewayRestApi" }, "execute-api.us-east-1.amazonaws.com"], + ], + }, + }, + ], + }, + }, + }); + }); + + it("should support a custom domain", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + assets: { + "/assets/*": "public", + }, + domain: "example.com", + certificate: + "arn:aws:acm:us-east-1:123456615250:certificate/0a28e63d-d3a9-4578-9f8b-14347bfe8123", + }, + }, + }), + }); + const cfDistributionLogicalId = computeLogicalId("backend", "CDN"); + expect(cfTemplate.Resources[cfDistributionLogicalId]).toMatchObject({ + Type: "AWS::CloudFront::Distribution", + Properties: { + DistributionConfig: { + // Check that CloudFront uses the custom ACM certificate and custom domain + Aliases: ["example.com"], + ViewerCertificate: { + AcmCertificateArn: + "arn:aws:acm:us-east-1:123456615250:certificate/0a28e63d-d3a9-4578-9f8b-14347bfe8123", + MinimumProtocolVersion: "TLSv1.2_2019", + SslSupportMethod: "sni-only", + }, + }, + }, + }); + // The domain should be the custom domain, not the CloudFront one + expect(cfTemplate.Outputs).toMatchObject({ + [computeLogicalId("backend", "Domain")]: { + Description: "Website domain name.", + Value: "example.com", + }, + [computeLogicalId("backend", "CloudFrontCName")]: { + Description: "CloudFront CNAME.", + Value: { + "Fn::GetAtt": [cfDistributionLogicalId, "DomainName"], + }, + }, + }); + }); + + it("should support multiple custom domains", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + assets: { + "/assets/*": "public", + }, + domain: ["example.com", "www.example.com"], + certificate: + "arn:aws:acm:us-east-1:123456615250:certificate/0a28e63d-d3a9-4578-9f8b-14347bfe8123", + }, + }, + }), + }); + const cfDistributionLogicalId = computeLogicalId("backend", "CDN"); + // Check that CloudFront uses all the custom domains + expect(cfTemplate.Resources[cfDistributionLogicalId]).toMatchObject({ + Type: "AWS::CloudFront::Distribution", + Properties: { + DistributionConfig: { + Aliases: ["example.com", "www.example.com"], + }, + }, + }); + // This should contain the first domain of the list + expect(cfTemplate.Outputs).toMatchObject({ + [computeLogicalId("backend", "Domain")]: { + Description: "Website domain name.", + Value: "example.com", + }, + [computeLogicalId("backend", "CloudFrontCName")]: { + Description: "CloudFront CNAME.", + Value: { + "Fn::GetAtt": [cfDistributionLogicalId, "DomainName"], + }, + }, + }); + }); + + it("should allow to customize the error page", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + errorPage: "my/custom/error-page.html", + }, + }, + }), + }); + const cfDistributionLogicalId = computeLogicalId("backend", "CDN"); + expect(cfTemplate.Resources[cfDistributionLogicalId]).toMatchObject({ + Properties: { + DistributionConfig: { + CustomErrorResponses: [ + { + ErrorCode: 500, + ErrorCachingMinTTL: 0, + ResponsePagePath: "/error-page.html", + }, + { + ErrorCode: 504, + ErrorCachingMinTTL: 0, + ResponsePagePath: "/error-page.html", + }, + ], + }, + }, + }); + }); + + it("should validate the error page path", async () => { + await expect(() => { + return runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + errorPage: "/error.css", + }, + }, + }), + }); + }).rejects.toThrowError( + "Invalid configuration in 'constructs.backend.errorPage': the custom error page must be a static HTML file. '/error.css' does not end with '.html'." + ); + }); + + it("should allow to override the forwarded headers", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + forwardedHeaders: ["X-My-Custom-Header", "X-My-Other-Custom-Header"], + }, + }, + }), + }); + expect(cfTemplate.Resources[computeLogicalId("backend", "BackendOriginPolicy")]).toMatchObject({ + Properties: { + OriginRequestPolicyConfig: { + HeadersConfig: { + HeaderBehavior: "whitelist", + Headers: ["X-My-Custom-Header", "X-My-Other-Custom-Header"], + }, + }, + }, + }); + }); + + it("should not forward the Authorization header in the Origin Policy", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + forwardedHeaders: ["Authorization", "X-My-Custom-Header"], + }, + }, + }), + }); + expect(cfTemplate.Resources[computeLogicalId("backend", "BackendOriginPolicy")]).toMatchObject({ + Properties: { + OriginRequestPolicyConfig: { + HeadersConfig: { + // Should not contain "Authorization" + Headers: ["X-My-Custom-Header"], + }, + }, + }, + }); + }); + + it("should forbid to force forwarding the Host header", async () => { + await expect(() => { + return runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + forwardedHeaders: ["Host"], + }, + }, + }), + }); + }).rejects.toThrowError( + "Invalid value in 'constructs.backend.forwardedHeaders': the 'Host' header cannot be forwarded (this is an API Gateway limitation). Use the 'X-Forwarded-Host' header in your code instead (it contains the value of the original 'Host' header)." + ); + }); + + it("should error if more than 10 headers are configured", async () => { + await expect(() => { + return runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + backend: { + type: "server-side-website", + forwardedHeaders: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"], + }, + }, + }), + }); + }).rejects.toThrowError( + "Invalid value in 'constructs.backend.forwardedHeaders': 11 headers are configured but only 10 headers can be forwarded (this is an CloudFront limitation)." + ); + }); + + it("should synchronize assets to S3", async () => { + const awsMock = mockAws(); + sinon.stub(CloudFormationHelpers, "getStackOutput").resolves("bucket-name"); + /* + * This scenario simulates the following: + * - assets/logo.png is up to date, it should be ignored + * - assets/styles.css has changes, it should be updated to S3 + * - assets/scripts.js is new, it should be created in S3 + * - assets/image.jpg doesn't exist on disk, it should be removed from S3 + */ + awsMock.mockService("S3", "listObjectsV2").resolves({ + IsTruncated: false, + Contents: [ + { + Key: "assets/logo.png", + ETag: computeS3ETag( + fs.readFileSync(path.join(__dirname, "../fixtures/serverSideWebsite/public/logo.png")) + ), + }, + { Key: "assets/styles.css" }, + { Key: "assets/image.jpg" }, + ], + }); + const putObjectSpy = awsMock.mockService("S3", "putObject"); + const deleteObjectsSpy = awsMock.mockService("S3", "deleteObjects"); + const cloudfrontInvalidationSpy = awsMock.mockService("CloudFront", "createInvalidation"); + + await runServerless({ + fixture: "serverSideWebsite", + configExt: pluginConfigExt, + command: "backend:assets:upload", + }); + + // scripts.js and styles.css were updated + sinon.assert.callCount(putObjectSpy, 3); + expect(putObjectSpy.firstCall.firstArg).toEqual({ + Bucket: "bucket-name", + Key: "assets/scripts.js", + Body: fs.readFileSync(path.join(__dirname, "../fixtures/serverSideWebsite/public/scripts.js")), + ContentType: "application/javascript", + }); + expect(putObjectSpy.secondCall.firstArg).toEqual({ + Bucket: "bucket-name", + Key: "assets/styles.css", + Body: fs.readFileSync(path.join(__dirname, "../fixtures/serverSideWebsite/public/styles.css")), + ContentType: "text/css", + }); + // It should upload the custom error page + expect(putObjectSpy.thirdCall.firstArg).toEqual({ + Bucket: "bucket-name", + Key: "error.html", + Body: fs.readFileSync(path.join(__dirname, "../fixtures/serverSideWebsite/error.html")), + ContentType: "text/html", + }); + // image.jpg was deleted + sinon.assert.calledOnce(deleteObjectsSpy); + expect(deleteObjectsSpy.firstCall.firstArg).toEqual({ + Bucket: "bucket-name", + Delete: { + Objects: [ + { + Key: "assets/image.jpg", + }, + ], + }, + }); + // A CloudFront invalidation was triggered + sinon.assert.calledOnce(cloudfrontInvalidationSpy); + }); +});