Skip to content

Commit

Permalink
Merge pull request #125 from kevin-mitchell/119-open-next-v2-isr-support
Browse files Browse the repository at this point in the history
fix: Initial support for open-next v2 and ISR

closes #119
  • Loading branch information
bestickley authored Aug 4, 2023
2 parents 92b4981 + 09fadd9 commit d0df996
Show file tree
Hide file tree
Showing 12 changed files with 715 additions and 37 deletions.
8 changes: 3 additions & 5 deletions .projenrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const project = new awscdk.AwsCdkConstructLibrary({
// ignorePatterns: ['assets/**/*']
},
majorVersion: 3,
// prerelease: 'pre',
prerelease: 'beta',

tsconfig: { compilerOptions: { noUnusedLocals: false }, include: ['assets/**/*.ts'] },
tsconfigDev: { compilerOptions: { noUnusedLocals: false } },
Expand Down Expand Up @@ -44,6 +44,7 @@ const project = new awscdk.AwsCdkConstructLibrary({
// do not generate sample test files
sampleCode: false,
});

const packageJson = project.tryFindObjectFile('package.json');
if (packageJson) {
packageJson.patch(
Expand All @@ -53,8 +54,5 @@ if (packageJson) {
])
);
}
// project.eslint.addOverride({
// rules: {},
// });
// project.tsconfig.addInclude('assets/**/*.ts');

project.synth();
546 changes: 538 additions & 8 deletions API.md

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions src/ImageOptimizationLambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Architecture, Code, Function, FunctionOptions } from 'aws-cdk-lib/aws-lambda';
import { IBucket } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
import { LAMBDA_RUNTIME } from './constants';
import { LAMBDA_RUNTIME, DEFAULT_LAMBA_MEMORY } from './constants';
import { NextjsBaseProps } from './NextjsBase';
import type { NextjsBuild } from './NextjsBuild';

Expand Down Expand Up @@ -43,22 +43,25 @@ export class ImageOptimizationLambda extends Function {

const code = isPlaceholder
? Code.fromInline(
"module.exports.handler = async () => { return { statusCode: 200, body: 'SST placeholder site' } }"
)
"module.exports.handler = async () => { return { statusCode: 200, body: 'cdk-nextjs placeholder site' } }"
)
: Code.fromAsset(props.nextBuild.nextImageFnDir);

