diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4de724b..42cf6182 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,10 +47,34 @@ jobs: uses: actions/setup-node@v3 with: node-version: 16 - cache: 'yarn' - cache-dependency-path: '**/yarn.lock' + # Note: this cache is not particularly useful... + # It is about 780 MB and is only needed if we run `yarn install`, + # which we only do if `yarn.lock` has changed, which means this cacue + # key is changed in that case... so... it's not useful in the one + # case where we need it because we are caching installed modules below. + # cache: 'yarn' + # cache-dependency-path: '**/yarn.lock' + + # https://www.jonathan-wilkinson.com/github-actions-cache-everything + - name: Cache Node Modules + id: cache-node-modules + uses: actions/cache@v3 + with: + path: | + **/node_modules + key: node-modules-ci-${{ hashFiles('**/yarn.lock') }} + + - name: Optionally Install Node Modules + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile - - name: Install Node Modules + # We do this here so the `build-jsii` modules are installed too + # and become part of the cache - if we don't then + # their install will get skipped during `build-jsii` but they won't + # be present in the cache so the build will fail + - name: Optionally Install CDK Construct Deps + if: steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: packages/microapps-cdk/ run: yarn install --frozen-lockfile - name: Extract App NPM Versions @@ -127,10 +151,25 @@ jobs: uses: actions/setup-node@v3 with: node-version: 16 - cache: 'yarn' - cache-dependency-path: '**/yarn.lock' + # Note: this cache is not particularly useful... + # It is about 780 MB and is only needed if we run `yarn install`, + # which we only do if `yarn.lock` has changed, which means this cacue + # key is changed in that case... so... it's not useful in the one + # case where we need it because we are caching installed modules below. + # cache: 'yarn' + # cache-dependency-path: '**/yarn.lock' + + # https://www.jonathan-wilkinson.com/github-actions-cache-everything + - name: Cache Node Modules + id: cache-node-modules + uses: actions/cache@v3 + with: + path: | + **/node_modules + key: node-modules-ci-${{ hashFiles('**/yarn.lock') }} - - name: Install Node Modules + - name: Optionally Install Node Modules + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn install --frozen-lockfile - name: Build All TypeScript @@ -164,18 +203,22 @@ jobs: npx replace-in-file "/\/u002F${RELEASE_APP_NAME}/g" ${PREFIX_U}/u002F${RELEASE_APP_NAME} --configFile=.release-replace.config.js --isRegex fi + # + # Synth and upload if BUILD-CDK-ZIP label is present + # - name: Synth CDK Stack + if: github.event_name == 'pull_request' && contains( github.event.pull_request.labels.*.name, 'BUILD-CDK-ZIP') run: | npx cdk synth --context @pwrdrvr/microapps:deployDemoApp=true \ --context @pwrdrvr/microapps:deployNexjsDemoApp=true \ --context @pwrdrvr/microapps:deployReleaseApp=true \ --require-approval never ${{ matrix.deployName }} - - # Upload Synth - name: Zip Package + if: github.event_name == 'pull_request' && contains( github.event.pull_request.labels.*.name, 'BUILD-CDK-ZIP') working-directory: . run: zip -r cdk-out.zip cdk.out - name: Upload Zip + if: github.event_name == 'pull_request' && contains( github.event.pull_request.labels.*.name, 'BUILD-CDK-ZIP') uses: actions/upload-artifact@v3 with: name: cdk_out_${{ matrix.deployName }} @@ -226,7 +269,7 @@ jobs: description: 'Passed' state: 'success' sha: ${{github.event.pull_request.head.sha || github.sha}} - target_url: https://${{ steps.getCDKExports.outputs.edgeDomain }}${{ steps.getCDKExports.outputs.prefix }}/${{ env.DEMO_APP_NAME }}?appver=${{ env.PACKAGE_VERSION }} + target_url: https://${{ steps.getCDKExports.outputs.edgeDomain }}${{ steps.getCDKExports.outputs.prefix }}/${{ env.DEMO_APP_NAME }}/?appver=${{ env.PACKAGE_VERSION }} - name: Test Demo App run: | @@ -310,7 +353,7 @@ jobs: description: 'Passed' state: 'success' sha: ${{github.event.pull_request.head.sha || github.sha}} - target_url: https://${{ steps.getCDKExports.outputs.edgeDomain }}${{ steps.getCDKExports.outputs.prefix }}/${{ env.NEXTJS_DEMO_APP_NAME }}/${{ needs.build.outputs.nextjsDemoAppPackageVersion }} + target_url: https://${{ steps.getCDKExports.outputs.edgeDomain }}${{ steps.getCDKExports.outputs.prefix }}/${{ env.NEXTJS_DEMO_APP_NAME }}?appver=${{ needs.build.outputs.nextjsDemoAppPackageVersion }} - name: Test Nextjs Demo App run: | @@ -341,7 +384,7 @@ jobs: description: 'Passed' state: 'success' sha: ${{github.event.pull_request.head.sha || github.sha}} - target_url: https://${{ steps.getCDKExports.outputs.edgeDomain }}${{ steps.getCDKExports.outputs.prefix }}/${{ env.RELEASE_APP_NAME }}/${{ needs.build.outputs.releaseAppPackageVersion }} + target_url: https://${{ steps.getCDKExports.outputs.edgeDomain }}${{ steps.getCDKExports.outputs.prefix }}/${{ env.RELEASE_APP_NAME }}?appver=${{ needs.build.outputs.releaseAppPackageVersion }} - name: Test Release App run: | @@ -362,10 +405,25 @@ jobs: uses: actions/setup-node@v3 with: node-version: 16 - cache: 'yarn' - cache-dependency-path: '**/yarn.lock' + # Note: this cache is not particularly useful... + # It is about 780 MB and is only needed if we run `yarn install`, + # which we only do if `yarn.lock` has changed, which means this cacue + # key is changed in that case... so... it's not useful in the one + # case where we need it because we are caching installed modules below. + # cache: 'yarn' + # cache-dependency-path: '**/yarn.lock' + + # https://www.jonathan-wilkinson.com/github-actions-cache-everything + - name: Cache Node Modules + id: cache-node-modules + uses: actions/cache@v3 + with: + path: | + **/node_modules + key: node-modules-ci-${{ hashFiles('**/yarn.lock') }} - - name: Install Node Modules + - name: Optionally Install Node Modules + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn install --frozen-lockfile # - name: Generate Projen Files @@ -385,7 +443,8 @@ jobs: # - name: Move root modules out of the way for CDK Construct build # run: mv node_modules node_modules_hide - - name: Install CDK Construct Deps + - name: Optionally Install CDK Construct Deps + if: steps.cache-node-modules.outputs.cache-hit != 'true' working-directory: packages/microapps-cdk/ run: yarn install --frozen-lockfile diff --git a/packages/demo-app/src/index.ts b/packages/demo-app/src/index.ts index e78e84d4..4c6a2900 100644 --- a/packages/demo-app/src/index.ts +++ b/packages/demo-app/src/index.ts @@ -11,6 +11,9 @@ export async function handler( // eslint-disable-next-line @typescript-eslint/no-unused-vars context?: lambda.Context, ): Promise { + // eslint-disable-next-line no-console + console.log('event', event); + if (event.rawPath.endsWith('/serverIncrement')) { const currValue = parseInt(event.queryStringParameters?.currValue ?? '0', 10); const newValue = currValue + 1; diff --git a/packages/microapps-cdk/API.md b/packages/microapps-cdk/API.md index 99f26563..ddd0aa3e 100644 --- a/packages/microapps-cdk/API.md +++ b/packages/microapps-cdk/API.md @@ -48,14 +48,6 @@ new MicroApps(scope: Construct, id: string, props?: MicroAppsProps) #### Properties -##### `apigwy`Required - -- *Type:* [`@pwrdrvr/microapps-cdk.IMicroAppsAPIGwy`](#@pwrdrvr/microapps-cdk.IMicroAppsAPIGwy) - -{@inheritdoc IMicroAppsAPIGwy}. - ---- - ##### `cf`Required - *Type:* [`@pwrdrvr/microapps-cdk.IMicroAppsCF`](#@pwrdrvr/microapps-cdk.IMicroAppsCF) @@ -80,6 +72,14 @@ new MicroApps(scope: Construct, id: string, props?: MicroAppsProps) --- +##### `apigwy`Optional + +- *Type:* [`@pwrdrvr/microapps-cdk.IMicroAppsAPIGwy`](#@pwrdrvr/microapps-cdk.IMicroAppsAPIGwy) + +{@inheritdoc IMicroAppsAPIGwy}. + +--- + ##### `edgeToOrigin`Optional - *Type:* [`@pwrdrvr/microapps-cdk.IMicroAppsEdgeToOrigin`](#@pwrdrvr/microapps-cdk.IMicroAppsEdgeToOrigin) @@ -498,15 +498,16 @@ import { AddRoutesOptions } from '@pwrdrvr/microapps-cdk' const addRoutesOptions: AddRoutesOptions = { ... } ``` -##### `apiGwyOrigin`Required +##### `appOrigin`Required - *Type:* [`aws-cdk-lib.aws_cloudfront.IOrigin`](#aws-cdk-lib.aws_cloudfront.IOrigin) +- *Default:* invalid URL (never used) -API Gateway CloudFront Origin for API calls. +Default origin (invalid URL or API Gateway). --- -##### `apigwyOriginRequestPolicy`Required +##### `appOriginRequestPolicy`Required - *Type:* [`aws-cdk-lib.aws_cloudfront.IOriginRequestPolicy`](#aws-cdk-lib.aws_cloudfront.IOriginRequestPolicy) @@ -533,7 +534,7 @@ CloudFront Distribution to add the Behaviors (Routes) to. ##### `createAPIPathRoute`Optional - *Type:* `boolean` -- *Default:* true +- *Default:* false Create an extra Behavior (Route) for /api/ that allows API routes to have a period in them. @@ -547,7 +548,7 @@ even if they have a period in the path. ##### `createNextDataPathRoute`Optional - *Type:* `boolean` -- *Default:* true +- *Default:* false Create an extra Behavior (Route) for /_next/data/ This route is used by Next.js to load data from the API Gateway on `getServerSideProps` calls. The requests can end in `.json`, which would cause them to be routed to S3 if this route is not created. @@ -772,14 +773,6 @@ S3 bucket origin for deployed applications. --- -##### `httpApi`Required - -- *Type:* [`@aws-cdk/aws-apigatewayv2-alpha.HttpApi`](#@aws-cdk/aws-apigatewayv2-alpha.HttpApi) - -API Gateway v2 HTTP API for apps. - ---- - ##### `assetNameRoot`Optional - *Type:* `string` @@ -817,7 +810,7 @@ ACM Certificate that covers `domainNameEdge` name. ##### `createAPIPathRoute`Optional - *Type:* `boolean` -- *Default:* true +- *Default:* true if httpApi is provided Create an extra Behavior (Route) for /api/ that allows API routes to have a period in them. @@ -831,7 +824,7 @@ even if they have a period in the path. ##### `createNextDataPathRoute`Optional - *Type:* `boolean` -- *Default:* true +- *Default:* true if httpApi is provided Create an extra Behavior (Route) for /_next/data/ This route is used by Next.js to load data from the API Gateway on `getServerSideProps` calls. The requests can end in `.json`, which would cause them to be routed to S3 if this route is not created. @@ -869,6 +862,26 @@ Configuration of the edge to origin lambda functions. --- +##### `httpApi`Optional + +- *Type:* [`@aws-cdk/aws-apigatewayv2-alpha.HttpApi`](#@aws-cdk/aws-apigatewayv2-alpha.HttpApi) + +API Gateway v2 HTTP API for apps. + +--- + +##### `originShieldRegion`Optional + +- *Type:* `string` +- *Default:* none + +Optional Origin Shield Region. + +This should be the region where the DynamoDB is located so the +EdgeToOrigin calls have the lowest latency (~1 ms). + +--- + ##### `r53Zone`Optional - *Type:* [`aws-cdk-lib.aws_route53.IHostedZone`](#aws-cdk-lib.aws_route53.IHostedZone) @@ -983,6 +996,15 @@ Path prefix on the root of the API Gateway Stage. --- +##### `setupApiGatewayPermissions`Optional + +- *Type:* `boolean` +- *Default:* false + +Enable invoking API Gateway from the Edge Lambda. + +--- + ##### `signingMode`Optional - *Type:* `string` @@ -1080,6 +1102,15 @@ Certificate in deployed region for the API Gateway. --- +##### `createAPIGateway`Optional + +- *Type:* `boolean` +- *Default:* false + +Create API Gateway for non-edge invocation. + +--- + ##### `createAPIPathRoute`Optional - *Type:* `boolean` @@ -1094,6 +1125,20 @@ even if they have a period in the path. --- +##### `createNextDataPathRoute`Optional + +- *Type:* `boolean` +- *Default:* true + +Create an extra Behavior (Route) for /_next/data/ This route is used by Next.js to load data from the API Gateway on `getServerSideProps` calls. The requests can end in `.json`, which would cause them to be routed to S3 if this route is not created. + +When false API routes with a period in the path will get routed to S3. + +When true API routes that contain /_next/data/ in the path will get routed to API Gateway +even if they have a period in the path. + +--- + ##### `domainNameEdge`Optional - *Type:* `string` @@ -1125,7 +1170,19 @@ Additional edge lambda functions. - *Type:* `string` - *Default:* undefined -Origin region that API Gateway will be deployed to, used for the config.yml on the Edge function to sign requests for the correct region. +Origin region that API Gateway or Lambda function will be deployed to, used for the config.yml on the Edge function to sign requests for the correct region. + +--- + +##### `originShieldRegion`Optional + +- *Type:* `string` +- *Default:* originRegion if specified, otherwise undefined + +Optional Origin Shield Region. + +This should be the region where the DynamoDB is located so the +EdgeToOrigin calls have the lowest latency (~1 ms). --- @@ -1337,6 +1394,18 @@ S3 logs bucket name. --- +##### `originShieldRegion`Optional + +- *Type:* `string` +- *Default:* none + +Optional Origin Shield Region. + +This should be the region where the DynamoDB is located so the +EdgeToOrigin calls have the lowest latency (~1 ms). + +--- + ##### `removalPolicy`Optional - *Type:* [`aws-cdk-lib.RemovalPolicy`](#aws-cdk-lib.RemovalPolicy) @@ -1392,14 +1461,6 @@ S3 bucket for staged applications (prior to deploy). --- -##### `httpApi`Required - -- *Type:* [`@aws-cdk/aws-apigatewayv2-alpha.HttpApi`](#@aws-cdk/aws-apigatewayv2-alpha.HttpApi) - -API Gateway v2 HTTP for Router and app. - ---- - ##### `assetNameRoot`Optional - *Type:* `string` @@ -1418,6 +1479,14 @@ Optional asset name suffix. --- +##### `httpApi`Optional + +- *Type:* [`@aws-cdk/aws-apigatewayv2-alpha.HttpApi`](#@aws-cdk/aws-apigatewayv2-alpha.HttpApi) + +API Gateway v2 HTTP for Router and app. + +--- + ##### `removalPolicy`Optional - *Type:* [`aws-cdk-lib.RemovalPolicy`](#aws-cdk-lib.RemovalPolicy) @@ -1582,14 +1651,6 @@ Represents a MicroApps. #### Properties -##### `apigwy`Required - -- *Type:* [`@pwrdrvr/microapps-cdk.IMicroAppsAPIGwy`](#@pwrdrvr/microapps-cdk.IMicroAppsAPIGwy) - -{@inheritdoc IMicroAppsAPIGwy}. - ---- - ##### `cf`Required - *Type:* [`@pwrdrvr/microapps-cdk.IMicroAppsCF`](#@pwrdrvr/microapps-cdk.IMicroAppsCF) @@ -1614,6 +1675,14 @@ Represents a MicroApps. --- +##### `apigwy`Optional + +- *Type:* [`@pwrdrvr/microapps-cdk.IMicroAppsAPIGwy`](#@pwrdrvr/microapps-cdk.IMicroAppsAPIGwy) + +{@inheritdoc IMicroAppsAPIGwy}. + +--- + ##### `edgeToOrigin`Optional - *Type:* [`@pwrdrvr/microapps-cdk.IMicroAppsEdgeToOrigin`](#@pwrdrvr/microapps-cdk.IMicroAppsEdgeToOrigin) diff --git a/packages/microapps-cdk/src/MicroApps.ts b/packages/microapps-cdk/src/MicroApps.ts index 8a5d1025..4f3d904d 100644 --- a/packages/microapps-cdk/src/MicroApps.ts +++ b/packages/microapps-cdk/src/MicroApps.ts @@ -178,6 +178,13 @@ export interface MicroAppsProps { */ readonly rootPathPrefix?: string; + /** + * Create API Gateway for non-edge invocation + * + * @default false + */ + readonly createAPIGateway?: boolean; + /** * Create an extra Behavior (Route) for /api/ that allows * API routes to have a period in them. @@ -191,6 +198,21 @@ export interface MicroAppsProps { */ readonly createAPIPathRoute?: boolean; + /** + * Create an extra Behavior (Route) for /_next/data/ + * This route is used by Next.js to load data from the API Gateway + * on `getServerSideProps` calls. The requests can end in `.json`, + * which would cause them to be routed to S3 if this route is not created. + * + * When false API routes with a period in the path will get routed to S3. + * + * When true API routes that contain /_next/data/ in the path will get routed to API Gateway + * even if they have a period in the path. + * + * @default true + */ + readonly createNextDataPathRoute?: boolean; + /** * Adds an X-Forwarded-Host-Header when calling API Gateway * @@ -228,7 +250,7 @@ export interface MicroAppsProps { readonly signingMode?: 'sign' | 'presign' | 'none'; /** - * Origin region that API Gateway will be deployed to, used + * Origin region that API Gateway or Lambda function will be deployed to, used * for the config.yml on the Edge function to sign requests for * the correct region * @@ -236,6 +258,16 @@ export interface MicroAppsProps { */ readonly originRegion?: string; + /** + * Optional Origin Shield Region + * + * This should be the region where the DynamoDB is located so the + * EdgeToOrigin calls have the lowest latency (~1 ms). + * + * @default originRegion if specified, otherwise undefined + */ + readonly originShieldRegion?: string; + /** * Existing table for apps/versions/rules * @@ -282,7 +314,7 @@ export interface IMicroApps { readonly svcs: IMicroAppsSvcs; /** {@inheritdoc IMicroAppsAPIGwy} */ - readonly apigwy: IMicroAppsAPIGwy; + readonly apigwy?: IMicroAppsAPIGwy; } /** @@ -321,8 +353,8 @@ export class MicroApps extends Construct implements IMicroApps { return this._s3; } - private _apigwy: MicroAppsAPIGwy; - public get apigwy(): IMicroAppsAPIGwy { + private _apigwy?: MicroAppsAPIGwy; + public get apigwy(): IMicroAppsAPIGwy | undefined { return this._apigwy; } @@ -352,13 +384,16 @@ export class MicroApps extends Construct implements IMicroApps { s3PolicyBypassPrincipalARNs, s3StrictBucketPolicy, rootPathPrefix, + createAPIGateway = false, createAPIPathRoute = true, + createNextDataPathRoute = true, addXForwardedHostHeader = true, replaceHostHeader = true, signingMode = 'sign', originRegion, table, tableNameForEdgeToOrigin, + originShieldRegion = originRegion, } = props; this._s3 = new MicroAppsS3(this, 's3', { @@ -370,20 +405,23 @@ export class MicroApps extends Construct implements IMicroApps { : undefined, assetNameRoot, assetNameSuffix, + originShieldRegion, }); - this._apigwy = new MicroAppsAPIGwy(this, 'api', { - removalPolicy, - assetNameRoot, - assetNameSuffix, - domainNameEdge, - domainNameOrigin, - r53Zone, - certOrigin, - rootPathPrefix, - requireIAMAuthorization: signingMode !== 'none', - }); + if (createAPIGateway) { + this._apigwy = new MicroAppsAPIGwy(this, 'api', { + removalPolicy, + assetNameRoot, + assetNameSuffix, + domainNameEdge, + domainNameOrigin, + r53Zone, + certOrigin, + rootPathPrefix, + requireIAMAuthorization: signingMode !== 'none', + }); + } this._svcs = new MicroAppsSvcs(this, 'svcs', { - httpApi: this.apigwy.httpApi, + ...(this._apigwy ? { httpApi: this._apigwy.httpApi } : {}), removalPolicy, bucketApps: this._s3.bucketApps, bucketAppsOAI: this._s3.bucketAppsOAI, @@ -406,6 +444,7 @@ export class MicroApps extends Construct implements IMicroApps { assetNameSuffix, removalPolicy, addXForwardedHostHeader, + setupApiGatewayPermissions: createAPIGateway, replaceHostHeader, originRegion, signingMode, @@ -425,13 +464,15 @@ export class MicroApps extends Construct implements IMicroApps { assetNameSuffix, domainNameEdge, domainNameOrigin, - httpApi: this._apigwy.httpApi, + ...(this._apigwy ? { httpApi: this._apigwy.httpApi } : {}), r53Zone, certEdge, bucketAppsOrigin: this._s3.bucketAppsOrigin, bucketLogs: this._s3.bucketLogs, rootPathPrefix, createAPIPathRoute, + createNextDataPathRoute, + originShieldRegion, ...(edgeLambdas.length ? { edgeLambdas } : {}), }); } diff --git a/packages/microapps-cdk/src/MicroAppsCF.ts b/packages/microapps-cdk/src/MicroAppsCF.ts index d452aa33..540d7cfc 100644 --- a/packages/microapps-cdk/src/MicroAppsCF.ts +++ b/packages/microapps-cdk/src/MicroAppsCF.ts @@ -62,7 +62,7 @@ export interface MicroAppsCFProps { /** * API Gateway v2 HTTP API for apps */ - readonly httpApi: apigwy.HttpApi; + readonly httpApi?: apigwy.HttpApi; /** * Optional asset name root @@ -106,7 +106,7 @@ export interface MicroAppsCFProps { * When true API routes that contain /api/ in the path will get routed to API Gateway * even if they have a period in the path. * - * @default true + * @default true if httpApi is provided */ readonly createAPIPathRoute?: boolean; @@ -121,7 +121,7 @@ export interface MicroAppsCFProps { * When true API routes that contain /_next/data/ in the path will get routed to API Gateway * even if they have a period in the path. * - * @default true + * @default true if httpApi is provided */ readonly createNextDataPathRoute?: boolean; @@ -131,6 +131,16 @@ export interface MicroAppsCFProps { * @default - no edge to API Gateway origin functions added */ readonly edgeLambdas?: cf.EdgeLambda[]; + + /** + * Optional Origin Shield Region + * + * This should be the region where the DynamoDB is located so the + * EdgeToOrigin calls have the lowest latency (~1 ms). + * + * @default - none + */ + readonly originShieldRegion?: string; } /** @@ -166,9 +176,11 @@ export interface CreateAPIOriginPolicyOptions { */ export interface AddRoutesOptions { /** - * API Gateway CloudFront Origin for API calls + * Default origin (invalid URL or API Gateway) + * + * @default invalid URL (never used) */ - readonly apiGwyOrigin: cf.IOrigin; + readonly appOrigin: cf.IOrigin; /** * S3 Bucket CloudFront Origin for static assets @@ -183,7 +195,7 @@ export interface AddRoutesOptions { /** * Origin Request policy for API Gateway Origin */ - readonly apigwyOriginRequestPolicy: cf.IOriginRequestPolicy; + readonly appOriginRequestPolicy: cf.IOriginRequestPolicy; /** * Path prefix on the root of the CloudFront distribution @@ -201,7 +213,7 @@ export interface AddRoutesOptions { * When true API routes that contain /api/ in the path will get routed to API Gateway * even if they have a period in the path. * - * @default true + * @default false */ readonly createAPIPathRoute?: boolean; @@ -216,7 +228,7 @@ export interface AddRoutesOptions { * When true API routes that contain /_next/data/ in the path will get routed to API Gateway * even if they have a period in the path. * - * @default true + * @default false */ readonly createNextDataPathRoute?: boolean; @@ -290,13 +302,13 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { */ public static addRoutes(_scope: Construct, props: AddRoutesOptions) { const { - apiGwyOrigin, + appOrigin: defaultOrigin, bucketAppsOrigin, distro, - apigwyOriginRequestPolicy, + appOriginRequestPolicy, rootPathPrefix = '', - createAPIPathRoute = true, - createNextDataPathRoute = true, + createAPIPathRoute = false, + createNextDataPathRoute = false, } = props; // @@ -309,12 +321,12 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { originRequestPolicy: cf.OriginRequestPolicy.CORS_S3_ORIGIN, viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, }; - const apiGwyBehaviorOptions: cf.AddBehaviorOptions = { + const appBehaviorOptions: cf.AddBehaviorOptions = { allowedMethods: cf.AllowedMethods.ALLOW_ALL, // TODO: Caching needs to be set by the app response cachePolicy: cf.CachePolicy.CACHING_DISABLED, compress: true, - originRequestPolicy: apigwyOriginRequestPolicy, + originRequestPolicy: appOriginRequestPolicy, viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, edgeLambdas: props.edgeLambdas, }; @@ -327,14 +339,14 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { if (createAPIPathRoute) { distro.addBehavior( posixPath.join(rootPathPrefix, '*/api/*'), - apiGwyOrigin, - apiGwyBehaviorOptions, + defaultOrigin, + appBehaviorOptions, ); distro.addBehavior( posixPath.join(rootPathPrefix, 'api/*'), - apiGwyOrigin, - apiGwyBehaviorOptions, + defaultOrigin, + appBehaviorOptions, ); } @@ -349,8 +361,8 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { // to the app origin as iframe-less will have no version before _next/data // in the path posixPath.join(rootPathPrefix, '*/_next/data/*'), - apiGwyOrigin, - apiGwyBehaviorOptions, + defaultOrigin, + appBehaviorOptions, ); distro.addBehavior( @@ -358,8 +370,8 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { // to the app origin as iframe-less will have no version before _next/data // in the path posixPath.join(rootPathPrefix, '_next/data/*'), - apiGwyOrigin, - apiGwyBehaviorOptions, + defaultOrigin, + appBehaviorOptions, ); } @@ -386,7 +398,7 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { // There is no trailing slash because Serverless Next.js wants // go load pages at /release/0.0.3 (with no trailing slash). // - distro.addBehavior(posixPath.join(rootPathPrefix, '/*'), apiGwyOrigin, apiGwyBehaviorOptions); + distro.addBehavior(posixPath.join(rootPathPrefix, '/*'), defaultOrigin, appBehaviorOptions); } private _cloudFrontDistro: cf.Distribution; @@ -420,12 +432,13 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { bucketLogs, bucketAppsOrigin, rootPathPrefix, - createAPIPathRoute = true, - createNextDataPathRoute = true, + createAPIPathRoute = !!props.httpApi, + createNextDataPathRoute = !!props.httpApi, edgeLambdas, + originShieldRegion, } = props; - const apigwyOriginRequestPolicy = MicroAppsCF.createAPIOriginPolicy(this, { + const appOriginRequestPolicy = MicroAppsCF.createAPIOriginPolicy(this, { assetNameRoot, assetNameSuffix, domainNameEdge, @@ -437,7 +450,7 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { let httpOriginFQDN: string = 'invalid.pwrdrvr.com'; if (domainNameOrigin !== undefined) { httpOriginFQDN = domainNameOrigin; - } else { + } else if (httpApi) { httpOriginFQDN = `${httpApi.apiId}.execute-api.${Aws.REGION}.amazonaws.com`; } @@ -448,10 +461,13 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { // // CloudFront Distro // - const apiGwyOrigin = new cforigins.HttpOrigin(httpOriginFQDN, { - protocolPolicy: cf.OriginProtocolPolicy.HTTPS_ONLY, - originSslProtocols: [cf.OriginSslPolicy.TLS_V1_2], - }); + const appOrigin = httpApi + ? new cforigins.HttpOrigin(httpOriginFQDN, { + protocolPolicy: cf.OriginProtocolPolicy.HTTPS_ONLY, + originSslProtocols: [cf.OriginSslPolicy.TLS_V1_2], + originShieldRegion, + }) + : bucketAppsOrigin; this._cloudFrontDistro = new cf.Distribution(this, 'cft', { comment: assetNameRoot ? `${assetNameRoot}${assetNameSuffix}` : domainNameEdge, domainNames: domainNameEdge !== undefined ? [domainNameEdge] : undefined, @@ -461,8 +477,8 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { allowedMethods: cf.AllowedMethods.ALLOW_ALL, cachePolicy: cf.CachePolicy.CACHING_DISABLED, compress: true, - originRequestPolicy: apigwyOriginRequestPolicy, - origin: apiGwyOrigin, + originRequestPolicy: appOriginRequestPolicy, + origin: appOrigin, viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, edgeLambdas, }, @@ -479,10 +495,10 @@ export class MicroAppsCF extends Construct implements IMicroAppsCF { // Add routes to the CloudFront Distribution MicroAppsCF.addRoutes(scope, { - apiGwyOrigin, + appOrigin, bucketAppsOrigin, distro: this._cloudFrontDistro, - apigwyOriginRequestPolicy: apigwyOriginRequestPolicy, + appOriginRequestPolicy, rootPathPrefix, createAPIPathRoute, createNextDataPathRoute, diff --git a/packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts b/packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts index a8e46884..8336899a 100644 --- a/packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts +++ b/packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts @@ -117,6 +117,13 @@ export interface MicroAppsEdgeToOriginProps { * Implies that 2nd generation routing is enabled. */ readonly tableRulesArn?: string; + + /** + * Enable invoking API Gateway from the Edge Lambda + * + * @default false + */ + readonly setupApiGatewayPermissions?: boolean; } export interface GenerateEdgeToOriginConfigOptions { @@ -168,6 +175,7 @@ ${props.rootPathPrefix ? `rootPathPrefix: '${props.rootPathPrefix}'` : ''}`; assetNameRoot, assetNameSuffix, originRegion, + setupApiGatewayPermissions = false, signingMode = 'sign', removalPolicy, rootPathPrefix, @@ -206,18 +214,22 @@ ${props.rootPathPrefix ? `rootPathPrefix: '${props.rootPathPrefix}'` : ''}`; // to invoke any API Gateway API that we apply a tag to // We allow the edge function to sign for all regions since // we may use custom closest region in the future. - new iam.PolicyStatement({ - actions: ['execute-api:Invoke'], - resources: [`arn:aws:execute-api:*:${Aws.ACCOUNT_ID}:*/*/*/*`], - // Unfortunately, API Gateway access cannot be restricted using - // tags on the target resource - // https://docs.aws.amazon.com/IAM/latest/UserGuide/access_tags.html - // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html#networking_svcs - // conditions: { - // // TODO: Set this to a string unique to each stack - // StringEquals: { 'aws:ResourceTag/microapp-managed': 'true' }, - // }, - }), + ...(setupApiGatewayPermissions + ? [ + new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [`arn:aws:execute-api:*:${Aws.ACCOUNT_ID}:*/*/*/*`], + // Unfortunately, API Gateway access cannot be restricted using + // tags on the target resource + // https://docs.aws.amazon.com/IAM/latest/UserGuide/access_tags.html + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html#networking_svcs + // conditions: { + // // TODO: Set this to a string unique to each stack + // StringEquals: { 'aws:ResourceTag/microapp-managed': 'true' }, + // }, + }), + ] + : []), // // Grant permission to invoke tagged Function URLs // diff --git a/packages/microapps-cdk/src/MicroAppsS3.ts b/packages/microapps-cdk/src/MicroAppsS3.ts index 3982f16b..980084b0 100644 --- a/packages/microapps-cdk/src/MicroAppsS3.ts +++ b/packages/microapps-cdk/src/MicroAppsS3.ts @@ -83,6 +83,16 @@ export interface MicroAppsS3Props { * @default none */ readonly assetNameSuffix?: string; + + /** + * Optional Origin Shield Region + * + * This should be the region where the DynamoDB is located so the + * EdgeToOrigin calls have the lowest latency (~1 ms). + * + * @default - none + */ + readonly originShieldRegion?: string; } /** @@ -124,7 +134,7 @@ export class MicroAppsS3 extends Construct implements IMicroAppsS3 { throw new Error('props must be set'); } - const { removalPolicy, assetNameRoot, assetNameSuffix } = props; + const { removalPolicy, assetNameRoot, assetNameSuffix, originShieldRegion } = props; // Use Auto-Delete S3Bucket if removal policy is DESTROY const s3AutoDeleteItems = removalPolicy === RemovalPolicy.DESTROY; @@ -163,6 +173,7 @@ export class MicroAppsS3 extends Construct implements IMicroAppsS3 { // Add Origin for CloudFront this._bucketAppsOrigin = new cforigins.S3Origin(this._bucketApps, { originAccessIdentity: this.bucketAppsOAI, + originShieldRegion, }); } } diff --git a/packages/microapps-cdk/src/MicroAppsSvcs.ts b/packages/microapps-cdk/src/MicroAppsSvcs.ts index ebc735ac..447b7fef 100644 --- a/packages/microapps-cdk/src/MicroAppsSvcs.ts +++ b/packages/microapps-cdk/src/MicroAppsSvcs.ts @@ -44,7 +44,7 @@ export interface MicroAppsSvcsProps { /** * API Gateway v2 HTTP for Router and app */ - readonly httpApi: apigwy.HttpApi; + readonly httpApi?: apigwy.HttpApi; /** * Application environment, passed as `NODE_ENV` @@ -359,7 +359,7 @@ export class MicroAppsSvcs extends Construct implements IMicroAppsSvcs { timeout: Duration.seconds(15), environment: { NODE_ENV: appEnv, - APIGWY_ID: httpApi.httpApiId, + ...(httpApi ? { APIGWY_ID: httpApi.httpApiId } : {}), DATABASE_TABLE_NAME: this._table.tableName, FILESTORE_STAGING_BUCKET: bucketAppsStaging.bucketName, FILESTORE_DEST_BUCKET: bucketApps.bucketName, @@ -598,19 +598,23 @@ export class MicroAppsSvcs extends Construct implements IMicroAppsSvcs { resources: [`arn:aws:apigateway:${Aws.REGION}::/apis`], }); this._deployerFunc.addToRolePolicy(policyAPIList); - // Grant full control over the API we created - const policyAPIManage = new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['apigateway:*'], - resources: [ - `arn:aws:apigateway:${Aws.REGION}:${Aws.ACCOUNT_ID}:${httpApi.httpApiId}/*`, - `arn:aws:apigateway:${Aws.REGION}::/apis/${httpApi.httpApiId}/integrations/*`, - `arn:aws:apigateway:${Aws.REGION}::/apis/${httpApi.httpApiId}/integrations`, - `arn:aws:apigateway:${Aws.REGION}::/apis/${httpApi.httpApiId}/routes`, - `arn:aws:apigateway:${Aws.REGION}::/apis/${httpApi.httpApiId}/routes/*`, - ], - }); - this._deployerFunc.addToRolePolicy(policyAPIManage); + + if (httpApi) { + // Grant full control over the API we created + const policyAPIManage = new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['apigateway:*'], + resources: [ + `arn:aws:apigateway:${Aws.REGION}:${Aws.ACCOUNT_ID}:${httpApi.httpApiId}/*`, + `arn:aws:apigateway:${Aws.REGION}::/apis/${httpApi.httpApiId}/integrations/*`, + `arn:aws:apigateway:${Aws.REGION}::/apis/${httpApi.httpApiId}/integrations`, + `arn:aws:apigateway:${Aws.REGION}::/apis/${httpApi.httpApiId}/routes`, + `arn:aws:apigateway:${Aws.REGION}::/apis/${httpApi.httpApiId}/routes/*`, + ], + }); + this._deployerFunc.addToRolePolicy(policyAPIManage); + } + // Grant full control over lambdas that indicate they are microapps const policyAPIManageLambdas = new iam.PolicyStatement({ effect: iam.Effect.ALLOW, @@ -625,26 +629,28 @@ export class MicroAppsSvcs extends Construct implements IMicroAppsSvcs { }); this._deployerFunc.addToRolePolicy(policyAPIManageLambdas); - // This creates an integration and a router - const route = new apigwy.HttpRoute(this, 'route-default', { - httpApi, - routeKey: apigwy.HttpRouteKey.DEFAULT, - integration: new apigwyint.HttpLambdaIntegration('router-integration', routerAlias), - authorizer: requireIAMAuthorization ? new apigwyAuth.HttpIamAuthorizer() : undefined, - }); + if (httpApi) { + // This creates an integration and a router + const route = new apigwy.HttpRoute(this, 'route-default', { + httpApi, + routeKey: apigwy.HttpRouteKey.DEFAULT, + integration: new apigwyint.HttpLambdaIntegration('router-integration', routerAlias), + authorizer: requireIAMAuthorization ? new apigwyAuth.HttpIamAuthorizer() : undefined, + }); - let routeArn = route.routeArn; - // Remove the trailing `/` on the ARN, which is not correct - if (routeArn.endsWith('/')) { - routeArn = routeArn.slice(0, routeArn.length - 1); - } + let routeArn = route.routeArn; + // Remove the trailing `/` on the ARN, which is not correct + if (routeArn.endsWith('/')) { + routeArn = routeArn.slice(0, routeArn.length - 1); + } - // Grant API Gateway permission to invoke the Lambda - new lambda.CfnPermission(this, 'router-invoke', { - action: 'lambda:InvokeFunction', - functionName: this._routerFunc.functionName, - principal: 'apigateway.amazonaws.com', - sourceArn: routeArn, - }); + // Grant API Gateway permission to invoke the Lambda + new lambda.CfnPermission(this, 'router-invoke', { + action: 'lambda:InvokeFunction', + functionName: this._routerFunc.functionName, + principal: 'apigateway.amazonaws.com', + sourceArn: routeArn, + }); + } } } diff --git a/packages/microapps-cdk/test/MicroApps.test.ts b/packages/microapps-cdk/test/MicroApps.test.ts index 81604d3a..168bdef4 100644 --- a/packages/microapps-cdk/test/MicroApps.test.ts +++ b/packages/microapps-cdk/test/MicroApps.test.ts @@ -19,7 +19,7 @@ describe('MicroApps', () => { }); expect(construct).toBeDefined(); - expect(construct.apigwy).toBeDefined(); + expect(construct.apigwy).not.toBeDefined(); expect(construct.edgeToOrigin).toBeDefined(); expect(construct.cf).toBeDefined(); expect(construct.s3).toBeDefined(); @@ -33,9 +33,6 @@ describe('MicroApps', () => { // Confirm that logical IDs have not changed accidentally (causes delete/create) Template.fromStack(stack).templateMatches({ Resources: { - constructapigwy894904EC: { - Type: 'AWS::ApiGatewayV2::Api', - }, constructcft0A8410EA: { Type: 'AWS::CloudFront::Distribution', }, @@ -84,7 +81,7 @@ describe('MicroApps', () => { }); expect(construct).toBeDefined(); - expect(construct.apigwy).toBeDefined(); + expect(construct.apigwy).not.toBeDefined(); expect(construct.edgeToOrigin).toBeDefined(); expect(construct.cf).toBeDefined(); expect(construct.s3).toBeDefined(); @@ -98,9 +95,6 @@ describe('MicroApps', () => { // Confirm that logical IDs have not changed accidentally (causes delete/create) Template.fromStack(stack).templateMatches({ Resources: { - constructapigwy894904EC: { - Type: 'AWS::ApiGatewayV2::Api', - }, constructcft0A8410EA: { Type: 'AWS::CloudFront::Distribution', }, @@ -150,7 +144,7 @@ describe('MicroApps', () => { }); expect(construct).toBeDefined(); - expect(construct.apigwy).toBeDefined(); + expect(construct.apigwy).not.toBeDefined(); expect(construct.edgeToOrigin).toBeDefined(); expect(construct.cf).toBeDefined(); expect(construct.s3).toBeDefined(); @@ -170,9 +164,6 @@ describe('MicroApps', () => { // Confirm that logical IDs have not changed accidentally (causes delete/create) Template.fromStack(stack).templateMatches({ Resources: { - constructapigwy894904EC: { - Type: 'AWS::ApiGatewayV2::Api', - }, constructcft0A8410EA: { Type: 'AWS::CloudFront::Distribution', }, @@ -223,7 +214,7 @@ describe('MicroApps', () => { }); expect(construct).toBeDefined(); - expect(construct.apigwy).toBeDefined(); + expect(construct.apigwy).not.toBeDefined(); expect(construct.edgeToOrigin).toBeDefined(); expect(construct.cf).toBeDefined(); expect(construct.s3).toBeDefined(); @@ -254,9 +245,6 @@ describe('MicroApps', () => { // Confirm that logical IDs have not changed accidentally (causes delete/create) Template.fromStack(stack).templateMatches({ Resources: { - constructapigwy894904EC: { - Type: 'AWS::ApiGatewayV2::Api', - }, constructcft0A8410EA: { Type: 'AWS::CloudFront::Distribution', }, @@ -315,7 +303,7 @@ describe('MicroApps', () => { }); expect(construct).toBeDefined(); - expect(construct.apigwy).toBeDefined(); + expect(construct.apigwy).not.toBeDefined(); expect(construct.cf).toBeDefined(); expect(construct.s3).toBeDefined(); expect(construct.svcs).toBeDefined(); @@ -328,9 +316,6 @@ describe('MicroApps', () => { // Confirm that logical IDs have not changed accidentally (causes delete/create) Template.fromStack(stack).templateMatches({ Resources: { - constructapigwy894904EC: { - Type: 'AWS::ApiGatewayV2::Api', - }, constructcft0A8410EA: { Type: 'AWS::CloudFront::Distribution', }, diff --git a/packages/microapps-edge-to-origin/src/index.ts b/packages/microapps-edge-to-origin/src/index.ts index 34da3cb4..72c5977d 100644 --- a/packages/microapps-edge-to-origin/src/index.ts +++ b/packages/microapps-edge-to-origin/src/index.ts @@ -203,14 +203,28 @@ export const handler: lambda.CloudFrontRequestHandler = async ( } // Overwrite the origin + // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-examples.html#lambda-examples-content-based-routing-examples if (request.origin?.custom?.domainName) { request.origin = { ...request.origin }; request.origin.custom.domainName = originHost; - - // Remove the appver query string to avoid problems with some frameworks - request.querystring = (request.querystring ?? '').replace(/&?appver=[^&]*/, ''); + } else { + request.origin = { + custom: { + domainName: originHost, + keepaliveTimeout: 5, + port: 443, + protocol: 'https', + readTimeout: 30, + sslProtocols: ['TLSv1.2'], + customHeaders: {}, + path: '', + }, + }; } + // Remove the appver query string to avoid problems with some frameworks + request.querystring = (request.querystring ?? '').replace(/&?appver=[^&]*/, ''); + // Lambda Function URLs cannot have a custom domain name // Function URLs will always contain `.lambda-url.` // API Gateway URLs can contain '.execute-api.' but will not diff --git a/packages/microapps-edge-to-origin/src/sign-request.ts b/packages/microapps-edge-to-origin/src/sign-request.ts index 4461b26d..cd254f24 100644 --- a/packages/microapps-edge-to-origin/src/sign-request.ts +++ b/packages/microapps-edge-to-origin/src/sign-request.ts @@ -2,6 +2,8 @@ import type * as lambda from 'aws-lambda'; import { SignatureV4 } from '@aws-sdk/signature-v4'; import { cloudfrontToSignableRequest } from './translate-request'; +const sigHeaders = ['authorization', 'x-amz-date', 'x-amz-security-token', 'x-amz-content-sha256']; + /** * Sign (headers) a request with AWS Signature V4 * @param request @@ -16,15 +18,15 @@ export async function signRequest( ): Promise { const httpRequest = cloudfrontToSignableRequest({ request }); + // Remove SigV4 headers before we sign because we will overwrite them + for (const key of sigHeaders) { + delete httpRequest.headers[key]; + } + const signedRequest = await signer.sign(httpRequest); // Copy the signature headers into the request to forward to origin - for (const key of [ - 'authorization', - 'x-amz-date', - 'x-amz-security-token', - 'x-amz-content-sha256', - ]) { + for (const key of sigHeaders) { request.headers[key] = [{ key: key, value: signedRequest.headers[key] }]; } @@ -48,12 +50,7 @@ export async function presignRequest( const signedRequest = await signer.presign(httpRequest); // Copy the signature headers into the request to forward to origin - for (const key of [ - 'authorization', - 'x-amz-date', - 'x-amz-security-token', - 'x-amz-content-sha256', - ]) { + for (const key of sigHeaders) { request.headers[key] = [{ key: key, value: signedRequest.headers[key] }]; }