Skip to content

Commit

Permalink
feat: add dynamodb table and provider for tag and path revalidation
Browse files Browse the repository at this point in the history
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 sst/sst@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.
  • Loading branch information
Kevin Mitchell committed Oct 11, 2023
1 parent c41e1d1 commit 5d77924
Show file tree
Hide file tree
Showing 9 changed files with 166 additions and 25 deletions.
4 changes: 2 additions & 2 deletions .projen/deps.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .projenrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
49 changes: 42 additions & 7 deletions API.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/NextjsBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*/
Expand Down
113 changes: 104 additions & 9 deletions src/NextjsRevalidation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -31,25 +35,35 @@ 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) {
super(scope, id);
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);
}

Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
}
1 change: 1 addition & 0 deletions src/NextjsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
8 changes: 4 additions & 4 deletions yarn.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 5d77924

Please sign in to comment.