├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── OFFLINE_HELPERS.md ├── README.md ├── lerna.json ├── package.json └── packages ├── .DS_Store ├── aws-appsync-auth-link ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── README.md ├── __tests__ │ └── link │ │ └── auth-link-test.ts ├── jest.config.js ├── package.json ├── src │ ├── auth-link.ts │ ├── index.ts │ ├── platform.native.ts │ ├── platform.ts │ └── signer │ │ ├── index.ts │ │ └── signer.ts └── tsconfig.json └── aws-appsync-subscription-link ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── __tests__ └── link │ └── realtime-subscription-handshake-link-test.ts ├── jest.config.js ├── package.json ├── src ├── index.ts ├── non-terminating-http-link.ts ├── non-terminating-link.ts ├── realtime-subscription-handshake-link.ts ├── subscription-handshake-link.ts ├── types │ └── index.ts ├── utils │ ├── index.ts │ ├── logger.ts │ └── retry.ts └── vendor │ └── paho-mqtt.js └── tsconfig.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @awslabs/amplify-data 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | > Note: If your issue/feature-request/question is regarding the AWS AppSync service, please log it in the 2 | [official AWS AppSync forum](https://forums.aws.amazon.com/forum.jspa?forumID=280&start=0) 3 | 4 | **Do you want to request a *feature* or report a *bug*?** 5 | 6 | **What is the current behavior?** 7 | 8 | **If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem.** 9 | 10 | **What is the expected behavior?** 11 | 12 | **Which versions and which environment (browser, react-native, nodejs) / OS are affected by this issue? Did this work in previous versions?** 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.tgz 4 | lerna-debug.log 5 | yarn.lock 6 | .npmrc 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "name": "vscode-jest-tests", 10 | "request": "launch", 11 | "args": [ 12 | "--config=${workspaceFolder}/packages/aws-appsync/jest.config.js", 13 | "--runInBand" 14 | ], 15 | "cwd": "${workspaceFolder}", 16 | "console": "integratedTerminal", 17 | "internalConsoleOptions": "neverOpen", 18 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/node_modules": true 9 | }, 10 | "search.exclude": { 11 | "**/node_modules": true, 12 | "**/bower_components": true, 13 | "**/lib": true 14 | }, 15 | "typescript.tsdk": "node_modules/typescript/lib" 16 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues), or [recently closed](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/awslabs/aws-mobile-appsync-sdk-js/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/awslabs/aws-mobile-appsync-sdk-js/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | AWS Mobile AppSync SDK JavaScript 2 | Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /OFFLINE_HELPERS.md: -------------------------------------------------------------------------------- 1 | # Offline Helpers 2 | 3 | The SDK provides the following helpers: 4 | - `graphqlMutation` 5 | - `buildSubscription` 6 | 7 | By default, the helpers look at the GraphQL operation name of the subscription/mutation and modify the cached results of the queries provided by either **adding**, **removing** or **updating** items in the result. 8 | 9 | --- 10 | 11 | ## `graphqlMutation` 12 | ### Import 13 | ```javascript 14 | import { graphqlMutation } from 'aws-appsync-react'; 15 | ``` 16 | 17 | ### Signature 18 | ```typescript 19 | graphqlMutation( 20 | mutation: DocumentNode, 21 | cacheUpdateQuery: CacheUpdatesOptions, 22 | typename: string, 23 | idField?: string, 24 | operationType?: CacheOperationTypes 25 | ): React.Component 26 | ``` 27 | 28 | ### Parameters 29 | - `mutation: DocumentNode` - A DocumentNode for the GraphQL mutation 30 | - `cacheUpdateQuery: CacheUpdatesOptions` - The queries for which the result needs to be updated 31 | - `typename: string` - Type name of the result of your mutation (__typename from your GraphQL schema) 32 | - (Optional) `idField: string` - Name of the field used to uniquely identify your records 33 | - (Optional) `operationType: CacheOperationTypes` - One of `'auto'`, `'add'`, `'remove'`, `'update'`. 34 | - Returns `React.Component` - A react HOC with a prop named after the graphql mutation (e.g. `this.props.addTodo`) 35 | 36 | --- 37 | 38 | ## `buildSubscription` 39 | 40 | Builds a SubscribeToMoreOptions object ready to be used by Apollo's `subscribeToMore()` to automatically update the query result in the cache according to the `cacheUpdateQuery` parameter 41 | 42 | ### Import 43 | ```javascript 44 | import { buildSubscription } from "aws-appsync"; 45 | ``` 46 | 47 | ### Signature 48 | ```typescript 49 | buildSubscription( 50 | subscriptionQuery: CacheUpdateQuery, 51 | cacheUpdateQuery: CacheUpdateQuery, 52 | idField?: string, 53 | operationType?: CacheOperationTypes 54 | ): SubscribeToMoreOptions 55 | ``` 56 | 57 | ### Parameters 58 | - `subscriptionQuery: CacheUpdateQuery` - The GraphQL subscription DocumentNode or CacheUpdateQuery 59 | - `cacheUpdateQuery: CacheUpdateQuery` - The query for which the result needs to be updated 60 | - (Optional) `idField: string` 61 | - (Optional) `operationType: CacheOperationTypes` - One of `'auto'`, `'add'`, `'remove'`, `'update'`. 62 | - Returns `SubscribeToMoreOptions` - Object expected by `subscribeToMore()` 63 | 64 | --- 65 | 66 | ## Actions and their list of prefixes when using `CacheOperationTypes.AUTO` 67 | | add | remove | update | 68 | | ---- | ---- | ---- | 69 | |create | delete | update 70 | |created | deleted | updated 71 | |put | discard | upsert 72 | |set | discarded | upserted 73 | |add | erase | edit 74 | |added | erased | edited 75 | |new | remove | modify 76 | |insert | removed | modified 77 | |inserted | | 78 | 79 | --- 80 | 81 | ## Examples 82 | 83 | ## Different ways `CacheUpdatesOptions` can be provided 84 | \* (All lines are equivalent) 85 | 86 | ```javascript 87 | // Passing a DocumentNode 88 | graphqlMutation(NewTodo, ListTodos) 89 | 90 | // Passing a QueryWithVariables 91 | graphqlMutation(NewTodo, { query: ListTodos }) 92 | 93 | // Passing an array of DocumentNode 94 | graphqlMutation(NewTodo, [ ListTodos ]) 95 | 96 | // Passing an array of QueryWithVariables 97 | graphqlMutation(NewTodo, [ { query: ListTodos, variables: {} } ]) 98 | 99 | // Passing an object 100 | graphqlMutation(NewTodo, { 'auto': [ ListTodos ] }) 101 | 102 | // Passing a function that returns an object 103 | graphqlMutation(NewTodo, (vars) => { 104 | return { 'auto': [ ListTodos ] }; 105 | }) 106 | ``` 107 | 108 | --- 109 | 110 | ## Types reference 111 | ```typescript 112 | enum CacheOperationTypes { 113 | AUTO = 'auto', 114 | ADD = 'add', 115 | REMOVE = 'remove', 116 | UPDATE = 'update', 117 | }; 118 | 119 | type CacheUpdatesOptions = (variables?: object) => CacheUpdatesDefinitions | CacheUpdatesDefinitions; 120 | 121 | type CacheUpdatesDefinitions = { 122 | [key in CacheOperationTypes]?: CacheUpdateQuery | CacheUpdateQuery[] 123 | }; 124 | 125 | type CacheUpdateQuery = QueryWithVariables | DocumentNode; // DocumentNode is an object return by the gql`` function 126 | 127 | type QueryWithVariables = { 128 | query: DocumentNode, 129 | variables?: object, 130 | }; 131 | ``` 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Use AWS AppSync with JavaScript apps · [![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lernajs.io/) 2 | 3 | ![AWS AppSync](https://s3.amazonaws.com/aws-mobile-hub-images/awsappsyncgithub.png) 4 | 5 | [AWS AppSync](https://aws.amazon.com/appsync/) is a fully managed service that makes it easy to develop GraphQL APIs by handling the heavy lifting of securely connecting to data sources like AWS DynamoDB, Lambda, and more. 6 | 7 | You can use any HTTP or GraphQL client to connect to a GraphQL API on AppSync. 8 | 9 | For front-end web and mobile development, we recommend using the [AWS Amplify library](https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/js/) which is optimized to connect to the AppSync backend. 10 | 11 | - For DynamoDB data sources where conflict detection and resolution are enabled on AppSync, use the [DataStore category in the Amplify library](https://docs.amplify.aws/lib/datastore/getting-started/q/platform/js/). 12 | - For non-DynamoDB data sources in scenarios where you have no offline requirements, use the [API (GraphQL) category in the Amplify library](https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/js/). 13 | - If you want to use the Apollo V3 client, use the Apollo Links in this repository to help with authorization and subscriptions. 14 | 15 | **Looking for the AWS AppSync SDK for JavaScript (built on Apollo v2)?** AWS AppSync SDK for JavaScript (V2) is now in Maintenance Mode until June 30th, 2024. This means that we will continue to include updates to ensure compatibility with backend services and security. No new features will be introduced in the AWS AppSync SDK for JavaScript (V2). Please review the [upgrade guide](https://docs.amplify.aws/lib/graphqlapi/upgrade-guide/q/platform/js) for recommended next steps. 16 | 17 | ## [AWS AppSync](https://aws.amazon.com/appsync/) Links for Apollo V3 (Maintenance mode) 18 | 19 | If you would like to use the [Apollo JavaScript client version 3](https://www.apollographql.com/docs/react/) to connect to your AppSync GraphQL API, this repository (on the current stable branch) provides Apollo links to use the different AppSync authorization modes, and to setup subscriptions over web sockets. Please log questions for this client SDK in this repo and questions for the AppSync service in the [official AWS AppSync forum](https://forums.aws.amazon.com/forum.jspa?forumID=280&start=0) . 20 | 21 | ![npm](https://img.shields.io/npm/dm/aws-appsync-auth-link.svg) 22 | ![npm](https://img.shields.io/npm/dm/aws-appsync-subscription-link.svg) 23 | 24 | | package | version | 25 | | ----------------------------- | ---------------------------------------------------------------------- | 26 | | aws-appsync-auth-link | ![npm](https://img.shields.io/npm/v/aws-appsync-auth-link.svg) | 27 | | aws-appsync-subscription-link | ![npm](https://img.shields.io/npm/v/aws-appsync-subscription-link.svg) | 28 | 29 | [Example usage of Apollo V3 links](#using-authorization-and-subscription-links-with-apollo-client-v3-no-offline-support) 30 | 31 | ### React / React Native 32 | 33 | For more documentation on `graphql` operations performed by React Apollo see their [documentation](https://www.apollographql.com/docs/react/). 34 | 35 | ### Using Authorization and Subscription links with Apollo Client V3 (No offline support) 36 | 37 | For versions of the Apollo client newer than 2.4.6 you can use custom links for Authorization and Subscriptions. Offline support is not available for these newer versions. The packages available are 38 | `aws-appsync-auth-link` and `aws-appsync-subscription-link`. Below is a sample code snippet that shows how to use it. 39 | 40 | ```javascript 41 | import { createAuthLink } from "aws-appsync-auth-link"; 42 | import { createSubscriptionHandshakeLink } from "aws-appsync-subscription-link"; 43 | 44 | import { 45 | ApolloProvider, 46 | ApolloClient, 47 | InMemoryCache, 48 | HttpLink, 49 | ApolloLink, 50 | } from "@apollo/client"; 51 | 52 | import appSyncConfig from "./aws-exports"; 53 | 54 | /* The HTTPS endpoint of the AWS AppSync API 55 | (e.g. *https://aaaaaaaaaaaaaaaaaaaaaaaaaa.appsync-api.us-east-1.amazonaws.com/graphql*). 56 | [Custom domain names](https://docs.aws.amazon.com/appsync/latest/devguide/custom-domain-name.html) can also be supplied here (e.g. *https://api.yourdomain.com/graphql*). 57 | Custom domain names can have any format, but must end with `/graphql` 58 | (see https://graphql.org/learn/serving-over-http/#uris-routes). */ 59 | const url = appSyncConfig.aws_appsync_graphqlEndpoint; 60 | 61 | 62 | const region = appSyncConfig.aws_appsync_region; 63 | 64 | const auth = { 65 | type: appSyncConfig.aws_appsync_authenticationType, 66 | apiKey: appSyncConfig.aws_appsync_apiKey, 67 | // jwtToken: async () => token, // Required when you use Cognito UserPools OR OpenID Connect. token object is obtained previously 68 | // credentials: async () => credentials, // Required when you use IAM-based auth. 69 | }; 70 | 71 | const httpLink = new HttpLink({ uri: url }); 72 | 73 | const link = ApolloLink.from([ 74 | createAuthLink({ url, region, auth }), 75 | createSubscriptionHandshakeLink({ url, region, auth }, httpLink), 76 | ]); 77 | 78 | const client = new ApolloClient({ 79 | link, 80 | cache: new InMemoryCache(), 81 | }); 82 | 83 | const ApolloWrapper = ({ children }) => { 84 | return {children}; 85 | }; 86 | ``` 87 | 88 | ### Queries and Subscriptions using Apollo V3 89 | 90 | ```js 91 | import React, { useState, useEffect } from "react"; 92 | import { gql, useSubscription } from "@apollo/client"; 93 | import { useMutation, useQuery } from "@apollo/client"; 94 | import { v4 as uuidv4 } from "uuid"; 95 | 96 | const initialState = { name: "", description: "" }; 97 | 98 | const App = () => { 99 | 100 | const LIST_TODOS = gql` 101 | query listTodos { 102 | listTodos { 103 | items { 104 | id 105 | name 106 | description 107 | } 108 | } 109 | } 110 | `; 111 | 112 | const { 113 | loading: listLoading, 114 | data: listData, 115 | error: listError, 116 | } = useQuery(LIST_TODOS); 117 | 118 | const CREATE_TODO = gql` 119 | mutation createTodo($input: CreateTodoInput!) { 120 | createTodo(input: $input) { 121 | id 122 | name 123 | description 124 | } 125 | } 126 | `; 127 | 128 | // https://www.apollographql.com/docs/react/data/mutations/ 129 | const [addTodoMutateFunction, { error: createError }] = 130 | useMutation(CREATE_TODO); 131 | 132 | async function addTodo() { 133 | try { 134 | addTodoMutateFunction({ variables: { input: { todo } } }); 135 | } catch (err) { 136 | console.log("error creating todo:", err); 137 | } 138 | } 139 | 140 | const DELETE_TODO = gql` 141 | mutation deleteTodo($input: DeleteTodoInput!) { 142 | deleteTodo(input: $input) { 143 | id 144 | name 145 | description 146 | } 147 | } 148 | `; 149 | 150 | const [deleteTodoMutateFunction] = useMutation(DELETE_TODO, { 151 | refetchQueries: [LIST_TODOS, "listTodos"], 152 | }); 153 | 154 | async function removeTodo(id) { 155 | try { 156 | deleteTodoMutateFunction({ variables: { input: { id } } }); 157 | } catch (err) { 158 | console.log("error deleting todo:", err); 159 | } 160 | } 161 | 162 | const CREATE_TODO_SUBSCRIPTION = gql` 163 | subscription OnCreateTodo { 164 | onCreateTodo { 165 | id 166 | name 167 | description 168 | } 169 | } 170 | `; 171 | 172 | const { data: createSubData, error: createSubError } = useSubscription( 173 | CREATE_TODO_SUBSCRIPTION 174 | ); 175 | 176 | return ( 177 | // Render TODOs 178 | ); 179 | }; 180 | 181 | export default App; 182 | ``` 183 | 184 | 185 | --- 186 | 187 | ## [AWS AppSync](https://aws.amazon.com/appsync/) JavaScript SDK based on Apollo V2 (Maintenance mode) 188 | 189 | The `aws-appsync` and `aws-appsync-react` packages work with the [Apollo client version 2](https://www.apollographql.com/docs/react/v2) and provide offline capabilities. 190 | 191 | **Note:** if you do not have any offline requirements in your app, we recommend using the [Amplify libraries](https://aws-amplify.github.io/). 192 | 193 | ![npm](https://img.shields.io/npm/dm/aws-appsync.svg) 194 | 195 | | package | version | 196 | | ----------------- | ---------------------------------------------------------- | 197 | | aws-appsync | ![npm](https://img.shields.io/npm/v/aws-appsync.svg) | 198 | | aws-appsync-react | ![npm](https://img.shields.io/npm/v/aws-appsync-react.svg) | 199 | 200 | ### Installation 201 | 202 | #### npm 203 | 204 | ```sh 205 | npm install --save aws-appsync 206 | ``` 207 | 208 | #### yarn 209 | 210 | ```sh 211 | yarn add aws-appsync 212 | ``` 213 | 214 | #### React Native Compatibility 215 | 216 | When using this library with React Native, you need to ensure you are using the correct version of the library based on your version of React Native. Take a look at the table below to determine what version to use. 217 | 218 | | `aws-appsync` version | Required React Native Version | 219 | | --------------------- | ----------------------------- | 220 | | `2.x.x` | `>= 0.60` | 221 | | `1.x.x` | `<= 0.59` | 222 | 223 | If you are using React Native `0.60` and above, you also need to install `@react-native-community/netinfo` and `@react-native-community/async-storage`: 224 | 225 | ```sh 226 | npm install --save @react-native-community/netinfo@5.9.4 @react-native-community/async-storage 227 | ``` 228 | 229 | or 230 | 231 | ```sh 232 | yarn add @react-native-community/netinfo@5.9.4 @react-native-community/async-storage 233 | ``` 234 | 235 | If you are using React Native `0.60+` for iOS, run the following command as an additional step: 236 | 237 | ```sh 238 | npx pod-install 239 | ``` 240 | 241 | ### Creating a client with AppSync SDK for JavaScript V2 (Maintenance mode) 242 | 243 | ```js 244 | import AWSAppSyncClient from "aws-appsync"; 245 | import AppSyncConfig from "./aws-exports"; 246 | import { ApolloProvider } from "react-apollo"; 247 | import { Rehydrated } from "aws-appsync-react"; // this needs to also be installed when working with React 248 | import App from "./App"; 249 | 250 | const client = new AWSAppSyncClient({ 251 | /* The HTTPS endpoint of the AWS AppSync API 252 | (e.g. *https://aaaaaaaaaaaaaaaaaaaaaaaaaa.appsync-api.us-east-1.amazonaws.com/graphql*). 253 | [Custom domain names](https://docs.aws.amazon.com/appsync/latest/devguide/custom-domain-name.html) can also be supplied here (e.g. *https://api.yourdomain.com/graphql*). 254 | Custom domain names can have any format, but must end with `/graphql` 255 | (see https://graphql.org/learn/serving-over-http/#uris-routes). */ 256 | url: AppSyncConfig.aws_appsync_graphqlEndpoint, 257 | region: AppSyncConfig.aws_appsync_region, 258 | auth: { 259 | type: AppSyncConfig.aws_appsync_authenticationType, 260 | apiKey: AppSyncConfig.aws_appsync_apiKey, 261 | // jwtToken: async () => token, // Required when you use Cognito UserPools OR OpenID Connect. Token object is obtained previously 262 | // credentials: async () => credentials, // Required when you use IAM-based auth. 263 | }, 264 | }); 265 | 266 | const WithProvider = () => ( 267 | 268 | 269 | 270 | 271 | 272 | ); 273 | 274 | export default WithProvider; 275 | ``` 276 | 277 | #### Queries 278 | 279 | ```js 280 | import gql from "graphql-tag"; 281 | import { graphql } from "react-apollo"; 282 | 283 | const listPosts = gql` 284 | query listPosts { 285 | listPosts { 286 | items { 287 | id 288 | name 289 | } 290 | } 291 | } 292 | `; 293 | class App extends Component { 294 | render() { 295 | return ( 296 |
297 | {this.props.posts.map((post, index) => ( 298 |

{post.name}

299 | ))} 300 |
301 | ); 302 | } 303 | } 304 | 305 | export default graphql(listPosts, { 306 | options: { 307 | fetchPolicy: "cache-and-network", 308 | }, 309 | props: (props) => ({ 310 | posts: props.data.listPosts ? props.data.listPosts.items : [], 311 | }), 312 | })(App); 313 | ``` 314 | 315 | #### Mutations & optimistic UI (with graphqlMutation helper) 316 | 317 | ```js 318 | import gql from "graphql-tag"; 319 | import { graphql, compose } from "react-apollo"; 320 | import { graphqlMutation } from "aws-appsync-react"; 321 | 322 | const CreatePost = gql` 323 | mutation createPost($name: String!) { 324 | createPost(input: { name: $name }) { 325 | name 326 | } 327 | } 328 | `; 329 | 330 | class App extends Component { 331 | state = { name: "" }; 332 | onChange = (e) => { 333 | this.setState({ name: e.target.value }); 334 | }; 335 | addTodo = () => this.props.createPost({ name: this.state.name }); 336 | render() { 337 | return ( 338 |
339 | 340 | 341 | {this.props.posts.map((post, index) => ( 342 |

{post.name}

343 | ))} 344 |
345 | ); 346 | } 347 | } 348 | 349 | export default compose( 350 | graphql(listPosts, { 351 | options: { 352 | fetchPolicy: "cache-and-network", 353 | }, 354 | props: (props) => ({ 355 | posts: props.data.listPosts ? props.data.listPosts.items : [], 356 | }), 357 | }), 358 | graphqlMutation(CreatePost, listPosts, "Post") 359 | )(App); 360 | ``` 361 | 362 | #### Mutations & optimistic UI (without graphqlMutation helper) 363 | 364 | ```js 365 | import gql from "graphql-tag"; 366 | import uuidV4 from "uuid/v4"; 367 | import { graphql, compose } from "react-apollo"; 368 | 369 | const CreatePost = gql` 370 | mutation createPost($name: String!) { 371 | createPost(input: { name: $name }) { 372 | name 373 | } 374 | } 375 | `; 376 | 377 | class App extends Component { 378 | state = { name: "" }; 379 | onChange = (e) => { 380 | this.setState({ name: e.target.value }); 381 | }; 382 | addTodo = () => this.props.onAdd({ id: uuidV4(), name: this.state.name }); 383 | render() { 384 | return ( 385 |
386 | 387 | 388 | {this.props.posts.map((post, index) => ( 389 |

{post.name}

390 | ))} 391 |
392 | ); 393 | } 394 | } 395 | 396 | export default compose( 397 | graphql(listPosts, { 398 | options: { 399 | fetchPolicy: "cache-and-network", 400 | }, 401 | props: (props) => ({ 402 | posts: props.data.listPosts ? props.data.listPosts.items : [], 403 | }), 404 | }), 405 | graphql(CreatePost, { 406 | options: { 407 | update: (dataProxy, { data: { createPost } }) => { 408 | const query = listPosts; 409 | const data = dataProxy.readQuery({ query }); 410 | data.listPosts.items.push(createPost); 411 | dataProxy.writeQuery({ query, data }); 412 | }, 413 | }, 414 | props: (props) => ({ 415 | onAdd: (post) => { 416 | props.mutate({ 417 | variables: post, 418 | optimisticResponse: () => ({ 419 | createPost: { ...post, __typename: "Post" }, 420 | }), 421 | }); 422 | }, 423 | }), 424 | }) 425 | )(App); 426 | ``` 427 | 428 | #### Subscriptions (with buildSubscription helper) 429 | 430 | ```js 431 | import gql from "graphql-tag"; 432 | import { graphql } from "react-apollo"; 433 | import { buildSubscription } from "aws-appsync"; 434 | 435 | const listPosts = gql` 436 | query listPosts { 437 | listPosts { 438 | items { 439 | id 440 | name 441 | } 442 | } 443 | } 444 | `; 445 | 446 | const PostSubscription = gql` 447 | subscription postSubscription { 448 | onCreatePost { 449 | id 450 | name 451 | } 452 | } 453 | `; 454 | 455 | class App extends React.Component { 456 | componentDidMount() { 457 | this.props.data.subscribeToMore( 458 | buildSubscription(PostSubscription, listPosts) 459 | ); 460 | } 461 | render() { 462 | return ( 463 |
464 | {this.props.posts.map((post, index) => ( 465 |

{post.name}

466 | ))} 467 |
468 | ); 469 | } 470 | } 471 | 472 | export default graphql(listPosts, { 473 | options: { 474 | fetchPolicy: "cache-and-network", 475 | }, 476 | props: (props) => ({ 477 | posts: props.data.listPosts ? props.data.listPosts.items : [], 478 | data: props.data, 479 | }), 480 | })(App); 481 | ``` 482 | 483 | #### Subscriptions (without buildSubscription helper) 484 | 485 | ```js 486 | import gql from "graphql-tag"; 487 | import { graphql } from "react-apollo"; 488 | 489 | const listPosts = gql` 490 | query listPosts { 491 | listPosts { 492 | items { 493 | id 494 | name 495 | } 496 | } 497 | } 498 | `; 499 | 500 | const PostSubscription = gql` 501 | subscription postSubscription { 502 | onCreatePost { 503 | id 504 | name 505 | } 506 | } 507 | `; 508 | 509 | class App extends React.Component { 510 | componentDidMount() { 511 | this.props.subscribeToNewPosts(); 512 | } 513 | render() { 514 | return ( 515 |
516 | {this.props.posts.map((post, index) => ( 517 |

{post.name}

518 | ))} 519 |
520 | ); 521 | } 522 | } 523 | 524 | export default graphql(listPosts, { 525 | options: { 526 | fetchPolicy: "cache-and-network", 527 | }, 528 | props: (props) => ({ 529 | posts: props.data.listPosts ? props.data.listPosts.items : [], 530 | subscribeToNewPosts: (params) => { 531 | props.data.subscribeToMore({ 532 | document: PostSubscription, 533 | updateQuery: ( 534 | prev, 535 | { 536 | subscriptionData: { 537 | data: { onCreatePost }, 538 | }, 539 | } 540 | ) => ({ 541 | ...prev, 542 | listPosts: { 543 | __typename: "PostConnection", 544 | items: [ 545 | onCreatePost, 546 | ...prev.listPosts.items.filter( 547 | (post) => post.id !== onCreatePost.id 548 | ), 549 | ], 550 | }, 551 | }), 552 | }); 553 | }, 554 | }), 555 | })(App); 556 | ``` 557 | 558 | ### Complex objects with AWS AppSync SDK for JavaScript (Maintenance mode) 559 | 560 | Many times you might want to create logical objects that have more complex data, such as images or videos, as part of their structure. For example, you might create a Person type with a profile picture or a Post type that has an associated image. With AWS AppSync, you can model these as GraphQL types, referred to as complex objects. If any of your mutations have a variable with bucket, key, region, mimeType and localUri fields, the SDK uploads the file to Amazon S3 for you. 561 | 562 | For a complete working example of this feature, see [aws-amplify-graphql](https://github.com/aws-samples/aws-amplify-graphql) on GitHub. 563 | 564 | If you're using AWS Amplify's GraphQL transformer, then configure your resolvers to write to DynamoDB and point at S3 objects when using the `S3Object` type. For example, run the following in an Amplify project: 565 | 566 | ```bash 567 | amplify add auth #Select default configuration 568 | amplify add storage #Select S3 with read/write access 569 | amplify add api #Select Cognito User Pool for authorization type 570 | ``` 571 | 572 | When prompted, use the following schema: 573 | 574 | ```graphql 575 | type Todo @model { 576 | id: ID! 577 | name: String! 578 | description: String! 579 | file: S3Object 580 | } 581 | type S3Object { 582 | bucket: String! 583 | key: String! 584 | region: String! 585 | } 586 | input CreateTodoInput { 587 | id: ID 588 | name: String! 589 | description: String 590 | file: S3ObjectInput # This input type will be generated for you 591 | } 592 | ``` 593 | 594 | Save and run `amplify push` to deploy changes. 595 | 596 | To use complex objects you need AWS Identity and Access Management credentials for reading and writing to Amazon S3 which `amplify add auth` configures in the default setting along with a Cognito user pool. These can be separate from the other auth credentials you use in your AWS AppSync client. Credentials for complex objects are set using the `complexObjectsCredentials` parameter, which you can use with AWS Amplify and the complex objects feature like so: 597 | 598 | ```javascript 599 | const client = new AWSAppSyncClient({ 600 | url: ENDPOINT, 601 | region: REGION, 602 | auth: { ... }, //Can be User Pools or API Key 603 | complexObjectsCredentials: () => Auth.currentCredentials(), 604 | }); 605 | (async () => { 606 | let file; 607 | if (selectedFile) { // selectedFile is the file to be uploaded, typically comes from an 608 | const { name, type: mimeType } = selectedFile; 609 | const [, , , extension] = /([^.]+)(\.(\w+))?$/.exec(name); 610 | const bucket = aws_config.aws_user_files_s3_bucket; 611 | const region = aws_config.aws_user_files_s3_bucket_region; 612 | const visibility = 'private'; 613 | const { identityId } = await Auth.currentCredentials(); 614 | const key = `${visibility}/${identityId}/${uuid()}${extension && '.'}${extension}`; 615 | file = { 616 | bucket, 617 | key, 618 | region, 619 | mimeType, 620 | localUri: selectedFile, 621 | }; 622 | } 623 | const result = await client.mutate({ 624 | mutation: gql(createTodo), 625 | variables: { 626 | input: { 627 | name: 'Upload file', 628 | description: 'Uses complex objects to upload', 629 | file: file, 630 | } 631 | } 632 | }); 633 | })(); 634 | ``` 635 | 636 | When you run the above mutation, a record will be in a DynamoDB table for your AppSync API as well as the corresponding file in an S3 bucket. 637 | 638 | ### Offline configuration with AWS AppSync SDK for JavaScript (Maintenance mode) 639 | 640 | When using the AWS AppSync SDK offline capabilities (e.g. `disableOffline: false`), you can provide configurations for the following: 641 | 642 | - Error handling 643 | - Custom storage engine 644 | 645 | #### Error handling 646 | 647 | If a mutation is done while the app was offline, it gets persisted to the platform storage engine. When coming back online, it is sent to the GraphQL endpoint. When a response is returned by the API, the SDK will notify you of the success or error using the callback provided in the `offlineConfig` param as follows: 648 | 649 | ```javascript 650 | const client = new AWSAppSyncClient({ 651 | url: appSyncConfig.graphqlEndpoint, 652 | region: appSyncConfig.region, 653 | auth: { 654 | type: appSyncConfig.authenticationType, 655 | apiKey: appSyncConfig.apiKey, 656 | }, 657 | offlineConfig: { 658 | callback: (err, succ) => { 659 | if (err) { 660 | const { mutation, variables } = err; 661 | 662 | console.warn(`ERROR for ${mutation}`, err); 663 | } else { 664 | const { mutation, variables } = succ; 665 | 666 | console.info(`SUCCESS for ${mutation}`, succ); 667 | } 668 | }, 669 | }, 670 | }); 671 | ``` 672 | 673 | #### Custom storage engine 674 | 675 | You can use any custom storage engine from the [redux-persist supported engines](https://github.com/rt2zz/redux-persist#storage-engines) list. 676 | 677 | Configuration is done as follows: (localForage shown in the example) 678 | 679 | ```javascript 680 | import * as localForage from "localforage"; 681 | 682 | const client = new AWSAppSyncClient({ 683 | url: appSyncConfig.graphqlEndpoint, 684 | region: appSyncConfig.region, 685 | auth: { 686 | type: appSyncConfig.authenticationType, 687 | apiKey: appSyncConfig.apiKey, 688 | }, 689 | offlineConfig: { 690 | storage: localForage, 691 | }, 692 | }); 693 | ``` 694 | 695 | #### Offline helpers 696 | 697 | For detailed documentation about the offline helpers, look at the [API Definition](OFFLINE_HELPERS.md). 698 | 699 | ### Vue sample with AWS AppSync SDK for JavaScript (Maintenance mode) 700 | 701 | For more documentation on Vue Apollo click [here](https://github.com/Akryum/vue-apollo). 702 | 703 | #### main.js 704 | 705 | ```js 706 | import Vue from "vue"; 707 | import App from "./App"; 708 | import router from "./router"; 709 | 710 | import AWSAppSyncClient from "aws-appsync"; 711 | import VueApollo from "vue-apollo"; 712 | import AppSyncConfig from "./aws-exports"; 713 | 714 | const config = { 715 | url: AppSyncConfig.graphqlEndpoint, 716 | region: AppSyncConfig.region, 717 | auth: { 718 | type: AppSyncConfig.authType, 719 | apiKey: AppSyncConfig.apiKey, 720 | }, 721 | }; 722 | const options = { 723 | defaultOptions: { 724 | watchQuery: { 725 | fetchPolicy: "cache-and-network", 726 | }, 727 | }, 728 | }; 729 | 730 | const client = new AWSAppSyncClient(config, options); 731 | 732 | const appsyncProvider = new VueApollo({ 733 | defaultClient: client, 734 | }); 735 | 736 | Vue.use(VueApollo); 737 | 738 | new Vue({ 739 | el: "#app", 740 | router, 741 | components: { App }, 742 | provide: appsyncProvider.provide(), 743 | template: "", 744 | }); 745 | ``` 746 | 747 | #### App.vue 748 | 749 | ```js 750 | 755 | 756 | 766 | ``` 767 | 768 | #### connected component 769 | 770 | ```js 771 | import gql from "graphql-tag"; 772 | import uuidV4 from "uuid/v4"; 773 | 774 | const CreateTask = gql` 775 | mutation createTask($id: ID!, $name: String!, $completed: Boolean!) { 776 | createTask(input: { id: $id, name: $name, completed: $completed }) { 777 | id 778 | name 779 | completed 780 | } 781 | } 782 | `; 783 | 784 | const DeleteTask = gql` 785 | mutation deleteTask($id: ID!) { 786 | deleteTask(input: { id: $id }) { 787 | id 788 | } 789 | } 790 | `; 791 | 792 | const ListTasks = gql` 793 | query listTasks { 794 | listTasks { 795 | items { 796 | id 797 | name 798 | completed 799 | } 800 | } 801 | } 802 | `; 803 | 804 | const UpdateTask = gql` 805 | mutation updateTask($id: ID!, $name: String!, $completed: Boolean!) { 806 | updateTask(input: { id: $id, name: $name, completed: $completed }) { 807 | id 808 | name 809 | completed 810 | } 811 | } 812 | `; 813 | 814 | // In your component (Examples of queries & mutations) 815 | export default { 816 | name: "Tasks", 817 | methods: { 818 | toggleComplete(task) { 819 | const updatedTask = { 820 | ...task, 821 | completed: !task.completed, 822 | }; 823 | this.$apollo 824 | .mutate({ 825 | mutation: UpdateTask, 826 | variables: updatedTask, 827 | update: (store, { data: { updateTask } }) => { 828 | const data = store.readQuery({ query: ListTasks }); 829 | const index = data.listTasks.items.findIndex( 830 | (item) => item.id === updateTask.id 831 | ); 832 | data.listTasks.items[index] = updateTask; 833 | store.writeQuery({ query: ListTasks, data }); 834 | }, 835 | optimisticResponse: { 836 | __typename: "Mutation", 837 | updateTask: { 838 | __typename: "Task", 839 | ...updatedTask, 840 | }, 841 | }, 842 | }) 843 | .then((data) => console.log(data)) 844 | .catch((error) => console.error(error)); 845 | }, 846 | deleteTask(task) { 847 | this.$apollo 848 | .mutate({ 849 | mutation: DeleteTask, 850 | variables: { 851 | id: task.id, 852 | }, 853 | update: (store, { data: { deleteTask } }) => { 854 | const data = store.readQuery({ query: ListTasks }); 855 | data.listTasks.items = data.listTasks.items.filter( 856 | (task) => task.id !== deleteTask.id 857 | ); 858 | store.writeQuery({ query: ListTasks, data }); 859 | }, 860 | optimisticResponse: { 861 | __typename: "Mutation", 862 | deleteTask: { 863 | __typename: "Task", 864 | ...task, 865 | }, 866 | }, 867 | }) 868 | .then((data) => console.log(data)) 869 | .catch((error) => console.error(error)); 870 | }, 871 | createTask() { 872 | const taskname = this.taskname; 873 | if (taskname === "") { 874 | alert("please create a task"); 875 | return; 876 | } 877 | this.taskname = ""; 878 | const id = uuidV4(); 879 | const task = { 880 | name: taskname, 881 | id, 882 | completed: false, 883 | }; 884 | this.$apollo 885 | .mutate({ 886 | mutation: CreateTask, 887 | variables: task, 888 | update: (store, { data: { createTask } }) => { 889 | const data = store.readQuery({ query: ListTasks }); 890 | data.listTasks.items.push(createTask); 891 | store.writeQuery({ query: ListTasks, data }); 892 | }, 893 | optimisticResponse: { 894 | __typename: "Mutation", 895 | createTask: { 896 | __typename: "Task", 897 | ...task, 898 | }, 899 | }, 900 | }) 901 | .then((data) => console.log(data)) 902 | .catch((error) => console.error("error!!!: ", error)); 903 | }, 904 | }, 905 | data() { 906 | return { 907 | taskname: "", 908 | tasks: [], 909 | }; 910 | }, 911 | apollo: { 912 | tasks: { 913 | query: () => ListTasks, 914 | update: (data) => data.listTasks.items, 915 | }, 916 | }, 917 | }; 918 | ``` 919 | 920 | ## Creating an AppSync Project 921 | 922 | To create a new AppSync project, go to . 923 | 924 | ## License 925 | 926 | This library is licensed under the Apache License 2.0. 927 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "2.9.0", 3 | "packages": [ 4 | "packages/*" 5 | ], 6 | "version": "independent", 7 | "npmClient": "yarn", 8 | "useWorkspaces": true 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "bootstrap": "lerna bootstrap" 5 | }, 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "resolutions": { 10 | "graphql-tag": "2.11.0", 11 | "@types/babel__traverse": "7.0.6" 12 | }, 13 | "devDependencies": { 14 | "@types/graphql": "0.12.4", 15 | "@types/jest": "24", 16 | "@types/node": "^8.0.46", 17 | "@types/zen-observable": "^0.5.3", 18 | "graphql": "15.6.0", 19 | "graphql-tag": "2.11.0", 20 | "jest": "24", 21 | "lerna": "^2.9.0", 22 | "node-fetch": "^2.2.0", 23 | "ts-jest": "24", 24 | "typescript": "~3.8", 25 | "zen-observable-ts": "^1.1.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awslabs/aws-mobile-appsync-sdk-js/23b3e712461a6b982ddc3bf7835c0f8ed575acc0/packages/.DS_Store -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.tgz 4 | .DS_Store -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | *.tgz 3 | tsconfig.json 4 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | 7 | ## [3.0.7](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@3.0.6...aws-appsync-auth-link@3.0.7) (2021-09-24) 8 | 9 | 10 | ### Bug Fixes 11 | 12 | * remove aws-sdk V2; import aws-sdk V3 packages ([#637](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/637)); refactor auth-link, signer, and types ([0996740](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/0996740)) 13 | 14 | 15 | 16 | 17 | 18 | ## [3.0.6](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@3.0.5...aws-appsync-auth-link@3.0.6) (2021-07-28) 19 | 20 | 21 | 22 | 23 | **Note:** Version bump only for package aws-appsync-auth-link 24 | 25 | 26 | ## [3.0.5](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@3.0.4...aws-appsync-auth-link@3.0.5) (2021-07-09) 27 | 28 | 29 | 30 | 31 | **Note:** Version bump only for package aws-appsync-auth-link 32 | 33 | 34 | ## [3.0.4](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@3.0.3...aws-appsync-auth-link@3.0.4) (2021-02-12) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * Bump aws-sdk to address CVE-2020-28472 ([#621](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/621)) ([396791c](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/396791c)) 40 | 41 | 42 | 43 | 44 | 45 | ## [3.0.3](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@3.0.2...aws-appsync-auth-link@3.0.3) (2021-01-26) 46 | 47 | 48 | 49 | 50 | **Note:** Version bump only for package aws-appsync-auth-link 51 | 52 | 53 | ## [3.0.2](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@2.0.3...aws-appsync-auth-link@3.0.2) (2020-10-01) 54 | 55 | 56 | 57 | 58 | **Note:** Version bump only for package aws-appsync-auth-link 59 | 60 | 61 | ## [2.0.3](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@2.0.2...aws-appsync-auth-link@2.0.3) (2020-09-10) 62 | 63 | 64 | 65 | 66 | **Note:** Version bump only for package aws-appsync-auth-link 67 | 68 | 69 | ## [2.0.2](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@2.0.1...aws-appsync-auth-link@2.0.2) (2020-04-15) 70 | 71 | 72 | 73 | 74 | **Note:** Version bump only for package aws-appsync-auth-link 75 | 76 | 77 | ## [2.0.1](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-auth-link@1.0.1...aws-appsync-auth-link@2.0.1) (2019-11-15) 78 | 79 | 80 | 81 | 82 | **Note:** Version bump only for package aws-appsync-auth-link 83 | 84 | 85 | ## 1.0.1 (2019-10-11) 86 | 87 | 88 | ### Features 89 | 90 | * exporting links ([#470](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/470)) ([50185bb](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/50185bb)) 91 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awslabs/aws-mobile-appsync-sdk-js/23b3e712461a6b982ddc3bf7835c0f8ed575acc0/packages/aws-appsync-auth-link/README.md -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/__tests__/link/auth-link-test.ts: -------------------------------------------------------------------------------- 1 | import { authLink, AUTH_TYPE } from "../../src/auth-link"; 2 | import { execute, ApolloLink, Observable } from "@apollo/client/core"; 3 | import gql from 'graphql-tag'; 4 | 5 | describe("Auth link", () => { 6 | test('Test AWS_LAMBDA authorizer for queries', (done) => { 7 | const query = gql`query { someQuery { aField } }` 8 | 9 | const link = authLink({ 10 | auth: { 11 | type: AUTH_TYPE.AWS_LAMBDA, 12 | token: 'token' 13 | }, 14 | region: 'us-east-1', 15 | url: 'https://xxxxx.appsync-api.amazonaws.com/graphql' 16 | }) 17 | 18 | 19 | const spyLink = new ApolloLink((operation, forward) => { 20 | const { headers: { Authorization} } = operation.getContext(); 21 | expect(Authorization).toBe('token'); 22 | done(); 23 | 24 | return new Observable(() => {}); 25 | }) 26 | 27 | const testLink = ApolloLink.from([link, spyLink]); 28 | 29 | execute(testLink, { query }).subscribe({ }) 30 | }); 31 | 32 | test('Test AMAZON_COGNITO_USER_POOLS authorizer for queries', (done) => { 33 | const query = gql`query { someQuery { aField } }` 34 | 35 | const link = authLink({ 36 | auth: { 37 | type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, 38 | jwtToken: 'token' 39 | }, 40 | region: 'us-east-1', 41 | url: 'https://xxxxx.appsync-api.amazonaws.com/graphql' 42 | }) 43 | 44 | 45 | const spyLink = new ApolloLink((operation, forward) => { 46 | const { headers: { Authorization} } = operation.getContext(); 47 | expect(Authorization).toBe('token'); 48 | done(); 49 | 50 | return new Observable(() => {}); 51 | }) 52 | 53 | const testLink = ApolloLink.from([link, spyLink]); 54 | 55 | execute(testLink, { query }).subscribe({ }) 56 | }); 57 | 58 | test('Test OPENID_CONNECT authorizer for queries', (done) => { 59 | const query = gql`query { someQuery { aField } }` 60 | 61 | const link = authLink({ 62 | auth: { 63 | type: AUTH_TYPE.OPENID_CONNECT, 64 | jwtToken: 'token' 65 | }, 66 | region: 'us-east-1', 67 | url: 'https://xxxxx.appsync-api.amazonaws.com/graphql' 68 | }) 69 | 70 | 71 | const spyLink = new ApolloLink((operation, forward) => { 72 | const { headers: { Authorization} } = operation.getContext(); 73 | expect(Authorization).toBe('token'); 74 | done(); 75 | 76 | return new Observable(() => {}); 77 | }) 78 | 79 | const testLink = ApolloLink.from([link, spyLink]); 80 | 81 | execute(testLink, { query }).subscribe({ }) 82 | }); 83 | 84 | test('Test API_KEY authorizer for queries', (done) => { 85 | const query = gql`query { someQuery { aField } }` 86 | 87 | const link = authLink({ 88 | auth: { 89 | type: AUTH_TYPE.API_KEY, 90 | apiKey: 'token' 91 | }, 92 | region: 'us-east-1', 93 | url: 'https://xxxxx.appsync-api.amazonaws.com/graphql' 94 | }) 95 | 96 | 97 | const spyLink = new ApolloLink((operation, forward) => { 98 | const { headers } = operation.getContext(); 99 | 100 | expect(headers["X-Api-Key"]).toBe('token'); 101 | done(); 102 | 103 | return new Observable(() => {}); 104 | }) 105 | 106 | const testLink = ApolloLink.from([link, spyLink]); 107 | 108 | execute(testLink, { query }).subscribe({ }) 109 | }); 110 | }); -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "ts-jest" 4 | }, 5 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 6 | collectCoverageFrom: [ 7 | "src/**/*", 8 | "!src/vendor/**" 9 | ], 10 | moduleFileExtensions: [ 11 | "ts", 12 | "tsx", 13 | "js", 14 | "jsx", 15 | "json", 16 | "node" 17 | ], 18 | testEnvironment: "node" 19 | }; 20 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-appsync-auth-link", 3 | "version": "3.0.7", 4 | "main": "lib/index.js", 5 | "license": "Apache-2.0", 6 | "description": "AWS Mobile AppSync Auth Link for JavaScript", 7 | "author": { 8 | "name": "Amazon Web Services", 9 | "url": "https://aws.amazon.com/" 10 | }, 11 | "homepage": "https://github.com/awslabs/aws-mobile-appsync-sdk-js.git", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/awslabs/aws-mobile-appsync-sdk-js.git" 15 | }, 16 | "scripts": { 17 | "prepare": "tsc", 18 | "test": "jest --coverage --coverageReporters=text --passWithNoTests", 19 | "test-watch": "jest --watch" 20 | }, 21 | "dependencies": { 22 | "@aws-crypto/sha256-js": "^1.2.0", 23 | "@aws-sdk/types": "^3.25.0", 24 | "@aws-sdk/util-hex-encoding": "^3.29.0", 25 | "debug": "2.6.9" 26 | }, 27 | "devDependencies": { 28 | "@apollo/client": "^3.2.0" 29 | }, 30 | "peerDependencies": { 31 | "@apollo/client": "3.x" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/src/auth-link.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2017-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | import { ApolloLink, Observable } from '@apollo/client/core'; 6 | import { print } from 'graphql/language/printer'; 7 | 8 | import { Signer } from './signer'; 9 | import * as Url from 'url'; 10 | 11 | import { userAgent } from "./platform"; 12 | import { Credentials, CredentialProvider } from '@aws-sdk/types'; 13 | 14 | const packageInfo = require("../package.json"); 15 | 16 | const SERVICE = 'appsync'; 17 | export const USER_AGENT_HEADER = 'x-amz-user-agent'; 18 | export const USER_AGENT = `aws-amplify/${packageInfo.version}${userAgent && ' '}${userAgent}`; 19 | 20 | export enum AUTH_TYPE { 21 | NONE = 'NONE', 22 | API_KEY = 'API_KEY', 23 | AWS_IAM = 'AWS_IAM', 24 | AMAZON_COGNITO_USER_POOLS = 'AMAZON_COGNITO_USER_POOLS', 25 | OPENID_CONNECT = 'OPENID_CONNECT', 26 | AWS_LAMBDA = 'AWS_LAMBDA' 27 | } 28 | 29 | export class AuthLink extends ApolloLink { 30 | 31 | private link: ApolloLink; 32 | 33 | /** 34 | * 35 | * @param {*} options 36 | */ 37 | constructor(options) { 38 | super(); 39 | 40 | this.link = authLink(options); 41 | } 42 | 43 | request(operation, forward) { 44 | return this.link.request(operation, forward); 45 | } 46 | } 47 | 48 | interface Headers { 49 | header: string, 50 | value: string | (() => (string | Promise)) 51 | } 52 | 53 | const headerBasedAuth = async ({ header, value }: Headers = { header: '', value: '' }, operation, forward) => { 54 | const origContext = operation.getContext(); 55 | let headers = { 56 | ...origContext.headers, 57 | [USER_AGENT_HEADER]: USER_AGENT, 58 | }; 59 | 60 | if (header && value) { 61 | const headerValue = typeof value === 'function' ? await value.call(undefined) : await value; 62 | 63 | headers = { 64 | ...{ [header]: headerValue }, 65 | ...headers 66 | }; 67 | } 68 | 69 | operation.setContext({ 70 | ...origContext, 71 | headers, 72 | }); 73 | 74 | return forward(operation); 75 | 76 | }; 77 | 78 | const iamBasedAuth = async ({ credentials, region, url }, operation, forward) => { 79 | const service = SERVICE; 80 | const origContext = operation.getContext(); 81 | 82 | let creds = typeof credentials === 'function' ? credentials.call() : (credentials || {}); 83 | 84 | if (creds && typeof creds.getPromise === 'function') { 85 | await creds.getPromise(); 86 | } 87 | 88 | const { accessKeyId, secretAccessKey, sessionToken } = await creds; 89 | 90 | const { host, path } = Url.parse(url); 91 | 92 | const formatted = { 93 | ...formatAsRequest(operation, {}), 94 | service, region, url, host, path 95 | }; 96 | 97 | const { headers } = Signer.sign(formatted, { access_key: accessKeyId, secret_key: secretAccessKey, session_token: sessionToken }); 98 | 99 | operation.setContext({ 100 | ...origContext, 101 | headers: { 102 | ...origContext.headers, 103 | ...headers, 104 | [USER_AGENT_HEADER]: USER_AGENT, 105 | }, 106 | }); 107 | 108 | return forward(operation); 109 | } 110 | 111 | type KeysWithType = { 112 | [K in keyof O]: O[K] extends T ? K : never 113 | }[keyof O]; 114 | type AuthOptionsNone = { type: AUTH_TYPE.NONE }; 115 | type AuthOptionsIAM = { 116 | type: KeysWithType, 117 | credentials: (() => Credentials | CredentialProvider | Promise) | Credentials | CredentialProvider | null, 118 | }; 119 | type AuthOptionsApiKey = { 120 | type: KeysWithType, 121 | apiKey: (() => (string | Promise)) | string, 122 | }; 123 | type AuthOptionsOAuth = { 124 | type: KeysWithType | KeysWithType, 125 | jwtToken: (() => (string | Promise)) | string, 126 | }; 127 | type AuthOptionsLambda = { 128 | type: KeysWithType, 129 | token: (() => (string | Promise)) | string, 130 | } 131 | export type AuthOptions = AuthOptionsNone | AuthOptionsIAM | AuthOptionsApiKey | AuthOptionsOAuth | AuthOptionsLambda; 132 | 133 | export const authLink = ({ url, region, auth: { type } = {}, auth }) => { 134 | return new ApolloLink((operation, forward) => { 135 | return new Observable(observer => { 136 | let handle; 137 | 138 | let promise: Promise>; 139 | 140 | switch (type) { 141 | case AUTH_TYPE.NONE: 142 | promise = headerBasedAuth(undefined, operation, forward); 143 | break; 144 | case AUTH_TYPE.AWS_IAM: 145 | const { credentials = {} } = auth; 146 | promise = iamBasedAuth({ 147 | credentials, 148 | region, 149 | url, 150 | }, operation, forward); 151 | break; 152 | case AUTH_TYPE.API_KEY: 153 | const { apiKey = '' } = auth; 154 | promise = headerBasedAuth({ header: 'X-Api-Key', value: apiKey }, operation, forward); 155 | break; 156 | case AUTH_TYPE.AMAZON_COGNITO_USER_POOLS: 157 | case AUTH_TYPE.OPENID_CONNECT: 158 | const { jwtToken = '' } = auth; 159 | promise = headerBasedAuth({ header: 'Authorization', value: jwtToken }, operation, forward); 160 | break; 161 | case AUTH_TYPE.AWS_LAMBDA: 162 | const { token = '' } = auth; 163 | promise = headerBasedAuth({ header: 'Authorization', value: token }, operation, forward); 164 | break 165 | default: 166 | const error = new Error(`Invalid AUTH_TYPE: ${(auth).type}`); 167 | 168 | throw error; 169 | } 170 | 171 | promise.then(observable => { 172 | handle = observable.subscribe({ 173 | next: observer.next.bind(observer), 174 | error: observer.error.bind(observer), 175 | complete: observer.complete.bind(observer), 176 | }); 177 | }) 178 | 179 | return () => { 180 | if (handle) handle.unsubscribe(); 181 | }; 182 | }); 183 | }); 184 | } 185 | 186 | const formatAsRequest = ({ operationName, variables, query }, options) => { 187 | const body = { 188 | operationName, 189 | variables: removeTemporaryVariables(variables), 190 | query: print(query) 191 | }; 192 | 193 | return { 194 | body: JSON.stringify(body), 195 | method: 'POST', 196 | ...options, 197 | headers: { 198 | accept: '*/*', 199 | 'content-type': 'application/json; charset=UTF-8', 200 | ...options.headers, 201 | }, 202 | }; 203 | } 204 | 205 | /** 206 | * Removes all temporary variables (starting with '@@') so that the signature matches the final request. 207 | */ 208 | const removeTemporaryVariables = (variables: any) => 209 | Object.keys(variables) 210 | .filter(key => !key.startsWith("@@")) 211 | .reduce((acc, key) => { 212 | acc[key] = variables[key]; 213 | return acc; 214 | }, {}); 215 | 216 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/src/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthLink, AuthOptions, AUTH_TYPE, USER_AGENT_HEADER, USER_AGENT } from './auth-link'; 2 | 3 | 4 | export const createAuthLink = ({ url, region, auth }: { url: string, region: string, auth: AuthOptions }) => new AuthLink({ url, region, auth }); 5 | 6 | export { AuthLink, AuthOptions, AUTH_TYPE, USER_AGENT_HEADER, USER_AGENT }; 7 | export { Signer } from './signer'; -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/src/platform.native.ts: -------------------------------------------------------------------------------- 1 | const userAgent = 'react-native'; 2 | 3 | export { userAgent }; 4 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/src/platform.ts: -------------------------------------------------------------------------------- 1 | const userAgent = ''; 2 | 3 | export { userAgent }; 4 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/src/signer/index.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | export * from './signer'; 6 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/src/signer/signer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 - 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy of the License is located at 4 | http://aws.amazon.com/apache2.0/ 5 | or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | See the License for the specific language governing permissions and limitations under the License. 7 | */ 8 | global.Buffer = global.Buffer || require('buffer').Buffer; // Required for aws sigv4 signing 9 | 10 | var url = require('url'); 11 | 12 | var { Sha256 } = require('@aws-crypto/sha256-js') 13 | var { toHex } = require("@aws-sdk/util-hex-encoding"); 14 | 15 | var encrypt = function(key, src, encoding = '') { 16 | const hash = new Sha256(key); 17 | hash.update(src); 18 | const result = hash.digestSync(); 19 | if (encoding === 'hex') { 20 | return toHex(result) 21 | } 22 | return result; 23 | }; 24 | 25 | var hash = function(src) { 26 | const arg = src || ''; 27 | const hash = new Sha256(); 28 | hash.update(arg); 29 | return toHex(hash.digestSync()); 30 | }; 31 | 32 | /** 33 | * @private 34 | * Create canonical headers 35 | * 36 |
 37 | CanonicalHeaders =
 38 |     CanonicalHeadersEntry0 + CanonicalHeadersEntry1 + ... + CanonicalHeadersEntryN
 39 | CanonicalHeadersEntry =
 40 |     Lowercase(HeaderName) + ':' + Trimall(HeaderValue) + '\n'
 41 | 
