├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── README.md ├── docker-compose.yml ├── illustrations ├── gql.png └── sns-sqs-with-bus.png ├── jest.config.js ├── package.json ├── src ├── __integration__ │ └── basic.integration.test.ts ├── __test__ │ └── pubsub.test.ts ├── index.ts ├── sns-sqs-pubsub.ts └── types.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json" 6 | }, 7 | "plugins": ["@typescript-eslint"], 8 | "rules": { 9 | "prettier/prettier": ["error", { "singleQuote": true, "trailingComma": "es5" }] 10 | }, 11 | "overrides": [ 12 | { 13 | "files": ["*.test.ts"], 14 | "rules": { 15 | "@typescript-eslint/no-var-requires": "off" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | localstack 4 | yarn-error.log 5 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 100, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 7 | 8 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 9 | 10 | ## Our Standards 11 | 12 | Examples of behavior that contributes to a positive environment for our community include: 13 | 14 | * Demonstrating empathy and kindness toward other people 15 | * Being respectful of differing opinions, viewpoints, and experiences 16 | * Giving and gracefully accepting constructive feedback 17 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 18 | * Focusing on what is best not just for us as individuals, but for the overall community 19 | 20 | Examples of unacceptable behavior include: 21 | 22 | * The use of sexualized language or imagery, and sexual attention or 23 | advances of any kind 24 | * Trolling, insulting or derogatory comments, and personal or political attacks 25 | * Public or private harassment 26 | * Publishing others' private information, such as a physical or email 27 | address, without their explicit permission 28 | * Other conduct which could reasonably be considered inappropriate in a 29 | professional setting 30 | 31 | ## Enforcement Responsibilities 32 | 33 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 34 | 35 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 36 | 37 | ## Scope 38 | 39 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 40 | 41 | ## Enforcement 42 | 43 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at valdas.mazrimas@gmail.com. All complaints will be reviewed and investigated promptly and fairly. 44 | 45 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 46 | 47 | ## Enforcement Guidelines 48 | 49 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 50 | 51 | ### 1. Correction 52 | 53 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 54 | 55 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 56 | 57 | ### 2. Warning 58 | 59 | **Community Impact**: A violation through a single incident or series of actions. 60 | 61 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 62 | 63 | ### 3. Temporary Ban 64 | 65 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 66 | 67 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 68 | 69 | ### 4. Permanent Ban 70 | 71 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 72 | 73 | **Consequence**: A permanent ban from any sort of public interaction within the community. 74 | 75 | ## Attribution 76 | 77 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 78 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 79 | 80 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 81 | 82 | [homepage]: https://www.contributor-covenant.org 83 | 84 | For answers to common questions about this code of conduct, see the FAQ at 85 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 86 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Valdas Mazrimas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-snssqs-subscriptions 2 | 3 | This package implements the PubSubEngine Interface from the [graphql-subscriptions](https://github.com/apollographql/graphql-subscriptions) package. Once initiated this library automatically create subscriptions between SNS and SQS by the given configuration. 4 | 5 | ```bash 6 | npm install -g graphql-snssqs-subscriptions 7 | ``` 8 | 9 | ## Usage 10 | 11 | ```typescript 12 | 13 | // file pubsub.ts 14 | 15 | import { SNSSQSPubSub } from 'graphql-snssqs-subscriptions'; 16 | import env from '../utils/env'; 17 | 18 | export type SNSSQSPubSubType = SNSSQSPubSub; 19 | 20 | let awsEndpoints = {}; 21 | 22 | if (!env.SERVICE_PRODUCTION) { 23 | awsEndpoints = { 24 | sns: { 25 | endpoint: `${env.AWS_SNS_ENDPOINT}`, 26 | }, 27 | sqs: { 28 | endpoint: `${env.AWS_SQS_ENDPOINT}`, 29 | }, 30 | }; 31 | } 32 | 33 | export const getPubSub = async (): Promise => { 34 | const pubsub = new SNSSQSPubSub( 35 | { 36 | accessKeyId: env.AWS_ACCESS_KEY_ID, 37 | secretAccessKey: env.AWS_SECRET_ACCESS_KEY, 38 | region: env.AWS_REGION, 39 | ...awsEndpoints, 40 | }, 41 | { 42 | serviceName: env.SERVICE_NAME, 43 | } 44 | ); 45 | await pubsub.init(); 46 | return pubsub; 47 | }; 48 | 49 | 50 | // file server.ts 51 | const bootstrap = async () => { 52 | const pubSub = await getPubSub(); 53 | 54 | const server = new ApolloServer({ 55 | schema, 56 | context: (req: any): MyServiceContext => ({ 57 | ...req, 58 | pubSub, // ctx.pubsub will be available in your service context 59 | }), 60 | }); 61 | 62 | await server.listen(env.SERVICE_PORT); 63 | logger.info(`Service is listening on port: ${env.SERVICE_PORT}`); 64 | }; 65 | 66 | bootstrap().catch(logger.error); 67 | ``` 68 | 69 | 70 | ## Simple usage in graphql context with TypeGraphQL 71 | 72 | ![Graphql Subscriptions](./illustrations/gql.png) 73 | 74 | ```typescript 75 | import { MessageAttributes } from 'graphql-snssqs-subscriptions'; 76 | 77 | class SimpleMessageDTO { 78 | readonly $name = `${env.APP_DOMAIN}/${env.MY_SERVICE_NAME}/message-subject-or-anything`; 79 | readonly $version = 1; 80 | 81 | msgDataString: string; 82 | 83 | constructor(msgDataString: string) { 84 | this.msgDataString = msgDataString; 85 | } 86 | } 87 | 88 | export class Resolver { 89 | Mutation(() => UpdateSomethingResponse) 90 | async updateSomething( 91 | @Ctx() ctx: MyServiceContext, 92 | @Arg('input') inputData: UpdateSomethigInput 93 | ): Promise { 94 | 95 | // ... some logic... 96 | 97 | ctx.pubSub.publish( 98 | env.MY_SERVICE_NAME, // this is your topic 99 | new SimpleMessageDTO({ 100 | msgDataString: 'some data in message', 101 | }), 102 | new MessageAttributes({ 103 | correlationId: `${ctx.account.id}`, 104 | }) 105 | ); 106 | 107 | return UpdateSomethingResponse(true); 108 | } 109 | 110 | @Subscription(() => Notification, { topics: env.MY_SERVICE_NAME, nullable: true }) 111 | simpleSubscription (@Root() { msgDataString }: NotificationPayload) { 112 | return { msgDataString }; 113 | } 114 | } 115 | ``` 116 | 117 | ## Simple usage with TypeGraphQL and @node-ts/bus-workflow 118 | 119 | - More Info on graphql framework [TypeGraphQL](https://typegraphql.ml/docs/introduction.html) 120 | - More Info on service bus framework [@node-ts/bus](https://github.com/node-ts/bus) 121 | 122 | ![GraphQl and @node-ts/bus](./illustrations/sns-sqs-with-bus.png) 123 | 124 | ```typescript 125 | // Service1 Resover 126 | import { MessageAttributes } from 'graphql-snssqs-subscriptions'; 127 | 128 | class SimpleMessageDTO { 129 | readonly $name = `${env.APP_DOMAIN}/${env.MY_SERVICE_NAME}/message-subject-or-anything`; 130 | readonly $version = 1; 131 | 132 | msgDataString: string; 133 | 134 | constructor(msgDataString: string) { 135 | this.msgDataString = msgDataString; 136 | } 137 | } 138 | 139 | export class Resolver { 140 | Mutation(() => UpdateSomethingResponse) 141 | async updateSomething( 142 | @Ctx() ctx: MyServiceContext, 143 | @Arg('input') inputData: UpdateSomethigInput 144 | ): Promise { 145 | 146 | // ... some logic... 147 | 148 | // Methods publish, sendEvent, sendCommand 149 | ctx.pubSub.sendEvent( 150 | new SimpleMessageDTO({ 151 | msgDataString: 'some data in message', 152 | }), 153 | new MessageAttributes({ 154 | correlationId: `${ctx.account.id}`, 155 | }) 156 | ); 157 | 158 | return UpdateSomethingResponse(true); 159 | } 160 | } 161 | ``` 162 | 163 | ```typescript 164 | // Service2 Workflows 165 | //...imports 166 | class SimpleMessageDTO { 167 | readonly $name = `${env.APP_DOMAIN}/${env.MY_SERVICE_NAME}/message-subject-or-anything`; 168 | readonly $version = 1; 169 | 170 | msgDataString: string; 171 | 172 | constructor(msgDataString: string) { 173 | super(); 174 | this.msgDataString = msgDataString; 175 | } 176 | } 177 | 178 | @injectable() 179 | export class MyWorkflow extends Workflow { 180 | constructor( 181 | @inject(BUS_SYMBOLS.Bus) private readonly bus: Bus, 182 | @inject(LOGGER_SYMBOLS.Logger) private readonly logger: Logger 183 | ) { 184 | super(); 185 | } 186 | 187 | /** 188 | * Starts a new workflow smessage SimpleMewssageDTO is fired 189 | */ 190 | @StartedBy( 191 | SimpleMessageDTO 192 | ) 193 | async handlesSimpleMessage( 194 | event: SimpleMessageDTO, 195 | _: MyWorkflowData, 196 | messageAttributes: MessageAttributes 197 | ): Promise> { 198 | const { msgDataString } = event; 199 | 200 | this.bus.send(new SomeOtherMessageDto()) 201 | 202 | return { 203 | msgDataString, 204 | correlationId: messageAttributes.correlationId, 205 | }; 206 | } 207 | 208 | @Handles< 209 | SomeOtherMessageDto, 210 | MyWorkflowData, 211 | 'someNewMessageHandler' 212 | >(SomeOtherMessageDto, (event, attributes) => attributes.correlationId, 'correlationId') 213 | someNewMessageHandler(): Partial { 214 | // Do whatever in this message handler 215 | this.bus.publish(new MessageToSomeIntegrationServiceMaybe()); 216 | this.complete(); 217 | } 218 | } 219 | 220 | ``` 221 | 222 | ## Benefits 223 | 224 | - Automatically creates subscriptions from SNS to SQS. 225 | - Automatically creates Dead Letter Queues. 226 | - Automatically maps MessageAttributes 227 | - Fully compatable with [@node-ts/bus](https://www.npmjs.com/package/node-ts) package 228 | - Typescript Based 229 | 230 | ## Contributing 231 | 232 | Bug reports and pull requests are welcome on GitHub at https://github.com/sagahead-io/graphql-snssqs-subscriptions/issues. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 233 | 234 | ## License 235 | 236 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 237 | 238 | ## Code of Conduct 239 | 240 | Everyone interacting in the graphql-snssqs-subscriptions project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/sagahead-io/graphql-snssqs-subscriptions/blob/master/CODE_OF_CONDUCT.md). 241 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | aws: 4 | image: localstack/localstack:latest 5 | ports: 6 | - '4575:4575' # SNS 7 | - '4576:4576' # SQS 8 | environment: 9 | - SERVICES=sqs,sns 10 | - DEFAULT_REGION=us-east-2 11 | - AWS_DEFAULT_REGION=us-east-2 12 | - AWS_EXECUTION_ENV=True 13 | volumes: 14 | - './localstack:/tmp/localstack' 15 | -------------------------------------------------------------------------------- /illustrations/gql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sagahead-io/graphql-snssqs-subscriptions/2bd3960e6127c5a02a8ffbe7fe60d7ce9782f4c5/illustrations/gql.png -------------------------------------------------------------------------------- /illustrations/sns-sqs-with-bus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sagahead-io/graphql-snssqs-subscriptions/2bd3960e6127c5a02a8ffbe7fe60d7ce9782f4c5/illustrations/sns-sqs-with-bus.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.ts?$': 'ts-jest', 4 | }, 5 | testEnvironment: 'node', 6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 7 | testPathIgnorePatterns: ['/node_modules/', '/dist', '/.bit', '/.git', '/tmp'], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-snssqs-subscriptions", 3 | "version": "1.2.3", 4 | "main": "./dist/index.js", 5 | "types": "./dist/index.d.ts", 6 | "author": "Valdas Mazrimas ", 7 | "homepage": "https://github.com/sagahead-io/graphql-snssqs-subscriptions", 8 | "scripts": { 9 | "build": "npm run clean && tsc", 10 | "clean": "rm -rf './dist'", 11 | "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts", 12 | "format": "prettier --write './src/**/*.{ts,tsx,js,jsx}'", 13 | "format:check": "prettier --check './src/**/*.{ts,tsx,js,jsx}'", 14 | "test": "yarn test:unit && yarn test:integration", 15 | "test:unit": "jest \"(src/__test__\\/.+\\.|/)test\\.ts$\"", 16 | "test:unit:watch": "yarn run test:unit --watch", 17 | "test:integration": "jest \"(src/__integration__\\/.+\\.|/)test\\.ts$\"" 18 | }, 19 | "files": [ 20 | "/dist" 21 | ], 22 | "keywords": [ 23 | "sqs", 24 | "graphql", 25 | "subscriptions", 26 | "sns", 27 | "aws" 28 | ], 29 | "license": "MIT", 30 | "dependencies": { 31 | "@node-ts/bus-core": "^0.5.2", 32 | "@node-ts/bus-messages": "^0.2.2", 33 | "@node-ts/bus-sqs": "^0.4.2", 34 | "@node-ts/logger-core": "^0.1.0", 35 | "aws-sdk": "^2.654.0", 36 | "debug": "^4.1.1", 37 | "graphql-subscriptions": "^1.1.0", 38 | "reflect-metadata": "^0.1.13" 39 | }, 40 | "devDependencies": { 41 | "@types/debug": "^4.1.5", 42 | "@types/inversify": "^2.0.33", 43 | "@types/jest": "^25.2.1", 44 | "@types/node": "^13.11.0", 45 | "@typescript-eslint/eslint-plugin": "^2.27.0", 46 | "aws-sdk-mock": "^5.1.0", 47 | "eslint": "^6.8.0", 48 | "jest": "^25.2.7", 49 | "jest-emotion": "^10.0.32", 50 | "nodemon": "^2.0.2", 51 | "prettier": "^2.0.4", 52 | "sinon": "^9.0.1", 53 | "ts-jest": "^25.3.1", 54 | "ts-node": "^8.8.2", 55 | "typescript": "^3.8.3" 56 | }, 57 | "jest-junit": { 58 | "preset": "ts-jest", 59 | "setupFilesAfterEnv": [ 60 | "/test/setup.ts" 61 | ], 62 | "transformIgnorePatterns": [ 63 | "[/\\\\]node_modules[/\\\\](?!node-ts.+).+\\.ts$" 64 | ], 65 | "testRegex": "(src\\/.+\\.|/)(integration|test)\\.ts$", 66 | "testMatch": "**/?(*.)+(spec|test|integration).[tj]s?(x)", 67 | "testEnvironment": "node", 68 | "bail": true 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/__integration__/basic.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { SNSSQSPubSub, Message, MessageAttributes, PubSubMessageBody } from '..'; 2 | 3 | const triggerName = 'service1'; 4 | 5 | class SimpleMessage extends Message { 6 | $name = `mydomain/${triggerName}/some-msg-subject`; 7 | 8 | test: string; 9 | 10 | constructor(test: string) { 11 | super(); 12 | this.test = test; 13 | } 14 | } 15 | 16 | const msg = new SimpleMessage('test'); 17 | 18 | const attributes = new MessageAttributes({ 19 | attributes: { 20 | stringAttr: 'string', 21 | numberAttr: 1.24, 22 | }, 23 | correlationId: 'some-correlation-id-1', 24 | stickyAttributes: { 25 | stickyStrAttr: 'string', 26 | stickyNumberAttr: 123, 27 | }, 28 | }); 29 | 30 | describe('sns-sqs-pub-sub basic integraiton', () => { 31 | it('should work', async (done) => { 32 | const instance = new SNSSQSPubSub( 33 | { 34 | region: 'us-east-2', 35 | sns: { 36 | endpoint: `http://localhost:4575`, 37 | }, 38 | sqs: { 39 | endpoint: `http://localhost:4576`, 40 | }, 41 | }, 42 | { serviceName: triggerName, prefix: 'myprefix' } 43 | ); 44 | await instance.init(); 45 | await instance.publish(triggerName, msg, attributes); 46 | await instance.subscribe(triggerName, (data: PubSubMessageBody) => { 47 | console.log(data); 48 | expect(data.raw.MessageId).toEqual(data.id); 49 | expect(data.domainMessage).toEqual(new SimpleMessage('test')); 50 | expect(data.attributes).toEqual(attributes); 51 | instance.unsubscribe(); 52 | done(); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/__test__/pubsub.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('aws-sdk'); 2 | import { SQS } from 'aws-sdk'; 3 | import { SNSSQSPubSub, ExtendedPubSubOptions } from '..'; 4 | 5 | describe('sqs-pub-sub', () => { 6 | it('should create an instance', async () => { 7 | const instance = new SNSSQSPubSub({}, { serviceName: 'mysuperservice' }); 8 | 9 | expect(instance).toBeDefined(); 10 | }); 11 | 12 | it('can call', async () => { 13 | const instance = new SNSSQSPubSub({}, { serviceName: 'mysuperservice', prefix: 'myprefix' }); 14 | 15 | const options: ExtendedPubSubOptions = instance.getOptions(); 16 | const config: SQS.Types.ClientConfiguration = instance.getClientConfig(); 17 | console.log(config); 18 | expect(config).toBeDefined(); 19 | expect(options).toBeDefined(); 20 | expect(instance.init).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | export * from './sns-sqs-pubsub'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/sns-sqs-pubsub.ts: -------------------------------------------------------------------------------- 1 | import { Command, Event, Message, MessageAttributes } from '@node-ts/bus-messages'; 2 | import { 3 | toMessageAttributeMap, 4 | fromMessageAttributeMap, 5 | SqsMessageAttributes, 6 | } from '@node-ts/bus-sqs/dist/sqs-transport'; 7 | import aws, { SQS, SNS } from 'aws-sdk'; 8 | import { PubSubEngine } from 'graphql-subscriptions'; 9 | import { PubSubAsyncIterator } from 'graphql-subscriptions/dist/pubsub-async-iterator'; 10 | import { PubSubOptions, ExtendedPubSubOptions, PubSubMessageBody } from './types'; 11 | import { ConfigurationOptions } from 'aws-sdk/lib/config'; 12 | import { ConfigurationServicePlaceholders } from 'aws-sdk/lib/config_service_placeholders'; 13 | import Debug from 'debug'; 14 | 15 | const debug = Debug('gql-snssqs-subscriptions'); 16 | 17 | const MILLISECONDS_IN_SECONDS = 1000; 18 | 19 | export class SNSSQSPubSub implements PubSubEngine { 20 | public sqs!: SQS; 21 | public sns!: SNS; 22 | private clientConfig: SQS.Types.ClientConfiguration; 23 | private options: ExtendedPubSubOptions = { 24 | withSNS: true, 25 | serviceName: '', 26 | stopped: false, 27 | queueUrl: '', 28 | dlQueueUrl: '', 29 | dlQueueArn: '', 30 | queueArn: '', 31 | topicArn: '', 32 | subscriptionArn: '', 33 | availableTopicsList: [], 34 | }; 35 | 36 | public constructor( 37 | config: ConfigurationOptions & ConfigurationServicePlaceholders = {}, 38 | pubSubOptions: PubSubOptions 39 | ) { 40 | aws.config.update(config); 41 | 42 | this.clientConfig = config; 43 | this.options = { ...this.options, ...pubSubOptions }; 44 | this.sqs = new aws.SQS(); 45 | 46 | if (this.options.withSNS) { 47 | this.sns = new aws.SNS(); 48 | } 49 | 50 | debug('Pubsub Engine is configured with :', this.options); 51 | debug('Pubsub Engine client is configured with :', this.clientConfig); 52 | } 53 | 54 | public init = async (): Promise => { 55 | try { 56 | await this.createPubSub(); 57 | debug('Pubsub Engine is created and has these options :', this.options); 58 | } catch (error) { 59 | debug('Pubsub Engine failed to create ', this.options, error); 60 | return undefined; 61 | } 62 | }; 63 | 64 | public asyncIterator = (triggers: string | string[]): AsyncIterator => { 65 | return new PubSubAsyncIterator(this, triggers); 66 | }; 67 | 68 | public getOptions = (): ExtendedPubSubOptions => ({ ...this.options }); 69 | 70 | public getClientConfig = (): SQS.Types.ClientConfiguration => ({ 71 | ...this.clientConfig, 72 | }); 73 | 74 | private setupPolicies = (queueName: string) => { 75 | if (!this.options.topicArn) { 76 | return {}; 77 | } 78 | 79 | const principalFromTopic = this.options.topicArn.split(':')[4]; 80 | const queueArn = `arn:aws:sqs:${this.clientConfig.region}:${principalFromTopic}:${queueName}`; 81 | const queueArnDLQ = `${queueArn}-DLQ`; 82 | const idFromTopic = `${queueArn}/SQSDefaultPolicy`; 83 | this.options.queueArn = queueArn; 84 | this.options.dlQueueArn = queueArnDLQ; 85 | 86 | return { 87 | Version: '2012-10-17', 88 | Id: `${idFromTopic}`, 89 | Statement: [ 90 | { 91 | Effect: 'Allow', 92 | Principal: '*', 93 | Action: 'SQS:*', 94 | Resource: queueArn, 95 | }, 96 | ], 97 | }; 98 | }; 99 | 100 | // generic pubsub engine publish method, still works with @node-ts/bus Event/Command 101 | async publish( 102 | triggerName: string, 103 | message: MessageType, 104 | messageAttributes?: MessageAttributes 105 | ): Promise { 106 | const resolvedMessageName = this.resolveTopicFromMessageName(message.$name); 107 | if (resolvedMessageName !== triggerName) { 108 | if (resolvedMessageName !== `${this.options.prefix}-${triggerName}`) { 109 | console.error('triggerName should be found in message.$name, message was not published.'); 110 | return; 111 | } 112 | } 113 | await this.publishMessage(message, messageAttributes); 114 | } 115 | 116 | // same as publish but specific for @node-ts/bus Event 117 | async sendEvent( 118 | event: EventType, 119 | messageAttributes?: MessageAttributes 120 | ): Promise { 121 | await this.publishMessage(event, messageAttributes); 122 | } 123 | 124 | // same as publish but specific for @node-ts/bus Command 125 | async sendCommand( 126 | command: CommandType, 127 | messageAttributes?: MessageAttributes 128 | ): Promise { 129 | await this.publishMessage(command, messageAttributes); 130 | } 131 | 132 | private async publishMessage( 133 | message: Message, 134 | messageAttributes: MessageAttributes = new MessageAttributes() 135 | ): Promise { 136 | const topicName = this.resolveTopicFromMessageName(message.$name); 137 | const topicArn = this.resolveTopicArnFromMessageName(topicName); 138 | debug('Publishing message to sns', { message, topicArn }); 139 | 140 | const attributeMap = toMessageAttributeMap(messageAttributes); 141 | debug('Resolved message attributes', { attributeMap }); 142 | 143 | const snsMessage: SNS.PublishInput = { 144 | TopicArn: topicArn, 145 | Subject: message.$name, 146 | Message: JSON.stringify(message), 147 | MessageAttributes: attributeMap, 148 | }; 149 | debug('Sending message to SNS', { snsMessage }); 150 | await this.sns.publish(snsMessage).promise(); 151 | } 152 | 153 | public subscribe = ( 154 | triggerName: string, 155 | onMessage: (body: PubSubMessageBody) => any 156 | ): Promise => { 157 | try { 158 | this.pollMessage(triggerName, onMessage); 159 | 160 | return Promise.resolve(1); 161 | } catch (error) { 162 | debug('Error happens before starting to poll', error); 163 | return Promise.resolve(0); 164 | } 165 | }; 166 | 167 | public unsubscribe = async (): Promise => { 168 | if (!this.options.stopped) { 169 | this.options.stopped = true; 170 | } 171 | }; 172 | 173 | public readonly pollMessage = async ( 174 | topic: string, 175 | onMessage: (body: PubSubMessageBody) => any 176 | ): Promise => { 177 | if (this.options.stopped) { 178 | return; 179 | } 180 | 181 | const params: SQS.ReceiveMessageRequest = { 182 | QueueUrl: this.options.queueUrl, 183 | WaitTimeSeconds: 10, 184 | MaxNumberOfMessages: 1, 185 | MessageAttributeNames: ['.*'], 186 | AttributeNames: ['ApproximateReceiveCount'], 187 | }; 188 | 189 | const result = await this.sqs.receiveMessage(params).promise(); 190 | 191 | if (!result.Messages || result.Messages.length === 0) { 192 | return; 193 | } 194 | 195 | // Only handle the expected number of messages, anything else just return and retry 196 | if (result.Messages.length > 1) { 197 | debug('Received more than the expected number of messages', { 198 | expected: 1, 199 | received: result.Messages.length, 200 | }); 201 | await Promise.all(result.Messages.map(async (message) => this.makeMessageVisible(message))); 202 | return; 203 | } 204 | debug('Received result and messages', { 205 | result, 206 | resultMessages: result.Messages[0], 207 | resultMessageAttribute: result.Messages[0].MessageAttributes, 208 | }); 209 | 210 | const sqsMessage = result.Messages[0]; 211 | 212 | debug('Received message from SQS', { sqsMessage }); 213 | 214 | try { 215 | const snsMessage = JSON.parse(sqsMessage.Body) as Message; 216 | 217 | if (!snsMessage) { 218 | debug('Message is not formatted with an SNS envelope and will be discarded', { 219 | sqsMessage, 220 | }); 221 | await this.deleteMessage(sqsMessage); 222 | return; 223 | } 224 | 225 | const msgName = this.resolveTopicFromMessageName(snsMessage.$name); 226 | 227 | if (msgName !== topic) { 228 | if (msgName !== `${this.options.prefix}-${topic}`) { 229 | debug('message.$name does not have same topic'); 230 | return; 231 | } 232 | } 233 | 234 | const transformedAttributes = this.attributesToComplyNodeBus(sqsMessage.MessageAttributes); 235 | const attributes = fromMessageAttributeMap(transformedAttributes); 236 | 237 | debug('Received message attributes', { 238 | transportAttributes: sqsMessage.MessageAttributes, 239 | messageAttributes: attributes, 240 | }); 241 | 242 | await this.deleteMessage(sqsMessage); 243 | 244 | onMessage({ 245 | id: sqsMessage.MessageId!, 246 | raw: sqsMessage, 247 | domainMessage: snsMessage, 248 | attributes, 249 | }); 250 | } catch (error) { 251 | debug( 252 | "Could not parse message. Message will be retried, though it's likely to end up in the dead letter queue", 253 | { sqsMessage, error } 254 | ); 255 | 256 | await this.makeMessageVisible(sqsMessage); 257 | return; 258 | } 259 | }; 260 | 261 | private createPubSub = async (): Promise => { 262 | // Create SNS Topic and SQS Queue 263 | try { 264 | if (this.options.withSNS) { 265 | await this.createTopic(); 266 | } 267 | await this.createQueue(); 268 | } catch (error) { 269 | debug(`Unable to configure PubSub channel ${error}`); 270 | } 271 | 272 | if (!this.options.withSNS) { 273 | return; 274 | } 275 | 276 | // Subscribe SNS Topic to SQS Queue 277 | try { 278 | const { SubscriptionArn } = await this.sns 279 | .subscribe({ 280 | TopicArn: this.options.topicArn, 281 | Protocol: 'sqs', 282 | Endpoint: this.options.queueArn, 283 | Attributes: { 284 | RawMessageDelivery: 'true', 285 | }, 286 | ReturnSubscriptionArn: true, 287 | }) 288 | .promise(); 289 | this.options.subscriptionArn = SubscriptionArn!; 290 | } catch (error) { 291 | debug(`Unable to subscribe with these options ${this.options}, error: ${error}`); 292 | return undefined; 293 | } 294 | 295 | // Persist available topics in the options 296 | try { 297 | const { Topics } = await this.sns.listTopics().promise(); 298 | this.options.availableTopicsList = Topics!; 299 | } catch (error) { 300 | debug( 301 | `Unable to fetch topics, might be problem when publishing messages ${this.options}, error ${error}` 302 | ); 303 | return undefined; 304 | } 305 | }; 306 | 307 | private createTopic = async (): Promise => { 308 | const { serviceName, prefix } = this.options; 309 | const formedPrefix = prefix ? `${prefix}-` : ''; 310 | try { 311 | const { TopicArn } = await this.sns 312 | .createTopic({ Name: `${formedPrefix}${serviceName}` }) 313 | .promise(); 314 | this.options.topicArn = TopicArn!; 315 | } catch (error) { 316 | debug(`Topic creation failed. ${error}`); 317 | return undefined; 318 | } 319 | }; 320 | 321 | private createQueue = async (): Promise => { 322 | const queueName = this.formQueueName(); 323 | const queueNameeDLQ = this.formQueueName('-DLQ'); 324 | 325 | const policy = { 326 | Policy: JSON.stringify(this.setupPolicies(queueName)), 327 | RedrivePolicy: `{"deadLetterTargetArn":"${this.options.dlQueueArn}","maxReceiveCount":"10"}`, 328 | }; 329 | 330 | const params = { 331 | QueueName: queueName, 332 | Attributes: { 333 | ...policy, 334 | }, 335 | }; 336 | 337 | const paramsDLQ = { 338 | QueueName: queueNameeDLQ, 339 | }; 340 | 341 | try { 342 | const dlqQueueResult = await this.sqs.createQueue(paramsDLQ).promise(); 343 | const queuResult = await this.sqs.createQueue(params).promise(); 344 | this.options.queueUrl = queuResult.QueueUrl!; 345 | this.options.dlQueueUrl = dlqQueueResult.QueueUrl!; 346 | } catch (error) { 347 | debug(`Queue creation failed. ${error}`); 348 | return undefined; 349 | } 350 | }; 351 | 352 | private async makeMessageVisible(sqsMessage: SQS.Message): Promise { 353 | const changeVisibilityRequest: SQS.ChangeMessageVisibilityRequest = { 354 | QueueUrl: this.options.queueUrl, 355 | ReceiptHandle: sqsMessage.ReceiptHandle!, 356 | VisibilityTimeout: this.calculateVisibilityTimeout(sqsMessage), 357 | }; 358 | 359 | await this.sqs.changeMessageVisibility(changeVisibilityRequest).promise(); 360 | } 361 | 362 | private deleteMessage = async (sqsMessage: SQS.Message): Promise => { 363 | const params = { 364 | QueueUrl: this.options.queueUrl, 365 | ReceiptHandle: sqsMessage.ReceiptHandle!, 366 | }; 367 | 368 | try { 369 | await this.sqs.deleteMessage(params).promise(); 370 | } catch (error) { 371 | debug(`Unable to delete message ${error}`); 372 | return; 373 | } 374 | }; 375 | 376 | private formQueueName = (providedSuffix?: string): string => { 377 | const { serviceName, prefix } = this.options; 378 | const queueRoot = serviceName; 379 | const formedPrefix = prefix ? `${prefix}-` : ''; 380 | 381 | return `${formedPrefix}${queueRoot}${providedSuffix || ''}`; 382 | }; 383 | 384 | private resolveTopicArnFromMessageName = (msgTopic: string): string => { 385 | // if provided topic arn resolve fn 386 | if (this.options.topicArnResolverFn) { 387 | return this.options.topicArnResolverFn(msgTopic); 388 | } 389 | 390 | // topic is itself service 391 | if (msgTopic === this.options.serviceName) { 392 | return this.options.topicArn; 393 | } 394 | 395 | // find topic by topic name in the topics list 396 | const result: SNS.Types.Topic[] = this.options.availableTopicsList.filter( 397 | (topic: SNS.Types.Topic) => { 398 | const topicParts = topic.TopicArn!.split(':'); 399 | const topicName = topicParts[5]; 400 | return topicName === msgTopic; 401 | } 402 | ); 403 | 404 | // return found topic or return given argument 405 | return !!result ? result[0].TopicArn! : msgTopic; 406 | }; 407 | 408 | private resolveTopicFromMessageName = (messageName: string): string => { 409 | const { prefix } = this.options; 410 | const resolvedTrigger = !!this.options.topicResolverFn 411 | ? this.options.topicResolverFn(messageName) 412 | : messageName.split('/')[1]; 413 | 414 | if (prefix) { 415 | return `${prefix}-${resolvedTrigger}`; 416 | } 417 | 418 | return resolvedTrigger; 419 | }; 420 | 421 | private calculateVisibilityTimeout = (sqsMessage: SQS.Message): number => { 422 | const currentReceiveCount = parseInt( 423 | (sqsMessage.Attributes && sqsMessage.Attributes.ApproximateReceiveCount) || '0', 424 | 10 425 | ); 426 | const numberOfFailures = currentReceiveCount + 1; 427 | 428 | const delay: number = 5 ^ numberOfFailures; // Delays from 5ms to ~2.5 hrs 429 | return delay / MILLISECONDS_IN_SECONDS; 430 | }; 431 | 432 | private attributesToComplyNodeBus = ( 433 | sqsAttributes: SNS.MessageAttributeMap 434 | ): SqsMessageAttributes => { 435 | let attributes: SqsMessageAttributes = {}; 436 | 437 | Object.keys(sqsAttributes).forEach((key) => { 438 | const attribute = sqsAttributes[key]; 439 | 440 | attributes[key] = { 441 | Type: attribute.DataType, 442 | Value: attribute.StringValue, 443 | }; 444 | }); 445 | 446 | return attributes; 447 | }; 448 | } 449 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { SNS } from 'aws-sdk'; 2 | import { MessageAttributes, Message } from '@node-ts/bus-messages'; 3 | import { SQSMessageBody } from '@node-ts/bus-sqs/dist/sqs-transport'; 4 | 5 | export type PubSubOptions = { 6 | serviceName: string; 7 | withSNS?: boolean; 8 | prefix?: string; 9 | }; 10 | 11 | export type ExtendedPubSubOptions = { 12 | stopped: boolean; 13 | queueUrl: string; 14 | dlQueueUrl: string; 15 | dlQueueArn: string; 16 | queueArn: string; 17 | topicArn: string; 18 | subscriptionArn: string; 19 | availableTopicsList: SNS.Types.TopicsList; 20 | topicResolverFn?: (msgName: string) => string; 21 | topicArnResolverFn?: (topic: string) => string; 22 | } & PubSubOptions; 23 | 24 | type ObjectType = { 25 | [key: string]: any; 26 | }; 27 | 28 | export { MessageAttributes, Message }; 29 | 30 | export interface PubSubMessageBody extends ObjectType { 31 | id: string; 32 | raw: any; 33 | domainMessage: Message; 34 | attributes: MessageAttributes; 35 | } 36 | 37 | export { SQSMessageBody as SNSSQSMessageBody }; 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "allowSyntheticDefaultImports": true, 6 | "target": "es6", 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "baseUrl": ".", 12 | "declaration": true, 13 | "paths": { 14 | "*": ["node_modules/*"] 15 | } 16 | }, 17 | "include": ["src/**/*"] 18 | } 19 | --------------------------------------------------------------------------------