aws-mobile-appsync-sdk-js: Custom domains do not work with pure websockets

Do you want to request a feature or report a bug? Bug

What is the current behavior? The current implementation requires that you specify a single AWS-supplied GraphQL URL in the config and then the client discovers the wss:// URL by replacing the AWS-specific “appsync-api” string with “appsync-realtime-api”. These differing hostnames clearly matter as they resolve to different addresses, but since a real-time URL cannot currently be specified in the configuration, it will attempt this same “discovery” on a custom URL.

If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem. Create a CloudFront distribution that points to your AppSync API hostname and attempt to use subscriptions. They cannot connect because the pure websockets implementation requires connecting to the appsync-realtime-api endpoint.

What is the expected behavior? Allow specifying a separate real-time URL in the config so custom domain subscription (websocket) connections can be made.

Which versions and which environment (browser, react-native, nodejs) / OS are affected by this issue? Did this work in previous versions? v3.0.2

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 15
  • Comments: 16 (1 by maintainers)

Most upvoted comments

@0xdevalias Thanks for the reminder. It is not polished, but I went ahead and threw the relevant portions for creating the WebSocketLink into a gist for those that are interested. The goal was to not fork the existings libs so as mentioned before all modifications were made outside of the library through a combination of callbacks, middleware, and extending the SubscriptionClient from subscriptions-transport-ws. It might need tweaking based on your needs but hopefully it can get you pointed in the right direction.

https://gist.github.com/zachboyd/f5630736b0a5a9b627d61bfd25299c90

AppSync now supports custom domain names. See details in this blog post and visit the docs

@zachboyd That sounds awesome. So where do you make modifications of the payload? Inside the subscriptions-transport-ws library? Is there any chance to opensource your solution? Thanks!

@bpceee All modifications were made outside of the library through a combination of callbacks, middleware, and extending the SubscriptionClient from subscriptions-transport-ws. We are still working on a few things and then would be happy to open-source the solution for those interested.

@scanning That is the same approach we took in regards to cloudfront.

@bensie Over the past few days I worked with the new realtime endpoint (pure websocket) behind a custom domain. There were a few nuances to be aware of that would require changes to this library. In our case we use JWT token for authentication and a host value that is included in a header query string parameter needs to be the default graphql endpoint (not the realtime) or you cannot successfully authenticate. This is why you have to pass in the normal endpoint and the library then translates the actual websocket url into the realtime url. In the end, we scrapped this lib due to crashes and moved to using https://github.com/apollographql/subscriptions-transport-ws which has proven to be more reliable. This took modifications since we are now responsible for generating the header and payload query parameters that are appended to the realtime endpoint url for the connection as well as extending the lib to support a custom start_ack message that the appsync gql server sends to the client. Not sure if it will be best in the long run but there does seem to be a larger community behind it.

I’m happy to share the origin-req lambda I cooked up. If the client adds the ws=true and authorization=<token> to the query string then this will change the custom origin domain to use the realtime one and add the required headers, etc.

'use strict'

process.env.NODE_ENV = 'production'

const realtimeSearchParam = 'ws'
const authorizationSearchParam = 'authorization'

const handler = (event, context, callback) => {
  const request = event.Records[0].cf.request
  const searchParams = new URLSearchParams(request.querystring)

  if (!isRealtimeReq(searchParams)) return callback(null, request)

  const { domainName } = request.origin.custom
  request.origin.custom.domainName = toRealtimeDomain(domainName)
  request.headers.host = [{ key: 'Host', value: domainName }]
  request.querystring = getQuerystring(request, searchParams)

  return callback(null, request)
}

const isRealtimeReq = (searchParams) => {
  const param = searchParams.get(realtimeSearchParam)
  return param && param.toString() === 'true'
}

const toRealtimeDomain = (domainName) =>
  domainName.replace('appsync-api', 'appsync-realtime-api')

const getQuerystring = (request, searchParams) => {
  const hostHeader = request.headers.host
  const authorization = searchParams.get(authorizationSearchParam)

  const headerObj = {}
  if (hostHeader && hostHeader[0]) headerObj.host = hostHeader[0].value
  if (authorization) headerObj.authorization = authorization
  const headerJson = JSON.stringify(headerObj)
  const headerBase64 = Buffer.from(headerJson).toString('base64')

  const payloadBase64 = Buffer.from('{}').toString('base64')

  searchParams.set('header', headerBase64)
  searchParams.set('payload', payloadBase64)
  searchParams.delete(realtimeSearchParam)
  searchParams.delete(authorizationSearchParam)

  return searchParams.toString()
}

exports.handler = handler