42 | */ 43 | var canonical_headers = function (headers) { 44 | if (!headers || Object.keys(headers).length === 0) { return ''; } 45 | 46 | return Object.keys(headers) 47 | .map(function (key) { 48 | return { 49 | key: key.toLowerCase(), 50 | value: headers[key] ? headers[key].trim().replace(/\s+/g, ' ') : '' 51 | }; 52 | }) 53 | .sort(function (a, b) { 54 | return a.key < b.key ? -1 : 1; 55 | }) 56 | .map(function (item) { 57 | return item.key + ':' + item.value; 58 | }) 59 | .join('\n') + '\n'; 60 | }; 61 | 62 | /** 63 | * List of header keys included in the canonical headers. 64 | * @access private 65 | */ 66 | var signed_headers = function (headers) { 67 | return Object.keys(headers) 68 | .map(function (key) { return key.toLowerCase(); }) 69 | .sort() 70 | .join(';'); 71 | }; 72 | 73 | /** 74 | * @private 75 | * Create canonical request 76 | * Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html|Create a Canonical Request} 77 | * 78 |
 79 | CanonicalRequest =
 80 |     HTTPRequestMethod + '\n' +
 81 |     CanonicalURI + '\n' +
 82 |     CanonicalQueryString + '\n' +
 83 |     CanonicalHeaders + '\n' +
 84 |     SignedHeaders + '\n' +
 85 |     HexEncode(Hash(RequestPayload))
 86 | 
87 | */ 88 | var canonical_request = function (request) { 89 | var url_info = url.parse(request.url); 90 | 91 | return [ 92 | request.method || '/', 93 | url_info.path, 94 | url_info.query, 95 | canonical_headers(request.headers), 96 | signed_headers(request.headers), 97 | hash(request.body) 98 | ].join('\n'); 99 | }; 100 | 101 | var parse_service_info = function (request) { 102 | var url_info = url.parse(request.url), 103 | host = url_info.host; 104 | 105 | var matched = host.match(/([^.]+)\.(?:([^.]*)\.)?amazonaws\.com$/), 106 | parsed = (matched || []).slice(1, 3); 107 | 108 | if (parsed[1] === 'es') { // Elastic Search 109 | parsed = parsed.reverse(); 110 | } 111 | 112 | return { 113 | service: request.service || parsed[0], 114 | region: request.region || parsed[1] 115 | }; 116 | }; 117 | 118 | var credential_scope = function (d_str, region, service) { 119 | return [ 120 | d_str, 121 | region, 122 | service, 123 | 'aws4_request', 124 | ].join('/'); 125 | }; 126 | 127 | /** 128 | * @private 129 | * Create a string to sign 130 | * Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html|Create String to Sign} 131 | * 132 |
133 | StringToSign =
134 |     Algorithm + \n +
135 |     RequestDateTime + \n +
136 |     CredentialScope + \n +
137 |     HashedCanonicalRequest
138 | 
139 | */ 140 | var string_to_sign = function (algorithm, canonical_request, dt_str, scope) { 141 | return [ 142 | algorithm, 143 | dt_str, 144 | scope, 145 | hash(canonical_request) 146 | ].join('\n'); 147 | }; 148 | 149 | /** 150 | * @private 151 | * Create signing key 152 | * Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html|Calculate Signature} 153 | * 154 |
155 | kSecret = your secret access key
156 | kDate = HMAC("AWS4" + kSecret, Date)
157 | kRegion = HMAC(kDate, Region)
158 | kService = HMAC(kRegion, Service)
159 | kSigning = HMAC(kService, "aws4_request")
160 | 
161 | */ 162 | var get_signing_key = function (secret_key = '', d_str, service_info) { 163 | var k = ('AWS4' + secret_key), 164 | k_date = encrypt(k, d_str), 165 | k_region = encrypt(k_date, service_info.region), 166 | k_service = encrypt(k_region, service_info.service), 167 | k_signing = encrypt(k_service, 'aws4_request'); 168 | 169 | return k_signing; 170 | }; 171 | 172 | var get_signature = function (signing_key, str_to_sign) { 173 | return encrypt(signing_key, str_to_sign, 'hex'); 174 | }; 175 | 176 | /** 177 | * @private 178 | * Create authorization header 179 | * Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html|Add the Signing Information} 180 | */ 181 | var get_authorization_header = function (algorithm, access_key = '', scope, signed_headers, signature) { 182 | return [ 183 | algorithm + ' ' + 'Credential=' + access_key + '/' + scope, 184 | 'SignedHeaders=' + signed_headers, 185 | 'Signature=' + signature 186 | ].join(', '); 187 | }; 188 | 189 | /** 190 | * Sign a HTTP request, add 'Authorization' header to request param 191 | * @method sign 192 | * @memberof Signer 193 | * @static 194 | * 195 | * @param {object} request - HTTP request object 196 |
197 | request: {
198 |     method: GET | POST | PUT ...
199 |     url: ...,
200 |     headers: {
201 |         header1: ...
202 |     },
203 |     body: data
204 | }
205 | 
206 | * @param {object} access_info - AWS access credential info 207 |
208 | access_info: {
209 |     access_key: ...,
210 |     secret_key: ...,
211 |     session_token: ...
212 | }
213 | 
214 | * @param {object} [service_info] - AWS service type and region, optional, 215 | * if not provided then parse out from url 216 |
217 | service_info: {
218 |     service: ...,
219 |     region: ...
220 | }
221 | 
222 | * 223 | * @returns {object} Signed HTTP request 224 | */ 225 | var sign = function (request, access_info, service_info = null) { 226 | request.headers = request.headers || {}; 227 | 228 | // datetime string and date string 229 | var dt = new Date(), 230 | dt_str = dt.toISOString().replace(/[:-]|\.\d{3}/g, ''), 231 | d_str = dt_str.substr(0, 8), 232 | algorithm = 'AWS4-HMAC-SHA256'; 233 | 234 | var url_info = url.parse(request.url) 235 | request.headers['host'] = url_info.host; 236 | request.headers['x-amz-date'] = dt_str; 237 | if (access_info.session_token) { 238 | request.headers['X-Amz-Security-Token'] = access_info.session_token; 239 | } 240 | 241 | // Task 1: Create a Canonical Request 242 | var request_str = canonical_request(request); 243 | 244 | // Task 2: Create a String to Sign 245 | service_info = service_info || parse_service_info(request); 246 | var scope = credential_scope( 247 | d_str, 248 | service_info.region, 249 | service_info.service 250 | ); 251 | var str_to_sign = string_to_sign( 252 | algorithm, 253 | request_str, 254 | dt_str, 255 | scope 256 | ); 257 | 258 | // Task 3: Calculate the Signature 259 | var signing_key = get_signing_key( 260 | access_info.secret_key, 261 | d_str, 262 | service_info 263 | ), 264 | signature = get_signature(signing_key, str_to_sign); 265 | 266 | // Task 4: Adding the Signing information to the Request 267 | var authorization_header = get_authorization_header( 268 | algorithm, 269 | access_info.access_key, 270 | scope, 271 | signed_headers(request.headers), 272 | signature 273 | ); 274 | request.headers['Authorization'] = authorization_header; 275 | 276 | return request; 277 | }; 278 | 279 | /** 280 | * AWS request signer. 281 | * Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html|Signature Version 4} 282 | * 283 | * @class Signer 284 | */ 285 | class Signer { 286 | static sign = sign; 287 | } 288 | 289 | export default Signer; 290 | export { Signer }; 291 | -------------------------------------------------------------------------------- /packages/aws-appsync-auth-link/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "lib": [ 10 | "dom", 11 | "es6", 12 | "esnext.asynciterable", 13 | "es2017.object" 14 | ], 15 | "skipLibCheck": true 16 | }, 17 | "exclude": [ 18 | "lib", 19 | "__tests__" 20 | ], 21 | "compileOnSave": true 22 | } 23 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.tgz 4 | .DS_Store -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | *.tgz 3 | tsconfig.json 4 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | 7 | ## [3.1.3](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.1.2...aws-appsync-subscription-link@3.1.3) (2024-01-23) 8 | 9 | 10 | ### Bug Fixes 11 | 12 | * exception due to illegal access to item in an empty map ([#747](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/747)) ([82cb58e](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/82cb58e)) 13 | 14 | 15 | 16 | 17 | 18 | ## [3.1.2](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.1.1...aws-appsync-subscription-link@3.1.2) (2022-11-17) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * added check for observer ([#735](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/735)) ([db8f9ae](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/db8f9ae)) 24 | 25 | 26 | 27 | 28 | 29 | ## [3.1.1](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.1.0...aws-appsync-subscription-link@3.1.1) (2022-10-07) 30 | 31 | 32 | 33 | 34 | **Note:** Version bump only for package aws-appsync-subscription-link 35 | 36 | 37 | # [3.1.0](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.11...aws-appsync-subscription-link@3.1.0) (2022-06-24) 38 | 39 | 40 | ### Features 41 | 42 | * Add keepAliveTimeoutMs config for AppSync WebSocket link ([#724](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/724)) ([74b8351](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/74b8351)) 43 | 44 | 45 | 46 | 47 | 48 | ## [3.0.11](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.10...aws-appsync-subscription-link@3.0.11) (2022-05-02) 49 | 50 | 51 | 52 | 53 | **Note:** Version bump only for package aws-appsync-subscription-link 54 | 55 | 56 | ## [3.0.10](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.9...aws-appsync-subscription-link@3.0.10) (2022-03-04) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * Port over Amplify fix for subscription race conditions ([#509](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/509)) ([#704](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/704)) ([92b50c4](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/92b50c4)) 62 | 63 | 64 | 65 | 66 | 67 | ## [3.0.9](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.8...aws-appsync-subscription-link@3.0.9) (2021-09-24) 68 | 69 | 70 | ### Bug Fixes 71 | 72 | * add type fix to subscription link to fix 'prepare' script ([#669](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/669)) ([c5202dc](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/c5202dc)) 73 | * **aws-appsync-subscription-link:** graphql header refactor to fix IAM-based auth ([#619](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/619)) ([#671](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/671)) ([dbf4959](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/dbf4959)) 74 | * remove aws-sdk V2; import aws-sdk V3 packages ([#637](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/637)); refactor auth-link, signer, and types ([0996740](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/0996740)) 75 | 76 | 77 | 78 | 79 | 80 | ## [3.0.8](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.7...aws-appsync-subscription-link@3.0.8) (2021-07-28) 81 | 82 | 83 | 84 | 85 | **Note:** Version bump only for package aws-appsync-subscription-link 86 | 87 | 88 | ## [3.0.7](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.6...aws-appsync-subscription-link@3.0.7) (2021-07-09) 89 | 90 | 91 | 92 | 93 | **Note:** Version bump only for package aws-appsync-subscription-link 94 | 95 | 96 | ## [3.0.6](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.5...aws-appsync-subscription-link@3.0.6) (2021-02-12) 97 | 98 | 99 | 100 | 101 | **Note:** Version bump only for package aws-appsync-subscription-link 102 | 103 | 104 | ## [3.0.5](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.4...aws-appsync-subscription-link@3.0.5) (2021-01-26) 105 | 106 | 107 | 108 | 109 | **Note:** Version bump only for package aws-appsync-subscription-link 110 | 111 | 112 | ## [3.0.4](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.3...aws-appsync-subscription-link@3.0.4) (2021-01-08) 113 | 114 | 115 | 116 | 117 | **Note:** Version bump only for package aws-appsync-subscription-link 118 | 119 | 120 | ## [3.0.3](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@3.0.2...aws-appsync-subscription-link@3.0.3) (2020-10-01) 121 | 122 | 123 | ### Bug Fixes 124 | 125 | * Add apollo-utilities dependency to subscription link ([3eeb77e](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/3eeb77e)) 126 | 127 | 128 | 129 | 130 | 131 | ## [3.0.2](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@2.2.1...aws-appsync-subscription-link@3.0.2) (2020-10-01) 132 | 133 | 134 | 135 | 136 | **Note:** Version bump only for package aws-appsync-subscription-link 137 | 138 | 139 | ## [2.2.1](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@2.2.0...aws-appsync-subscription-link@2.2.1) (2020-09-10) 140 | 141 | 142 | 143 | 144 | **Note:** Version bump only for package aws-appsync-subscription-link 145 | 146 | 147 | # [2.2.0](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@2.1.0...aws-appsync-subscription-link@2.2.0) (2020-06-24) 148 | 149 | 150 | ### Bug Fixes 151 | 152 | * **aws-appsync-subscription-link:** change relativ ([#564](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/564)) ([f9d95a6](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/f9d95a6)) 153 | 154 | 155 | ### Features 156 | 157 | * **aws-appsync-subscription-link:** allow custom headers to be processed by a Subscription resolver's vtl ([#497](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/497)) ([6851a36](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/6851a36)) 158 | 159 | 160 | 161 | 162 | 163 | # [2.1.0](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@2.0.1...aws-appsync-subscription-link@2.1.0) (2020-04-15) 164 | 165 | 166 | ### Features 167 | 168 | * **aws-appsync-subscription-link:** add mock support for websocket subscriptions ([#548](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/548)) ([bac8808](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/bac8808)) 169 | 170 | 171 | 172 | 173 | 174 | ## [2.0.1](https://github.com/awslabs/aws-mobile-appsync-sdk-js/compare/aws-appsync-subscription-link@1.0.1...aws-appsync-subscription-link@2.0.1) (2019-11-15) 175 | 176 | 177 | 178 | 179 | **Note:** Version bump only for package aws-appsync-subscription-link 180 | 181 | 182 | ## 1.0.1 (2019-10-11) 183 | 184 | 185 | ### Features 186 | 187 | * exporting links ([#470](https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/470)) ([50185bb](https://github.com/awslabs/aws-mobile-appsync-sdk-js/commit/50185bb)) 188 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/__tests__/link/realtime-subscription-handshake-link-test.ts: -------------------------------------------------------------------------------- 1 | import { AUTH_TYPE } from "aws-appsync-auth-link"; 2 | import { execute } from "@apollo/client/core"; 3 | import gql from 'graphql-tag'; 4 | import { AppSyncRealTimeSubscriptionHandshakeLink } from '../../src/realtime-subscription-handshake-link'; 5 | import { MESSAGE_TYPES } from "../../src/types"; 6 | import { v4 as uuid } from "uuid"; 7 | jest.mock('uuid', () => ({ v4: jest.fn() })); 8 | 9 | const query = gql`subscription { someSubscription { aField } }` 10 | 11 | class myWebSocket implements WebSocket { 12 | binaryType: BinaryType; 13 | bufferedAmount: number; 14 | extensions: string; 15 | onclose: (this: WebSocket, ev: CloseEvent) => any; 16 | onerror: (this: WebSocket, ev: Event) => any; 17 | onmessage: (this: WebSocket, ev: MessageEvent) => any; 18 | onopen: (this: WebSocket, ev: Event) => any; 19 | protocol: string; 20 | readyState: number; 21 | url: string; 22 | close(code?: number, reason?: string): void { 23 | throw new Error("Method not implemented."); 24 | } 25 | send(data: string | ArrayBuffer | Blob | ArrayBufferView): void { 26 | throw new Error("Method not implemented."); 27 | } 28 | CLOSED: number; 29 | CLOSING: number; 30 | CONNECTING: number; 31 | OPEN: number; 32 | addEventListener(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => void, options?: boolean | AddEventListenerOptions): void; 33 | addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; 34 | addEventListener(type: any, listener: any, options?: any) { 35 | throw new Error("Method not implemented."); 36 | } 37 | removeEventListener(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => void, options?: boolean | EventListenerOptions): void; 38 | removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; 39 | removeEventListener(type: any, listener: any, options?: any) { 40 | throw new Error("Method not implemented."); 41 | } 42 | dispatchEvent(event: Event): boolean { 43 | throw new Error("Method not implemented."); 44 | } 45 | } 46 | 47 | describe("RealTime subscription link", () => { 48 | 49 | test("Can instantiate link", () => { 50 | expect.assertions(1); 51 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 52 | auth: { 53 | type: AUTH_TYPE.API_KEY, 54 | apiKey: 'xxxxx' 55 | }, 56 | region: 'us-west-2', 57 | url: 'https://firsttesturl12345678901234.appsync-api.us-west-2.amazonaws.com/graphql' 58 | }); 59 | 60 | expect(link).toBeInstanceOf(AppSyncRealTimeSubscriptionHandshakeLink); 61 | }); 62 | 63 | test("Can instantiate link with custom domain", () => { 64 | expect.assertions(1); 65 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 66 | auth: { 67 | type: AUTH_TYPE.API_KEY, 68 | apiKey: 'xxxxx' 69 | }, 70 | region: 'us-west-2', 71 | url: 'https://test1.testcustomdomain.com/graphql' 72 | }); 73 | 74 | expect(link).toBeInstanceOf(AppSyncRealTimeSubscriptionHandshakeLink); 75 | }); 76 | 77 | test("Initialize WebSocket correctly for API KEY", (done) => { 78 | expect.assertions(2); 79 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 80 | return "2019-11-13T18:47:04.733Z"; 81 | })); 82 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 83 | expect(url).toBe('wss://apikeytesturl1234567890123.appsync-realtime-api.us-west-2.amazonaws.com/graphql?header=eyJob3N0IjoiYXBpa2V5dGVzdHVybDEyMzQ1Njc4OTAxMjMuYXBwc3luYy1hcGkudXMtd2VzdC0yLmFtYXpvbmF3cy5jb20iLCJ4LWFtei1kYXRlIjoiMjAxOTExMTNUMTg0NzA0WiIsIngtYXBpLWtleSI6Inh4eHh4In0=&payload=e30='); 84 | expect(protocol).toBe('graphql-ws'); 85 | done(); 86 | return new myWebSocket(); 87 | }); 88 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 89 | auth: { 90 | type: AUTH_TYPE.API_KEY, 91 | apiKey: 'xxxxx' 92 | }, 93 | region: 'us-west-2', 94 | url: 'https://apikeytesturl1234567890123.appsync-api.us-west-2.amazonaws.com/graphql' 95 | }); 96 | 97 | execute(link, { query }).subscribe({ 98 | error: (err) => { 99 | console.log(JSON.stringify(err)); 100 | fail; 101 | }, 102 | next: (data) => { 103 | console.log({ data }); 104 | done(); 105 | }, 106 | complete: () => { 107 | console.log('done with this'); 108 | done(); 109 | } 110 | 111 | }); 112 | }); 113 | 114 | test("Initialize WebSocket correctly for API KEY with custom domain", (done) => { 115 | expect.assertions(2); 116 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 117 | return "2019-11-13T18:47:04.733Z"; 118 | })); 119 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 120 | expect(url).toBe('wss://apikeytest.testcustomdomain.com/graphql/realtime?header=eyJob3N0IjoiYXBpa2V5dGVzdC50ZXN0Y3VzdG9tZG9tYWluLmNvbSIsIngtYW16LWRhdGUiOiIyMDE5MTExM1QxODQ3MDRaIiwieC1hcGkta2V5IjoieHh4eHgifQ==&payload=e30='); 121 | expect(protocol).toBe('graphql-ws'); 122 | done(); 123 | return new myWebSocket(); 124 | }); 125 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 126 | auth: { 127 | type: AUTH_TYPE.API_KEY, 128 | apiKey: 'xxxxx' 129 | }, 130 | region: 'us-west-2', 131 | url: 'https://apikeytest.testcustomdomain.com/graphql' 132 | }); 133 | 134 | execute(link, { query }).subscribe({ 135 | error: (err) => { 136 | console.log(JSON.stringify(err)); 137 | fail; 138 | }, 139 | next: (data) => { 140 | console.log({ data }); 141 | done(); 142 | }, 143 | complete: () => { 144 | console.log('done with this'); 145 | done(); 146 | } 147 | 148 | }); 149 | }); 150 | 151 | test("Initialize WebSocket correctly for COGNITO USER POOLS", (done) => { 152 | expect.assertions(2); 153 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 154 | return "2019-11-13T18:47:04.733Z"; 155 | })); 156 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 157 | expect(url).toBe('wss://cognitouserpooltesturl1234.appsync-realtime-api.us-west-2.amazonaws.com/graphql?header=eyJBdXRob3JpemF0aW9uIjoidG9rZW4iLCJob3N0IjoiY29nbml0b3VzZXJwb29sdGVzdHVybDEyMzQuYXBwc3luYy1hcGkudXMtd2VzdC0yLmFtYXpvbmF3cy5jb20ifQ==&payload=e30='); 158 | expect(protocol).toBe('graphql-ws'); 159 | done(); 160 | return new myWebSocket(); 161 | }); 162 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 163 | auth: { 164 | type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, 165 | jwtToken: 'token' 166 | }, 167 | region: 'us-west-2', 168 | url: 'https://cognitouserpooltesturl1234.appsync-api.us-west-2.amazonaws.com/graphql' 169 | }); 170 | 171 | execute(link, { query }).subscribe({ 172 | error: (err) => { 173 | console.log(JSON.stringify(err)); 174 | fail; 175 | }, 176 | next: (data) => { 177 | console.log({ data }); 178 | done(); 179 | }, 180 | complete: () => { 181 | console.log('done with this'); 182 | done(); 183 | } 184 | 185 | }); 186 | }); 187 | 188 | test("Initialize WebSocket correctly for COGNITO USER POOLS with custom domain", (done) => { 189 | expect.assertions(2); 190 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 191 | return "2019-11-13T18:47:04.733Z"; 192 | })); 193 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 194 | expect(url).toBe('wss://cognitouserpools.testcustomdomain.com/graphql/realtime?header=eyJBdXRob3JpemF0aW9uIjoidG9rZW4iLCJob3N0IjoiY29nbml0b3VzZXJwb29scy50ZXN0Y3VzdG9tZG9tYWluLmNvbSJ9&payload=e30='); 195 | expect(protocol).toBe('graphql-ws'); 196 | done(); 197 | return new myWebSocket(); 198 | }); 199 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 200 | auth: { 201 | type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, 202 | jwtToken: 'token' 203 | }, 204 | region: 'us-west-2', 205 | url: 'https://cognitouserpools.testcustomdomain.com/graphql' 206 | }); 207 | 208 | execute(link, { query }).subscribe({ 209 | error: (err) => { 210 | console.log(JSON.stringify(err)); 211 | fail; 212 | }, 213 | next: (data) => { 214 | console.log({ data }); 215 | done(); 216 | }, 217 | complete: () => { 218 | console.log('done with this'); 219 | done(); 220 | } 221 | 222 | }); 223 | }); 224 | 225 | test("Initialize WebSocket correctly for OPENID_CONNECT", (done) => { 226 | expect.assertions(2); 227 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 228 | return "2019-11-13T18:47:04.733Z"; 229 | })); 230 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 231 | expect(url).toBe('wss://openidconnecttesturl123456.appsync-realtime-api.us-west-2.amazonaws.com/graphql?header=eyJBdXRob3JpemF0aW9uIjoidG9rZW4iLCJob3N0Ijoib3BlbmlkY29ubmVjdHRlc3R1cmwxMjM0NTYuYXBwc3luYy1hcGkudXMtd2VzdC0yLmFtYXpvbmF3cy5jb20ifQ==&payload=e30='); 232 | expect(protocol).toBe('graphql-ws'); 233 | done(); 234 | return new myWebSocket(); 235 | }); 236 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 237 | auth: { 238 | type: AUTH_TYPE.OPENID_CONNECT, 239 | jwtToken: 'token' 240 | }, 241 | region: 'us-west-2', 242 | url: 'https://openidconnecttesturl123456.appsync-api.us-west-2.amazonaws.com/graphql' 243 | }); 244 | 245 | execute(link, { query }).subscribe({ 246 | error: (err) => { 247 | console.log(JSON.stringify(err)); 248 | fail; 249 | }, 250 | next: (data) => { 251 | console.log({ data }); 252 | done(); 253 | }, 254 | complete: () => { 255 | console.log('done with this'); 256 | done(); 257 | } 258 | 259 | }); 260 | }); 261 | 262 | test("Initialize WebSocket correctly for OPENID_CONNECT with custom domain", (done) => { 263 | expect.assertions(2); 264 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 265 | return "2019-11-13T18:47:04.733Z"; 266 | })); 267 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 268 | expect(url).toBe('wss://openidconnecttesturl.testcustomdomain.com/graphql/realtime?header=eyJBdXRob3JpemF0aW9uIjoidG9rZW4iLCJob3N0Ijoib3BlbmlkY29ubmVjdHRlc3R1cmwudGVzdGN1c3RvbWRvbWFpbi5jb20ifQ==&payload=e30='); 269 | expect(protocol).toBe('graphql-ws'); 270 | done(); 271 | return new myWebSocket(); 272 | }); 273 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 274 | auth: { 275 | type: AUTH_TYPE.OPENID_CONNECT, 276 | jwtToken: 'token' 277 | }, 278 | region: 'us-west-2', 279 | url: 'https://openidconnecttesturl.testcustomdomain.com/graphql' 280 | }); 281 | 282 | execute(link, { query }).subscribe({ 283 | error: (err) => { 284 | console.log(JSON.stringify(err)); 285 | fail; 286 | }, 287 | next: (data) => { 288 | console.log({ data }); 289 | done(); 290 | }, 291 | complete: () => { 292 | console.log('done with this'); 293 | done(); 294 | } 295 | 296 | }); 297 | }); 298 | 299 | test('Initialize WebSocket correctly for AWS_LAMBDA', (done) => { 300 | expect.assertions(2); 301 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 302 | return "2019-11-13T18:47:04.733Z"; 303 | })); 304 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 305 | expect(url).toBe('wss://awslambdatesturl1234567890.appsync-realtime-api.us-west-2.amazonaws.com/graphql?header=eyJBdXRob3JpemF0aW9uIjoidG9rZW4iLCJob3N0IjoiYXdzbGFtYmRhdGVzdHVybDEyMzQ1Njc4OTAuYXBwc3luYy1hcGkudXMtd2VzdC0yLmFtYXpvbmF3cy5jb20ifQ==&payload=e30='); 306 | expect(protocol).toBe('graphql-ws'); 307 | done(); 308 | return new myWebSocket(); 309 | }); 310 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 311 | auth: { 312 | type: AUTH_TYPE.AWS_LAMBDA, 313 | token: 'token' 314 | }, 315 | region: 'us-west-2', 316 | url: 'https://awslambdatesturl1234567890.appsync-api.us-west-2.amazonaws.com/graphql' 317 | }); 318 | 319 | execute(link, { query }).subscribe({ 320 | error: (err) => { 321 | fail; 322 | }, 323 | next: (data) => { 324 | done(); 325 | }, 326 | complete: () => { 327 | done(); 328 | } 329 | 330 | }); 331 | }) 332 | 333 | test('Initialize WebSocket correctly for AWS_LAMBDA with custom domain', (done) => { 334 | expect.assertions(2); 335 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 336 | return "2019-11-13T18:47:04.733Z"; 337 | })); 338 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 339 | expect(url).toBe('wss://awslambdatesturl.testcustomdomain.com/graphql/realtime?header=eyJBdXRob3JpemF0aW9uIjoidG9rZW4iLCJob3N0IjoiYXdzbGFtYmRhdGVzdHVybC50ZXN0Y3VzdG9tZG9tYWluLmNvbSJ9&payload=e30='); 340 | expect(protocol).toBe('graphql-ws'); 341 | done(); 342 | return new myWebSocket(); 343 | }); 344 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 345 | auth: { 346 | type: AUTH_TYPE.AWS_LAMBDA, 347 | token: 'token' 348 | }, 349 | region: 'us-west-2', 350 | url: 'https://awslambdatesturl.testcustomdomain.com/graphql' 351 | }); 352 | 353 | execute(link, { query }).subscribe({ 354 | error: (err) => { 355 | fail; 356 | }, 357 | next: (data) => { 358 | done(); 359 | }, 360 | complete: () => { 361 | done(); 362 | } 363 | 364 | }); 365 | }); 366 | 367 | test("Can use a custom keepAliveTimeoutMs", (done) => { 368 | const id = "abcd-efgh-ijkl-mnop"; 369 | uuid.mockImplementationOnce(() => id); 370 | 371 | expect.assertions(5); 372 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 373 | return "2019-11-13T18:47:04.733Z"; 374 | })); 375 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 376 | expect(url).toBe('wss://apikeytest.testcustomdomain.com/graphql/realtime?header=eyJob3N0IjoiYXBpa2V5dGVzdC50ZXN0Y3VzdG9tZG9tYWluLmNvbSIsIngtYW16LWRhdGUiOiIyMDE5MTExM1QxODQ3MDRaIiwieC1hcGkta2V5IjoieHh4eHgifQ==&payload=e30='); 377 | expect(protocol).toBe('graphql-ws'); 378 | const socket = new myWebSocket(); 379 | 380 | setTimeout(() => { 381 | socket.close = () => {}; 382 | socket.onopen.call(socket, (undefined as unknown as Event)); 383 | socket.send = (msg: string) => { 384 | const { type } = JSON.parse(msg); 385 | 386 | switch (type) { 387 | case MESSAGE_TYPES.GQL_CONNECTION_INIT: 388 | socket.onmessage.call(socket, { 389 | data: JSON.stringify({ 390 | type: MESSAGE_TYPES.GQL_CONNECTION_ACK, 391 | payload: { 392 | connectionTimeoutMs: 99999, 393 | }, 394 | }) 395 | } as MessageEvent); 396 | setTimeout(() => { 397 | socket.onmessage.call(socket, { 398 | data: JSON.stringify({ 399 | id, 400 | type: MESSAGE_TYPES.GQL_DATA, 401 | payload: { 402 | data: { something: 123 }, 403 | }, 404 | }) 405 | } as MessageEvent); 406 | 407 | }, 100); 408 | break; 409 | } 410 | }; 411 | }, 100); 412 | 413 | return socket; 414 | }); 415 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 416 | auth: { 417 | type: AUTH_TYPE.API_KEY, 418 | apiKey: 'xxxxx' 419 | }, 420 | region: 'us-west-2', 421 | url: 'https://apikeytest.testcustomdomain.com/graphql', 422 | keepAliveTimeoutMs: 123456, 423 | }); 424 | 425 | expect(link).toBeInstanceOf(AppSyncRealTimeSubscriptionHandshakeLink); 426 | expect((link as any).keepAliveTimeout).toBe(123456); 427 | 428 | const sub = execute(link, { query }).subscribe({ 429 | error: (err) => { 430 | console.log(JSON.stringify(err)); 431 | fail(); 432 | }, 433 | next: (data) => { 434 | expect((link as any).keepAliveTimeout).toBe(123456); 435 | done(); 436 | sub.unsubscribe(); 437 | }, 438 | complete: () => { 439 | console.log('done with this'); 440 | fail(); 441 | } 442 | 443 | }); 444 | }); 445 | 446 | test("Uses service-provided timeout when no custom keepAliveTimeoutMs is configured", (done) => { 447 | const id = "abcd-efgh-ijkl-mnop"; 448 | uuid.mockImplementationOnce(() => id); 449 | 450 | expect.assertions(5); 451 | jest.spyOn(Date.prototype, 'toISOString').mockImplementation(jest.fn(() => { 452 | return "2019-11-13T18:47:04.733Z"; 453 | })); 454 | AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket = jest.fn((url, protocol) => { 455 | expect(url).toBe('wss://apikeytest.testcustomdomain.com/graphql/realtime?header=eyJob3N0IjoiYXBpa2V5dGVzdC50ZXN0Y3VzdG9tZG9tYWluLmNvbSIsIngtYW16LWRhdGUiOiIyMDE5MTExM1QxODQ3MDRaIiwieC1hcGkta2V5IjoieHh4eHgifQ==&payload=e30='); 456 | expect(protocol).toBe('graphql-ws'); 457 | const socket = new myWebSocket(); 458 | 459 | setTimeout(() => { 460 | socket.close = () => {}; 461 | socket.onopen.call(socket, (undefined as unknown as Event)); 462 | socket.send = (msg: string) => { 463 | const { type } = JSON.parse(msg); 464 | 465 | switch (type) { 466 | case MESSAGE_TYPES.GQL_CONNECTION_INIT: 467 | socket.onmessage.call(socket, { 468 | data: JSON.stringify({ 469 | type: MESSAGE_TYPES.GQL_CONNECTION_ACK, 470 | payload: { 471 | connectionTimeoutMs: 99999, 472 | }, 473 | }) 474 | } as MessageEvent); 475 | setTimeout(() => { 476 | socket.onmessage.call(socket, { 477 | data: JSON.stringify({ 478 | id, 479 | type: MESSAGE_TYPES.GQL_DATA, 480 | payload: { 481 | data: { something: 123 }, 482 | }, 483 | }) 484 | } as MessageEvent); 485 | 486 | }, 100); 487 | break; 488 | } 489 | }; 490 | }, 100); 491 | 492 | return socket; 493 | }); 494 | const link = new AppSyncRealTimeSubscriptionHandshakeLink({ 495 | auth: { 496 | type: AUTH_TYPE.API_KEY, 497 | apiKey: 'xxxxx' 498 | }, 499 | region: 'us-west-2', 500 | url: 'https://apikeytest.testcustomdomain.com/graphql', 501 | }); 502 | 503 | expect(link).toBeInstanceOf(AppSyncRealTimeSubscriptionHandshakeLink); 504 | expect((link as any).keepAliveTimeout).toBeUndefined(); 505 | 506 | const sub = execute(link, { query }).subscribe({ 507 | error: (err) => { 508 | console.log(JSON.stringify(err)); 509 | fail(); 510 | }, 511 | next: (data) => { 512 | expect((link as any).keepAliveTimeout).toBe(99999); 513 | done(); 514 | sub.unsubscribe(); 515 | }, 516 | complete: () => { 517 | console.log('done with this'); 518 | fail(); 519 | } 520 | 521 | }); 522 | }); 523 | 524 | 525 | }); 526 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "ts-jest" 4 | }, 5 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 6 | collectCoverageFrom: [ 7 | "src/**/*", 8 | "!src/vendor/**" 9 | ], 10 | moduleFileExtensions: [ 11 | "ts", 12 | "tsx", 13 | "js", 14 | "jsx", 15 | "json", 16 | "node" 17 | ], 18 | testEnvironment: "node" 19 | }; 20 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-appsync-subscription-link", 3 | "version": "3.1.3", 4 | "main": "lib/index.js", 5 | "license": "Apache-2.0", 6 | "description": "AWS Mobile AppSync SDK for JavaScript", 7 | "author": { 8 | "name": "Amazon Web Services", 9 | "url": "https://aws.amazon.com/" 10 | }, 11 | "homepage": "https://github.com/awslabs/aws-mobile-appsync-sdk-js.git", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/awslabs/aws-mobile-appsync-sdk-js.git" 15 | }, 16 | "scripts": { 17 | "prepare": "tsc && cp -r src/vendor lib/vendor", 18 | "test": "jest --coverage --coverageReporters=text", 19 | "test-watch": "jest --watch" 20 | }, 21 | "dependencies": { 22 | "aws-appsync-auth-link": "^3.0.7", 23 | "debug": "2.6.9", 24 | "url": "^0.11.0", 25 | "zen-observable-ts": "^1.2.5" 26 | }, 27 | "devDependencies": { 28 | "@apollo/client": "^3.2.0", 29 | "@redux-offline/redux-offline": "2.5.2-native.0" 30 | }, 31 | "peerDependencies": { 32 | "@apollo/client": "3.x" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SubscriptionHandshakeLink, 3 | CONTROL_EVENTS_KEY 4 | } from "./subscription-handshake-link"; 5 | import { ApolloLink, Observable } from "@apollo/client/core"; 6 | import { createHttpLink } from "@apollo/client/link/http"; 7 | import { getMainDefinition } from "@apollo/client/utilities"; 8 | import { NonTerminatingLink } from "./non-terminating-link"; 9 | import type { OperationDefinitionNode } from "graphql"; 10 | 11 | import { 12 | AppSyncRealTimeSubscriptionHandshakeLink, 13 | } from "./realtime-subscription-handshake-link"; 14 | import { AppSyncRealTimeSubscriptionConfig } from "./types"; 15 | 16 | function createSubscriptionHandshakeLink( 17 | args: AppSyncRealTimeSubscriptionConfig, 18 | resultsFetcherLink?: ApolloLink 19 | ): ApolloLink; 20 | function createSubscriptionHandshakeLink( 21 | url: string, 22 | resultsFetcherLink?: ApolloLink 23 | ): ApolloLink; 24 | function createSubscriptionHandshakeLink( 25 | infoOrUrl: AppSyncRealTimeSubscriptionConfig | string, 26 | theResultsFetcherLink?: ApolloLink 27 | ) { 28 | let resultsFetcherLink: ApolloLink, subscriptionLinks: ApolloLink; 29 | 30 | if (typeof infoOrUrl === "string") { 31 | resultsFetcherLink = 32 | theResultsFetcherLink || createHttpLink({ uri: infoOrUrl }); 33 | subscriptionLinks = ApolloLink.from([ 34 | new NonTerminatingLink("controlMessages", { 35 | link: new ApolloLink( 36 | (operation, _forward) => 37 | new Observable(observer => { 38 | const { 39 | variables: { [CONTROL_EVENTS_KEY]: controlEvents, ...variables } 40 | } = operation; 41 | 42 | if (typeof controlEvents !== "undefined") { 43 | operation.variables = variables; 44 | } 45 | 46 | observer.next({ [CONTROL_EVENTS_KEY]: controlEvents }); 47 | 48 | return () => { }; 49 | }) 50 | ) 51 | }), 52 | new NonTerminatingLink("subsInfo", { link: resultsFetcherLink }), 53 | new SubscriptionHandshakeLink("subsInfo") 54 | ]); 55 | } else { 56 | const { url } = infoOrUrl; 57 | resultsFetcherLink = theResultsFetcherLink || createHttpLink({ uri: url }); 58 | subscriptionLinks = new AppSyncRealTimeSubscriptionHandshakeLink(infoOrUrl); 59 | } 60 | 61 | return ApolloLink.split( 62 | operation => { 63 | const { query } = operation; 64 | const { kind, operation: graphqlOperation } = getMainDefinition( 65 | query 66 | ) as OperationDefinitionNode; 67 | const isSubscription = 68 | kind === "OperationDefinition" && graphqlOperation === "subscription"; 69 | 70 | return isSubscription; 71 | }, 72 | subscriptionLinks, 73 | resultsFetcherLink 74 | ); 75 | } 76 | 77 | export { CONTROL_EVENTS_KEY, createSubscriptionHandshakeLink }; 78 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/non-terminating-http-link.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | import { createHttpLink, HttpOptions } from '@apollo/client/link/http'; 6 | import { NonTerminatingLink } from './non-terminating-link'; 7 | 8 | export class NonTerminatingHttpLink extends NonTerminatingLink { 9 | constructor(contextKey: string, options: HttpOptions) { 10 | const link = createHttpLink(options); 11 | 12 | super(contextKey, { link }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/non-terminating-link.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | import { ApolloLink } from '@apollo/client/core'; 6 | import type { NextLink, FetchResult } from '@apollo/client/core'; 7 | import { setContext } from '@apollo/client/link/context'; 8 | import type { Observable } from 'zen-observable-ts'; 9 | 10 | export class NonTerminatingLink extends ApolloLink { 11 | 12 | private contextKey: string; 13 | private link: ApolloLink; 14 | 15 | constructor(contextKey: string, { link }: { link: ApolloLink }) { 16 | super(); 17 | 18 | this.contextKey = contextKey; 19 | this.link = link; 20 | } 21 | 22 | request(operation, forward?: NextLink): Observable { 23 | return (setContext(async (_request, prevContext) => { 24 | const result = await new Promise((resolve, reject) => { 25 | this.link.request(operation).subscribe({ 26 | next: resolve, 27 | error: reject, 28 | }); 29 | }); 30 | 31 | return { 32 | ...prevContext, 33 | [this.contextKey]: result, 34 | } 35 | })).request(operation, forward); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/realtime-subscription-handshake-link.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | import { ApolloLink, Observable, Operation, FetchResult } from "@apollo/client/core"; 6 | 7 | import { rootLogger } from "./utils"; 8 | 9 | import { 10 | Signer, 11 | AuthOptions, 12 | AUTH_TYPE, 13 | USER_AGENT_HEADER, 14 | USER_AGENT 15 | } from "aws-appsync-auth-link"; 16 | import { GraphQLError, print } from "graphql"; 17 | import * as url from "url"; 18 | import { v4 as uuid } from "uuid"; 19 | import { 20 | AppSyncRealTimeSubscriptionConfig, 21 | SOCKET_STATUS, 22 | ObserverQuery, 23 | SUBSCRIPTION_STATUS, 24 | MESSAGE_TYPES, 25 | CONTROL_MSG 26 | } from "./types"; 27 | import { jitteredExponentialRetry, NonRetryableError } from "./utils/retry"; 28 | 29 | const logger = rootLogger.extend("subscriptions"); 30 | 31 | export const CONTROL_EVENTS_KEY = "@@controlEvents"; 32 | 33 | const NON_RETRYABLE_CODES = [400, 401, 403]; 34 | 35 | const SERVICE = "appsync"; 36 | 37 | const APPSYNC_REALTIME_HEADERS = { 38 | accept: 'application/json, text/javascript', 39 | 'content-encoding': 'amz-1.0', 40 | 'content-type': 'application/json; charset=UTF-8' 41 | }; 42 | 43 | /** 44 | * Time in milliseconds to wait for GQL_CONNECTION_INIT message 45 | */ 46 | const CONNECTION_INIT_TIMEOUT = 15000; 47 | 48 | /** 49 | * Time in milliseconds to wait for GQL_START_ACK message 50 | */ 51 | const START_ACK_TIMEOUT = 15000; 52 | 53 | /** 54 | * Frequency in milliseconds in which the server sends GQL_CONNECTION_KEEP_ALIVE messages 55 | */ 56 | const SERVER_KEEP_ALIVE_TIMEOUT = 1 * 60 * 1000; 57 | 58 | /** 59 | * Default Time in milliseconds to wait for GQL_CONNECTION_KEEP_ALIVE message 60 | */ 61 | const DEFAULT_KEEP_ALIVE_TIMEOUT = 5 * 60 * 1000; 62 | 63 | const standardDomainPattern = /^https:\/\/\w{26}\.appsync\-api\.\w{2}(?:(?:\-\w{2,})+)\-\d\.amazonaws.com\/graphql$/i; 64 | 65 | const customDomainPath = '/realtime'; 66 | 67 | export class AppSyncRealTimeSubscriptionHandshakeLink extends ApolloLink { 68 | private url: string; 69 | private region: string; 70 | private auth: AuthOptions; 71 | private awsRealTimeSocket: WebSocket; 72 | private socketStatus: SOCKET_STATUS = SOCKET_STATUS.CLOSED; 73 | private keepAliveTimeoutId; 74 | private keepAliveTimeout?: number = undefined; 75 | private subscriptionObserverMap: Map = new Map(); 76 | private promiseArray: Array<{ res: Function; rej: Function }> = []; 77 | 78 | constructor({ url: theUrl, region: theRegion, auth: theAuth, keepAliveTimeoutMs }: AppSyncRealTimeSubscriptionConfig) { 79 | super(); 80 | this.url = theUrl; 81 | this.region = theRegion; 82 | this.auth = theAuth; 83 | this.keepAliveTimeout = keepAliveTimeoutMs; 84 | 85 | if (this.keepAliveTimeout < SERVER_KEEP_ALIVE_TIMEOUT) { 86 | let configName: keyof AppSyncRealTimeSubscriptionConfig = 'keepAliveTimeoutMs'; 87 | 88 | throw new Error(`${configName} must be greater than or equal to ${SERVER_KEEP_ALIVE_TIMEOUT} (${this.keepAliveTimeout} used).`); 89 | } 90 | } 91 | 92 | // Check if url matches standard domain pattern 93 | private isCustomDomain(url: string): boolean { 94 | return url.match(standardDomainPattern) === null; 95 | } 96 | 97 | request(operation: Operation) { 98 | const { query, variables } = operation; 99 | const { 100 | controlMessages: { [CONTROL_EVENTS_KEY]: controlEvents } = { 101 | [CONTROL_EVENTS_KEY]: undefined 102 | }, 103 | headers 104 | } = operation.getContext(); 105 | return new Observable(observer => { 106 | if (!this.url) { 107 | observer.error({ 108 | errors: [ 109 | { 110 | ...new GraphQLError( 111 | `Subscribe only available for AWS AppSync endpoint` 112 | ), 113 | }, 114 | ], 115 | }); 116 | observer.complete(); 117 | } else { 118 | const subscriptionId = uuid(); 119 | let token = this.auth.type === AUTH_TYPE.AMAZON_COGNITO_USER_POOLS || 120 | this.auth.type === AUTH_TYPE.OPENID_CONNECT 121 | ? this.auth.jwtToken 122 | : null; 123 | 124 | token = this.auth.type === AUTH_TYPE.AWS_LAMBDA ? this.auth.token : token; 125 | 126 | const options = { 127 | appSyncGraphqlEndpoint: this.url, 128 | authenticationType: this.auth.type, 129 | query: print(query), 130 | region: this.region, 131 | graphql_headers: () => (headers), 132 | variables, 133 | apiKey: this.auth.type === AUTH_TYPE.API_KEY ? this.auth.apiKey : "", 134 | credentials: 135 | this.auth.type === AUTH_TYPE.AWS_IAM ? this.auth.credentials : null, 136 | token 137 | }; 138 | 139 | this._startSubscriptionWithAWSAppSyncRealTime({ 140 | options, 141 | observer, 142 | subscriptionId 143 | }); 144 | 145 | return async () => { 146 | // Cleanup after unsubscribing or observer.complete was called after _startSubscriptionWithAWSAppSyncRealTime 147 | try { 148 | this._verifySubscriptionAlreadyStarted(subscriptionId); 149 | const { subscriptionState } = this.subscriptionObserverMap.get( 150 | subscriptionId 151 | ); 152 | if (subscriptionState === SUBSCRIPTION_STATUS.CONNECTED) { 153 | this._sendUnsubscriptionMessage(subscriptionId); 154 | } else { 155 | throw new Error( 156 | "Subscription has failed, starting to remove subscription." 157 | ); 158 | } 159 | } catch (err) { 160 | this._removeSubscriptionObserver(subscriptionId); 161 | return; 162 | } 163 | }; 164 | } 165 | }).filter(data => { 166 | const { extensions: { controlMsgType = undefined } = {} } = data; 167 | const isControlMsg = typeof controlMsgType !== "undefined"; 168 | 169 | return controlEvents === true || !isControlMsg; 170 | }); 171 | } 172 | 173 | private async _verifySubscriptionAlreadyStarted(subscriptionId) { 174 | const { subscriptionState } = this.subscriptionObserverMap.get( 175 | subscriptionId 176 | ); 177 | // This in case unsubscribe is invoked before sending start subscription message 178 | if (subscriptionState === SUBSCRIPTION_STATUS.PENDING) { 179 | return new Promise((res, rej) => { 180 | const { 181 | observer, 182 | subscriptionState, 183 | variables, 184 | query 185 | } = this.subscriptionObserverMap.get(subscriptionId); 186 | this.subscriptionObserverMap.set(subscriptionId, { 187 | observer, 188 | subscriptionState, 189 | variables, 190 | query, 191 | subscriptionReadyCallback: res, 192 | subscriptionFailedCallback: rej 193 | }); 194 | }); 195 | } 196 | } 197 | 198 | private _sendUnsubscriptionMessage(subscriptionId) { 199 | try { 200 | if ( 201 | this.awsRealTimeSocket && 202 | this.awsRealTimeSocket.readyState === WebSocket.OPEN && 203 | this.socketStatus === SOCKET_STATUS.READY 204 | ) { 205 | // Preparing unsubscribe message to stop receiving messages for that subscription 206 | const unsubscribeMessage = { 207 | id: subscriptionId, 208 | type: MESSAGE_TYPES.GQL_STOP 209 | }; 210 | const stringToAWSRealTime = JSON.stringify(unsubscribeMessage); 211 | this.awsRealTimeSocket.send(stringToAWSRealTime); 212 | 213 | this._removeSubscriptionObserver(subscriptionId); 214 | } 215 | } catch (err) { 216 | // If GQL_STOP is not sent because of disconnection issue, then there is nothing the client can do 217 | logger({ err }); 218 | } 219 | } 220 | 221 | private _removeSubscriptionObserver(subscriptionId) { 222 | this.subscriptionObserverMap.delete(subscriptionId); 223 | // Verifying for 1000ms after removing subscription in case there are new subscriptions on mount / unmount 224 | setTimeout(this._closeSocketIfRequired.bind(this), 1000); 225 | } 226 | 227 | private _closeSocketIfRequired() { 228 | if (this.subscriptionObserverMap.size > 0) { 229 | // There are active subscriptions on the WebSocket 230 | return; 231 | } 232 | 233 | if (!this.awsRealTimeSocket) { 234 | this.socketStatus = SOCKET_STATUS.CLOSED; 235 | return; 236 | } 237 | if (this.awsRealTimeSocket.bufferedAmount > 0) { 238 | // There is still data on the WebSocket 239 | setTimeout(this._closeSocketIfRequired.bind(this), 1000); 240 | } else { 241 | logger("closing WebSocket..."); 242 | clearTimeout(this.keepAliveTimeoutId); 243 | const tempSocket = this.awsRealTimeSocket; 244 | tempSocket.close(1000); 245 | this.awsRealTimeSocket = null; 246 | this.socketStatus = SOCKET_STATUS.CLOSED; 247 | } 248 | } 249 | 250 | private async _startSubscriptionWithAWSAppSyncRealTime({ 251 | options, 252 | observer, 253 | subscriptionId 254 | }) { 255 | const { 256 | appSyncGraphqlEndpoint, 257 | authenticationType, 258 | query, 259 | variables, 260 | apiKey, 261 | region, 262 | graphql_headers = () => ({}), 263 | credentials, 264 | token 265 | } = options; 266 | const subscriptionState: SUBSCRIPTION_STATUS = SUBSCRIPTION_STATUS.PENDING; 267 | const data = { 268 | query, 269 | variables 270 | }; 271 | // Having a subscription id map will make it simple to forward messages received 272 | this.subscriptionObserverMap.set(subscriptionId, { 273 | observer, 274 | query, 275 | variables, 276 | subscriptionState, 277 | startAckTimeoutId: null, 278 | }); 279 | 280 | // Preparing payload for subscription message 281 | 282 | const dataString = JSON.stringify(data); 283 | const headerObj = { 284 | ...(await this._awsRealTimeHeaderBasedAuth({ 285 | apiKey, 286 | appSyncGraphqlEndpoint, 287 | authenticationType, 288 | payload: dataString, 289 | canonicalUri: "", 290 | region, 291 | credentials, 292 | token, 293 | graphql_headers 294 | })), 295 | [USER_AGENT_HEADER]: USER_AGENT 296 | }; 297 | 298 | const subscriptionMessage = { 299 | id: subscriptionId, 300 | payload: { 301 | data: dataString, 302 | extensions: { 303 | authorization: { 304 | ...headerObj 305 | } 306 | } 307 | }, 308 | type: MESSAGE_TYPES.GQL_START 309 | }; 310 | 311 | const stringToAWSRealTime = JSON.stringify(subscriptionMessage); 312 | 313 | try { 314 | await this._initializeWebSocketConnection({ 315 | apiKey, 316 | appSyncGraphqlEndpoint, 317 | authenticationType, 318 | region, 319 | credentials, 320 | token 321 | }); 322 | } catch (err) { 323 | const { message = "" } = err; 324 | observer.error({ 325 | errors: [ 326 | { 327 | ...new GraphQLError(`Connection failed: ${message}`) 328 | } 329 | ] 330 | }); 331 | observer.complete(); 332 | 333 | const { subscriptionFailedCallback } = 334 | this.subscriptionObserverMap.get(subscriptionId) || {}; 335 | 336 | // Notify concurrent unsubscription 337 | if (typeof subscriptionFailedCallback === "function") { 338 | subscriptionFailedCallback(); 339 | } 340 | return; 341 | } 342 | 343 | // There could be a race condition when unsubscribe gets called during _initializeWebSocketConnection 344 | // For example if unsubscribe gets invoked before it finishes WebSocket handshake or START_ACK 345 | // subscriptionFailedCallback subscriptionReadyCallback are used to synchonized that 346 | 347 | const { 348 | subscriptionFailedCallback, 349 | subscriptionReadyCallback 350 | } = this.subscriptionObserverMap.get(subscriptionId) || {}; 351 | 352 | // This must be done before sending the message in order to be listening immediately 353 | this.subscriptionObserverMap.set(subscriptionId, { 354 | observer, 355 | subscriptionState, 356 | variables, 357 | query, 358 | subscriptionReadyCallback, 359 | subscriptionFailedCallback, 360 | startAckTimeoutId: (setTimeout(() => { 361 | this._timeoutStartSubscriptionAck.call(this, subscriptionId); 362 | }, START_ACK_TIMEOUT) as unknown) as number 363 | }); 364 | 365 | if (this.awsRealTimeSocket) { 366 | this.awsRealTimeSocket.send(stringToAWSRealTime); 367 | } 368 | } 369 | 370 | private _initializeWebSocketConnection({ 371 | appSyncGraphqlEndpoint, 372 | authenticationType, 373 | apiKey, 374 | region, 375 | credentials, 376 | token 377 | }): Promise { 378 | if (this.socketStatus === SOCKET_STATUS.READY) { 379 | return; 380 | } 381 | return new Promise(async (res, rej) => { 382 | this.promiseArray.push({ res, rej }); 383 | 384 | if (this.socketStatus === SOCKET_STATUS.CLOSED) { 385 | try { 386 | this.socketStatus = SOCKET_STATUS.CONNECTING; 387 | 388 | const payloadString = "{}"; 389 | const headerString = JSON.stringify( 390 | await this._awsRealTimeHeaderBasedAuth({ 391 | authenticationType, 392 | payload: payloadString, 393 | canonicalUri: "/connect", 394 | apiKey, 395 | appSyncGraphqlEndpoint, 396 | region, 397 | credentials, 398 | token, 399 | graphql_headers: () => { } 400 | }) 401 | ); 402 | const headerQs = Buffer.from(headerString).toString("base64"); 403 | 404 | const payloadQs = Buffer.from(payloadString).toString("base64"); 405 | 406 | let discoverableEndpoint = appSyncGraphqlEndpoint; 407 | 408 | if (this.isCustomDomain(discoverableEndpoint)) { 409 | discoverableEndpoint = discoverableEndpoint.concat( 410 | customDomainPath 411 | ); 412 | } else { 413 | discoverableEndpoint = discoverableEndpoint.replace('appsync-api', 'appsync-realtime-api').replace('gogi-beta', 'grt-beta'); 414 | } 415 | 416 | discoverableEndpoint = discoverableEndpoint 417 | .replace("https://", "wss://") 418 | .replace('http://', 'ws://') 419 | 420 | const awsRealTimeUrl = `${discoverableEndpoint}?header=${headerQs}&payload=${payloadQs}`; 421 | 422 | await this._initializeRetryableHandshake({ awsRealTimeUrl }); 423 | 424 | this.promiseArray.forEach(({ res }) => { 425 | logger("Notifying connection successful"); 426 | res(); 427 | }); 428 | this.socketStatus = SOCKET_STATUS.READY; 429 | this.promiseArray = []; 430 | } catch (err) { 431 | this.promiseArray.forEach(({ rej }) => rej(err)); 432 | this.promiseArray = []; 433 | if ( 434 | this.awsRealTimeSocket && 435 | this.awsRealTimeSocket.readyState === WebSocket.OPEN 436 | ) { 437 | this.awsRealTimeSocket.close(3001); 438 | } 439 | this.awsRealTimeSocket = null; 440 | this.socketStatus = SOCKET_STATUS.CLOSED; 441 | } 442 | } 443 | }); 444 | } 445 | 446 | private async _awsRealTimeHeaderBasedAuth({ 447 | authenticationType, 448 | payload, 449 | canonicalUri, 450 | appSyncGraphqlEndpoint, 451 | apiKey, 452 | region, 453 | credentials, 454 | token, 455 | graphql_headers 456 | }) { 457 | const headerHandler: Record< 458 | string, 459 | (info: any) => Promise> 460 | > = { 461 | API_KEY: this._awsRealTimeApiKeyHeader.bind(this), 462 | AWS_IAM: this._awsRealTimeIAMHeader.bind(this), 463 | OPENID_CONNECT: this._awsRealTimeAuthorizationHeader.bind(this), 464 | AMAZON_COGNITO_USER_POOLS: this._awsRealTimeAuthorizationHeader.bind(this), 465 | AWS_LAMBDA: this._awsRealTimeAuthorizationHeader.bind(this) 466 | }; 467 | 468 | const handler = headerHandler[authenticationType]; 469 | 470 | if (typeof handler !== "function") { 471 | logger(`Authentication type ${authenticationType} not supported`); 472 | return {}; 473 | } 474 | 475 | const { host } = url.parse(appSyncGraphqlEndpoint); 476 | 477 | const result = await handler({ 478 | payload, 479 | canonicalUri, 480 | appSyncGraphqlEndpoint, 481 | apiKey, 482 | region, 483 | host, 484 | credentials, 485 | token, 486 | graphql_headers 487 | }); 488 | 489 | return result; 490 | } 491 | 492 | private async _awsRealTimeAuthorizationHeader({ 493 | host, 494 | token, 495 | graphql_headers 496 | }): Promise> { 497 | return { 498 | Authorization: 499 | typeof token === "function" 500 | ? await token.call(undefined) 501 | : await token, 502 | host, 503 | ...(await graphql_headers()) 504 | }; 505 | } 506 | 507 | private async _awsRealTimeApiKeyHeader({ 508 | apiKey, 509 | host, 510 | graphql_headers 511 | }): Promise> { 512 | const dt = new Date(); 513 | const dtStr = dt.toISOString().replace(/[:\-]|\.\d{3}/g, ""); 514 | 515 | return { 516 | host, 517 | "x-amz-date": dtStr, 518 | "x-api-key": apiKey, 519 | ...(await graphql_headers()), 520 | }; 521 | } 522 | 523 | private async _awsRealTimeIAMHeader({ 524 | payload, 525 | canonicalUri, 526 | appSyncGraphqlEndpoint, 527 | region, 528 | credentials 529 | }): Promise> { 530 | const endpointInfo = { 531 | region, 532 | service: SERVICE 533 | }; 534 | 535 | let creds = 536 | typeof credentials === "function" 537 | ? credentials.call() 538 | : credentials || {}; 539 | 540 | if (creds && typeof creds.getPromise === "function") { 541 | await creds.getPromise(); 542 | } 543 | 544 | if (!creds) { 545 | throw new Error("No credentials"); 546 | } 547 | const { accessKeyId, secretAccessKey, sessionToken } = await creds; 548 | 549 | const formattedCredentials = { 550 | access_key: accessKeyId, 551 | secret_key: secretAccessKey, 552 | session_token: sessionToken 553 | }; 554 | 555 | const request = { 556 | url: `${appSyncGraphqlEndpoint}${canonicalUri}`, 557 | body: payload, 558 | method: "POST", 559 | headers: { ...APPSYNC_REALTIME_HEADERS } 560 | }; 561 | 562 | const signed_params = Signer.sign( 563 | request, 564 | formattedCredentials, 565 | endpointInfo 566 | ); 567 | return signed_params.headers; 568 | } 569 | 570 | private async _initializeRetryableHandshake({ awsRealTimeUrl }) { 571 | logger(`Initializaling retryable Handshake`); 572 | await jitteredExponentialRetry(this._initializeHandshake.bind(this), [ 573 | { awsRealTimeUrl } 574 | ]); 575 | } 576 | 577 | private async _initializeHandshake({ awsRealTimeUrl }) { 578 | logger(`Initializing handshake ${awsRealTimeUrl}`); 579 | // Because connecting the socket is async, is waiting until connection is open 580 | // Step 1: connect websocket 581 | try { 582 | await (() => { 583 | return new Promise((res, rej) => { 584 | const newSocket = AppSyncRealTimeSubscriptionHandshakeLink.createWebSocket(awsRealTimeUrl, "graphql-ws"); 585 | newSocket.onerror = () => { 586 | logger(`WebSocket connection error`); 587 | }; 588 | newSocket.onclose = () => { 589 | rej(new Error("Connection handshake error")); 590 | }; 591 | newSocket.onopen = () => { 592 | this.awsRealTimeSocket = newSocket; 593 | return res(); 594 | }; 595 | }); 596 | })(); 597 | 598 | 599 | // Step 2: wait for ack from AWS AppSyncReaTime after sending init 600 | await (() => { 601 | return new Promise((res, rej) => { 602 | let ackOk = false; 603 | this.awsRealTimeSocket.onerror = error => { 604 | logger(`WebSocket closed ${JSON.stringify(error)}`); 605 | }; 606 | this.awsRealTimeSocket.onclose = event => { 607 | logger(`WebSocket closed ${event.reason}`); 608 | rej(new Error(JSON.stringify(event))); 609 | }; 610 | 611 | this.awsRealTimeSocket.onmessage = (message: MessageEvent) => { 612 | logger( 613 | `subscription message from AWS AppSyncRealTime: ${message.data} ` 614 | ); 615 | const data = JSON.parse(message.data); 616 | const { 617 | type, 618 | payload: { connectionTimeoutMs = DEFAULT_KEEP_ALIVE_TIMEOUT } = {} 619 | } = data; 620 | if (type === MESSAGE_TYPES.GQL_CONNECTION_ACK) { 621 | ackOk = true; 622 | this.keepAliveTimeout = this.keepAliveTimeout ?? connectionTimeoutMs; 623 | this.awsRealTimeSocket.onmessage = this._handleIncomingSubscriptionMessage.bind( 624 | this 625 | ); 626 | 627 | this.awsRealTimeSocket.onerror = err => { 628 | logger(err); 629 | this._errorDisconnect(CONTROL_MSG.CONNECTION_CLOSED); 630 | }; 631 | 632 | this.awsRealTimeSocket.onclose = event => { 633 | logger(`WebSocket closed ${event.reason}`); 634 | this._errorDisconnect(CONTROL_MSG.CONNECTION_CLOSED); 635 | }; 636 | 637 | res("Cool, connected to AWS AppSyncRealTime"); 638 | return; 639 | } 640 | 641 | if (type === MESSAGE_TYPES.GQL_CONNECTION_ERROR) { 642 | const { 643 | payload: { 644 | errors: [{ errorType = "", errorCode = 0 } = {}] = [] 645 | } = {} 646 | } = data; 647 | 648 | rej({ errorType, errorCode }); 649 | } 650 | }; 651 | 652 | const gqlInit = { 653 | type: MESSAGE_TYPES.GQL_CONNECTION_INIT 654 | }; 655 | this.awsRealTimeSocket.send(JSON.stringify(gqlInit)); 656 | 657 | function checkAckOk() { 658 | if (!ackOk) { 659 | rej( 660 | new Error( 661 | `Connection timeout: ack from AWSRealTime was not received on ${CONNECTION_INIT_TIMEOUT} ms` 662 | ) 663 | ); 664 | } 665 | } 666 | 667 | setTimeout(checkAckOk.bind(this), CONNECTION_INIT_TIMEOUT); 668 | }); 669 | })(); 670 | } catch (err) { 671 | const { errorType, errorCode } = err; 672 | 673 | if (NON_RETRYABLE_CODES.indexOf(errorCode) >= 0) { 674 | throw new NonRetryableError(errorType); 675 | } else if (errorType) { 676 | throw new Error(errorType); 677 | } else { 678 | throw err; 679 | } 680 | } 681 | } 682 | 683 | private _handleIncomingSubscriptionMessage(message: MessageEvent) { 684 | logger(`subscription message from AWS AppSync RealTime: ${message.data}`); 685 | const { id = "", payload, type } = JSON.parse(message.data); 686 | const { 687 | observer = null, 688 | query = "", 689 | variables = {}, 690 | startAckTimeoutId = 0, 691 | subscriptionReadyCallback = null, 692 | subscriptionFailedCallback = null 693 | } = this.subscriptionObserverMap.get(id) || {}; 694 | 695 | logger({ id, observer, query, variables }); 696 | 697 | if (type === MESSAGE_TYPES.GQL_DATA && payload && payload.data) { 698 | if (observer) { 699 | observer.next(payload); 700 | } else { 701 | logger(`observer not found for id: ${id}`); 702 | } 703 | return; 704 | } 705 | 706 | if (type === MESSAGE_TYPES.GQL_START_ACK) { 707 | logger(`subscription ready for ${JSON.stringify({ query, variables })}`); 708 | if (typeof subscriptionReadyCallback === "function") { 709 | subscriptionReadyCallback(); 710 | } 711 | clearTimeout(startAckTimeoutId as number); 712 | if (observer) { 713 | observer.next({ 714 | data: payload, 715 | extensions: { 716 | controlMsgType: "CONNECTED" 717 | } 718 | }); 719 | } else { 720 | logger(`observer not found for id: ${id}`); 721 | } 722 | 723 | const subscriptionState = SUBSCRIPTION_STATUS.CONNECTED; 724 | this.subscriptionObserverMap.set(id, { 725 | observer, 726 | query, 727 | variables, 728 | startAckTimeoutId: null, 729 | subscriptionState, 730 | subscriptionReadyCallback, 731 | subscriptionFailedCallback 732 | }); 733 | 734 | return; 735 | } 736 | 737 | if (type === MESSAGE_TYPES.GQL_CONNECTION_KEEP_ALIVE) { 738 | clearTimeout(this.keepAliveTimeoutId); 739 | this.keepAliveTimeoutId = setTimeout( 740 | this._errorDisconnect.bind(this, CONTROL_MSG.TIMEOUT_DISCONNECT), 741 | this.keepAliveTimeout 742 | ); 743 | return; 744 | } 745 | 746 | if (type === MESSAGE_TYPES.GQL_ERROR) { 747 | const subscriptionState = SUBSCRIPTION_STATUS.FAILED; 748 | this.subscriptionObserverMap.set(id, { 749 | observer, 750 | query, 751 | variables, 752 | startAckTimeoutId, 753 | subscriptionReadyCallback, 754 | subscriptionFailedCallback, 755 | subscriptionState 756 | }); 757 | 758 | clearTimeout(startAckTimeoutId); 759 | 760 | if (observer) { 761 | observer.error({ 762 | errors: [ 763 | { 764 | ...new GraphQLError(`Connection failed: ${JSON.stringify(payload)}`) 765 | } 766 | ] 767 | }); 768 | observer.complete(); 769 | } else { 770 | logger(`observer not found for id: ${id}`); 771 | } 772 | 773 | if (typeof subscriptionFailedCallback === "function") { 774 | subscriptionFailedCallback(); 775 | } 776 | } 777 | } 778 | 779 | private _errorDisconnect(msg: string) { 780 | logger(`Disconnect error: ${msg}`); 781 | this.subscriptionObserverMap.forEach(({ observer }) => { 782 | if (observer && !observer.closed) { 783 | observer.error({ 784 | errors: [{ ...new GraphQLError(msg) }], 785 | }); 786 | } 787 | }); 788 | this.subscriptionObserverMap.clear(); 789 | if (this.awsRealTimeSocket) { 790 | this.awsRealTimeSocket.close(); 791 | } 792 | 793 | this.socketStatus = SOCKET_STATUS.CLOSED; 794 | } 795 | 796 | private _timeoutStartSubscriptionAck(subscriptionId) { 797 | const { observer, query, variables } = 798 | this.subscriptionObserverMap.get(subscriptionId) || {}; 799 | 800 | if (!observer) { 801 | return; 802 | } 803 | 804 | this.subscriptionObserverMap.set(subscriptionId, { 805 | observer, 806 | query, 807 | variables, 808 | subscriptionState: SUBSCRIPTION_STATUS.FAILED 809 | }); 810 | 811 | if (observer && !observer.closed) { 812 | observer.error({ 813 | errors: [ 814 | { 815 | ...new GraphQLError( 816 | `Subscription timeout ${JSON.stringify({ query, variables })}` 817 | ) 818 | } 819 | ] 820 | }); 821 | // Cleanup will be automatically executed 822 | observer.complete(); 823 | } 824 | logger("timeoutStartSubscription", JSON.stringify({ query, variables })); 825 | } 826 | 827 | static createWebSocket(awsRealTimeUrl: string, protocol: string): WebSocket { 828 | return new WebSocket(awsRealTimeUrl, protocol); 829 | } 830 | } 831 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/subscription-handshake-link.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | import { ApolloLink, Observable, Operation, FetchResult, ApolloError } from "@apollo/client/core"; 6 | import * as ZenObservable from 'zen-observable-ts'; 7 | 8 | import { rootLogger } from "./utils"; 9 | import * as Paho from './vendor/paho-mqtt'; 10 | import type { FieldNode } from "graphql"; 11 | import { getMainDefinition } from "@apollo/client/utilities"; 12 | 13 | const logger = rootLogger.extend('subscriptions'); 14 | const mqttLogger = logger.extend('mqtt'); 15 | 16 | type SubscriptionExtension = { 17 | mqttConnections: MqttConnectionInfo[], 18 | newSubscriptions: NewSubscriptions, 19 | } 20 | 21 | type MqttConnectionInfo = { 22 | client: string, 23 | url: string, 24 | topics: string[], 25 | }; 26 | 27 | type NewSubscriptions = { 28 | [key: string]: { 29 | topic: string, 30 | expireTime: number, 31 | } 32 | }; 33 | 34 | type ClientObservers = { 35 | client: any, 36 | observers: Set>, 37 | } 38 | 39 | export const CONTROL_EVENTS_KEY = '@@controlEvents'; 40 | 41 | export class SubscriptionHandshakeLink extends ApolloLink { 42 | 43 | private subsInfoContextKey: string; 44 | 45 | private topicObservers: Map>> = new Map(); 46 | 47 | private clientObservers: Map = new Map(); 48 | 49 | constructor(subsInfoContextKey) { 50 | super(); 51 | this.subsInfoContextKey = subsInfoContextKey; 52 | } 53 | 54 | request(operation: Operation): Observable | null { 55 | const { 56 | [this.subsInfoContextKey]: subsInfo, 57 | controlMessages: { [CONTROL_EVENTS_KEY]: controlEvents } = { [CONTROL_EVENTS_KEY]: undefined } 58 | } = operation.getContext(); 59 | const { 60 | extensions: { 61 | subscription: { newSubscriptions, mqttConnections } 62 | } = { subscription: { newSubscriptions: {}, mqttConnections: [] } }, 63 | errors = [], 64 | }: { 65 | extensions?: { 66 | subscription: SubscriptionExtension 67 | }, 68 | errors: any[] 69 | } = subsInfo; 70 | 71 | if (errors && errors.length) { 72 | return new Observable(observer => { 73 | observer.error(new ApolloError({ 74 | errorMessage: 'Error during subscription handshake', 75 | extraInfo: { errors }, 76 | graphQLErrors: errors 77 | })); 78 | 79 | return () => { }; 80 | }); 81 | } 82 | 83 | const newSubscriptionTopics = Object.keys(newSubscriptions).map(subKey => newSubscriptions[subKey].topic); 84 | const existingTopicsWithObserver = new Set(newSubscriptionTopics.filter(t => this.topicObservers.has(t))); 85 | const newTopics = new Set(newSubscriptionTopics.filter(t => !existingTopicsWithObserver.has(t))); 86 | 87 | return new Observable(observer => { 88 | existingTopicsWithObserver.forEach(t => { 89 | this.topicObservers.get(t).add(observer); 90 | const anObserver = Array.from(this.topicObservers.get(t)).find(() => true); 91 | 92 | const [clientId] = Array.from(this.clientObservers).find(([, { observers }]) => observers.has(anObserver)); 93 | this.clientObservers.get(clientId).observers.add(observer); 94 | }); 95 | 96 | const newTopicsConnectionInfo = mqttConnections 97 | .filter(c => c.topics.some(t => newTopics.has(t))) 98 | .map(({ topics, ...rest }) => ({ 99 | ...rest, 100 | topics: topics.filter(t => newTopics.has(t)) 101 | } as MqttConnectionInfo)); 102 | 103 | this.connectNewClients(newTopicsConnectionInfo, observer, operation); 104 | 105 | return () => { 106 | const clientsForCurrentObserver = Array.from(this.clientObservers).filter(([, { observers }]) => observers.has(observer)); 107 | clientsForCurrentObserver.forEach(([clientId]) => this.clientObservers.get(clientId).observers.delete(observer)); 108 | 109 | this.clientObservers.forEach(({ observers, client }) => { 110 | if (observers.size === 0) { 111 | if (client.isConnected()) { 112 | client.disconnect(); 113 | } 114 | this.clientObservers.delete(client.clientId); 115 | } 116 | }); 117 | this.clientObservers = new Map( 118 | Array.from(this.clientObservers).filter(([, { observers }]) => observers.size > 0) 119 | ); 120 | 121 | this.topicObservers.forEach(observers => observers.delete(observer)); 122 | 123 | this.topicObservers = new Map( 124 | Array.from(this.topicObservers) 125 | .filter(([, observers]) => observers.size > 0) 126 | ); 127 | }; 128 | }).filter(data => { 129 | const { extensions: { controlMsgType = undefined } = {} } = data; 130 | const isControlMsg = typeof controlMsgType !== 'undefined'; 131 | 132 | return controlEvents === true || !isControlMsg; 133 | }); 134 | } 135 | 136 | async connectNewClients(connectionInfo: MqttConnectionInfo[], observer: ZenObservable.Observer, operation: Operation) { 137 | const { query } = operation; 138 | const selectionNames = (getMainDefinition(query).selectionSet.selections as FieldNode[]).map(({ name: { value } }) => value); 139 | 140 | const result = Promise.all(connectionInfo.map(c => this.connectNewClient(c, observer, selectionNames))); 141 | 142 | const data = selectionNames.reduce( 143 | (acc, name) => (acc[name] = acc[name] || null, acc), 144 | {} 145 | ); 146 | 147 | observer.next({ 148 | data, 149 | extensions: { 150 | controlMsgType: 'CONNECTED', 151 | controlMsgInfo: { 152 | connectionInfo, 153 | }, 154 | } 155 | }); 156 | 157 | return result 158 | }; 159 | 160 | async connectNewClient(connectionInfo: MqttConnectionInfo, observer: ZenObservable.Observer, selectionNames: string[]) { 161 | const { client: clientId, url, topics } = connectionInfo; 162 | const client: any = new Paho.Client(url, clientId); 163 | 164 | client.trace = mqttLogger.bind(null, clientId); 165 | client.onConnectionLost = ({ errorCode, ...args }) => { 166 | if (errorCode !== 0) { 167 | topics.forEach(t => { 168 | if (this.topicObservers.has(t)) { 169 | this.topicObservers.get(t).forEach(observer => observer.error({ ...args, permanent: true })); 170 | } 171 | }); 172 | } 173 | 174 | topics.forEach(t => this.topicObservers.delete(t)); 175 | }; 176 | 177 | (client as any).onMessageArrived = ({ destinationName, payloadString }) => this.onMessage(destinationName, payloadString, selectionNames); 178 | 179 | await new Promise((resolve, reject) => { 180 | client.connect({ 181 | useSSL: url.indexOf('wss://') === 0, 182 | mqttVersion: 3, 183 | onSuccess: () => resolve(client), 184 | onFailure: reject, 185 | }); 186 | }); 187 | 188 | await this.subscribeToTopics(client, topics, observer); 189 | 190 | return client; 191 | } 192 | 193 | subscribeToTopics(client, topics: string[], observer: ZenObservable.Observer) { 194 | return Promise.all(topics.map(topic => this.subscribeToTopic(client, topic, observer))); 195 | } 196 | 197 | subscribeToTopic(client, topic: string, observer: ZenObservable.Observer) { 198 | return new Promise((resolve, reject) => { 199 | (client as any).subscribe(topic, { 200 | onSuccess: () => { 201 | if (!this.topicObservers.has(topic)) { 202 | this.topicObservers.set(topic, new Set()); 203 | } 204 | if (!this.clientObservers.has(client.clientId)) { 205 | this.clientObservers.set(client.clientId, { client, observers: new Set() }); 206 | } 207 | 208 | this.topicObservers.get(topic).add(observer); 209 | this.clientObservers.get(client.clientId).observers.add(observer); 210 | 211 | resolve(topic); 212 | }, 213 | onFailure: reject, 214 | }); 215 | }); 216 | } 217 | 218 | onMessage = (topic: string, message: string, selectionNames: string[]) => { 219 | const parsedMessage = JSON.parse(message); 220 | const observers = this.topicObservers.get(topic); 221 | 222 | const data = selectionNames.reduce( 223 | (acc, name) => (acc[name] = acc[name] || null, acc), 224 | parsedMessage.data || {} 225 | ); 226 | 227 | logger('Message received', { data, topic, observers }); 228 | 229 | observers.forEach(observer => { 230 | try { 231 | observer.next({ 232 | ...parsedMessage, 233 | ...{ data }, 234 | }) 235 | } catch (err) { 236 | logger(err); 237 | } 238 | }); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthOptions } from "aws-appsync-auth-link"; 2 | import * as ZenObservable from 'zen-observable-ts'; 3 | 4 | //#region Subscription link enums 5 | 6 | export enum SUBSCRIPTION_STATUS { 7 | PENDING, 8 | CONNECTED, 9 | FAILED 10 | } 11 | 12 | export enum SOCKET_STATUS { 13 | CLOSED, 14 | READY, 15 | CONNECTING 16 | } 17 | 18 | export enum MESSAGE_TYPES { 19 | /** 20 | * Client -> Server message. 21 | * This message type is the first message after handshake and this will initialize AWS AppSync RealTime communication 22 | */ 23 | GQL_CONNECTION_INIT = "connection_init", 24 | /** 25 | * Server -> Client message 26 | * This message type is in case there is an issue with AWS AppSync RealTime when establishing connection 27 | */ 28 | GQL_CONNECTION_ERROR = "connection_error", 29 | /** 30 | * Server -> Client message. 31 | * This message type is for the ack response from AWS AppSync RealTime for GQL_CONNECTION_INIT message 32 | */ 33 | GQL_CONNECTION_ACK = "connection_ack", 34 | /** 35 | * Client -> Server message. 36 | * This message type is for register subscriptions with AWS AppSync RealTime 37 | */ 38 | GQL_START = "start", 39 | /** 40 | * Server -> Client message. 41 | * This message type is for the ack response from AWS AppSync RealTime for GQL_START message 42 | */ 43 | GQL_START_ACK = "start_ack", 44 | /** 45 | * Server -> Client message. 46 | * This message type is for subscription message from AWS AppSync RealTime 47 | */ 48 | GQL_DATA = "data", 49 | /** 50 | * Server -> Client message. 51 | * This message type helps the client to know is still receiving messages from AWS AppSync RealTime 52 | */ 53 | GQL_CONNECTION_KEEP_ALIVE = "ka", 54 | /** 55 | * Client -> Server message. 56 | * This message type is for unregister subscriptions with AWS AppSync RealTime 57 | */ 58 | GQL_STOP = "stop", 59 | /** 60 | * Server -> Client message. 61 | * This message type is for the ack response from AWS AppSync RealTime for GQL_STOP message 62 | */ 63 | GQL_COMPLETE = "complete", 64 | /** 65 | * Server -> Client message. 66 | * This message type is for sending error messages from AWS AppSync RealTime to the client 67 | */ 68 | GQL_ERROR = "error" // Server -> Client 69 | } 70 | 71 | export enum CONTROL_MSG { 72 | CONNECTION_CLOSED = 'Connection closed', 73 | TIMEOUT_DISCONNECT = 'Timeout disconnect', 74 | SUBSCRIPTION_ACK = 'Subscription ack', 75 | } 76 | 77 | //#endregion 78 | 79 | //#region Subscription link types 80 | 81 | export type UrlInfo = { 82 | url: string; 83 | auth: AuthOptions; 84 | region: string; 85 | }; 86 | 87 | export type AppSyncRealTimeSubscriptionConfig = UrlInfo & { 88 | keepAliveTimeoutMs?: number; 89 | }; 90 | 91 | export type ObserverQuery = { 92 | observer: ZenObservable.SubscriptionObserver; 93 | query: string; 94 | variables: object; 95 | subscriptionState: SUBSCRIPTION_STATUS; 96 | subscriptionReadyCallback?: Function; 97 | subscriptionFailedCallback?: Function; 98 | startAckTimeoutId?: number; 99 | }; 100 | 101 | //#endregion 102 | 103 | //#region Retry logic types 104 | 105 | export type DelayFunction = ( 106 | attempt: number, 107 | args?: any[], 108 | error?: Error 109 | ) => number | false; 110 | 111 | //#endregion 112 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | export { default as rootLogger } from './logger'; 6 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | 3 | export type Logger = Function & { 4 | extend(category: string): Logger; 5 | }; 6 | 7 | const debugLogger = debug('aws-appsync') as Logger; 8 | 9 | const extend = function (category = '') { 10 | const newCategory = category ? [...this.namespace.split(':'), category].join(':') : this.namespace; 11 | 12 | const result = debug(newCategory); 13 | result.extend = extend.bind(result); 14 | 15 | return result; 16 | }; 17 | debugLogger.extend = extend.bind(debugLogger); 18 | 19 | export default debugLogger; 20 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/src/utils/retry.ts: -------------------------------------------------------------------------------- 1 | import { rootLogger } from "./index"; 2 | import { DelayFunction } from "../types"; 3 | 4 | const logger = rootLogger.extend("retry"); 5 | 6 | const MAX_DELAY_MS = 5000; 7 | 8 | /** 9 | * Internal use of Subscription link 10 | * @private 11 | */ 12 | export class NonRetryableError extends Error { 13 | public readonly nonRetryable = true; 14 | constructor(message: string) { 15 | super(message); 16 | } 17 | } 18 | 19 | const isNonRetryableError = (obj: any): obj is NonRetryableError => { 20 | const key: keyof NonRetryableError = "nonRetryable"; 21 | return obj && obj[key]; 22 | }; 23 | 24 | /** 25 | * @private 26 | * Internal use of Subscription link 27 | */ 28 | export async function retry( 29 | functionToRetry: Function, 30 | args: any[], 31 | delayFn: DelayFunction, 32 | attempt: number = 1 33 | ) { 34 | logger(`Attempt #${attempt} for this vars: ${JSON.stringify(args)}`); 35 | try { 36 | await functionToRetry.apply(undefined, args); 37 | } catch (err) { 38 | logger(`error ${err}`); 39 | if (isNonRetryableError(err)) { 40 | logger("non retryable error"); 41 | throw err; 42 | } 43 | 44 | const retryIn = delayFn(attempt, args, err); 45 | logger("retryIn ", retryIn); 46 | if (retryIn !== false) { 47 | await new Promise(res => setTimeout(res, retryIn)); 48 | return await retry(functionToRetry, args, delayFn, attempt + 1); 49 | } else { 50 | throw err; 51 | } 52 | } 53 | } 54 | 55 | function jitteredBackoff(maxDelayMs: number): DelayFunction { 56 | const BASE_TIME_MS = 100; 57 | const JITTER_FACTOR = 100; 58 | 59 | return attempt => { 60 | const delay = 2 ** attempt * BASE_TIME_MS + JITTER_FACTOR * Math.random(); 61 | return delay > maxDelayMs ? false : delay; 62 | }; 63 | } 64 | 65 | /** 66 | * @private 67 | * Internal use of Subscription link 68 | */ 69 | export const jitteredExponentialRetry = ( 70 | functionToRetry: Function, 71 | args: any[], 72 | maxDelayMs: number = MAX_DELAY_MS 73 | ) => retry(functionToRetry, args, jitteredBackoff(maxDelayMs)); 74 | -------------------------------------------------------------------------------- /packages/aws-appsync-subscription-link/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "lib": [ 10 | "dom", 11 | "es6", 12 | "esnext.asynciterable", 13 | "es2017.object" 14 | ], 15 | "skipLibCheck": true 16 | }, 17 | "exclude": [ 18 | "lib", 19 | "__tests__" 20 | ], 21 | "compileOnSave": true 22 | } 23 | --------------------------------------------------------------------------------