diff --git a/docs/content/2.deploy/0.index.md b/docs/content/2.deploy/0.index.md index 53fc93b85d..43512a7ee9 100644 --- a/docs/content/2.deploy/0.index.md +++ b/docs/content/2.deploy/0.index.md @@ -19,6 +19,7 @@ When running Nitro in development mode, Nitro will always use a special preset c When deploying to the production using CI/CD, Nitro tries to automatically detect the provider environment and set the right one without any additional configuration. Currently, providers below can be auto-detected with zero config. +- [aws](/deploy/providers/aws) - [azure](/deploy/providers/azure) - [cloudflare pages](/deploy/providers/cloudflare#cloudflare-pages) - [netlify](/deploy/providers/netlify) diff --git a/docs/content/2.deploy/20.providers/aws.md b/docs/content/2.deploy/20.providers/aws.md index 4c421bfc72..4b8fade906 100644 --- a/docs/content/2.deploy/20.providers/aws.md +++ b/docs/content/2.deploy/20.providers/aws.md @@ -1,6 +1,8 @@ -# AWS Lambda +# AWS -Deploy Nitro apps to AWS Lambda. +Deploy Nitro apps to AWS. + +## AWS Lambda **Preset:** `aws_lambda` ([switch to this preset](/deploy/#changing-the-deployment-preset)) @@ -35,3 +37,269 @@ export default defineNuxtConfig({ }) ``` :: + +## AWS Lambda@Edge + +**Preset:** `aws-lambda-edge-cdk` ([switch to this preset](/deploy/#changing-the-deployment-preset)) + +::alert +**Zero Config Provider** +:br +Integration with this provider is possible with zero configuration. ([Learn More](/deploy/#zero-config-providers)) +:: + +Nitro provides a built-in preset to generate output format compatible with [AWS Lambda@Edge](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html). + +The output entrypoint in `.output/server/index.mjs` is compatible with [AWS Lambda@Edge format](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html). + +::alert{type=warning} +**Bootstrap** +:br +Nitro uses [AWS CDK](https://github.com/aws/aws-cdk) for deploying Lambda@Edge. +If you are using AWS CDK for the first time on a region-by-region basis, you will need to run the following commands. +```bash +npx cdk bootstrap \ + aws:///us-east-1 \ + aws:/// +``` +Lambda@Edge uses us-east-1, so be sure to include it in your bootstrap, even when deploying to a different region. +:: + +### Deploy from your local machine + +To deploy, run the following commands. + +```bash +NITRO_PRESET=aws-lambda-edge npm run build +cd .output/cdk +APP_ID= npm run deploy --all +``` + +### Deploy from CI/CD via GitHub Actions + +First, settings IAM Role following [these instructions](https://github.com/aws-actions/configure-aws-credentials#assuming-a-role) and add the IAM Role ARN as a secret to your GitHub repository. + +Then create the following file as a workflow: + +```yml +# .github/workflows/cdk.yml +name: cdk +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + deploy: + env: + APP_ID: + runs-on: ubuntu-latest + name: Deploy to AWS + permissions: + id-token: write + contents: read + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN_TO_ASSUME }} + aws-region: + + - name: Install Dependencies + run: yarn + + - name: Build + run: yarn build + env: + NITRO_PRESET: aws-lambda-edge + + - name: Install CDK Dependencies + working-directory: .output/cdk + run: yarn + + - name: Deploy to AWS Lambda@Edge + working-directory: .output/cdk + run: yarn cdk deploy --require-approval never --all +``` + +### Customize CDK App + +
+Expand here to see +
+ +To customize it, you must create your own AWS CDK application. +Create your AWS CDK application with the following command. + +```bash +mkdir nitro-lambda-edge && cd nitro-lambda-edge +npx cdk init app --language typescript +npm i nitro-aws-cdk-lib +``` + +The following code is an example of deploying a Nuxt3 project using custom domain to CloudFront and Lambda@Edge with AWS CDK. Using this stack, paths under `_nuxt/` (static assets) will get their data from the S3 origin, and all other paths will be resolved by Lambda@Edge. + +```ts +// nitro-lambda-edge/lib/nitro-lambda-edge-stack.ts +import { + CfnOutput, + DockerImage, + RemovalPolicy, + Stack, + StackProps, +} from "aws-cdk-lib"; +import * as acm from "aws-cdk-lib/aws-certificatemanager"; +import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; +import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as route53 from "aws-cdk-lib/aws-route53"; +import * as targets from "aws-cdk-lib/aws-route53-targets"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as s3deployment from "aws-cdk-lib/aws-s3-deployment"; +import { spawnSync } from "child_process"; +import { Construct } from "constructs"; +import { NitroAsset } from "nitro-aws-cdk-lib"; + +export interface NitroLambdaEdgeStackProps extends StackProps { + /** + * Your site domain name. + * @example example.com + */ + readonly domainName: string; + /** + * Your site subdomain. + * @example www + * @default - Use domainName as it is. + */ + readonly subdomain?: string; +} + +export class NitroLambdaEdgeStack extends Stack { + constructor(scope: Construct, id: string, props: NitroLambdaEdgeStackProps) { + super(scope, id, props); + + // Resolve nitro server and public assets + const nitro = new NitroAsset(this, "NitroAsset", { + path: "", + + // Uncomment this option if you want to build nitro app from CDK app. + // bundling: { + // workingDirectory: "", + // image: DockerImage.fromRegistry('node:lts'), + // local: { + // tryBundle(outputDir, options) { + // const spawnOptions = { + // stdio: "inherit" as const, + // cwd: options.workingDirectory + // } + // spawnSync('npm', ['ci'], spawnOptions) + // spawnSync('npm', ['run', 'build'], spawnOptions) + // spawnSync('cp', ['-r', '.output', outputDir], spawnOptions) + // return true + // }, + // } + // } + }); + + // Lambda@Edge with working Nitro server code + const edgeFunction = new cloudfront.experimental.EdgeFunction( + this, + "EdgeFunction", + { + runtime: lambda.Runtime.NODEJS_18_X, + handler: "index.handler", + code: nitro.serverHandler, + } + ); + + // Static assets bucket + const bucket = new s3.Bucket(this, "Bucket", { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }); + + const hostedZone = route53.HostedZone.fromLookup(this, "HostedZone", { + domainName: props.domainName, + }); + const siteDomain = [props.subdomain, props.domainName] + .filter((v) => !!v) + .join("."); + new CfnOutput(this, "URL", { + value: `https://${siteDomain}`, + }); + + // TLS certificate + const certificate = new acm.DnsValidatedCertificate(this, "Certificate", { + domainName: siteDomain, + hostedZone, + region: "us-east-1", // always must be set us-east-1 + }); + + // CloudFront distribution + const s3Origin = new origins.S3Origin(bucket); + const distribution = new cloudfront.Distribution(this, "Distribution", { + certificate, + domainNames: [siteDomain], + defaultBehavior: { + origin: s3Origin, + edgeLambdas: [ + { + functionVersion: edgeFunction.currentVersion, + eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST, + }, + ], + }, + additionalBehaviors: nitro.staticAsset.resolveCloudFrontBehaviors({ + resolve: () => ({ + origin: s3Origin, + }), + }), + priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL, + httpVersion: cloudfront.HttpVersion.HTTP3, + }); + new route53.ARecord(this, "AliasRecord", { + recordName: siteDomain, + target: route53.RecordTarget.fromAlias( + new targets.CloudFrontTarget(distribution) + ), + zone: hostedZone, + }); + + // Deploy static assets to S3 bucket + new s3deployment.BucketDeployment(this, "Deployment", { + sources: [nitro.staticAsset], + destinationBucket: bucket, + distribution, + }); + } +} +``` + +```ts +// nitro-lambda-edge/bin/nitro-lambda-edge.ts +#!/usr/bin/env node +import "source-map-support/register"; +import * as cdk from "aws-cdk-lib"; +import { NitroLambdaEdgeStack } from "../lib/nitro-lambda-edge-stack"; + +const app = new cdk.App(); +new NitroLambdaEdgeStack(app, "NitroLambdaEdgeStack", { + domainName: "your-site.com", + subdomain: "www", + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); +``` + +
+
\ No newline at end of file diff --git a/src/build.ts b/src/build.ts index 2c1207841d..c50657c4f4 100644 --- a/src/build.ts +++ b/src/build.ts @@ -409,6 +409,16 @@ async function _build(nitro: Nitro, rollupConfig: RollupConfig) { preview: nitro.options.commands.preview, deploy: nitro.options.commands.deploy, }, + output: { + serverDir: relative( + nitro.options.output.dir, + nitro.options.output.serverDir + ), + publicDir: relative( + nitro.options.output.dir, + nitro.options.output.publicDir + ), + }, }; await writeFile(nitroConfigPath, JSON.stringify(buildInfo, null, 2)); diff --git a/src/presets/aws-lambda-edge.ts b/src/presets/aws-lambda-edge.ts new file mode 100644 index 0000000000..f147db2d39 --- /dev/null +++ b/src/presets/aws-lambda-edge.ts @@ -0,0 +1,167 @@ +import { resolve, basename } from "pathe"; +import { defineNitroPreset } from "../preset"; +import { Nitro } from "../types"; +import { writeFile } from "../utils"; + +export const awsLambdaEdge = defineNitroPreset({ + entry: "#internal/nitro/entries/aws-lambda-edge", + commands: { + deploy: "cd ./cdk && APP_ID= npm run deploy -- --all", + }, + hooks: { + async compiled(nitro: Nitro) { + await generateCdkApp(nitro); + }, + }, +}); + +async function generateCdkApp(nitro: Nitro) { + const cdkDir = resolve(nitro.options.output.dir, "cdk"); + await writeFile(resolve(cdkDir, "bin/nitro-lambda-edge.ts"), entryTemplate()); + await writeFile( + resolve(cdkDir, "lib/nitro-lambda-edge-stack.ts"), + nitroLambdaEdgeStackTemplate(nitro) + ); + await writeFile( + resolve(cdkDir, "package.json"), + JSON.stringify({ + private: true, + scripts: { + cdk: "cdk", + deploy: "npm install && cdk deploy", + }, + devDependencies: { + "@types/node": "18", + "aws-cdk": "^2", + jiti: "^1", + typescript: "~3.9.7", + }, + dependencies: { + "aws-cdk-lib": "^2", + constructs: "^10.0.0", + "nitro-aws-cdk-lib": "latest", + "source-map-support": "^0.5.21", + }, + }) + ); + await writeFile( + resolve(cdkDir, "cdk.json"), + JSON.stringify({ + app: "npx jiti bin/nitro-lambda-edge.ts", + }) + ); + await writeFile( + resolve(cdkDir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + target: "ES2018", + module: "commonjs", + lib: ["es2018"], + declaration: true, + strict: true, + noImplicitAny: true, + strictNullChecks: true, + noImplicitThis: true, + alwaysStrict: true, + noUnusedLocals: false, + noUnusedParameters: false, + noImplicitReturns: true, + noFallthroughCasesInSwitch: false, + inlineSourceMap: true, + inlineSources: true, + experimentalDecorators: true, + strictPropertyInitialization: false, + typeRoots: ["./node_modules/@types"], + }, + exclude: ["node_modules", "cdk.out"], + }) + ); +} + +function entryTemplate() { + return ` +#!/usr/bin/env node +import "source-map-support/register"; +import * as cdk from "aws-cdk-lib"; +import { NitroLambdaEdgeStack } from "../lib/nitro-lambda-edge-stack"; + +if (!process.env.APP_ID) { + throw new Error("$APP_ID is not set. Please rerun after set it."); +} + +const app = new cdk.App(); +new NitroLambdaEdgeStack(app, process.env.APP_ID, { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); +`.trim(); +} + +function nitroLambdaEdgeStackTemplate(nitro: Nitro) { + return ` +import * as path from "node:path"; +import { CfnOutput, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib"; +import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; +import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as s3deployment from "aws-cdk-lib/aws-s3-deployment"; +import { Construct } from "constructs"; +import { NitroAsset } from "nitro-aws-cdk-lib"; + +export class NitroLambdaEdgeStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const nitro = new NitroAsset(this, "NitroAsset", { + path: path.join(__dirname, "../../../"), + outputDir: "${basename(nitro.options.output.dir)}", + }); + const edgeFunction = new cloudfront.experimental.EdgeFunction( + this, + "EdgeFunction", + { + runtime: lambda.Runtime.NODEJS_18_X, + handler: "index.handler", + code: nitro.serverHandler, + } + ); + const bucket = new s3.Bucket(this, "Bucket", { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }); + const s3Origin = new origins.S3Origin(bucket); + const distribution = new cloudfront.Distribution(this, "Distribution", { + defaultBehavior: { + origin: s3Origin, + edgeLambdas: [ + { + functionVersion: edgeFunction.currentVersion, + eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST, + }, + ], + }, + additionalBehaviors: nitro.staticAsset.resolveCloudFrontBehaviors({ + resolve: () => ({ + origin: s3Origin, + }), + }), + priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL, + httpVersion: cloudfront.HttpVersion.HTTP3, + }); + new s3deployment.BucketDeployment(this, "Deployment", { + sources: [nitro.staticAsset], + destinationBucket: bucket, + distribution, + }); + new CfnOutput(this, "URL", { + value: \`https://\${distribution.distributionDomainName}\`, + }); + } +} +`.trim(); +} diff --git a/src/presets/index.ts b/src/presets/index.ts index f602f8d5fc..7c97aafacc 100644 --- a/src/presets/index.ts +++ b/src/presets/index.ts @@ -1,4 +1,5 @@ export * from "./aws-lambda"; +export * from "./aws-lambda-edge"; export * from "./azure-functions"; export * from "./azure"; export * from "./base-worker"; diff --git a/src/runtime/entries/aws-lambda-edge.ts b/src/runtime/entries/aws-lambda-edge.ts new file mode 100644 index 0000000000..f8a4a2f4ab --- /dev/null +++ b/src/runtime/entries/aws-lambda-edge.ts @@ -0,0 +1,76 @@ +import type { + CloudFrontHeaders, + Context, + CloudFrontRequest, + CloudFrontRequestEvent, + CloudFrontResultResponse, +} from "aws-lambda"; +import "#internal/nitro/virtual/polyfill"; +import { nitroApp } from "../app"; +import { normalizeLambdaOutgoingBody } from "../utils.lambda"; + +export const handler = async function handler( + event: CloudFrontRequestEvent, + context: Context +): Promise { + const request = event.Records[0].cf.request; + + const r = await nitroApp.localCall({ + event, + url: request.uri + (request.querystring ? `?${request.querystring}` : ""), + context, + headers: normalizeIncomingHeaders(request.headers), + method: request.method, + query: request.querystring, + body: normalizeIncomingBody(request.body), + }); + + const awsBody = await normalizeLambdaOutgoingBody(r.body, r.headers); + return { + status: r.status.toString(), + headers: normalizeOutgoingHeaders(r.headers), + body: awsBody.body, + bodyEncoding: awsBody.type === "binary" ? "base64" : awsBody.type, + }; +}; + +function normalizeIncomingHeaders(headers: CloudFrontHeaders) { + return Object.fromEntries( + Object.entries(headers).map(([key, keyValues]) => [ + key, + keyValues.map((kv) => kv.value), + ]) + ); +} + +function normalizeOutgoingHeaders( + headers: Record +): CloudFrontHeaders { + return Object.fromEntries( + Object.entries(headers).map(([k, v]) => [ + k, + Array.isArray(v) + ? v.flatMap((values) => + values.split(",").map((value) => ({ value: value.trim() })) + ) + : v + ?.toString() + .split(",") + .map((splited) => ({ value: splited.trim() })) ?? [], + ]) + ); +} + +function normalizeIncomingBody(body?: CloudFrontRequest["body"]) { + switch (body?.encoding) { + case "base64": { + return Buffer.from(body.data, "base64").toString("utf8"); + } + case "text": { + return body.data; + } + default: { + return ""; + } + } +} diff --git a/test/presets/aws-lambda-edge.test.ts b/test/presets/aws-lambda-edge.test.ts new file mode 100644 index 0000000000..1109a5db5d --- /dev/null +++ b/test/presets/aws-lambda-edge.test.ts @@ -0,0 +1,74 @@ +import { resolve } from "pathe"; +import { describe } from "vitest"; +import destr from "destr"; +import type { + CloudFrontHeaders, + CloudFrontRequestEvent, + CloudFrontResultResponse, +} from "aws-lambda"; +import { setupTest, testNitro } from "../tests"; + +describe("nitro:preset:aws-lambda-edge", async () => { + const ctx = await setupTest("aws-lambda-edge"); + testNitro(ctx, async () => { + const { handler } = await import(resolve(ctx.outDir, "server/index.mjs")); + return async ({ url: rawRelativeUrl, headers, method, body }) => { + // creating new URL object to parse query easier + const [uri, querystring] = rawRelativeUrl.split("?"); + // modify headers to CloudFrontHeaders. + const reqHeaders: CloudFrontHeaders = Object.fromEntries( + Object.entries(headers || {}).map(([k, v]) => [ + k, + Array.isArray(v) ? v.map((value) => ({ value })) : [{ value: v }], + ]) + ); + + const event: CloudFrontRequestEvent = { + Records: [ + { + cf: { + config: { + distributionDomainName: "nitro.cloudfront.net", + distributionId: "EDFDVBD6EXAMPLE", + eventType: "origin-request", + requestId: + "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==", + }, + request: { + clientIp: "203.0.113.178", + method: method || "GET", + uri, + querystring, + headers: reqHeaders, + body: body + ? { + action: "read-only", + data: body, + encoding: "text", + inputTruncated: false, + } + : undefined, + }, + }, + }, + ], + }; + const res: CloudFrontResultResponse = await handler(event); + // The headers that CloudFront responds to are in array format, so normalise them to the string format expected by testNitro. + const resHeaders = Object.fromEntries( + Object.entries(res.headers).map(([key, keyValues]) => [ + key, + keyValues.length === 1 + ? keyValues[0].value + : keyValues.map((kv) => kv.value), + ]) + ); + + return { + data: destr(res.body), + status: Number.parseInt(res.status), + headers: resHeaders, + }; + }; + }); +}); diff --git a/test/tests.ts b/test/tests.ts index 62dfdb2b77..61bd225e1f 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -236,7 +236,7 @@ export function testNitro( }); // aws lambda requires buffer responses to be base 64 - const LambdaPresets = new Set(["netlify", "aws-lambda"]); + const LambdaPresets = new Set(["netlify", "aws-lambda", "aws-lambda-edge"]); it.runIf(LambdaPresets.has(ctx.preset))( "buffer image responses", async () => {