From c556e8d3e1b0e912e00f8bf58133745d1ade96ad Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 10 Jan 2026 13:30:47 +0900 Subject: [PATCH 1/4] feat: implement AppSync custom domain support - Added Route53 Hosted Zone lookup for smalruby.app - Added ACM certificate creation with DNS validation - Configured AppSync custom domain (CfnDomainName) - Added AppSync domain name association - Added Route53 CNAME record for graphql.smalruby.app - Added CustomDomainUrl output - Enabled env in cdk app for context lookups Related to: https://github.com/smalruby/smalruby3-develop/issues/10 --- bin/mesh-v2.ts | 17 ++++------------- lib/mesh-v2-stack.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/bin/mesh-v2.ts b/bin/mesh-v2.ts index a7f94f2..31d450b 100644 --- a/bin/mesh-v2.ts +++ b/bin/mesh-v2.ts @@ -15,17 +15,8 @@ const stackName = stage === 'prod' ? 'MeshV2Stack' : `MeshV2Stack-${stage}`; new MeshV2Stack(app, stackName, { stackName: stackName, - /* If you don't specify 'env', this stack will be environment-agnostic. - * Account/Region-dependent features and context lookups will not work, - * but a single synthesized template can be deployed anywhere. */ - - /* Uncomment the next line to specialize this stack for the AWS Account - * and Region that are implied by the current CLI configuration. */ - // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, - - /* Uncomment the next line if you know exactly what Account and Region you - * want to deploy the stack to. */ - // env: { account: '123456789012', region: 'us-east-1' }, - - /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ + env: { + account: process.env.CDK_DEFAULT_ACCOUNT || process.env.AWS_ACCOUNT_ID, + region: process.env.CDK_DEFAULT_REGION || process.env.AWS_REGION || 'ap-northeast-1', + }, }); diff --git a/lib/mesh-v2-stack.ts b/lib/mesh-v2-stack.ts index 8662d4f..7fb4409 100644 --- a/lib/mesh-v2-stack.ts +++ b/lib/mesh-v2-stack.ts @@ -2,6 +2,8 @@ import * as cdk from 'aws-cdk-lib/core'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as appsync from 'aws-cdk-lib/aws-appsync'; import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as route53 from 'aws-cdk-lib/aws-route53'; +import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as path from 'path'; import { Construct } from 'constructs'; @@ -16,6 +18,22 @@ export class MeshV2Stack extends cdk.Stack { const stage = this.node.tryGetContext('stage') || process.env.STAGE || 'stg'; const stageSuffix = stage === 'prod' ? '' : `-${stage}`; + // Custom Domain configuration + const graphqlDomainName = stage === 'prod' ? 'graphql.smalruby.app' : `${stage}-graphql.smalruby.app`; + const zone = route53.HostedZone.fromLookup(this, 'HostedZone', { + domainName: 'smalruby.app', + }); + + const certificate = new acm.Certificate(this, 'ApiCertificate', { + domainName: graphqlDomainName, + validation: acm.CertificateValidation.fromDns(zone), + }); + + const appsyncDomainName = new appsync.CfnDomainName(this, 'AppSyncCustomDomain', { + domainName: graphqlDomainName, + certificateArn: certificate.certificateArn, + }); + // Stack全体にタグ付与 cdk.Tags.of(this).add('Project', 'MeshV2'); cdk.Tags.of(this).add('Stage', stage); @@ -100,6 +118,19 @@ export class MeshV2Stack extends cdk.Stack { }, }); + // Associate Custom Domain with API + new appsync.CfnDomainNameApiAssociation(this, 'ApiAssociation', { + apiId: this.api.apiId, + domainName: appsyncDomainName.attrDomainName, + }); + + // Route53 CNAME record for Custom Domain + new route53.CnameRecord(this, 'ApiCnameRecord', { + zone, + recordName: graphqlDomainName, + domainName: appsyncDomainName.attrAppSyncDomainName, + }); + // AppSync APIにタグ付与 cdk.Tags.of(this.api).add('ResourceType', 'GraphQLAPI'); @@ -420,5 +451,11 @@ export class MeshV2Stack extends cdk.Stack { value: this.api.apiId, description: 'AppSync GraphQL API ID', }); + + // Output Custom Domain URL + new cdk.CfnOutput(this, 'CustomDomainUrl', { + value: `https://${graphqlDomainName}/graphql`, + description: 'AppSync Custom Domain URL', + }); } } From 5cf79b813d02a5050029c3fe5abff1eed8349bb1 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 10 Jan 2026 13:46:59 +0900 Subject: [PATCH 2/4] fix: address review comments for AppSync custom domain - Use L2 construct (GraphqlApi.domainName property) instead of L1 - Use environment variables APPSYNC_CUSTOM_DOMAIN and ROUTE53_PARENT_ZONE_NAME - Update domain naming to match Issue #10 requirements - Make custom domain configuration optional - Use Route53 Alias record with AppSyncTarget - Update .env.example with new variables --- .env.example | 4 +++ lib/mesh-v2-stack.ts | 66 +++++++++++++++++++++++++------------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index 01f153f..5869f05 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ MESH_MEMBER_HEARTBEAT_TTL_SECONDS=600 # Maximum connection time (expiresAt) for a group MESH_MAX_CONNECTION_TIME_SECONDS=1500 +# Custom Domain Settings +# APPSYNC_CUSTOM_DOMAIN=graphql.api.smalruby.app +# ROUTE53_PARENT_ZONE_NAME=api.smalruby.app + # Development Recommended Values (uncomment to use) # MESH_HOST_HEARTBEAT_INTERVAL_SECONDS=15 # MESH_HOST_HEARTBEAT_TTL_SECONDS=60 diff --git a/lib/mesh-v2-stack.ts b/lib/mesh-v2-stack.ts index 7fb4409..d5fa692 100644 --- a/lib/mesh-v2-stack.ts +++ b/lib/mesh-v2-stack.ts @@ -4,6 +4,7 @@ import * as appsync from 'aws-cdk-lib/aws-appsync'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as route53 from 'aws-cdk-lib/aws-route53'; import * as acm from 'aws-cdk-lib/aws-certificatemanager'; +import * as targets from 'aws-cdk-lib/aws-route53-targets'; import * as path from 'path'; import { Construct } from 'constructs'; @@ -18,21 +19,29 @@ export class MeshV2Stack extends cdk.Stack { const stage = this.node.tryGetContext('stage') || process.env.STAGE || 'stg'; const stageSuffix = stage === 'prod' ? '' : `-${stage}`; - // Custom Domain configuration - const graphqlDomainName = stage === 'prod' ? 'graphql.smalruby.app' : `${stage}-graphql.smalruby.app`; - const zone = route53.HostedZone.fromLookup(this, 'HostedZone', { - domainName: 'smalruby.app', - }); + // Custom Domain configuration from environment variables + const parentZoneName = process.env.ROUTE53_PARENT_ZONE_NAME || 'api.smalruby.app'; + const defaultCustomDomain = stage === 'prod' ? `graphql.${parentZoneName}` : `${stage}.graphql.${parentZoneName}`; + const customDomain = process.env.APPSYNC_CUSTOM_DOMAIN || (process.env.STAGE || this.node.tryGetContext('stage') ? defaultCustomDomain : undefined); - const certificate = new acm.Certificate(this, 'ApiCertificate', { - domainName: graphqlDomainName, - validation: acm.CertificateValidation.fromDns(zone), - }); + let domainOptions: appsync.DomainOptions | undefined; + let zone: route53.IHostedZone | undefined; - const appsyncDomainName = new appsync.CfnDomainName(this, 'AppSyncCustomDomain', { - domainName: graphqlDomainName, - certificateArn: certificate.certificateArn, - }); + if (customDomain) { + zone = route53.HostedZone.fromLookup(this, 'HostedZone', { + domainName: parentZoneName, + }); + + const certificate = new acm.Certificate(this, 'ApiCertificate', { + domainName: customDomain, + validation: acm.CertificateValidation.fromDns(zone), + }); + + domainOptions = { + certificate, + domainName: customDomain, + }; + } // Stack全体にタグ付与 cdk.Tags.of(this).add('Project', 'MeshV2'); @@ -95,6 +104,7 @@ export class MeshV2Stack extends cdk.Stack { this.api = new appsync.GraphqlApi(this, 'MeshV2Api', { name: `MeshV2Api${stageSuffix}`, definition: appsync.Definition.fromFile(path.join(__dirname, '../graphql/schema.graphql')), + domainName: domainOptions, authorizationConfig: { defaultAuthorization: { authorizationType: appsync.AuthorizationType.API_KEY, @@ -118,18 +128,14 @@ export class MeshV2Stack extends cdk.Stack { }, }); - // Associate Custom Domain with API - new appsync.CfnDomainNameApiAssociation(this, 'ApiAssociation', { - apiId: this.api.apiId, - domainName: appsyncDomainName.attrDomainName, - }); - - // Route53 CNAME record for Custom Domain - new route53.CnameRecord(this, 'ApiCnameRecord', { - zone, - recordName: graphqlDomainName, - domainName: appsyncDomainName.attrAppSyncDomainName, - }); + // Route53 Alias record for Custom Domain + if (customDomain && zone) { + new route53.ARecord(this, 'ApiAliasRecord', { + zone, + recordName: customDomain, + target: route53.RecordTarget.fromAlias(new targets.AppSyncTarget(this.api)), + }); + } // AppSync APIにタグ付与 cdk.Tags.of(this.api).add('ResourceType', 'GraphQLAPI'); @@ -453,9 +459,11 @@ export class MeshV2Stack extends cdk.Stack { }); // Output Custom Domain URL - new cdk.CfnOutput(this, 'CustomDomainUrl', { - value: `https://${graphqlDomainName}/graphql`, - description: 'AppSync Custom Domain URL', - }); + if (customDomain) { + new cdk.CfnOutput(this, 'CustomDomainUrl', { + value: `https://${customDomain}/graphql`, + description: 'AppSync Custom Domain URL', + }); + } } } From eafb2a1b5a4391b280807dfef8bd6a5e4d0d56d3 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 10 Jan 2026 14:07:02 +0900 Subject: [PATCH 3/4] fix: address review comments on ARecord recordName and custom domain logic - Corrected ARecord recordName to use relative subdomain instead of FQDN - Simplified custom domain optionality logic and allowed disabling via APPSYNC_CUSTOM_DOMAIN=false --- lib/mesh-v2-stack.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/mesh-v2-stack.ts b/lib/mesh-v2-stack.ts index d5fa692..da65814 100644 --- a/lib/mesh-v2-stack.ts +++ b/lib/mesh-v2-stack.ts @@ -22,7 +22,9 @@ export class MeshV2Stack extends cdk.Stack { // Custom Domain configuration from environment variables const parentZoneName = process.env.ROUTE53_PARENT_ZONE_NAME || 'api.smalruby.app'; const defaultCustomDomain = stage === 'prod' ? `graphql.${parentZoneName}` : `${stage}.graphql.${parentZoneName}`; - const customDomain = process.env.APPSYNC_CUSTOM_DOMAIN || (process.env.STAGE || this.node.tryGetContext('stage') ? defaultCustomDomain : undefined); + const customDomain = process.env.APPSYNC_CUSTOM_DOMAIN === 'false' + ? undefined + : (process.env.APPSYNC_CUSTOM_DOMAIN || defaultCustomDomain); let domainOptions: appsync.DomainOptions | undefined; let zone: route53.IHostedZone | undefined; @@ -130,9 +132,12 @@ export class MeshV2Stack extends cdk.Stack { // Route53 Alias record for Custom Domain if (customDomain && zone) { + // Extract subdomain from customDomain (e.g., "graphql.api.smalruby.app" -> "graphql") + const subdomain = customDomain.replace(`.${parentZoneName}`, ''); + new route53.ARecord(this, 'ApiAliasRecord', { zone, - recordName: customDomain, + recordName: subdomain, target: route53.RecordTarget.fromAlias(new targets.AppSyncTarget(this.api)), }); } From e632b23a14b2a72da8fcf2daad361b119f4329b2 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 10 Jan 2026 18:08:45 +0900 Subject: [PATCH 4/4] fix: fix WebSocket test for custom domains and CI synthesis failure - Update AppSyncSubscriptionHelper to correctly construct WebSocket URLs for custom domains - Append /realtime to custom domain WebSocket endpoints - Use DnsValidatedCertificate for cross-region certificate support - Skip Route53 lookup in CI by setting APPSYNC_CUSTOM_DOMAIN=false in ci.yml - Update manual WebSocket testing guide to use correct /realtime path --- .github/workflows/ci.yml | 2 ++ cdk.context.json | 6 +++++- lib/mesh-v2-stack.ts | 5 +++-- spec/requests/subscriptions_spec.rb | 2 +- spec/support/appsync_subscription_helper.rb | 13 ++++++++++--- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e51e476..9dd035f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,8 @@ jobs: - name: Synthesize CDK stack run: npx cdk synth --context stage=stg + env: + APPSYNC_CUSTOM_DOMAIN: 'false' security: name: Security Audits diff --git a/cdk.context.json b/cdk.context.json index e26be24..4be60ea 100644 --- a/cdk.context.json +++ b/cdk.context.json @@ -1,5 +1,9 @@ { "acknowledged-issue-numbers": [ 34892 - ] + ], + "hosted-zone:account=007325983811:domainName=api.smalruby.app:region=ap-northeast-1": { + "Id": "/hostedzone/Z04680371PP076PW332Q5", + "Name": "api.smalruby.app." + } } diff --git a/lib/mesh-v2-stack.ts b/lib/mesh-v2-stack.ts index da65814..15e7c6a 100644 --- a/lib/mesh-v2-stack.ts +++ b/lib/mesh-v2-stack.ts @@ -34,9 +34,10 @@ export class MeshV2Stack extends cdk.Stack { domainName: parentZoneName, }); - const certificate = new acm.Certificate(this, 'ApiCertificate', { + const certificate = new acm.DnsValidatedCertificate(this, 'ApiCertificate', { domainName: customDomain, - validation: acm.CertificateValidation.fromDns(zone), + hostedZone: zone, + region: 'us-east-1', }); domainOptions = { diff --git a/spec/requests/subscriptions_spec.rb b/spec/requests/subscriptions_spec.rb index aea171b..7deb3c1 100644 --- a/spec/requests/subscriptions_spec.rb +++ b/spec/requests/subscriptions_spec.rb @@ -138,7 +138,7 @@ puts "2. Connect to AppSync WebSocket endpoint:" puts " API_URL='#{ENV["APPSYNC_ENDPOINT"]}'" puts " API_KEY='#{ENV["APPSYNC_API_KEY"]}'" - puts " WS_URL=$(echo $API_URL | sed 's/https:/wss:/g' | sed 's/graphql$/graphql\\/connect/g')" + puts " WS_URL=$(echo $API_URL | sed 's/https:/wss:/g' | sed 's/graphql$/graphql\\/realtime/g')" puts "" puts "3. Subscribe to onMessageInGroup:" puts " subscription {" diff --git a/spec/support/appsync_subscription_helper.rb b/spec/support/appsync_subscription_helper.rb index 9d3f43a..f1f6986 100644 --- a/spec/support/appsync_subscription_helper.rb +++ b/spec/support/appsync_subscription_helper.rb @@ -132,9 +132,16 @@ def subscribe_and_execute(query, variables = {}, wait_time: 2, timeout: 15, &blo private def convert_to_websocket_endpoint(https_endpoint) - https_endpoint - .sub("https://", "wss://") - .sub(".appsync-api.", ".appsync-realtime-api.") + if https_endpoint.include?(".appsync-api.") + https_endpoint + .sub("https://", "wss://") + .sub(".appsync-api.", ".appsync-realtime-api.") + else + # For custom domains, AppSync WebSocket endpoint requires /realtime path + https_endpoint + .sub("https://", "wss://") + .sub(/\/graphql\/?$/, "/graphql/realtime") + end end def extract_host(endpoint)