Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server-side Website construct #44

Merged
merged 33 commits into from
Sep 6, 2021
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cf6fdff
New "server-side website" construct
mnapoli Jun 15, 2021
e775abb
Improve "server-side website" construct
mnapoli Jun 16, 2021
4a13659
Improve "server-side website" construct
mnapoli Jun 16, 2021
84fafe9
Adapt the server-side website construct to the new Construct syntax
mnapoli Jun 26, 2021
dc9b4c7
Server-side website: set a `X-Forwarded-Host` header when using a sin…
mnapoli Jun 26, 2021
aef9dd9
Server-side website: forward the `Host` header to Lambda via `X-Forwa…
mnapoli Jun 26, 2021
afc903e
Add an `errorPage` option in server-side websites
mnapoli Jul 4, 2021
8865224
Merge branch 'master' into server-side-website
mnapoli Jul 4, 2021
4ae0ed5
Server-side website: support REST APIs
mnapoli Jul 7, 2021
effcbcc
Server-side website: add test
mnapoli Jul 7, 2021
acb6180
Allow configuring the forwarded headers
mnapoli Jul 7, 2021
505d5a3
Static website: documentation
mnapoli Jul 8, 2021
f839c8b
Merge branch 'master' into server-side-website
mnapoli Jul 8, 2021
6b86573
Fix code formatting
mnapoli Jul 8, 2021
15f42ba
Merge branch 'master' into server-side-website
mnapoli Jul 9, 2021
fb05ed3
Document variables
mnapoli Jul 9, 2021
de40837
Update to the new "variables" system
mnapoli Jul 9, 2021
540b793
Link to server-side websites in the README
mnapoli Jul 9, 2021
9771a07
Typo
mnapoli Jul 9, 2021
95038b5
Documentation fix
mnapoli Jul 9, 2021
961ce04
Clarify documentation
mnapoli Jul 9, 2021
07eb4ff
Merge branch 'master' into server-side-website
mnapoli Jul 9, 2021
d2b345b
Document the `forwardedHeaders` option for server-side websites
mnapoli Jul 13, 2021
5a856c5
Optimize array filtering
mnapoli Jul 13, 2021
d93833c
Merge branch 'master' into server-side-website
mnapoli Jul 13, 2021
4bdf3ec
Merge branch 'master' into server-side-website
mnapoli Aug 28, 2021
647d369
Reorganize server-side websites documentation
mnapoli Aug 28, 2021
9636320
Server-side website header list: switch from "extend" to override
mnapoli Aug 28, 2021
2024053
Fix variable
mnapoli Aug 29, 2021
f6492de
Merge branch 'master' into server-side-website
mnapoli Aug 30, 2021
fa8a224
Server-side website: throw an error if more than 10 headers are confi…
mnapoli Sep 6, 2021
cbab928
Server-side website: forward the "Content-Type" and "User-Agent" headers
mnapoli Sep 6, 2021
b09781e
Server-side website: expose the `cname` as a variable (similar to #92)
mnapoli Sep 6, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ constructs:

[Read more...](docs/webhook.md)

### [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/components).

## Ejecting
Expand Down
Binary file added docs/img/server-side-website.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
282 changes: 282 additions & 0 deletions docs/server-side-website.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
# 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://<domain>/*` -> the website through API Gateway + Lambda
- `https://<domain>/css/*` and `https://<domain>/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.)
mnapoli marked this conversation as resolved.
Show resolved Hide resolved
- 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 custom domain, if configured)
mnapoli marked this conversation as resolved.
Show resolved Hide resolved

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._

## 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 <function-name>` to deploy a single Lambda function
- `serverless <construct-name>:assets:upload` to upload assets to S3 (the CloudFront cache will be cleared as well)

## Configuration reference

### 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)
```

### 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://<domain>/*` -> Lambda
- `https://<domain>/css/*` -> serves the files uploaded from the local `dist/css` directory
- `https://<domain>/images/*` -> serves the files uploaded from the local `assets/animations` directory
- `https://<domain>/favicon.ico` -> serves the file uploaded from `public/favicon.ico`

### 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 `[email protected]`

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.

mnapoli marked this conversation as resolved.
Show resolved Hide resolved
### 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.

### More options

Looking for more options in the construct configuration? [Open a GitHub issue](https://github.com/getlift/lift/issues/new).
10 changes: 8 additions & 2 deletions src/classes/AwsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Storage } from "../constructs/Storage";
import { Queue } from "../constructs/Queue";
import { Webhook } from "../constructs/Webhook";
import { StaticWebsite } from "../constructs/StaticWebsite";
import { ServerSideWebsite } from "../constructs/ServerSideWebsite";

export class AwsProvider {
private static readonly constructClasses: Record<string, StaticConstructInterface> = {};
Expand Down Expand Up @@ -39,7 +40,12 @@ export class AwsProvider {
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.app = new App();
Expand Down Expand Up @@ -113,4 +119,4 @@ export class AwsProvider {
* 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);
AwsProvider.registerConstructs(Storage, Queue, Webhook, StaticWebsite, ServerSideWebsite);
39 changes: 39 additions & 0 deletions src/classes/aws.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import {
DeleteObjectsOutput,
DeleteObjectsRequest,
ListObjectsV2Output,
ListObjectsV2Request,
} from "aws-sdk/clients/s3";
import { CreateInvalidationRequest, CreateInvalidationResult } from "aws-sdk/clients/cloudfront";
import { Provider as LegacyAwsProvider } from "../types/serverless";
import { AwsProvider } from "./AwsProvider";

// This is defined as a separate function to allow mocking in tests
export async function awsRequest<Input, Output>(
Expand All @@ -9,3 +17,34 @@ export async function awsRequest<Input, Output>(
): Promise<Output> {
return await provider.request<Input, Output>(service, method, params);
}

export async function emptyBucket(aws: AwsProvider, bucketName: string): Promise<void> {
const data = await aws.request<ListObjectsV2Request, ListObjectsV2Output>("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<DeleteObjectsRequest, DeleteObjectsOutput>("S3", "deleteObjects", {
Bucket: bucketName,
Delete: {
Objects: keys.map((key) => ({ Key: key })),
},
});
}

export async function invalidateCloudFrontCache(aws: AwsProvider, distributionId: string): Promise<void> {
await aws.request<CreateInvalidationRequest, CreateInvalidationResult>("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,
},
},
});
}
Loading