diff --git a/src/build/withNextDeploy.ts b/src/build/withNextDeploy.ts index 002b07d..2c0eb50 100644 --- a/src/build/withNextDeploy.ts +++ b/src/build/withNextDeploy.ts @@ -1,4 +1,4 @@ -import type { NextConfig } from 'next/dist/server/config-shared' +import type { NextConfig } from 'next/types' import path from 'node:path' import loadConfig from '../commands/helpers/loadConfig' diff --git a/src/cdk/constructs/CloudFrontDistribution.ts b/src/cdk/constructs/CloudFrontDistribution.ts index 6d09b06..4b76725 100644 --- a/src/cdk/constructs/CloudFrontDistribution.ts +++ b/src/cdk/constructs/CloudFrontDistribution.ts @@ -13,6 +13,7 @@ interface CloudFrontPropsDistribution { requestEdgeFunction: cloudfront.experimental.EdgeFunction responseEdgeFunction: cloudfront.experimental.EdgeFunction viewerResponseEdgeFunction: cloudfront.experimental.EdgeFunction + viewerRequestLambdaEdge: cloudfront.experimental.EdgeFunction cacheConfig: CacheConfig imageTTL?: number } @@ -35,6 +36,7 @@ export class CloudFrontDistribution extends Construct { requestEdgeFunction, responseEdgeFunction, viewerResponseEdgeFunction, + viewerRequestLambdaEdge, cacheConfig, renderServerDomain, imageTTL @@ -99,6 +101,10 @@ export class CloudFrontDistribution extends Construct { { functionVersion: viewerResponseEdgeFunction.currentVersion, eventType: cloudfront.LambdaEdgeEventType.VIEWER_RESPONSE + }, + { + functionVersion: viewerRequestLambdaEdge.currentVersion, + eventType: cloudfront.LambdaEdgeEventType.VIEWER_REQUEST } ], cachePolicy: splitCachePolicy diff --git a/src/cdk/constructs/ViewerRequestLambdaEdge.ts b/src/cdk/constructs/ViewerRequestLambdaEdge.ts new file mode 100644 index 0000000..fb5f8d5 --- /dev/null +++ b/src/cdk/constructs/ViewerRequestLambdaEdge.ts @@ -0,0 +1,60 @@ +import { Construct } from 'constructs' +import * as cdk from 'aws-cdk-lib' +import * as lambda from 'aws-cdk-lib/aws-lambda' +import * as cloudfront from 'aws-cdk-lib/aws-cloudfront' +import * as logs from 'aws-cdk-lib/aws-logs' +import * as iam from 'aws-cdk-lib/aws-iam' +import path from 'node:path' +import { buildLambda } from '../../build/edge' +import { NextRedirects } from '../../types' + +interface ViewerRequestLambdaEdgeProps extends cdk.StackProps { + buildOutputPath: string + nodejs?: string + redirects?: NextRedirects +} + +const NodeJSEnvironmentMapping: Record = { + '18': lambda.Runtime.NODEJS_18_X, + '20': lambda.Runtime.NODEJS_20_X +} + +export class ViewerRequestLambdaEdge extends Construct { + public readonly lambdaEdge: cloudfront.experimental.EdgeFunction + + constructor(scope: Construct, id: string, props: ViewerRequestLambdaEdgeProps) { + const { nodejs, buildOutputPath } = props + super(scope, id) + + const nodeJSEnvironment = NodeJSEnvironmentMapping[nodejs ?? ''] ?? NodeJSEnvironmentMapping['20'] + const name = 'viewerRequest' + + buildLambda(name, buildOutputPath, { + define: { + 'process.env.REDIRECTS': JSON.stringify(props.redirects ?? []) + } + }) + + const logGroup = new logs.LogGroup(this, 'ViewerRequestLambdaEdgeLogGroup', { + logGroupName: `/aws/lambda/${id}-viewerRequest`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + retention: logs.RetentionDays.ONE_DAY + }) + + this.lambdaEdge = new cloudfront.experimental.EdgeFunction(this, 'ViewerRequestLambdaEdge', { + runtime: nodeJSEnvironment, + code: lambda.Code.fromAsset(path.join(buildOutputPath, 'server-functions', name)), + handler: 'index.handler', + logGroup + }) + + logGroup.grantWrite(this.lambdaEdge) + + const policyStatement = new iam.PolicyStatement({ + actions: ['logs:CreateLogStream', 'logs:PutLogEvents'], + resources: [`${logGroup.logGroupArn}:*`] + }) + + this.lambdaEdge.addToRolePolicy(policyStatement) + } +} diff --git a/src/cdk/stacks/NextCloudfrontStack.ts b/src/cdk/stacks/NextCloudfrontStack.ts index 10a61d6..fd88875 100644 --- a/src/cdk/stacks/NextCloudfrontStack.ts +++ b/src/cdk/stacks/NextCloudfrontStack.ts @@ -3,9 +3,10 @@ import { Construct } from 'constructs' import * as s3 from 'aws-cdk-lib/aws-s3' import { OriginRequestLambdaEdge } from '../constructs/OriginRequestLambdaEdge' import { CloudFrontDistribution } from '../constructs/CloudFrontDistribution' -import { CacheConfig } from '../../types' import { OriginResponseLambdaEdge } from '../constructs/OriginResponseLambdaEdge' import { ViewerResponseLambdaEdge } from '../constructs/ViewerResponseLambdaEdge' +import { ViewerRequestLambdaEdge } from '../constructs/ViewerRequestLambdaEdge' +import { CacheConfig, NextRedirects } from '../../types' export interface NextCloudfrontStackProps extends StackProps { nodejs?: string @@ -17,12 +18,14 @@ export interface NextCloudfrontStackProps extends StackProps { buildOutputPath: string cacheConfig: CacheConfig imageTTL?: number + redirects?: NextRedirects } export class NextCloudfrontStack extends Stack { public readonly originRequestLambdaEdge: OriginRequestLambdaEdge public readonly originResponseLambdaEdge: OriginResponseLambdaEdge public readonly viewerResponseLambdaEdge: ViewerResponseLambdaEdge + public readonly viewerRequestLambdaEdge: ViewerRequestLambdaEdge public readonly cloudfront: CloudFrontDistribution constructor(scope: Construct, id: string, props: NextCloudfrontStackProps) { @@ -36,7 +39,8 @@ export class NextCloudfrontStack extends Stack { renderWorkerQueueArn, region, cacheConfig, - imageTTL + imageTTL, + redirects } = props this.originRequestLambdaEdge = new OriginRequestLambdaEdge(this, `${id}-OriginRequestLambdaEdge`, { @@ -57,6 +61,12 @@ export class NextCloudfrontStack extends Stack { region }) + this.viewerRequestLambdaEdge = new ViewerRequestLambdaEdge(this, `${id}-ViewerRequestLambdaEdge`, { + buildOutputPath, + nodejs, + redirects + }) + this.viewerResponseLambdaEdge = new ViewerResponseLambdaEdge(this, `${id}-ViewerResponseLambdaEdge`, { nodejs, buildOutputPath @@ -73,6 +83,7 @@ export class NextCloudfrontStack extends Stack { requestEdgeFunction: this.originRequestLambdaEdge.lambdaEdge, responseEdgeFunction: this.originResponseLambdaEdge.lambdaEdge, viewerResponseEdgeFunction: this.viewerResponseLambdaEdge.lambdaEdge, + viewerRequestLambdaEdge: this.viewerRequestLambdaEdge.lambdaEdge, cacheConfig, imageTTL }) diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index c112c25..1cc1911 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -11,9 +11,9 @@ interface BootstrapProps { profile?: string } -const runTask = (command: string, env: Record) => { +const runTask = (command: string, env: NodeJS.ProcessEnv) => { const task = childProcess.spawn(command, { - env: env, + env, shell: true, stdio: 'pipe' }) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index c5b7280..be3489c 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,6 +1,7 @@ import { ElasticBeanstalk } from '@aws-sdk/client-elastic-beanstalk' import { S3 } from '@aws-sdk/client-s3' import { CloudFront } from '@aws-sdk/client-cloudfront' +import type { NextConfig } from 'next/types' import fs from 'node:fs' import childProcess from 'node:child_process' import path from 'node:path' @@ -83,7 +84,8 @@ export const deploy = async (config: DeployConfig) => { const cacheConfig = await loadConfig() - const nextConfig = await loadFile(projectSettings.nextConfigPath) + const nextConfig = (await loadFile(projectSettings.nextConfigPath)) as NextConfig + const nextRedirects = nextConfig.redirects ? await nextConfig.redirects() : undefined const outputPath = createOutputFolder() @@ -148,6 +150,7 @@ export const deploy = async (config: DeployConfig) => { region, cacheConfig, imageTTL: nextConfig.imageTTL, + redirects: nextRedirects, env: { region: AWS_EDGE_REGION // required since Edge can be deployed only here. } diff --git a/src/lambdas/viewerRequest.ts b/src/lambdas/viewerRequest.ts new file mode 100644 index 0000000..a1a16a5 --- /dev/null +++ b/src/lambdas/viewerRequest.ts @@ -0,0 +1,33 @@ +import type { CloudFrontRequestCallback, Context, CloudFrontResponseEvent } from 'aws-lambda' +import type { NextRedirects } from '../types' + +/** + * AWS Lambda@Edge Viewer Request handler for Next.js redirects + * This function processes CloudFront viewer requests and handles redirects configured in Next.js + * + * @param {CloudFrontResponseEvent} event - The CloudFront event object containing request details + * @param {Context} _context - AWS Lambda Context object (unused) + * @param {CloudFrontRequestCallback} callback - Callback function to return the response + * @returns {Promise} - Returns either a redirect response or the original request + */ +export const handler = async ( + event: CloudFrontResponseEvent, + _context: Context, + callback: CloudFrontRequestCallback +) => { + const request = event.Records[0].cf.request + const redirectsConfig = process.env.REDIRECTS as unknown as NextRedirects + + const redirect = redirectsConfig.find((r) => r.source === request.uri) + + if (redirect) { + return callback(null, { + status: redirect.statusCode ? String(redirect.statusCode) : redirect.permanent ? '308' : '307', + headers: { + location: [{ key: 'Location', value: redirect.destination }] + } + }) + } + + return callback(null, request) +} diff --git a/src/types/index.ts b/src/types/index.ts index 921261f..db8d4f6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,10 @@ +import type { NextConfig } from 'next/types' + export interface CacheConfig { noCacheRoutes?: string[] cacheCookies?: string[] cacheQueries?: string[] enableDeviceSplit?: boolean } + +export type NextRedirects = Awaited['redirects']>>