infra/cdk/lib/constructs/cache-infra.ts

import { CustomResource, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import { Provider } from 'aws-cdk-lib/custom-resources'; import fs from 'node:fs'; import { Construct } from 'constructs'; import { OpenNextOutput } from '../types';

export interface CacheInfraProps { openNextOutput: OpenNextOutput; resolveBundlePath: (bundle: string) => string; grantSecretAccess: (grantable: iam.IGrantable) => void; }

export class CacheInfra extends Construct { readonly revalidationTable: dynamodb.Table; readonly revalidationQueue: sqs.Queue;

private readonly openNextOutput: OpenNextOutput; private readonly resolveBundlePath: (bundle: string) => string; private readonly grantSecretAccessFn: (grantable: iam.IGrantable) => void;

constructor(scope: Construct, id: string, props: CacheInfraProps) { super(scope, id);

this.openNextOutput = props.openNextOutput;
this.resolveBundlePath = props.resolveBundlePath;
this.grantSecretAccessFn = props.grantSecretAccess;

this.revalidationTable = this.createRevalidationTable();
this.revalidationQueue = this.createRevalidationQueue();

}

addInitializer(baseEnv: Record<string, string>, selectEnv: (keys: string[]) => Record<string, string>) { const initConfig = this.openNextOutput.additionalProps?.initializationFunction; const bundlePath = initConfig?.bundle ? this.resolveBundlePath(initConfig.bundle) : this.resolveBundlePath('dynamodb-provider');

if (!fs.existsSync(bundlePath)) {
  return;
}

const initLogGroup = new logs.LogGroup(this, 'CacheInitializationFunctionLogs', {
  retention: logs.RetentionDays.TWO_WEEKS,
  removalPolicy: RemovalPolicy.DESTROY,
});

const initFn = new lambda.Function(this, 'CacheInitializationFunction', {
  runtime: lambda.Runtime.NODEJS_20_X,
  architecture: lambda.Architecture.ARM_64,
  handler: initConfig?.handler ?? 'index.handler',
  code: lambda.Code.fromAsset(bundlePath),
  timeout: Duration.minutes(5),
  memorySize: 256,
  environment: selectEnv(['CACHE_DYNAMO_TABLE', 'NODE_ENV', 'NEXT_PUBLIC_SITE_URL']),
  logGroup: initLogGroup,
});

this.revalidationTable.grantReadWriteData(initFn);
this.grantSecretAccessFn(initFn);

const providerLogGroup = new logs.LogGroup(this, 'CacheInitializationProviderLogs', {
  retention: logs.RetentionDays.ONE_DAY,
  removalPolicy: RemovalPolicy.DESTROY,
});

const provider = new Provider(this, 'CacheInitializationProvider', {
  onEventHandler: initFn,
  logGroup: providerLogGroup,
});

new CustomResource(this, 'CacheInitializationResource', {
  serviceToken: provider.serviceToken,
  properties: {
    version: Date.now().toString(),
  },
});

return initFn;

}

addRevalidationConsumer( baseEnv: Record<string, string>, selectEnv: (keys: string[]) => Record<string, string> ): lambda.Function | undefined { const revalidationConfig = this.openNextOutput.additionalProps?.revalidationFunction; if (!revalidationConfig?.bundle) { return undefined; }

const bundlePath = this.resolveBundlePath(revalidationConfig.bundle);
if (!fs.existsSync(bundlePath)) {
  return undefined;
}

const workerLogGroup = new logs.LogGroup(this, 'RevalidationWorkerLogs', {
  retention: logs.RetentionDays.TWO_WEEKS,
  removalPolicy: RemovalPolicy.DESTROY,
});

const fn = new lambda.Function(this, 'RevalidationWorkerFunction', {
  runtime: lambda.Runtime.NODEJS_20_X,
  architecture: lambda.Architecture.ARM_64,
  handler: revalidationConfig.handler,
  code: lambda.Code.fromAsset(bundlePath),
  timeout: Duration.seconds(30),
  memorySize: 512,
  environment: selectEnv([
    'CACHE_DYNAMO_TABLE',
    'REVALIDATION_QUEUE_URL',
    'REVALIDATION_QUEUE_REGION',
    'AWS_REGION',
    'AWS_SECRETS_MANAGER_PRIMARY_REGION',
    'NODE_ENV',
    'NEXT_PUBLIC_SITE_URL',
  ]),
  logGroup: workerLogGroup,
});

fn.addEventSource(
  new lambdaEventSources.SqsEventSource(this.revalidationQueue, {
    batchSize: 5,
  })
);

return fn;

}

private createRevalidationTable(): dynamodb.Table { const table = new dynamodb.Table(this, 'RevalidationTable', { partitionKey: { name: 'tag', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'path', type: dynamodb.AttributeType.STRING }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, // Allow replacement if schema changes; be sure data loss is acceptable before deploy. removalPolicy: RemovalPolicy.DESTROY, tableName: ${Stack.of(this).stackName}-Revalidation, });

table.addGlobalSecondaryIndex({
  indexName: 'revalidate',
  partitionKey: { name: 'path', type: dynamodb.AttributeType.STRING },
  sortKey: { name: 'revalidatedAt', type: dynamodb.AttributeType.NUMBER },
});

return table;

}

private createRevalidationQueue(): sqs.Queue { const deadLetterQueue = new sqs.Queue(this, 'RevalidationDLQ', { fifo: true, contentBasedDeduplication: true, retentionPeriod: Duration.days(14), });

return new sqs.Queue(this, 'RevalidationQueue', {
  fifo: true,
  contentBasedDeduplication: true,
  visibilityTimeout: Duration.seconds(45),
  deadLetterQueue: {
    queue: deadLetterQueue,
    maxReceiveCount: 5,
  },
});

} }