super(scope, id, {
// open-next image-optimization-function
// see: https://github.com/serverless-stack/open-next/blob/274d446ed7e940cfbe7ce05a21108f4c854ee37a/README.md?plain=1#L66
code,
handler: 'index.handler',
runtime: LAMBDA_RUNTIME,
architecture: Architecture.ARM_64,
description: 'Next.js Image Optimization Function',
// prevents "Resolution error: Cannot use resource in a cross-environment
// fashion, the resource's physical name must be explicit set or use
// PhysicalName.GENERATE_IF_NEEDED."
functionName: Stack.of(scope).region !== 'us-east-1' ? PhysicalName.GENERATE_IF_NEEDED : undefined,
...lambdaOptions,
// defaults
memorySize: lambdaOptions?.memorySize || 1024,
memorySize: lambdaOptions?.memorySize || DEFAULT_LAMBA_MEMORY,
timeout: lambdaOptions?.timeout ?? Duration.seconds(10),
environment: {
BUCKET_NAME: bucket.bucketName,
Expand Down
47 changes: 42 additions & 5 deletions src/Nextjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@ import { RemovalPolicy } from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { FunctionOptions } from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';
import * as fs from 'fs-extra';
import { CACHE_BUCKET_KEY_PREFIX } from './constants';
import { ImageOptimizationLambda } from './ImageOptimizationLambda';
import { NextJsAssetsDeployment, NextjsAssetsDeploymentProps } from './NextjsAssetsDeployment';
import { BaseSiteDomainProps, NextjsBaseProps } from './NextjsBase';
import { NextjsBuild } from './NextjsBuild';
import { NextjsDistribution, NextjsDistributionProps } from './NextjsDistribution';
import { NextJsLambda } from './NextjsLambda';
import { NextjsRevalidation } from './NextjsRevalidation';

// contains server-side resolved environment vars in config bucket
export const CONFIG_ENV_JSON_PATH = 'next-env.json';

export interface NextjsDomainProps extends BaseSiteDomainProps { }
export interface NextjsDomainProps extends BaseSiteDomainProps {}

/**
* Defaults for created resources.
Expand All @@ -29,6 +32,11 @@ export interface NextjsDefaultsProps {
*/
readonly assetDeployment?: NextjsAssetsDeploymentProps | any;

/**
* Override cache bucket.
*/
readonly cacheBucket?: s3.IBucket | any;

/**
* Override server lambda function settings.
*/
Expand Down Expand Up @@ -99,6 +107,11 @@ export class Nextjs extends Construct {
*/
public tempBuildDir: string;

/**
* Revalidation handler and queue.
*/
public revalidation: NextjsRevalidation;

public configBucket?: s3.Bucket;
public lambdaFunctionUrl!: lambda.FunctionUrl;
public imageOptimizationLambdaFunctionUrl!: lambda.FunctionUrl;
Expand All @@ -113,27 +126,28 @@ export class Nextjs extends Construct {
// get dir to store temp build files in
const tempBuildDir = props.tempBuildDir
? path.resolve(
path.join(props.tempBuildDir, `nextjs-cdk-build-${this.node.id}-${this.node.addr.substring(0, 4)}`)
)
path.join(props.tempBuildDir, `nextjs-cdk-build-${this.node.id}-${this.node.addr.substring(0, 4)}`)
)
: fs.mkdtempSync(path.join(os.tmpdir(), 'nextjs-cdk-build-'));

this.tempBuildDir = tempBuildDir;

// create static asset bucket
this.staticAssetBucket =
props.defaults?.assetDeployment?.bucket ??
new s3.Bucket(this, 'Bucket', {
new s3.Bucket(this, 'Assets', {
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});

// build nextjs app
this.nextBuild = new NextjsBuild(this, id, { ...props, tempBuildDir });
this.serverFunction = new NextJsLambda(this, 'Fn', {
this.serverFunction = new NextJsLambda(this, 'ServerFn', {
...props,
tempBuildDir,
nextBuild: this.nextBuild,
lambda: props.defaults?.lambda,
staticAssetBucket: this.staticAssetBucket,
});
// build image optimization
this.imageOptimizationFunction = new ImageOptimizationLambda(this, 'ImgOptFn', {
Expand All @@ -143,6 +157,13 @@ export class Nextjs extends Construct {
lambdaOptions: props.defaults?.lambda,
});

// build revalidation queue and handler function
this.revalidation = new NextjsRevalidation(this, 'Revalidation', {
...props,
nextBuild: this.nextBuild,
serverFunction: this.serverFunction,
});

// deploy nextjs static assets to s3
this.assetsDeployment = new NextJsAssetsDeployment(this, 'AssetDeployment', {
...props,
Expand Down Expand Up @@ -170,6 +191,22 @@ export class Nextjs extends Construct {
imageOptFunction: this.imageOptimizationFunction,
});

// We only want to provide the distribution options below if
// we are keep to invalidate the cache
const invalidationOptions = this.props.skipFullInvalidation
? {}
: {
distribution: this.distribution.distribution,
distributionPaths: ['/*'],
};

new BucketDeployment(this, 'DeployCacheFiles', {
sources: [Source.asset(this.nextBuild.nextCacheDir)],
destinationBucket: this.staticAssetBucket,
destinationKeyPrefix: CACHE_BUCKET_KEY_PREFIX,
...invalidationOptions,
});

if (!props.quiet) console.debug('└ Finished preparing NextJS app for deployment');
}

Expand Down
9 changes: 8 additions & 1 deletion src/NextjsBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export interface NextjsBaseProps {

/**
* Optional value used to install NextJS node dependencies.
* It defaults to 'npx --yes open-next@1 build'
* It defaults to 'npx --yes open-next@2 build'
*/
readonly buildCommand?: string;

Expand All @@ -72,6 +72,13 @@ export interface NextjsBaseProps {
* If omitted, the layer will be created.
*/
readonly sharpLayerArn?: string;

/**
* By default all CloudFront cache will be invalidated on deployment.
* This can be set to true to skip the full cache invalidation, which
* could be important for some users.
*/
readonly skipFullInvalidation?: boolean;
}

///// stuff below taken from https://github.com/serverless-stack/sst/blob/8d377e941467ced81d8cc31ee67d5a06550f04d4/packages/resources/src/BaseSite.ts
Expand Down
19 changes: 18 additions & 1 deletion src/NextjsBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { CompressionLevel, NextjsBaseProps } from './NextjsBase';

const NEXTJS_BUILD_DIR = '.open-next';
const NEXTJS_STATIC_DIR = 'assets';
const NEXTJS_CACHE_DIR = 'cache';
const NEXTJS_BUILD_MIDDLEWARE_FN_DIR = 'middleware-function';
const NEXTJS_BUILD_REVALIDATE_FN_DIR = 'revalidation-function';
const NEXTJS_BUILD_IMAGE_FN_DIR = 'image-optimization-function';
const NEXTJS_BUILD_SERVER_FN_DIR = 'server-function';

Expand All @@ -34,10 +36,18 @@ export class NextjsBuild extends Construct {
* Should be arm64.
*/
public nextImageFnDir: string;
/**
* Contains function for processing items from revalidation queue.
*/
public nextRevalidateFnDir: string;
/**
* Static files containing client-side code.
*/
public nextStaticDir: string;
/**
* Cache directory for generated data.
*/
public nextCacheDir: string;

public props: NextjsBuildProps;

Expand Down Expand Up @@ -65,7 +75,9 @@ export class NextjsBuild extends Construct {

// our outputs
this.nextStaticDir = this._getNextStaticDir();
this.nextCacheDir = this._getNextCacheDir();
this.nextImageFnDir = this._getOutputDir(NEXTJS_BUILD_IMAGE_FN_DIR);
this.nextRevalidateFnDir = this._getOutputDir(NEXTJS_BUILD_REVALIDATE_FN_DIR);
this.nextServerFnDir = this._getOutputDir(NEXTJS_BUILD_SERVER_FN_DIR);
this.nextMiddlewareFnDir = this._getOutputDir(NEXTJS_BUILD_MIDDLEWARE_FN_DIR, true);
// this.nextDir = this._getNextDir();
Expand Down Expand Up @@ -100,7 +112,7 @@ export class NextjsBuild extends Construct {
};

const buildPath = this.props.buildPath ?? nextjsPath;
const buildCommand = this.props.buildCommand ?? 'npx --yes open-next@1 build';
const buildCommand = this.props.buildCommand ?? 'npx --yes open-next@2 build';
// run build
console.debug(`├ Running "${buildCommand}" in`, buildPath);
const cmdParts = buildCommand.split(/\s+/);
Expand Down Expand Up @@ -155,6 +167,11 @@ export class NextjsBuild extends Construct {
private _getNextStaticDir() {
return path.join(this._getNextBuildDir(), NEXTJS_STATIC_DIR);
}

// contains cache files
private _getNextCacheDir() {
return path.join(this._getNextBuildDir(), NEXTJS_CACHE_DIR);
}
}

export interface CreateArchiveArgs {
Expand Down
13 changes: 5 additions & 8 deletions src/NextjsDistribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,12 @@ export class NextjsDistribution extends Construct {
];

// default handler for requests that don't match any other path:
// - try lambda handler first (/some-page, etc...)
// - if 403, fall back to S3
// - if 404, fall back to lambda handler
// - if 503, fall back to lambda handler
// - try S3 bucket first
// - if 403, 404, or 503 fall back to Lambda handler
// see discussion here: https://github.com/jetbridge/cdk-nextjs/pull/125#discussion_r1279212678
const fallbackOriginGroup = new origins.OriginGroup({
primaryOrigin: serverFunctionOrigin,
fallbackOrigin: s3Origin,
primaryOrigin: s3Origin,
fallbackOrigin: serverFunctionOrigin,
fallbackStatusCodes: [403, 404, 503],
});

Expand Down Expand Up @@ -464,11 +463,9 @@ export class NextjsDistribution extends Construct {
origin: fallbackOriginGroup, // try S3 first, then lambda
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
compress: true,

// what goes here? static or lambda?
cachePolicy: lambdaCachePolicy,
originRequestPolicy: fallbackOriginRequestPolicy,

edgeLambdas: lambdaOriginEdgeFns,
},

Expand Down
23 changes: 19 additions & 4 deletions src/NextjsLambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import * as path from 'path';
import { Duration, PhysicalName, RemovalPolicy, Stack, Token } from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Function, FunctionOptions } from 'aws-cdk-lib/aws-lambda';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Bucket, IBucket } from 'aws-cdk-lib/aws-s3';
import * as s3Assets from 'aws-cdk-lib/aws-s3-assets';
import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
import * as fs from 'fs-extra';
import { LAMBDA_RUNTIME } from './constants';
import { LAMBDA_RUNTIME, DEFAULT_LAMBA_MEMORY, CACHE_BUCKET_KEY_PREFIX } from './constants';
import { CONFIG_ENV_JSON_PATH } from './Nextjs';
import { NextjsBaseProps } from './NextjsBase';
import { createArchive, NextjsBuild } from './NextjsBuild';
Expand All @@ -22,6 +22,11 @@ function getEnvironment(props: NextjsLambdaProps): { [name: string]: string } {
...props.environment,
...props.lambda?.environment,
...(props.nodeEnv ? { NODE_ENV: props.nodeEnv } : {}),
...{
CACHE_BUCKET_NAME: props.staticAssetBucket?.bucketName || '',
CACHE_BUCKET_REGION: Stack.of(props.staticAssetBucket).region,
CACHE_BUCKET_KEY_PREFIX,
},
};

return environmentVariables;
Expand All @@ -37,6 +42,11 @@ export interface NextjsLambdaProps extends NextjsBaseProps {
* Override function properties.
*/
readonly lambda?: FunctionOptions;

/**
* Static asset bucket. Function needs bucket to read from cache.
*/
readonly staticAssetBucket: IBucket;
}

/**
Expand Down Expand Up @@ -76,20 +86,25 @@ export class NextJsLambda extends Construct {
// build the lambda function
const environment = getEnvironment(props);
const fn = new Function(scope, 'ServerHandler', {
memorySize: functionOptions?.memorySize || 1024,
memorySize: functionOptions?.memorySize || DEFAULT_LAMBA_MEMORY,
timeout: functionOptions?.timeout ?? Duration.seconds(10),
runtime: LAMBDA_RUNTIME,
handler: path.join('index.handler'),
code,
environment,
// prevents "Resolution error: Cannot use resource in a cross-environment
// fashion, the resource's physical name must be explicit set or use
// PhysicalName.GENERATE_IF_NEEDED."
functionName: Stack.of(this).region !== 'us-east-1' ? PhysicalName.GENERATE_IF_NEEDED : undefined,
...functionOptions,
// `environment` needs to go after `functionOptions` b/c if
// `functionOptions.environment` is defined, it will override
// CACHE_* environment variables which are required
environment,
});
this.lambdaFunction = fn;

props.staticAssetBucket.grantReadWrite(fn);

// rewrite env var placeholders in server code
const replacementParams = this._getReplacementParams(environment);
if (!isPlaceholder && Object.keys(replacementParams).length) {
Expand Down
Loading

0 comments on commit d0df996

Please sign in to comment.