From 5d779240fd17b251c30d5ca0859114a7afe3a697 Mon Sep 17 00:00:00 2001 From: Kevin Mitchell <> Date: Wed, 11 Oct 2023 17:45:32 +0900 Subject: [PATCH] feat: add dynamodb table and provider for tag and path revalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This diff adds the DynamoDB table and supporting data provider function to support Next.js path and tag revalition. This feature is supported by `open-next` (read more about it here: https://open-next.js.org/inner_workings/isr#tags) and is inspired by https://github.com/sst/sst/compare/v2.27.0...v2.28.0 The changes include * Adding a new DynamoDB table which stores time stamps, tags, and paths for revalidation consideration * Adds a function (`dynamodb-provider`) which populates the DynamoDB table on deployment with the initial set of tags / paths / timestamps generated in the Next.js build phase * **Bumps `aws-cdk-lib` to 2.95.1**, matching the SST version for access to `DynamoDB.TableV2` * Adds a simple description to the "primary" server handler A bit more info: ### Bump of `aws-cdk-lib` 2.95.1 I did this because SST uses the DynamoDB `TableV2` construct and I thought it would be best to match. ### Description of ServerFunction I added a description because at least for me the function names end up mangled (I actually think it’d be great to clean these up, but I frankly don’t know the best practice here from IaC / AWS perspective) and it’s nice to be able to easily identify different functions, in particular the server handler, in the AWS console for debugging. ### Non breaking change, but… From what I can tell this is a non-breaking change, however this brings me back to a general question about how we should be managing versions and default open-next build commands… SST uses `2.2.1` as the default version of `open-next` in the build command. --- .projen/deps.json | 4 +- .projenrc.ts | 2 +- API.md | 49 ++++++++++++++--- package.json | 4 +- src/NextjsBuild.ts | 9 +++ src/NextjsRevalidation.ts | 113 +++++++++++++++++++++++++++++++++++--- src/NextjsServer.ts | 1 + src/constants.ts | 1 + yarn.lock | 8 +-- 9 files changed, 166 insertions(+), 25 deletions(-) diff --git a/.projen/deps.json b/.projen/deps.json index db054760..957aab33 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -49,7 +49,7 @@ }, { "name": "aws-cdk-lib", - "version": "2.93.0", + "version": "2.95.1", "type": "build" }, { @@ -173,7 +173,7 @@ }, { "name": "aws-cdk-lib", - "version": "^2.93.0", + "version": "^2.95.1", "type": "peer" }, { diff --git a/.projenrc.ts b/.projenrc.ts index 2eb80301..76a5789b 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -39,7 +39,7 @@ const project = new awscdk.AwsCdkConstructLibrary({ tsconfigDev: { compilerOptions: { ...commonTscOptions } }, // dependency config jsiiVersion: '~5.0.0', - cdkVersion: '2.93.0', + cdkVersion: '2.95.1', bundledDeps: ['esbuild'] /* Runtime dependencies of this module. */, devDeps: [ '@aws-crypto/sha256-js', diff --git a/API.md b/API.md index 6febcbb8..ae7dc330 100644 --- a/API.md +++ b/API.md @@ -626,6 +626,7 @@ Any object. | node | constructs.Node | The tree node. | | nextCacheDir | string | Cache directory for generated data. | | nextImageFnDir | string | Contains function for processessing image requests. | +| nextRevalidateDynamoDBProviderFnDir | string | Contains function for inserting revalidation items into the table. | | nextRevalidateFnDir | string | Contains function for processing items from revalidation queue. | | nextServerFnDir | string | Contains server code and dependencies. | | nextStaticDir | string | Static files containing client-side code. | @@ -671,6 +672,18 @@ Should be arm64. --- +##### `nextRevalidateDynamoDBProviderFnDir`Required + +```typescript +public readonly nextRevalidateDynamoDBProviderFnDir: string; +``` + +- *Type:* string + +Contains function for inserting revalidation items into the table. + +--- + ##### `nextRevalidateFnDir`Required ```typescript @@ -2099,7 +2112,7 @@ The tree node. ### NextjsRevalidation -Builds the system for revalidating Next.js resources. This includes a Lambda function handler and queue system. +Builds the system for revalidating Next.js resources. This includes a Lambda function handler and queue system as well as the DynamoDB table and provider function. > [{@link https://github.com/serverless-stack/open-next/blob/main/README.md?plain=1#L65}]({@link https://github.com/serverless-stack/open-next/blob/main/README.md?plain=1#L65}) @@ -2184,8 +2197,10 @@ Any object. | **Name** | **Type** | **Description** | | --- | --- | --- | | node | constructs.Node | The tree node. | -| function | aws-cdk-lib.aws_lambda_nodejs.NodejsFunction | *No description.* | | queue | aws-cdk-lib.aws_sqs.Queue | *No description.* | +| queueFunction | aws-cdk-lib.aws_lambda_nodejs.NodejsFunction | *No description.* | +| table | aws-cdk-lib.aws_dynamodb.TableV2 | *No description.* | +| tableFunction | aws-cdk-lib.aws_lambda_nodejs.NodejsFunction | *No description.* | --- @@ -2201,23 +2216,43 @@ The tree node. --- -##### `function`Required +##### `queue`Required + +```typescript +public readonly queue: Queue; +``` + +- *Type:* aws-cdk-lib.aws_sqs.Queue + +--- + +##### `queueFunction`Required ```typescript -public readonly function: NodejsFunction; +public readonly queueFunction: NodejsFunction; ``` - *Type:* aws-cdk-lib.aws_lambda_nodejs.NodejsFunction --- -##### `queue`Required +##### `table`Required ```typescript -public readonly queue: Queue; +public readonly table: TableV2; ``` -- *Type:* aws-cdk-lib.aws_sqs.Queue +- *Type:* aws-cdk-lib.aws_dynamodb.TableV2 + +--- + +##### `tableFunction`Optional + +```typescript +public readonly tableFunction: NodejsFunction; +``` + +- *Type:* aws-cdk-lib.aws_lambda_nodejs.NodejsFunction --- diff --git a/package.json b/package.json index b8645da7..90b131fd 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@types/node": "^18", "@typescript-eslint/eslint-plugin": "^5", "@typescript-eslint/parser": "^5", - "aws-cdk-lib": "2.93.0", + "aws-cdk-lib": "2.95.1", "aws-lambda": "^1.0.7", "constructs": "10.0.5", "esbuild": "^0.19.2", @@ -81,7 +81,7 @@ "undici": "^5.23.0" }, "peerDependencies": { - "aws-cdk-lib": "^2.93.0", + "aws-cdk-lib": "^2.95.1", "constructs": "^10.0.5" }, "dependencies": { diff --git a/src/NextjsBuild.ts b/src/NextjsBuild.ts index fc3ec25c..f3fb77ec 100644 --- a/src/NextjsBuild.ts +++ b/src/NextjsBuild.ts @@ -10,6 +10,7 @@ import { NEXTJS_BUILD_SERVER_FN_DIR, NEXTJS_CACHE_DIR, NEXTJS_STATIC_DIR, + NEXTJS_BUILD_DYNAMODB_PROVIDER_FN_DIR, } from './constants'; import { NextjsBaseProps } from './NextjsBase'; import { NextjsBucketDeployment } from './NextjsBucketDeployment'; @@ -51,6 +52,14 @@ export class NextjsBuild extends Construct { this.warnIfMissing(fnPath); return fnPath; } + /** + * Contains function for inserting revalidation items into the table. + */ + public get nextRevalidateDynamoDBProviderFnDir(): string { + const fnPath = path.join(this.getNextBuildDir(), NEXTJS_BUILD_DYNAMODB_PROVIDER_FN_DIR); + this.warnIfMissing(fnPath); + return fnPath; + } /** * Static files containing client-side code. */ diff --git a/src/NextjsRevalidation.ts b/src/NextjsRevalidation.ts index 696123ab..d29b2954 100644 --- a/src/NextjsRevalidation.ts +++ b/src/NextjsRevalidation.ts @@ -1,10 +1,14 @@ +import * as fs from 'fs'; import { join } from 'path'; -import { Duration, Stack } from 'aws-cdk-lib'; +import { CustomResource, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { AttributeType, Billing, TableV2 as Table } from 'aws-cdk-lib/aws-dynamodb'; import { AnyPrincipal, Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { FunctionOptions } from 'aws-cdk-lib/aws-lambda'; import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { RetentionDays } from 'aws-cdk-lib/aws-logs'; import { Queue } from 'aws-cdk-lib/aws-sqs'; +import { Provider } from 'aws-cdk-lib/custom-resources'; import { Construct } from 'constructs'; import { NEXTJS_BUILD_INDEX_FILE } from './constants'; import { NextjsBaseProps } from './NextjsBase'; @@ -31,14 +35,17 @@ export interface NextjsRevalidationProps extends NextjsBaseProps { } /** - * Builds the system for revalidating Next.js resources. This includes a Lambda function handler and queue system. + * Builds the system for revalidating Next.js resources. This includes a Lambda function handler and queue system as well + * as the DynamoDB table and provider function. * * @see {@link https://github.com/serverless-stack/open-next/blob/main/README.md?plain=1#L65} * */ export class NextjsRevalidation extends Construct { queue: Queue; - function: NodejsFunction; + table: Table; + queueFunction: NodejsFunction; + tableFunction: NodejsFunction | undefined; private props: NextjsRevalidationProps; constructor(scope: Construct, id: string, props: NextjsRevalidationProps) { @@ -46,10 +53,17 @@ export class NextjsRevalidation extends Construct { this.props = props; this.queue = this.createQueue(); - this.function = this.createFunction(); + this.queueFunction = this.createFunction(); - // allow server fn to send messages to queue - props.serverFunction.lambdaFunction?.addEnvironment('REVALIDATION_QUEUE_URL', this.queue.queueUrl); + this.table = this.createRevalidationTable(); + this.tableFunction = this.createRevalidationInsertFunction(this.table); + + // todo: set these things + this.props.serverFunction.lambdaFunction.addEnvironment('CACHE_DYNAMO_TABLE', this.table.tableName); + this.table.grantReadWriteData(this.props.serverFunction.lambdaFunction.role!); + + this.props.serverFunction.lambdaFunction // allow server fn to send messages to queue + ?.addEnvironment('REVALIDATION_QUEUE_URL', this.queue.queueUrl); props.serverFunction.lambdaFunction?.addEnvironment('REVALIDATION_QUEUE_REGION', Stack.of(this).region); } @@ -78,7 +92,7 @@ export class NextjsRevalidation extends Construct { private createFunction(): NodejsFunction { const nodejsFnProps = getCommonNodejsFunctionProps(this); - const fn = new NodejsFunction(this, 'Fn', { + const fn = new NodejsFunction(this, 'QueueRevalidationFn', { ...nodejsFnProps, bundling: { ...nodejsFnProps.bundling, @@ -94,13 +108,94 @@ export class NextjsRevalidation extends Construct { }, }, // open-next revalidation-function - // see: https://github.com/serverless-stack/open-next/blob/274d446ed7e940cfbe7ce05a21108f4c854ee37a/README.md?plain=1#L65 + // see: https://github.com/sst/open-next/blob/c2b05e3a5f82de40da1181e11c087265983c349d/docs/pages/advanced/architecture.mdx#L150 entry: join(this.props.nextBuild.nextRevalidateFnDir, NEXTJS_BUILD_INDEX_FILE), handler: 'index.handler', - description: 'Next.js revalidation function', + description: 'Next.js queue revalidation function', timeout: Duration.seconds(30), }); fn.addEventSource(new SqsEventSource(this.queue, { batchSize: 5 })); return fn; } + + private createRevalidationTable() { + return new Table(this, 'RevalidationTable', { + partitionKey: { name: 'tag', type: AttributeType.STRING }, + sortKey: { name: 'path', type: AttributeType.STRING }, + pointInTimeRecovery: true, + billing: Billing.onDemand(), + globalSecondaryIndexes: [ + { + indexName: 'revalidate', + partitionKey: { name: 'path', type: AttributeType.STRING }, + sortKey: { name: 'revalidatedAt', type: AttributeType.NUMBER }, + }, + ], + removalPolicy: RemovalPolicy.DESTROY, + }); + } + + /** + * This function will insert the initial batch of tag / path / revalidation data into the DynamoDB table during deployment. + * @see: {@link https://open-next.js.org/inner_workings/isr#tags} + * + * @param revalidationTable table to grant function access to + * @returns the revalidation insert provider function + */ + private createRevalidationInsertFunction(revalidationTable: Table) { + const dynamodbProviderPath = this.props.nextBuild.nextRevalidateDynamoDBProviderFnDir; + + // note the function may not exist - it only exists if there are cache tags values defined in Next.js build meta files to be inserted + // see: https://github.com/sst/open-next/blob/c2b05e3a5f82de40da1181e11c087265983c349d/packages/open-next/src/build.ts#L426-L458 + if (fs.existsSync(dynamodbProviderPath)) { + const nodejsFnProps = getCommonNodejsFunctionProps(this); + const insertFn = new NodejsFunction(this, 'DynamoDBProviderFn', { + ...nodejsFnProps, + bundling: { + ...nodejsFnProps.bundling, + commandHooks: { + afterBundling: () => [], + beforeBundling: (_inputDir, outputDir) => [ + // copy non-bundled assets into zip. use node -e so cross-os compatible + `node -e "fs.cpSync('${fixPath(dynamodbProviderPath)}', '${fixPath( + outputDir + )}', { recursive: true, filter: (src) => !src.endsWith('index.mjs') })"`, + ], + beforeInstall: () => [], + }, + }, + // open-next dynamo-provider + // see: https://github.com/sst/open-next/blob/c2b05e3a5f82de40da1181e11c087265983c349d/docs/pages/advanced/architecture.mdx#L170 + entry: join(dynamodbProviderPath, NEXTJS_BUILD_INDEX_FILE), + handler: 'index.handler', + initialPolicy: [ + new PolicyStatement({ + actions: ['dynamodb:BatchWriteItem', 'dynamodb:PutItem', 'dynamodb:DescribeTable'], + resources: [revalidationTable.tableArn], + }), + ], + environment: { + CACHE_DYNAMO_TABLE: revalidationTable.tableName, + }, + description: 'Next.js revalidation DynamoDB provider', + timeout: Duration.minutes(15), + }); + + const provider = new Provider(this, 'RevalidationProvider', { + onEventHandler: insertFn, + logRetention: RetentionDays.ONE_DAY, + }); + + new CustomResource(this, 'RevalidationResource', { + serviceToken: provider.serviceToken, + properties: { + version: Date.now().toString(), + }, + }); + + return insertFn; + } + + return undefined; + } } diff --git a/src/NextjsServer.ts b/src/NextjsServer.ts index b7915bb0..51098ce1 100644 --- a/src/NextjsServer.ts +++ b/src/NextjsServer.ts @@ -117,6 +117,7 @@ export class NextjsServer extends Construct { ...getCommonFunctionProps(this), code: Code.fromBucket(asset.bucket, asset.s3ObjectKey), handler: 'index.handler', + description: 'Next.js server handler', ...this.props.lambda, // `environment` needs to go after `this.props.lambda` b/c if // `this.props.lambda.environment` is defined, it will override diff --git a/src/constants.ts b/src/constants.ts index c5c0153d..2ff202d0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,6 +9,7 @@ export const NEXTJS_STATIC_DIR = 'assets'; export const NEXTJS_BUILD_DIR = '.open-next'; export const NEXTJS_CACHE_DIR = 'cache'; export const NEXTJS_BUILD_REVALIDATE_FN_DIR = 'revalidation-function'; +export const NEXTJS_BUILD_DYNAMODB_PROVIDER_FN_DIR = 'dynamodb-provider'; export const NEXTJS_BUILD_IMAGE_FN_DIR = 'image-optimization-function'; export const NEXTJS_BUILD_SERVER_FN_DIR = 'server-function'; export const NEXTJS_BUILD_INDEX_FILE = 'index.mjs'; diff --git a/yarn.lock b/yarn.lock index 4d3684d8..f6814d67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2515,10 +2515,10 @@ available-typed-arrays@^1.0.5: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== -aws-cdk-lib@2.93.0: - version "2.93.0" - resolved "https://registry.yarnpkg.com/aws-cdk-lib/-/aws-cdk-lib-2.93.0.tgz#545bc0072bc0f2e27cb0fecb0c9e54de29b10731" - integrity sha512-kKbcKkts272Ju5xjGKI3pXTOpiJxW4OQbDF8Vmw/NIkkuJLo8GlRCFfeOfoN/hilvlYQgENA67GCgSWccbvu7w== +aws-cdk-lib@2.95.1: + version "2.95.1" + resolved "https://registry.yarnpkg.com/aws-cdk-lib/-/aws-cdk-lib-2.95.1.tgz#624321c7c0b6e6417a9f1bdbc07bd7fc2fee5eee" + integrity sha512-FQlnW3+c1j2W7hmu+QMSiWnBgbW1Lhn1ZpBQ6cwYZa97rII1zlEyTowAfzQk6szPIzUhJv5xK03nWZtvCvpAWw== dependencies: "@aws-cdk/asset-awscli-v1" "^2.2.200" "@aws-cdk/asset-kubectl-v20" "^2.1.2"