From 83207d87d30b622bce299895bb2cf605ee0f39af Mon Sep 17 00:00:00 2001 From: conico974 Date: Thu, 14 Dec 2023 16:59:15 +0100 Subject: [PATCH] updated docs for v3 (#334) --- docs/pages/v3/_meta.json | 3 +- docs/pages/v3/config.mdx | 13 +- docs/pages/v3/index.mdx | 38 +- docs/pages/v3/reference-implementation.mdx | 408 +++++++++++++++++++++ 4 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 docs/pages/v3/reference-implementation.mdx diff --git a/docs/pages/v3/_meta.json b/docs/pages/v3/_meta.json index e9a8d568..8e89d285 100644 --- a/docs/pages/v3/_meta.json +++ b/docs/pages/v3/_meta.json @@ -1,5 +1,6 @@ { "index": "What's new", "config": "Configuration file", - "override": "Create your own override" + "reference-implementation": "Reference Construct", + "override": "Advanced - Create your own override" } \ No newline at end of file diff --git a/docs/pages/v3/config.mdx b/docs/pages/v3/config.mdx index 5dbc24e7..008dabfd 100644 --- a/docs/pages/v3/config.mdx +++ b/docs/pages/v3/config.mdx @@ -21,7 +21,8 @@ const config = { }) }, }, - functions: { // here we define the functions that we want to deploy in a different server + // Below we define the functions that we want to deploy in a different server + functions: { ssr: { routes: [ "app/api/isr/route", "app/api/sse/route", "app/api/revalidateTag/route", // app dir Api routes @@ -40,15 +41,23 @@ const config = { override: { wrapper: "node", converter: "node", + // This is necessary to generate the dockerfile and for the implementation to know that it needs to deploy on docker generateDockerfile: true, }, - }, + }, + edge: { + runtime: "edge", + routes: ["app/ssr/page"], + patterns: ["ssr"], + override: {} + } }, // By setting this, it will create another bundle for the middleware, // and the middleware will be deployed in a separate server. // If not set middleware will be bundled inside the servers // It could be in lambda@edge, cloudflare workers, or anywhere else // By default it uses lambda@edge + // This is not implemented in the reference construct implementation. middleware: { external: true } diff --git a/docs/pages/v3/index.mdx b/docs/pages/v3/index.mdx index 4d4500d4..10416ea0 100644 --- a/docs/pages/v3/index.mdx +++ b/docs/pages/v3/index.mdx @@ -2,11 +2,11 @@ import { Callout } from 'nextra/components' - This is a release candidate, it is not yet ready for production, but we are getting close. We are looking for feedback on this release, so please try it out and let us know what you think. - - `open-next@3.0.0-rc.1` is here!!! Please report any issues you find on [discord](https://discord.com/channels/983865673656705025/1164872233223729152) or on the github [PR](https://github.com/sst/open-next/pull/327) +`open-next@3.0.0-rc.2` is here!!! Please report any issues you find on [discord](https://discord.com/channels/983865673656705025/1164872233223729152) or on the github [PR](https://github.com/sst/open-next/pull/327) + + This is a release candidate, it is not yet ready for production, but we are getting close. We are looking for feedback on this release, so please try it out and let us know what you think. See [getting started](#get-started) to quickly test it. - It also requires an updated version of the IAC tools that you use, see the sst PR [here](https://github.com/sst/sst/pull/3567) for more information + It also requires an updated version of the IAC tools that you use, see the sst PR [here](https://github.com/sst/sst/pull/3567) for more information. ## What's new in V3? @@ -27,4 +27,32 @@ import { Callout } from 'nextra/components' - Allow for splitting, you can now split your next app into multiple servers, which could each have their own configuration - An experimental bundled `NextServer` could be used which can reduce the size of your lambda by up to 24 MB -- ~~Support for the `edge` runtime of next~~ (coming soon) \ No newline at end of file +- Support for the `edge` runtime of next (Only app router for now, only 1 route per function) + +## Get started + +The easiest way to get started is to use the [reference implementation construct](/v3/reference-implementation). Copy this reference implementation into your project and then use it like that in your sst or cdk project: + +```ts +import { OpenNextCdkReferenceImplementation } from "path/to/reference-implementation" + +const site = new OpenNextCdkReferenceImplementation(stack, "site", { + openNextPath: ".open-next", +}) +``` + +You also need to create an `open-next.config.ts` file in your project root, you can find more info [here](/v3/config). + +A very simple example of this file could be: + +```ts +import type { BuildOptions } from 'open-next/types/open-next' +const config = { + default: { + + } +} +module.exports = config +``` + +Then you need to run `npx open-next@3.0.0-rc.2 build` to build your project before running the `sst deploy` or `cdk deploy` command to deploy your project. \ No newline at end of file diff --git a/docs/pages/v3/reference-implementation.mdx b/docs/pages/v3/reference-implementation.mdx new file mode 100644 index 00000000..e79960b9 --- /dev/null +++ b/docs/pages/v3/reference-implementation.mdx @@ -0,0 +1,408 @@ +import { Callout } from 'nextra/components' + +In order to help testing the rc release, we created a simple reference implementation using aws-cdk. + +If you wish to use it, just copy the code for the construct below. If you use it inside sst, make sure to use the same version of aws-cdk as sst. + + + + This is a reference implementation, and it is not meant to be used in production. + + The goal is to help with the adoption of the rc release, and to gather feedback from the community. + It also serves as a good example of how to use the new features. + + There is some features that are not implemented like the warmer function, or everything related to lambda@edge(It requires inserting env variables which is out of scope of this implementation). + + +```ts +import { Construct } from "constructs"; +import { readFileSync } from "fs"; +import path from "path"; +import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3"; +import { BucketDeployment, Source } from "aws-cdk-lib/aws-s3-deployment"; +import { + CustomResource, + Duration, + Fn, + RemovalPolicy, + Stack, +} from "aws-cdk-lib/core"; +import { + AllowedMethods, + BehaviorOptions, + CacheCookieBehavior, + CacheHeaderBehavior, + CachePolicy, + CacheQueryStringBehavior, + CachedMethods, + Distribution, + ICachePolicy, + ViewerProtocolPolicy, +} from "aws-cdk-lib/aws-cloudfront"; +import { HttpOrigin, S3Origin } from "aws-cdk-lib/aws-cloudfront-origins"; +import { + Code, + Function as CdkFunction, + FunctionUrlAuthType, + InvokeMode, + Runtime, +} from "aws-cdk-lib/aws-lambda"; +import { + TableV2 as Table, + AttributeType, + Billing, +} from "aws-cdk-lib/aws-dynamodb"; +import { + Service, + Source as AppRunnerSource, + Memory, + HealthCheck, + Cpu, +} from "@aws-cdk/aws-apprunner-alpha"; +import { DockerImageAsset } from "aws-cdk-lib/aws-ecr-assets"; +import { Queue } from "aws-cdk-lib/aws-sqs"; +import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources"; +import { IGrantable } from "aws-cdk-lib/aws-iam"; +import { Provider } from "aws-cdk-lib/custom-resources"; +import { RetentionDays } from "aws-cdk-lib/aws-logs"; + +type BaseFunction = { + handler: string; + bundle: string; +}; + +type OpenNextFunctionOrigin = { + type: "function"; + streaming?: boolean; +} & BaseFunction; + +type OpenNextECSOrigin = { + type: "ecs"; + bundle: string; + dockerfile: string; +}; + +type OpenNextS3Origin = { + type: "s3"; + originPath: string; + copy: { + from: string; + to: string; + cached: boolean; + versionedSubDir?: string; + }[]; +}; + +type OpenNextOrigins = + | OpenNextFunctionOrigin + | OpenNextECSOrigin + | OpenNextS3Origin; + +interface OpenNextOutput { + edgeFunctions: { + [key: string]: BaseFunction; + }; + origins: { + s3: OpenNextS3Origin; + default: OpenNextFunctionOrigin | OpenNextECSOrigin; + imageOptimizer: OpenNextFunctionOrigin | OpenNextECSOrigin; + [key: string]: OpenNextOrigins; + }; + behaviors: { + pattern: string; + origin?: string; + edgeFunction?: string; + }[]; + additionalProps?: { + disableIncrementalCache?: boolean; + disableTagCache?: boolean; + initializationFunction?: BaseFunction; + warmer?: BaseFunction; + revalidationFunction?: BaseFunction; + }; +} + +interface OpenNextCdkReferenceImplementationProps { + openNextPath: string; +} + +export class OpenNextCdkReferenceImplementation extends Construct { + private openNextOutput: OpenNextOutput; + private bucket: Bucket; + private table: Table; + private queue: Queue; + + private staticCachePolicy: ICachePolicy; + private serverCachePolicy: CachePolicy; + + public distribution: Distribution; + + constructor( + scope: Construct, + id: string, + props: OpenNextCdkReferenceImplementationProps, + ) { + super(scope, id); + this.openNextOutput = JSON.parse( + readFileSync( + path.join(props.openNextPath, "open-next.output.json"), + "utf-8", + ), + ) as OpenNextOutput; + + this.bucket = new Bucket(this, "OpenNextBucket", { + publicReadAccess: false, + blockPublicAccess: BlockPublicAccess.BLOCK_ALL, + autoDeleteObjects: true, + removalPolicy: RemovalPolicy.DESTROY, + enforceSSL: true, + }); + this.table = this.createRevalidationTable(); + this.queue = this.createRevalidationQueue(); + + const origins = this.createOrigins(); + this.serverCachePolicy = this.createServerCachePolicy(); + this.staticCachePolicy = this.createStaticCachePolicy(); + this.distribution = this.createDistribution(origins); + } + + private createRevalidationTable() { + const table = 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, + }); + + const initFn = this.openNextOutput.additionalProps?.initializationFunction; + + const insertFn = new CdkFunction(this, "RevalidationInsertFunction", { + description: "Next.js revalidation data insert", + handler: initFn?.handler ?? "index.handler", + // code: Code.fromAsset(initFn?.bundle ?? ""), + code: Code.fromAsset(".open-next/dynamodb-provider"), + runtime: Runtime.NODEJS_18_X, + timeout: Duration.minutes(15), + memorySize: 128, + environment: { + CACHE_DYNAMO_TABLE: table.tableName, + }, + }); + + 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 table; + } + + private createOrigins() { + const { + s3: s3Origin, + default: defaultOrigin, + imageOptimizer: imageOrigin, + ...restOrigins + } = this.openNextOutput.origins; + const s3 = new S3Origin(this.bucket, { + originPath: s3Origin.originPath, + }); + for (const copy of s3Origin.copy) { + new BucketDeployment(this, `OpenNextBucketDeployment${copy.from}`, { + sources: [Source.asset(copy.from)], + destinationBucket: this.bucket, + destinationKeyPrefix: copy.to, + prune: false, + }); + } + const origins = { + s3: new S3Origin(this.bucket, { + originPath: s3Origin.originPath, + originAccessIdentity: undefined, + }), + default: + defaultOrigin.type === "function" + ? this.createFunctionOrigin("default", defaultOrigin) + : this.createAppRunnerOrigin("default", defaultOrigin), + imageOptimizer: + imageOrigin.type === "function" + ? this.createFunctionOrigin("imageOptimizer", imageOrigin) + : this.createAppRunnerOrigin("imageOptimizer", imageOrigin), + ...Object.entries(restOrigins).reduce( + (acc, [key, value]) => { + if (value.type === "function") { + acc[key] = this.createFunctionOrigin(key, value); + } else if (value.type === "ecs") { + acc[key] = this.createAppRunnerOrigin(key, value); + } + return acc; + }, + {} as Record, + ), + }; + return origins; + } + + private createRevalidationQueue() { + const queue = new Queue(this, "RevalidationQueue", { + fifo: true, + receiveMessageWaitTime: Duration.seconds(20), + }); + const consumer = new CdkFunction(this, "RevalidationFunction", { + description: "Next.js revalidator", + handler: "index.handler", + code: Code.fromAsset( + this.openNextOutput.additionalProps?.revalidationFunction?.bundle ?? "", + ), + runtime: Runtime.NODEJS_18_X, + timeout: Duration.seconds(30), + }); + consumer.addEventSource(new SqsEventSource(queue, { batchSize: 5 })); + return queue; + } + + private getEnvironment() { + return { + CACHE_BUCKET_NAME: this.bucket.bucketName, + CACHE_BUCKET_KEY_PREFIX: "_cache", + CACHE_BUCKET_REGION: Stack.of(this).region, + REVALIDATION_QUEUE_URL: this.queue.queueUrl, + REVALIDATION_QUEUE_REGION: Stack.of(this).region, + CACHE_DYNAMO_TABLE: this.table.tableName, + // Those 2 are used only for image optimizer + BUCKET_NAME: this.bucket.bucketName, + BUCKET_KEY_PREFIX: "_assets", + }; + } + + private grantPermissions(grantable: IGrantable) { + this.bucket.grantReadWrite(grantable); + this.table.grantReadWriteData(grantable); + this.queue.grantSendMessages(grantable); + } + + private createFunctionOrigin(key: string, origin: OpenNextFunctionOrigin) { + const environment = this.getEnvironment(); + const fn = new CdkFunction(this, `${key}Function`, { + runtime: Runtime.NODEJS_18_X, + handler: origin.handler, + code: Code.fromAsset(origin.bundle), + environment, + memorySize: 1024, + }); + const fnUrl = fn.addFunctionUrl({ + authType: FunctionUrlAuthType.NONE, + invokeMode: origin.streaming + ? InvokeMode.RESPONSE_STREAM + : InvokeMode.BUFFERED, + }); + this.grantPermissions(fn); + return new HttpOrigin(Fn.parseDomainName(fnUrl.url)); + } + + // We are using AppRunner because it is the easiest way to demonstrate the new feature. + // You can use any other container service like ECS, EKS, Fargate, etc. + private createAppRunnerOrigin( + key: string, + origin: OpenNextECSOrigin, + ): HttpOrigin { + const imageAsset = new DockerImageAsset(this, `${key}ImageAsset`, { + directory: origin.bundle, + // file: origin.dockerfile, + }); + const service = new Service(this, `${key}Service`, { + source: AppRunnerSource.fromAsset({ + asset: imageAsset, + + imageConfiguration: { + port: 3000, + environmentVariables: this.getEnvironment(), + }, + }), + serviceName: key, + autoDeploymentsEnabled: false, + cpu: Cpu.HALF_VCPU, + memory: Memory.ONE_GB, + healthCheck: HealthCheck.http({ + path: "/__health", + }), + }); + this.grantPermissions(service); + return new HttpOrigin(service.serviceUrl); + } + + private createDistribution(origins: Record) { + const distribution = new Distribution(this, "OpenNextDistribution", { + defaultBehavior: { + origin: origins.default, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS, + cachedMethods: CachedMethods.CACHE_GET_HEAD_OPTIONS, + cachePolicy: this.serverCachePolicy, + }, + additionalBehaviors: this.openNextOutput.behaviors + .filter((b) => b.pattern !== "*") + .reduce( + (acc, behavior) => { + return { + ...acc, + [behavior.pattern]: { + origin: behavior.origin + ? origins[behavior.origin] + : origins.default, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS, + cachedMethods: CachedMethods.CACHE_GET_HEAD_OPTIONS, + cachePolicy: + behavior.origin === "s3" + ? this.staticCachePolicy + : this.serverCachePolicy, + }, + }; + }, + {} as Record, + ), + }); + return distribution; + } + + private createServerCachePolicy() { + return new CachePolicy(this, "OpenNextServerCachePolicy", { + queryStringBehavior: CacheQueryStringBehavior.all(), + headerBehavior: CacheHeaderBehavior.allowList( + "accept", + "accept-encoding", + "rsc", + "next-router-prefetch", + "next-router-state-tree", + "next-url", + "x-prerender-revalidate", + ), + cookieBehavior: CacheCookieBehavior.none(), + defaultTtl: Duration.days(0), + maxTtl: Duration.days(365), + minTtl: Duration.days(0), + }); + } + + private createStaticCachePolicy() { + return CachePolicy.CACHING_OPTIMIZED; + } +} + +```