├── .eslintignore ├── .npmignore ├── .gitignore ├── src ├── index.ts ├── with-filter.ts ├── pubsub-async-iterator.ts ├── test │ ├── integration-tests.ts │ └── tests.ts └── redis-pubsub.ts ├── .mocharc.json ├── .nycrc.json ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── publish.yml ├── tsconfig.json ├── .eslintrc.js ├── LICENSE ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src/test 3 | dist 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules 3 | typings 4 | tsconfig.json 5 | typings.json 6 | tslint.json 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | coverage 5 | typings 6 | npm-debug.log 7 | .nyc_output 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { RedisPubSub } from './redis-pubsub'; 2 | export type { PubSubRedisOptions } from './redis-pubsub' 3 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui": "bdd", 3 | "exit": true, 4 | "check-leaks": true, 5 | "require": ["ts-node/register"] 6 | } 7 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "all": false, 4 | "include": ["src/**"], 5 | "exclude": ["src/test/**"], 6 | "check-coverage": true 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: "/" 7 | # Check the npm registry for updates every day (weekdays) 8 | schedule: 9 | interval: "daily" 10 | # Enable version updates for GitHub Actions 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "noImplicitAny": false, 8 | "rootDir": "./src", 9 | "outDir": "./dist", 10 | "allowSyntheticDefaultImports": true, 11 | "pretty": true, 12 | "removeComments": true, 13 | "declaration": true, 14 | "lib": ["es6","esnext"] 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "src/test", 19 | "dist" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | rules: { 12 | '@typescript-eslint/no-explicit-any': 'off', 13 | '@typescript-eslint/no-require-imports': 'warn', 14 | '@typescript-eslint/no-unused-vars': [ 'error', 15 | { caughtErrors: 'none' } 16 | ] 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | services: 13 | redis: 14 | image: redis 15 | ports: 16 | - 6379:6379 17 | redis-cluster: 18 | image: grokzen/redis-cluster:7.0.10 19 | ports: 20 | - 7006:7000 21 | - 7001:7001 22 | - 7002:7002 23 | - 7003:7003 24 | - 7004:7004 25 | - 7005:7005 26 | strategy: 27 | matrix: 28 | node-version: [lts/*] 29 | 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Setup node 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | cache: 'npm' 39 | 40 | - name: Install dependencies 41 | run: npm ci 42 | 43 | - name: Test 44 | run: npm test 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 David Yahalomi 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-and-publish: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | services: 16 | redis: 17 | image: redis 18 | ports: 19 | - 6379:6379 20 | redis-cluster: 21 | image: grokzen/redis-cluster:7.0.10 22 | ports: 23 | - 7006:7000 24 | - 7001:7001 25 | - 7002:7002 26 | - 7003:7003 27 | - 7004:7004 28 | - 7005:7005 29 | 30 | strategy: 31 | matrix: 32 | node-version: [lts/*] 33 | 34 | steps: 35 | - name: Checkout code 36 | uses: actions/checkout@v4 37 | 38 | - name: Set up Node.js 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | registry-url: 'https://registry.npmjs.org' 43 | cache: 'npm' 44 | 45 | - name: Install Dependencies 46 | run: npm ci 47 | 48 | - name: Publish to npm 49 | run: npm publish --provenance --access public 50 | env: 51 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | -------------------------------------------------------------------------------- /src/with-filter.ts: -------------------------------------------------------------------------------- 1 | export type FilterFn = (rootValue?: any, args?: any, context?: any, info?: any) => boolean; 2 | 3 | export const withFilter = (asyncIteratorFn: () => AsyncIterableIterator, filterFn: FilterFn) => { 4 | return (rootValue: any, args: any, context: any, info: any): AsyncIterator => { 5 | const asyncIterator = asyncIteratorFn(); 6 | 7 | const getNextPromise = () => { 8 | return asyncIterator 9 | .next() 10 | .then(payload => Promise.all([ 11 | payload, 12 | Promise.resolve(filterFn(payload.value, args, context, info)).catch(() => false), 13 | ])) 14 | .then(([payload, filterResult]) => { 15 | if (filterResult === true) { 16 | return payload; 17 | } 18 | 19 | // Skip the current value and wait for the next one 20 | return getNextPromise(); 21 | }); 22 | }; 23 | 24 | return { 25 | next() { 26 | return getNextPromise(); 27 | }, 28 | return() { 29 | return asyncIterator.return(); 30 | }, 31 | throw(error) { 32 | return asyncIterator.throw(error); 33 | }, 34 | [Symbol.asyncIterator]() { 35 | return this; 36 | }, 37 | } as any; 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-redis-subscriptions", 3 | "version": "2.7.0", 4 | "description": "A graphql-subscriptions PubSub Engine using redis", 5 | "main": "dist/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/davidyaha/graphql-redis-subscriptions.git" 9 | }, 10 | "keywords": [ 11 | "graphql", 12 | "redis", 13 | "apollo", 14 | "subscriptions" 15 | ], 16 | "author": "David Yahalomi", 17 | "contributors": [ 18 | { 19 | "name": "Michał Lytek", 20 | "url": "https://github.com/19majkel94" 21 | } 22 | ], 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/davidyaha/graphql-redis-subscriptions/issues" 26 | }, 27 | "homepage": "https://github.com/davidyaha/graphql-redis-subscriptions", 28 | "scripts": { 29 | "compile": "tsc", 30 | "test": "npm run coverage && npm run lint", 31 | "lint": "eslint src --ext ts", 32 | "watch": "tsc -w", 33 | "testonly": "mocha --reporter spec src/test/tests.ts", 34 | "integration": "mocha --reporter spec src/test/integration-tests.ts", 35 | "coverage": "nyc --reporter=html --reporter=text mocha src/test/**/*.ts", 36 | "prepublish": "tsc", 37 | "prepublishOnly": "npm run test" 38 | }, 39 | "peerDependencies": { 40 | "graphql-subscriptions": "^1.0.0 || ^2.0.0 || ^3.0.0" 41 | }, 42 | "devDependencies": { 43 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 44 | "@types/chai": "^4.2.12", 45 | "@types/chai-as-promised": "^7.1.3", 46 | "@types/ioredis": "^5.0.0", 47 | "@types/mocha": "^9.1.1", 48 | "@types/node": "22.10.2", 49 | "@types/simple-mock": "^0.8.1", 50 | "@typescript-eslint/eslint-plugin": "^8.18.0", 51 | "@typescript-eslint/parser": "^8.18.0", 52 | "chai": "^4.2.0", 53 | "chai-as-promised": "^7.1.1", 54 | "eslint": "^8.57.1", 55 | "graphql": "^16.6.0", 56 | "graphql-subscriptions": "^3.0.0", 57 | "ioredis": "^5.3.2", 58 | "mocha": "^10.0.0", 59 | "nyc": "^17.1.0", 60 | "simple-mock": "^0.8.0", 61 | "ts-node": "^10.9.1", 62 | "typescript": "^5.7.2" 63 | }, 64 | "optionalDependencies": { 65 | "ioredis": "^5.3.2" 66 | }, 67 | "typings": "dist/index.d.ts", 68 | "typescript": { 69 | "definition": "dist/index.d.ts" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers 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, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at davidyaha@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /src/pubsub-async-iterator.ts: -------------------------------------------------------------------------------- 1 | import { PubSubEngine } from 'graphql-subscriptions'; 2 | 3 | /** 4 | * A class for digesting PubSubEngine events via the new AsyncIterator interface. 5 | * This implementation is a generic version of the one located at 6 | * https://github.com/apollographql/graphql-subscriptions/blob/master/src/event-emitter-to-async-iterator.ts 7 | * @class 8 | * 9 | * @constructor 10 | * 11 | * @property pullQueue @type {Function[]} 12 | * A queue of resolve functions waiting for an incoming event which has not yet arrived. 13 | * This queue expands as next() calls are made without PubSubEngine events occurring in between. 14 | * 15 | * @property pushQueue @type {any[]} 16 | * A queue of PubSubEngine events waiting for next() calls to be made. 17 | * This queue expands as PubSubEngine events arrice without next() calls occurring in between. 18 | * 19 | * @property eventsArray @type {string[]} 20 | * An array of PubSubEngine event names which this PubSubAsyncIterator should watch. 21 | * 22 | * @property allSubscribed @type {Promise} 23 | * A promise of a list of all subscription ids to the passed PubSubEngine. 24 | * 25 | * @property listening @type {boolean} 26 | * Whether or not the PubSubAsynIterator is in listening mode (responding to incoming PubSubEngine events and next() calls). 27 | * Listening begins as true and turns to false once the return method is called. 28 | * 29 | * @property pubsub @type {PubSubEngine} 30 | * The PubSubEngine whose events will be observed. 31 | */ 32 | export class PubSubAsyncIterator implements AsyncIterableIterator { 33 | 34 | constructor(pubsub: PubSubEngine, eventNames: string | readonly string[], options?: unknown) { 35 | this.pubsub = pubsub; 36 | this.options = options; 37 | this.pullQueue = []; 38 | this.pushQueue = []; 39 | this.listening = true; 40 | this.eventsArray = typeof eventNames === 'string' ? [eventNames] : eventNames; 41 | } 42 | 43 | public async next() { 44 | await this.subscribeAll(); 45 | return this.listening ? this.pullValue() : this.return(); 46 | } 47 | 48 | public async return(): Promise<{ value: unknown, done: true }> { 49 | await this.emptyQueue(); 50 | return { value: undefined, done: true }; 51 | } 52 | 53 | public async throw(error): Promise { 54 | await this.emptyQueue(); 55 | return Promise.reject(error); 56 | } 57 | 58 | public [Symbol.asyncIterator]() { 59 | return this; 60 | } 61 | 62 | private pullQueue: Array<(data: { value: unknown, done: boolean }) => void>; 63 | private pushQueue: any[]; 64 | private eventsArray: readonly string[]; 65 | private subscriptionIds: Promise | undefined; 66 | private listening: boolean; 67 | private pubsub: PubSubEngine; 68 | private options: unknown; 69 | 70 | private async pushValue(event) { 71 | await this.subscribeAll(); 72 | if (this.pullQueue.length !== 0) { 73 | this.pullQueue.shift()({ value: event, done: false }); 74 | } else { 75 | this.pushQueue.push(event); 76 | } 77 | } 78 | 79 | private pullValue(): Promise> { 80 | return new Promise(resolve => { 81 | if (this.pushQueue.length !== 0) { 82 | resolve({ value: this.pushQueue.shift(), done: false }); 83 | } else { 84 | this.pullQueue.push(resolve); 85 | } 86 | }); 87 | } 88 | 89 | private async emptyQueue() { 90 | if (this.listening) { 91 | this.listening = false; 92 | if (this.subscriptionIds) this.unsubscribeAll(await this.subscriptionIds); 93 | this.pullQueue.forEach(resolve => resolve({ value: undefined, done: true })); 94 | this.pullQueue.length = 0; 95 | this.pushQueue.length = 0; 96 | } 97 | } 98 | 99 | private subscribeAll() { 100 | if (!this.subscriptionIds) { 101 | this.subscriptionIds = Promise.all(this.eventsArray.map( 102 | eventName => this.pubsub.subscribe(eventName, this.pushValue.bind(this), this.options), 103 | )); 104 | } 105 | return this.subscriptionIds; 106 | } 107 | 108 | private unsubscribeAll(subscriptionIds: number[]) { 109 | for (const subscriptionId of subscriptionIds) { 110 | this.pubsub.unsubscribe(subscriptionId); 111 | } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/test/integration-tests.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as chaiAsPromised from 'chai-as-promised'; 3 | import { mock } from 'simple-mock'; 4 | import { parse, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLFieldResolver } from 'graphql'; 5 | import { subscribe } from 'graphql/subscription'; 6 | 7 | import { RedisPubSub } from '../redis-pubsub'; 8 | import { withFilter } from '../with-filter'; 9 | import { Cluster } from 'ioredis'; 10 | 11 | chai.use(chaiAsPromised); 12 | const expect = chai.expect; 13 | 14 | const FIRST_EVENT = 'FIRST_EVENT'; 15 | const SECOND_EVENT = 'SECOND_EVENT'; 16 | 17 | function buildSchema(iterator, patternIterator) { 18 | return new GraphQLSchema({ 19 | query: new GraphQLObjectType({ 20 | name: 'Query', 21 | fields: { 22 | testString: { 23 | type: GraphQLString, 24 | resolve: function(_, args) { 25 | return 'works'; 26 | }, 27 | }, 28 | }, 29 | }), 30 | subscription: new GraphQLObjectType({ 31 | name: 'Subscription', 32 | fields: { 33 | testSubscription: { 34 | type: GraphQLString, 35 | subscribe: withFilter(() => iterator, () => true) as GraphQLFieldResolver, 36 | resolve: root => { 37 | return 'FIRST_EVENT'; 38 | }, 39 | }, 40 | 41 | testPatternSubscription: { 42 | type: GraphQLString, 43 | subscribe: withFilter(() => patternIterator, () => true) as GraphQLFieldResolver, 44 | resolve: root => { 45 | return 'SECOND_EVENT'; 46 | }, 47 | }, 48 | }, 49 | }), 50 | }); 51 | } 52 | 53 | describe('PubSubAsyncIterator', function() { 54 | const query = parse(` 55 | subscription S1 { 56 | testSubscription 57 | } 58 | `); 59 | 60 | const patternQuery = parse(` 61 | subscription S1 { 62 | testPatternSubscription 63 | } 64 | `); 65 | 66 | const pubsub = new RedisPubSub(); 67 | const origIterator = pubsub.asyncIterableIterator(FIRST_EVENT); 68 | const origPatternIterator = pubsub.asyncIterableIterator('SECOND*', { pattern: true }); 69 | const returnSpy = mock(origIterator, 'return'); 70 | const schema = buildSchema(origIterator, origPatternIterator); 71 | 72 | before(() => { 73 | // Warm the redis connection so that tests would pass 74 | pubsub.publish('WARM_UP', {}); 75 | }); 76 | 77 | after(() => { 78 | pubsub.close(); 79 | }); 80 | 81 | it('should allow subscriptions', () => 82 | subscribe({ schema, document: query}) 83 | .then(ai => { 84 | // tslint:disable-next-line:no-unused-expression 85 | expect(ai[Symbol.asyncIterator]).not.to.be.undefined; 86 | 87 | const r = (ai as AsyncIterator).next(); 88 | setTimeout(() => pubsub.publish(FIRST_EVENT, {}), 50); 89 | 90 | return r; 91 | }) 92 | .then(res => { 93 | expect(res.value.data.testSubscription).to.equal('FIRST_EVENT'); 94 | })); 95 | 96 | it('should allow pattern subscriptions', () => 97 | subscribe({ schema, document: patternQuery }) 98 | .then(ai => { 99 | // tslint:disable-next-line:no-unused-expression 100 | expect(ai[Symbol.asyncIterator]).not.to.be.undefined; 101 | 102 | const r = (ai as AsyncIterator).next(); 103 | setTimeout(() => pubsub.publish(SECOND_EVENT, {}), 50); 104 | 105 | return r; 106 | }) 107 | .then(res => { 108 | expect(res.value.data.testPatternSubscription).to.equal('SECOND_EVENT'); 109 | })); 110 | 111 | it('should clear event handlers', () => 112 | subscribe({ schema, document: query}) 113 | .then(ai => { 114 | // tslint:disable-next-line:no-unused-expression 115 | expect(ai[Symbol.asyncIterator]).not.to.be.undefined; 116 | 117 | pubsub.publish(FIRST_EVENT, {}); 118 | 119 | return (ai as AsyncIterator).return(); 120 | }) 121 | .then(res => { 122 | expect(returnSpy.callCount).to.be.gte(1); 123 | })); 124 | }); 125 | 126 | describe('Subscribe to buffer', () => { 127 | it('can publish buffers as well' , done => { 128 | // when using messageBuffer, with redis instance the channel name is not a string but a buffer 129 | const pubSub = new RedisPubSub({ messageEventName: 'messageBuffer'}); 130 | const payload = 'This is amazing'; 131 | 132 | pubSub.subscribe('Posts', message => { 133 | try { 134 | expect(message).to.be.instanceOf(Buffer); 135 | expect(message.toString('utf-8')).to.be.equal(payload); 136 | done(); 137 | } catch (e) { 138 | done(e); 139 | } 140 | }).then(async subId => { 141 | try { 142 | await pubSub.publish('Posts', Buffer.from(payload, 'utf-8')); 143 | } catch (e) { 144 | done(e); 145 | } 146 | }); 147 | }); 148 | }) 149 | 150 | describe('PubSubCluster', () => { 151 | const nodes = [7006, 7001, 7002, 7003, 7004, 7005].map(port => ({ host: '127.0.0.1', port })); 152 | const cluster = new Cluster(nodes); 153 | const eventKey = 'clusterEvtKey'; 154 | const pubsub = new RedisPubSub({ 155 | publisher: cluster, 156 | subscriber: cluster, 157 | }); 158 | 159 | before(async () => { 160 | await cluster.set('toto', 'aaa'); 161 | setTimeout(() => { 162 | pubsub.publish(eventKey, { fired: true, from: 'cluster' }); 163 | }, 500); 164 | }); 165 | 166 | it('Cluster should work', async () => { 167 | expect(await cluster.get('toto')).to.eq('aaa'); 168 | }); 169 | 170 | it('Cluster subscribe', () => { 171 | pubsub.subscribe<{fire: boolean, from: string}>(eventKey, (data) => { 172 | expect(data).to.contains({ fired: true, from: 'cluster' }); 173 | }); 174 | }).timeout(2000); 175 | }); 176 | -------------------------------------------------------------------------------- /src/redis-pubsub.ts: -------------------------------------------------------------------------------- 1 | import {Cluster, Redis, RedisOptions} from 'ioredis'; 2 | import {PubSubEngine} from 'graphql-subscriptions'; 3 | import {PubSubAsyncIterator} from './pubsub-async-iterator'; 4 | 5 | type RedisClient = Redis | Cluster; 6 | type OnMessage = (message: T) => void; 7 | type DeserializerContext = { channel: string, pattern?: string }; 8 | 9 | export interface PubSubRedisOptions { 10 | connection?: RedisOptions | string; 11 | triggerTransform?: TriggerTransform; 12 | connectionListener?: (err: Error) => void; 13 | publisher?: RedisClient; 14 | subscriber?: RedisClient; 15 | reviver?: Reviver; 16 | serializer?: Serializer; 17 | deserializer?: Deserializer; 18 | messageEventName?: string; 19 | pmessageEventName?: string; 20 | } 21 | 22 | export class RedisPubSub implements PubSubEngine { 23 | 24 | constructor(options: PubSubRedisOptions = {}) { 25 | const { 26 | triggerTransform, 27 | connection, 28 | connectionListener, 29 | subscriber, 30 | publisher, 31 | reviver, 32 | serializer, 33 | deserializer, 34 | messageEventName = 'message', 35 | pmessageEventName = 'pmessage', 36 | } = options; 37 | 38 | this.triggerTransform = triggerTransform || (trigger => trigger as string); 39 | 40 | if (reviver && deserializer) { 41 | throw new Error("Reviver and deserializer can't be used together"); 42 | } 43 | 44 | this.reviver = reviver; 45 | this.serializer = serializer; 46 | this.deserializer = deserializer; 47 | 48 | if (subscriber && publisher) { 49 | this.redisPublisher = publisher; 50 | this.redisSubscriber = subscriber; 51 | } else { 52 | try { 53 | // eslint-disable-next-line @typescript-eslint/no-var-requires 54 | const IORedis = require('ioredis'); 55 | this.redisPublisher = new IORedis(connection); 56 | this.redisSubscriber = new IORedis(connection); 57 | 58 | if (connectionListener) { 59 | this.redisPublisher 60 | .on('connect', connectionListener) 61 | .on('error', connectionListener); 62 | this.redisSubscriber 63 | .on('connect', connectionListener) 64 | .on('error', connectionListener); 65 | } else { 66 | this.redisPublisher.on('error', console.error); 67 | this.redisSubscriber.on('error', console.error); 68 | } 69 | } catch (error) { 70 | console.error( 71 | `No publisher or subscriber instances were provided and the package 'ioredis' wasn't found. Couldn't create Redis clients.`, 72 | ); 73 | } 74 | } 75 | 76 | // handle messages received via psubscribe and subscribe 77 | this.redisSubscriber.on(pmessageEventName, this.onMessage.bind(this)); 78 | // partially applied function passes undefined for pattern arg since 'message' event won't provide it: 79 | this.redisSubscriber.on(messageEventName, this.onMessage.bind(this, undefined)); 80 | 81 | this.subscriptionMap = {}; 82 | this.subsRefsMap = new Map>(); 83 | this.subsPendingRefsMap = new Map }>(); 84 | this.currentSubscriptionId = 0; 85 | } 86 | 87 | public async publish(trigger: string, payload: T): Promise { 88 | if(this.serializer) { 89 | await this.redisPublisher.publish(trigger, this.serializer(payload)); 90 | } else if (payload instanceof Buffer){ 91 | await this.redisPublisher.publish(trigger, payload); 92 | } else { 93 | await this.redisPublisher.publish(trigger, JSON.stringify(payload)); 94 | } 95 | } 96 | 97 | public subscribe( 98 | trigger: string, 99 | onMessage: OnMessage, 100 | options: unknown = {}, 101 | ): Promise { 102 | 103 | const triggerName: string = this.triggerTransform(trigger, options); 104 | const id = this.currentSubscriptionId++; 105 | this.subscriptionMap[id] = [triggerName, onMessage]; 106 | 107 | if (!this.subsRefsMap.has(triggerName)) { 108 | this.subsRefsMap.set(triggerName, new Set()); 109 | } 110 | 111 | const refs = this.subsRefsMap.get(triggerName); 112 | 113 | const pendingRefs = this.subsPendingRefsMap.get(triggerName) 114 | if (pendingRefs != null) { 115 | // A pending remote subscribe call is currently in flight, piggyback on it 116 | pendingRefs.refs.push(id) 117 | return pendingRefs.pending.then(() => id) 118 | } else if (refs.size > 0) { 119 | // Already actively subscribed to redis 120 | refs.add(id); 121 | return Promise.resolve(id); 122 | } else { 123 | // New subscription. 124 | // Keep a pending state until the remote subscribe call is completed 125 | const pending = new Deferred() 126 | const subsPendingRefsMap = this.subsPendingRefsMap 127 | subsPendingRefsMap.set(triggerName, { refs: [], pending }); 128 | 129 | const sub = new Promise((resolve, reject) => { 130 | const subscribeFn = options['pattern'] ? this.redisSubscriber.psubscribe : this.redisSubscriber.subscribe; 131 | 132 | subscribeFn.call(this.redisSubscriber, triggerName, err => { 133 | if (err) { 134 | subsPendingRefsMap.delete(triggerName) 135 | reject(err); 136 | } else { 137 | // Add ids of subscribe calls initiated when waiting for the remote call response 138 | const pendingRefs = subsPendingRefsMap.get(triggerName) 139 | pendingRefs.refs.forEach((id) => refs.add(id)) 140 | subsPendingRefsMap.delete(triggerName) 141 | 142 | refs.add(id); 143 | resolve(id); 144 | } 145 | }); 146 | }); 147 | // Ensure waiting subscribe will complete 148 | sub.then(pending.resolve).catch(pending.reject) 149 | return sub; 150 | } 151 | } 152 | 153 | public unsubscribe(subId: number): void { 154 | const [triggerName = null] = this.subscriptionMap[subId] || []; 155 | const refs = this.subsRefsMap.get(triggerName); 156 | 157 | if (!refs) throw new Error(`There is no subscription of id "${subId}"`); 158 | 159 | if (refs.size === 1) { 160 | // unsubscribe from specific channel and pattern match 161 | this.redisSubscriber.unsubscribe(triggerName); 162 | this.redisSubscriber.punsubscribe(triggerName); 163 | 164 | this.subsRefsMap.delete(triggerName); 165 | } else { 166 | refs.delete(subId); 167 | } 168 | delete this.subscriptionMap[subId]; 169 | } 170 | 171 | public asyncIterator(triggers: string | string[], options?: unknown) { 172 | return new PubSubAsyncIterator(this, triggers, options); 173 | } 174 | 175 | public asyncIterableIterator(triggers: string | string[], options?: unknown) { 176 | return new PubSubAsyncIterator(this, triggers, options); 177 | } 178 | 179 | public getSubscriber(): RedisClient { 180 | return this.redisSubscriber; 181 | } 182 | 183 | public getPublisher(): RedisClient { 184 | return this.redisPublisher; 185 | } 186 | 187 | public close(): Promise<'OK'[]> { 188 | return Promise.all([ 189 | this.redisPublisher.quit(), 190 | this.redisSubscriber.quit(), 191 | ]); 192 | } 193 | 194 | private readonly serializer?: Serializer; 195 | private readonly deserializer?: Deserializer; 196 | private readonly triggerTransform: TriggerTransform; 197 | private readonly redisSubscriber: RedisClient; 198 | private readonly redisPublisher: RedisClient; 199 | private readonly reviver: Reviver; 200 | 201 | private readonly subscriptionMap: { [subId: number]: [string, OnMessage] }; 202 | private readonly subsRefsMap: Map>; 203 | private readonly subsPendingRefsMap: Map }>; 204 | private currentSubscriptionId: number; 205 | 206 | private onMessage(pattern: string, channel: string | Buffer, message: string | Buffer) { 207 | if(typeof channel === 'object') channel = channel.toString('utf8'); 208 | 209 | const subscribers = this.subsRefsMap.get(pattern || channel); 210 | 211 | // Don't work for nothing.. 212 | if (!subscribers?.size) return; 213 | 214 | let parsedMessage; 215 | try { 216 | if(this.deserializer){ 217 | parsedMessage = this.deserializer(Buffer.from(message), { pattern, channel }) 218 | } else if(typeof message === 'string'){ 219 | parsedMessage = JSON.parse(message, this.reviver); 220 | } else { 221 | parsedMessage = message; 222 | } 223 | } catch (e) { 224 | parsedMessage = message; 225 | } 226 | 227 | subscribers.forEach(subId => { 228 | const [, listener] = this.subscriptionMap[subId]; 229 | listener(parsedMessage); 230 | }); 231 | } 232 | } 233 | 234 | // Unexported deferrable promise used to complete waiting subscribe calls 235 | function Deferred() { 236 | const p = this.promise = new Promise((resolve, reject) => { 237 | this.resolve = resolve; 238 | this.reject = reject; 239 | }); 240 | this.then = p.then.bind(p); 241 | this.catch = p.catch.bind(p); 242 | if (p.finally) { 243 | this.finally = p.finally.bind(p); 244 | } 245 | } 246 | 247 | export type Path = Array; 248 | export type Trigger = string | Path; 249 | export type TriggerTransform = ( 250 | trigger: Trigger, 251 | channelOptions?: unknown, 252 | ) => string; 253 | export type Reviver = (key: any, value: any) => any; 254 | export type Serializer = (source: any) => string; 255 | export type Deserializer = (source: string | Buffer, context: DeserializerContext) => any; 256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-redis-subscriptions 2 | 3 | [![Build Status](https://travis-ci.org/davidyaha/graphql-redis-subscriptions.svg?branch=master)](https://travis-ci.org/davidyaha/graphql-redis-subscriptions) 4 | 5 | This package implements the PubSubEngine Interface from the [graphql-subscriptions](https://github.com/apollographql/graphql-subscriptions) package and also the new AsyncIterator interface. 6 | It allows you to connect your subscriptions manager to a Redis Pub Sub mechanism to support 7 | multiple subscription manager instances. 8 | 9 | ## Installation 10 | At first, install the `graphql-redis-subscriptions` package: 11 | ``` 12 | npm install graphql-redis-subscriptions 13 | ``` 14 | 15 | As the [graphql-subscriptions](https://github.com/apollographql/graphql-subscriptions) package is declared as a peer dependency, you might receive warning about an unmet peer dependency if it's not installed already by one of your other packages. In that case you also need to install it too: 16 | ``` 17 | npm install graphql-subscriptions 18 | ``` 19 | 20 | ## Using as AsyncIterator 21 | 22 | Define your GraphQL schema with a `Subscription` type: 23 | 24 | ```graphql 25 | schema { 26 | query: Query 27 | mutation: Mutation 28 | subscription: Subscription 29 | } 30 | 31 | type Subscription { 32 | somethingChanged: Result 33 | } 34 | 35 | type Result { 36 | id: String 37 | } 38 | ``` 39 | 40 | Now, let's create a simple `RedisPubSub` instance: 41 | 42 | ```javascript 43 | import { RedisPubSub } from 'graphql-redis-subscriptions'; 44 | const pubsub = new RedisPubSub(); 45 | ``` 46 | 47 | Now, implement your Subscriptions type resolver, using the `pubsub.asyncIterator` to map the event you need: 48 | 49 | ```javascript 50 | const SOMETHING_CHANGED_TOPIC = 'something_changed'; 51 | 52 | export const resolvers = { 53 | Subscription: { 54 | somethingChanged: { 55 | subscribe: () => pubsub.asyncIterator(SOMETHING_CHANGED_TOPIC), 56 | }, 57 | }, 58 | } 59 | ``` 60 | 61 | > Subscriptions resolvers are not a function, but an object with `subscribe` method, that returns `AsyncIterable`. 62 | 63 | Calling the method `asyncIterator` of the `RedisPubSub` instance will send redis a `SUBSCRIBE` message to the topic provided and will return an `AsyncIterator` binded to the RedisPubSub instance and listens to any event published on that topic. 64 | Now, the GraphQL engine knows that `somethingChanged` is a subscription, and every time we will use `pubsub.publish` over this topic, the `RedisPubSub` will `PUBLISH` the event over redis to all other subscribed instances and those in their turn will emit the event to GraphQL using the `next` callback given by the GraphQL engine. 65 | 66 | ```js 67 | pubsub.publish(SOMETHING_CHANGED_TOPIC, { somethingChanged: { id: "123" }}); 68 | ``` 69 | 70 | ## Dynamically create a topic based on subscription args passed on the query 71 | 72 | ```javascript 73 | export const resolvers = { 74 | Subscription: { 75 | somethingChanged: { 76 | subscribe: (_, args) => pubsub.asyncIterator(`${SOMETHING_CHANGED_TOPIC}.${args.relevantId}`), 77 | }, 78 | }, 79 | } 80 | ``` 81 | 82 | ## Using a pattern on subscription 83 | 84 | ```javascript 85 | export const resolvers = { 86 | Subscription: { 87 | somethingChanged: { 88 | subscribe: (_, args) => pubsub.asyncIterator(`${SOMETHING_CHANGED_TOPIC}.${args.relevantId}.*`, { pattern: true }) 89 | }, 90 | }, 91 | } 92 | ``` 93 | 94 | ## Using both arguments and payload to filter events 95 | 96 | ```javascript 97 | import { withFilter } from 'graphql-subscriptions'; 98 | 99 | export const resolvers = { 100 | Subscription: { 101 | somethingChanged: { 102 | subscribe: withFilter( 103 | (_, args) => pubsub.asyncIterator(`${SOMETHING_CHANGED_TOPIC}.${args.relevantId}`), 104 | (payload, variables) => payload.somethingChanged.id === variables.relevantId, 105 | ), 106 | }, 107 | }, 108 | } 109 | ``` 110 | 111 | ## Configuring RedisPubSub 112 | 113 | `RedisPubSub` constructor can be passed a configuration object to enable some advanced features. 114 | 115 | ```ts 116 | export interface PubSubRedisOptions { 117 | connection?: RedisOptions | string; 118 | triggerTransform?: TriggerTransform; 119 | connectionListener?: (err?: Error) => void; 120 | publisher?: RedisClient; 121 | subscriber?: RedisClient; 122 | reviver?: Reviver; 123 | serializer?: Serializer; 124 | deserializer?: Deserializer; 125 | messageEventName?: string; 126 | pmessageEventName?: string; 127 | } 128 | ``` 129 | 130 | | option | type | default | description | 131 | | ------ | ---- | ------- | ----------- | 132 | | `connection` | [`object \| string`](https://github.com/luin/ioredis#connect-to-redis) | `undefined` | the connection option is passed as is to the `ioredis` constructor to create redis subscriber and publisher instances. for greater controll, use `publisher` and `subscriber` options. | 133 | | `triggerTransform` | `function` | (trigger) => trigger | [deprecated](#using-trigger-transform-deprecated) | 134 | | `connectionListener` | `function` | `undefined` | pass in connection listener to log errors or make sure connection to redis instance was created successfully. | 135 | | `publisher` | `function` | `undefined` | must be passed along side `subscriber`. see [#creating-a-redis-client](#creating-a-redis-client) | 136 | | `subscriber` | `function` | `undefined` | must be passed along side `publisher`. see [#creating-a-redis-client](#creating-a-redis-client) | 137 | | `reviver` | `function` | `undefined` | see [#using-a-custom-reviver](#using-a-custom-reviver) | 138 | | `serializer` | `function` | `undefined` | see [#using-a-custom-serializerdeserializer](#using-a-custom-serializerdeserializer) | 139 | | `deserializer` | `function` | `undefined` | see [#using-a-custom-serializerdeserializer](#using-a-custom-serializerdeserializer) | 140 | | `messageEventName` | `string` | `undefined` | see [#receiving-messages-as-buffers](#receiving-messages-as-buffers) | 141 | | `pmessageEventName` | `string` | `undefined` | see [#receiving-messages-as-buffers](#receiving-messages-as-buffers) | 142 | 143 | ## Creating a Redis Client 144 | 145 | The basic usage is great for development and you will be able to connect to a Redis server running on your system seamlessly. For production usage, it is recommended to pass a redis client (like ioredis) to the RedisPubSub constructor. This way you can control all the options of your redis connection, for example the connection retry strategy. 146 | 147 | ```javascript 148 | import { RedisPubSub } from 'graphql-redis-subscriptions'; 149 | import * as Redis from 'ioredis'; 150 | 151 | const options = { 152 | host: REDIS_DOMAIN_NAME, 153 | port: PORT_NUMBER, 154 | retryStrategy: times => { 155 | // reconnect after 156 | return Math.min(times * 50, 2000); 157 | } 158 | }; 159 | 160 | const pubsub = new RedisPubSub({ 161 | ..., 162 | publisher: new Redis(options), 163 | subscriber: new Redis(options) 164 | }); 165 | ``` 166 | 167 | ### Receiving messages as Buffers 168 | 169 | Some Redis use cases require receiving binary-safe data back from redis (in a Buffer). To accomplish this, override the event names for receiving messages and pmessages. Different redis clients use different names, for example: 170 | 171 | | library | message event | message event (Buffer) | pmessage event | pmessage event (Buffer) | 172 | | ------- | ------------- | ---------------------- | -------------- | ----------------------- | 173 | | ioredis | `message` | `messageBuffer` | `pmessage` | `pmessageBuffer` | 174 | | node-redis | `message` | `message_buffer` | `pmessage` | `pmessage_buffer` | 175 | 176 | ```javascript 177 | import { RedisPubSub } from 'graphql-redis-subscriptions'; 178 | import * as Redis from 'ioredis'; 179 | 180 | const pubsub = new RedisPubSub({ 181 | ..., 182 | // Tells RedisPubSub to register callbacks on the messageBuffer and pmessageBuffer EventEmitters 183 | messageEventName: 'messageBuffer', 184 | pmessageEventName: 'pmessageBuffer', 185 | }); 186 | ``` 187 | 188 | 189 | **Also works with your Redis Cluster** 190 | 191 | ```javascript 192 | import { RedisPubSub } from 'graphql-redis-subscriptions'; 193 | import { Cluster } from 'ioredis'; 194 | 195 | const cluster = new Cluster(REDIS_NODES); // like: [{host: 'ipOrHost', port: 1234}, ...] 196 | const pubsub = new RedisPubSub({ 197 | ..., 198 | publisher: cluster, 199 | subscriber: cluster 200 | }); 201 | ``` 202 | 203 | 204 | You can learn more on the `ioredis` package [here](https://github.com/luin/ioredis). 205 | 206 | ## Using a custom serializer/deserializer 207 | 208 | By default, Javascript objects are (de)serialized using the `JSON.stringify` and `JSON.parse` methods. 209 | You may pass your own serializer and/or deserializer function(s) as part of the options. 210 | 211 | The `deserializer` will be called with an extra context object containing `pattern` (if available) and `channel` properties, allowing you to access this information when subscribing to a pattern. 212 | 213 | ```javascript 214 | import { RedisPubSub } from 'graphql-redis-subscriptions'; 215 | import { someSerializer, someDeserializer } from 'some-serializer-library'; 216 | 217 | const serialize = (source) => { 218 | return someSerializer(source); 219 | }; 220 | 221 | const deserialize = (sourceOrBuffer, { channel, pattern }) => { 222 | return someDeserializer(sourceOrBuffer, channel, pattern); 223 | }; 224 | 225 | const pubSub = new RedisPubSub({ ..., serializer: serialize, deserializer: deserialize }); 226 | ``` 227 | 228 | ## Using a custom reviver 229 | 230 | By default, Javascript objects are serialized using the `JSON.stringify` and `JSON.parse` methods. 231 | This means that not all objects - such as Date or Regexp objects - will deserialize correctly without a custom reviver, that work out of the box with the default in-memory implementation. 232 | For handling such objects, you may pass your own reviver function to `JSON.parse`, for example to handle Date objects the following reviver can be used: 233 | 234 | ```javascript 235 | import { RedisPubSub } from 'graphql-redis-subscriptions'; 236 | 237 | const dateReviver = (key, value) => { 238 | const isISO8601Z = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/; 239 | if (typeof value === 'string' && isISO8601Z.test(value)) { 240 | const tempDateNumber = Date.parse(value); 241 | if (!isNaN(tempDateNumber)) { 242 | return new Date(tempDateNumber); 243 | } 244 | } 245 | return value; 246 | }; 247 | 248 | const pubSub = new RedisPubSub({ ..., reviver: dateReviver }); 249 | 250 | pubSub.publish('Test', { 251 | validTime: new Date(), 252 | invalidTime: '2018-13-01T12:00:00Z' 253 | }); 254 | pubSub.subscribe('Test', message => { 255 | message.validTime; // Javascript Date 256 | message.invalidTime; // string 257 | }); 258 | ``` 259 | 260 | ## Old Usage (Deprecated) 261 | 262 | ```javascript 263 | import { RedisPubSub } from 'graphql-redis-subscriptions'; 264 | const pubsub = new RedisPubSub(); 265 | const subscriptionManager = new SubscriptionManager({ 266 | schema, 267 | pubsub, 268 | setupFunctions: {}, 269 | }); 270 | ``` 271 | 272 | ## Using Trigger Transform (Deprecated) 273 | 274 | Recently, graphql-subscriptions package added a way to pass in options to each call of subscribe. 275 | Those options are constructed via the setupFunctions object you provide the Subscription Manager constructor. 276 | The reason for graphql-subscriptions to add that feature is to allow pub sub engines a way to reduce their subscription set using the best method of said engine. 277 | For example, Meteor's live query could use Mongo selector with arguments passed from the subscription like the subscribed entity id. 278 | For Redis, this could be a bit more simplified, but much more generic. 279 | The standard for Redis subscriptions is to use dot notations to make the subscription more specific. 280 | This is only the standard but I would like to present an example of creating a specific subscription using the channel options feature. 281 | 282 | First I create a simple and generic trigger transform 283 | ```javascript 284 | const triggerTransform = (trigger, {path}) => [trigger, ...path].join('.'); 285 | ``` 286 | 287 | Then I pass it to the `RedisPubSub` constructor. 288 | ```javascript 289 | const pubsub = new RedisPubSub({ 290 | triggerTransform, 291 | }); 292 | ``` 293 | Lastly, I provide a setupFunction for `commentsAdded` subscription field. 294 | It specifies one trigger called `comments.added` and it is called with the channelOptions object that holds `repoName` path fragment. 295 | ```javascript 296 | const subscriptionManager = new SubscriptionManager({ 297 | schema, 298 | setupFunctions: { 299 | commentsAdded: (options, {repoName}) => ({ 300 | 'comments.added': { 301 | channelOptions: {path: [repoName]}, 302 | }, 303 | }), 304 | }, 305 | pubsub, 306 | }); 307 | ``` 308 | 309 | When I call `subscribe` like this: 310 | ```javascript 311 | const query = ` 312 | subscription X($repoName: String!) { 313 | commentsAdded(repoName: $repoName) 314 | } 315 | `; 316 | const variables = {repoName: 'graphql-redis-subscriptions'}; 317 | subscriptionManager.subscribe({query, operationName: 'X', variables, callback}); 318 | ``` 319 | 320 | The subscription string that Redis will receive will be `comments.added.graphql-redis-subscriptions`. 321 | This subscription string is much more specific and means the the filtering required for this type of subscription is not needed anymore. 322 | This is one step towards lifting the load off of the GraphQL API server regarding subscriptions. 323 | 324 | ## Publishing New Versions 325 | 326 | This package uses GitHub Actions for automated publishing. To publish a new version: 327 | 328 | 1. Go to the GitHub repository 329 | 2. Click "Releases" in the right sidebar 330 | 3. Click "Draft a new release" 331 | 4. Choose or create a new tag (e.g., v2.7.1) 332 | 5. Fill in the release title and description 333 | 6. Click "Publish release" 334 | 335 | The GitHub Action will automatically: 336 | - Run tests 337 | - Build the package 338 | - Publish to npm with provenance 339 | 340 | You can verify the published version on [npm](https://www.npmjs.com/package/graphql-redis-subscriptions). 341 | 342 | ## Tests 343 | 344 | ### Spin a Redis in docker server and cluster 345 | Please refer to https://github.com/Grokzen/docker-redis-cluster documentation to start a cluster 346 | ```shell script 347 | $ docker run --rm -p 6379:6379 redis:alpine 348 | $ export REDIS_CLUSTER_IP=0.0.0.0; docker run -e "IP=0.0.0.0" --rm -p 7006:7000 -p 7001:7001 -p 7002:7002 -p 7003:7003 -p 7004:7004 -p 7005:7005 grokzen/redis-cluster 349 | ``` 350 | 351 | ### Test 352 | ```shell script 353 | npm run test 354 | ``` 355 | -------------------------------------------------------------------------------- /src/test/tests.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as chaiAsPromised from 'chai-as-promised'; 3 | import { spy, restore, stub } from 'simple-mock'; 4 | import { RedisPubSub } from '../redis-pubsub'; 5 | import * as IORedis from 'ioredis'; 6 | 7 | chai.use(chaiAsPromised); 8 | const expect = chai.expect; 9 | 10 | // -------------- Mocking Redis Client ------------------ 11 | 12 | let listener; 13 | 14 | const publishSpy = spy((channel, message) => listener && listener(channel, message)); 15 | const subscribeSpy = spy((channel, cb) => cb && cb(null, channel)); 16 | const unsubscribeSpy = spy((channel, cb) => cb && cb(channel)); 17 | const psubscribeSpy = spy((channel, cb) => cb && cb(null, channel)); 18 | const punsubscribeSpy = spy((channel, cb) => cb && cb(channel)); 19 | 20 | const quitSpy = spy(cb => cb); 21 | const mockRedisClient = { 22 | publish: publishSpy, 23 | subscribe: subscribeSpy, 24 | unsubscribe: unsubscribeSpy, 25 | psubscribe: psubscribeSpy, 26 | punsubscribe: punsubscribeSpy, 27 | on: (event, cb) => { 28 | if (event === 'message') { 29 | listener = cb; 30 | } 31 | }, 32 | quit: quitSpy, 33 | }; 34 | const mockOptions = { 35 | publisher: (mockRedisClient as any), 36 | subscriber: (mockRedisClient as any), 37 | }; 38 | 39 | 40 | 41 | // -------------- Mocking Redis Client ------------------ 42 | 43 | describe('RedisPubSub', () => { 44 | 45 | it('should create default ioredis clients if none were provided', done => { 46 | const pubSub = new RedisPubSub(); 47 | expect(pubSub.getSubscriber()).to.be.an.instanceOf(IORedis); 48 | expect(pubSub.getPublisher()).to.be.an.instanceOf(IORedis); 49 | pubSub.close(); 50 | done(); 51 | }); 52 | 53 | it('should verify close calls pub and sub quit methods', done => { 54 | const pubSub = new RedisPubSub(mockOptions); 55 | 56 | pubSub.close() 57 | .then(() => { 58 | expect(quitSpy.callCount).to.equal(2); 59 | done(); 60 | }) 61 | .catch(done); 62 | }); 63 | 64 | it('can subscribe to specific redis channel and called when a message is published on it', done => { 65 | const pubSub = new RedisPubSub(mockOptions); 66 | pubSub.subscribe('Posts', message => { 67 | try { 68 | expect(message).to.equals('test'); 69 | done(); 70 | } catch (e) { 71 | done(e); 72 | } 73 | 74 | }).then(async subId => { 75 | expect(subId).to.be.a('number'); 76 | await pubSub.publish('Posts', 'test'); 77 | pubSub.unsubscribe(subId); 78 | }); 79 | }); 80 | 81 | it('can subscribe to a redis channel pattern and called when a message is published on it', done => { 82 | const pubSub = new RedisPubSub(mockOptions); 83 | 84 | pubSub.subscribe('Posts*', message => { 85 | try { 86 | expect(psubscribeSpy.callCount).to.equal(1); 87 | expect(message).to.equals('test'); 88 | done(); 89 | } catch (e) { 90 | done(e); 91 | } 92 | }, { pattern: true }).then(async subId => { 93 | expect(subId).to.be.a('number'); 94 | await pubSub.publish('Posts*', 'test'); 95 | pubSub.unsubscribe(subId); 96 | }); 97 | }); 98 | 99 | it('can unsubscribe from specific redis channel', done => { 100 | const pubSub = new RedisPubSub(mockOptions); 101 | pubSub.subscribe('Posts', () => null).then(subId => { 102 | pubSub.unsubscribe(subId); 103 | 104 | try { 105 | 106 | expect(unsubscribeSpy.callCount).to.equals(1); 107 | expect(unsubscribeSpy.lastCall.args).to.have.members(['Posts']); 108 | 109 | expect(punsubscribeSpy.callCount).to.equals(1); 110 | expect(punsubscribeSpy.lastCall.args).to.have.members(['Posts']); 111 | 112 | done(); 113 | 114 | } catch (e) { 115 | done(e); 116 | } 117 | }); 118 | }); 119 | 120 | it('cleans up correctly the memory when unsubscribing', done => { 121 | const pubSub = new RedisPubSub(mockOptions); 122 | Promise.all([ 123 | pubSub.subscribe('Posts', () => null), 124 | pubSub.subscribe('Posts', () => null), 125 | ]) 126 | .then(([subId, secondSubId]) => { 127 | try { 128 | // This assertion is done against a private member, if you change the internals, you may want to change that 129 | expect((pubSub as any).subscriptionMap[subId]).not.to.be.an('undefined'); 130 | pubSub.unsubscribe(subId); 131 | // This assertion is done against a private member, if you change the internals, you may want to change that 132 | expect((pubSub as any).subscriptionMap[subId]).to.be.an('undefined'); 133 | expect(() => pubSub.unsubscribe(subId)).to.throw(`There is no subscription of id "${subId}"`); 134 | pubSub.unsubscribe(secondSubId); 135 | done(); 136 | 137 | } catch (e) { 138 | done(e); 139 | } 140 | }); 141 | }); 142 | 143 | it('concurrent subscribe, unsubscribe first sub before second sub complete', done => { 144 | const promises = { 145 | firstSub: null as Promise, 146 | secondSub: null as Promise, 147 | } 148 | 149 | let firstCb, secondCb 150 | const redisSubCallback = (channel, cb) => { 151 | process.nextTick(() => { 152 | if (!firstCb) { 153 | firstCb = () => cb(null, channel) 154 | // Handling first call, init second sub 155 | promises.secondSub = pubSub.subscribe('Posts', () => null) 156 | // Continue first sub callback 157 | firstCb() 158 | } else { 159 | secondCb = () => cb(null, channel) 160 | } 161 | }) 162 | } 163 | const subscribeStub = stub().callFn(redisSubCallback); 164 | const mockRedisClientWithSubStub = {...mockRedisClient, ...{subscribe: subscribeStub}}; 165 | const mockOptionsWithSubStub = {...mockOptions, ...{subscriber: (mockRedisClientWithSubStub as any)}} 166 | const pubSub = new RedisPubSub(mockOptionsWithSubStub); 167 | 168 | // First leg of the test, init first sub and immediately unsubscribe. The second sub is triggered in the redis cb 169 | // before the first promise sub complete 170 | promises.firstSub = pubSub.subscribe('Posts', () => null) 171 | .then(subId => { 172 | // This assertion is done against a private member, if you change the internals, you may want to change that 173 | expect((pubSub as any).subscriptionMap[subId]).not.to.be.an('undefined'); 174 | pubSub.unsubscribe(subId); 175 | 176 | // Continue second sub callback 177 | promises.firstSub.then(() => secondCb()) 178 | return subId; 179 | }); 180 | 181 | // Second leg of the test, here we have unsubscribed from the first sub. We try unsubbing from the second sub 182 | // as soon it is ready 183 | promises.firstSub 184 | .then((subId) => { 185 | // This assertion is done against a private member, if you change the internals, you may want to change that 186 | expect((pubSub as any).subscriptionMap[subId]).to.be.an('undefined'); 187 | expect(() => pubSub.unsubscribe(subId)).to.throw(`There is no subscription of id "${subId}"`); 188 | 189 | return promises.secondSub.then(secondSubId => { 190 | pubSub.unsubscribe(secondSubId); 191 | }) 192 | .then(done) 193 | .catch(done) 194 | }); 195 | }); 196 | 197 | it('will not unsubscribe from the redis channel if there is another subscriber on it\'s subscriber list', done => { 198 | const pubSub = new RedisPubSub(mockOptions); 199 | const subscriptionPromises = [ 200 | pubSub.subscribe('Posts', () => { 201 | done('Not supposed to be triggered'); 202 | }), 203 | pubSub.subscribe('Posts', (msg) => { 204 | try { 205 | expect(msg).to.equals('test'); 206 | done(); 207 | } catch (e) { 208 | done(e); 209 | } 210 | }), 211 | ]; 212 | 213 | Promise.all(subscriptionPromises).then(async subIds => { 214 | try { 215 | expect(subIds.length).to.equals(2); 216 | 217 | pubSub.unsubscribe(subIds[0]); 218 | expect(unsubscribeSpy.callCount).to.equals(0); 219 | 220 | await pubSub.publish('Posts', 'test'); 221 | pubSub.unsubscribe(subIds[1]); 222 | expect(unsubscribeSpy.callCount).to.equals(1); 223 | } catch (e) { 224 | done(e); 225 | } 226 | }); 227 | }); 228 | 229 | it('will subscribe to redis channel only once', done => { 230 | const pubSub = new RedisPubSub(mockOptions); 231 | const onMessage = () => null; 232 | const subscriptionPromises = [ 233 | pubSub.subscribe('Posts', onMessage), 234 | pubSub.subscribe('Posts', onMessage), 235 | ]; 236 | 237 | Promise.all(subscriptionPromises).then(subIds => { 238 | try { 239 | expect(subIds.length).to.equals(2); 240 | expect(subscribeSpy.callCount).to.equals(1); 241 | 242 | pubSub.unsubscribe(subIds[0]); 243 | pubSub.unsubscribe(subIds[1]); 244 | done(); 245 | } catch (e) { 246 | done(e); 247 | } 248 | }); 249 | }); 250 | 251 | it('can have multiple subscribers and all will be called when a message is published to this channel', done => { 252 | const pubSub = new RedisPubSub(mockOptions); 253 | const onMessageSpy = spy(() => null); 254 | const subscriptionPromises = [ 255 | pubSub.subscribe('Posts', onMessageSpy), 256 | pubSub.subscribe('Posts', onMessageSpy), 257 | ]; 258 | 259 | Promise.all(subscriptionPromises).then(async subIds => { 260 | try { 261 | expect(subIds.length).to.equals(2); 262 | 263 | await pubSub.publish('Posts', 'test'); 264 | 265 | expect(onMessageSpy.callCount).to.equals(2); 266 | onMessageSpy.calls.forEach(call => { 267 | expect(call.args).to.have.members(['test']); 268 | }); 269 | 270 | pubSub.unsubscribe(subIds[0]); 271 | pubSub.unsubscribe(subIds[1]); 272 | done(); 273 | } catch (e) { 274 | done(e); 275 | } 276 | }); 277 | }); 278 | 279 | it('can publish objects as well', done => { 280 | const pubSub = new RedisPubSub(mockOptions); 281 | pubSub.subscribe('Posts', message => { 282 | try { 283 | expect(message).to.have.property('comment', 'This is amazing'); 284 | done(); 285 | } catch (e) { 286 | done(e); 287 | } 288 | }).then(async subId => { 289 | try { 290 | await pubSub.publish('Posts', { comment: 'This is amazing' }); 291 | pubSub.unsubscribe(subId); 292 | } catch (e) { 293 | done(e); 294 | } 295 | }); 296 | }); 297 | 298 | it('can accept custom reviver option (eg. for Javascript Dates)', done => { 299 | const dateReviver = (key, value) => { 300 | const isISO8601Z = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/; 301 | if (typeof value === 'string' && isISO8601Z.test(value)) { 302 | const tempDateNumber = Date.parse(value); 303 | if (!isNaN(tempDateNumber)) { 304 | return new Date(tempDateNumber); 305 | } 306 | } 307 | return value; 308 | }; 309 | 310 | const pubSub = new RedisPubSub({...mockOptions, reviver: dateReviver}); 311 | const validTime = new Date(); 312 | const invalidTime = '2018-13-01T12:00:00Z'; 313 | pubSub.subscribe('Times', message => { 314 | try { 315 | expect(message).to.have.property('invalidTime', invalidTime); 316 | expect(message).to.have.property('validTime'); 317 | expect(message.validTime.getTime()).to.equals(validTime.getTime()); 318 | done(); 319 | } catch (e) { 320 | done(e); 321 | } 322 | }).then(async subId => { 323 | try { 324 | await pubSub.publish('Times', { validTime, invalidTime }); 325 | pubSub.unsubscribe(subId); 326 | } catch (e) { 327 | done(e); 328 | } 329 | }); 330 | }); 331 | 332 | it('refuses custom reviver with a deserializer', done => { 333 | const reviver = stub(); 334 | const deserializer = stub(); 335 | 336 | try { 337 | expect(() => new RedisPubSub({...mockOptions, reviver, deserializer})) 338 | .to.throw("Reviver and deserializer can't be used together"); 339 | done(); 340 | } catch (e) { 341 | done(e); 342 | } 343 | }); 344 | 345 | it('allows to use a custom serializer', done => { 346 | const serializer = stub(); 347 | const serializedPayload = `{ "hello": "custom" }`; 348 | serializer.returnWith(serializedPayload); 349 | 350 | const pubSub = new RedisPubSub({...mockOptions, serializer }); 351 | 352 | try { 353 | pubSub.subscribe('TOPIC', message => { 354 | try { 355 | expect(message).to.eql({hello: 'custom'}); 356 | done(); 357 | } catch (e) { 358 | done(e); 359 | } 360 | }).then(() => { 361 | pubSub.publish('TOPIC', {hello: 'world'}); 362 | }); 363 | } catch (e) { 364 | done(e); 365 | } 366 | }); 367 | 368 | it('custom serializer can throw an error', done => { 369 | const serializer = stub(); 370 | serializer.throwWith(new Error('Custom serialization error')); 371 | 372 | const pubSub = new RedisPubSub({...mockOptions, serializer }); 373 | 374 | try { 375 | pubSub.publish('TOPIC', {hello: 'world'}).then(() => { 376 | done(new Error('Expected error to be thrown upon publish')); 377 | }, err => { 378 | expect(err.message).to.eql('Custom serialization error'); 379 | done(); 380 | }); 381 | } catch (e) { 382 | done(e); 383 | } 384 | }); 385 | 386 | it('allows to use a custom deserializer', done => { 387 | const deserializer = stub(); 388 | const deserializedPayload = { hello: 'custom' }; 389 | deserializer.returnWith(deserializedPayload); 390 | 391 | const pubSub = new RedisPubSub({...mockOptions, deserializer }); 392 | 393 | try { 394 | pubSub.subscribe('TOPIC', message => { 395 | try { 396 | expect(message).to.eql({hello: 'custom'}); 397 | done(); 398 | } catch (e) { 399 | done(e); 400 | } 401 | }).then(() => { 402 | pubSub.publish('TOPIC', {hello: 'world'}); 403 | }); 404 | } catch (e) { 405 | done(e); 406 | } 407 | }); 408 | 409 | it('unparsed payload is returned if custom deserializer throws an error', done => { 410 | const deserializer = stub(); 411 | deserializer.throwWith(new Error('Custom deserialization error')); 412 | 413 | const pubSub = new RedisPubSub({...mockOptions, deserializer }); 414 | 415 | try { 416 | pubSub.subscribe('TOPIC', message => { 417 | try { 418 | expect(message).to.be.a('string'); 419 | expect(message).to.eql('{"hello":"world"}'); 420 | done(); 421 | } catch (e) { 422 | done(e); 423 | } 424 | }).then(() => { 425 | pubSub.publish('TOPIC', {hello: 'world'}); 426 | }); 427 | } catch (e) { 428 | done(e); 429 | } 430 | }); 431 | 432 | it('throws if you try to unsubscribe with an unknown id', () => { 433 | const pubSub = new RedisPubSub(mockOptions); 434 | return expect(() => pubSub.unsubscribe(123)) 435 | .to.throw('There is no subscription of id "123"'); 436 | }); 437 | 438 | it('can use transform function to convert the trigger name given into more explicit channel name', done => { 439 | const triggerTransform = (trigger, { repoName }) => `${trigger}.${repoName}`; 440 | const pubSub = new RedisPubSub({ 441 | triggerTransform, 442 | publisher: (mockRedisClient as any), 443 | subscriber: (mockRedisClient as any), 444 | }); 445 | 446 | const validateMessage = message => { 447 | try { 448 | expect(message).to.equals('test'); 449 | done(); 450 | } catch (e) { 451 | done(e); 452 | } 453 | }; 454 | 455 | pubSub.subscribe('comments', validateMessage, { repoName: 'graphql-redis-subscriptions' }).then(async subId => { 456 | await pubSub.publish('comments.graphql-redis-subscriptions', 'test'); 457 | pubSub.unsubscribe(subId); 458 | }); 459 | 460 | }); 461 | 462 | // TODO pattern subs 463 | 464 | afterEach('Reset spy count', () => { 465 | publishSpy.reset(); 466 | subscribeSpy.reset(); 467 | unsubscribeSpy.reset(); 468 | psubscribeSpy.reset(); 469 | punsubscribeSpy.reset(); 470 | }); 471 | 472 | after('Restore redis client', () => { 473 | restore(); 474 | }); 475 | 476 | }); 477 | 478 | describe('PubSubAsyncIterator', () => { 479 | 480 | it('should expose valid asyncItrator for a specific event', () => { 481 | const pubSub = new RedisPubSub(mockOptions); 482 | const eventName = 'test'; 483 | const iterator = pubSub.asyncIterableIterator(eventName); 484 | // tslint:disable-next-line:no-unused-expression 485 | expect(iterator).to.exist; 486 | // tslint:disable-next-line:no-unused-expression 487 | expect(iterator[Symbol.asyncIterator]).not.to.be.undefined; 488 | }); 489 | 490 | it('should trigger event on asyncIterableIterator when published', done => { 491 | const pubSub = new RedisPubSub(mockOptions); 492 | const eventName = 'test'; 493 | const iterator = pubSub.asyncIterableIterator(eventName); 494 | 495 | iterator.next().then(result => { 496 | // tslint:disable-next-line:no-unused-expression 497 | expect(result).to.exist; 498 | // tslint:disable-next-line:no-unused-expression 499 | expect(result.value).to.exist; 500 | // tslint:disable-next-line:no-unused-expression 501 | expect(result.done).to.exist; 502 | done(); 503 | }); 504 | 505 | pubSub.publish(eventName, { test: true }); 506 | }); 507 | 508 | it('should not trigger event on asyncIterableIterator when publishing other event', async () => { 509 | const pubSub = new RedisPubSub(mockOptions); 510 | const eventName = 'test2'; 511 | const iterator = pubSub.asyncIterableIterator('test'); 512 | const triggerSpy = spy(() => undefined); 513 | 514 | iterator.next().then(triggerSpy); 515 | await pubSub.publish(eventName, { test: true }); 516 | expect(triggerSpy.callCount).to.equal(0); 517 | }); 518 | 519 | it('register to multiple events', done => { 520 | const pubSub = new RedisPubSub(mockOptions); 521 | const eventName = 'test2'; 522 | const iterator = pubSub.asyncIterableIterator(['test', 'test2']); 523 | const triggerSpy = spy(() => undefined); 524 | 525 | iterator.next().then(() => { 526 | triggerSpy(); 527 | expect(triggerSpy.callCount).to.be.gte(1); 528 | done(); 529 | }); 530 | pubSub.publish(eventName, { test: true }); 531 | }); 532 | 533 | it('should not trigger event on asyncIterableIterator already returned', done => { 534 | const pubSub = new RedisPubSub(mockOptions); 535 | const eventName = 'test'; 536 | const iterator = pubSub.asyncIterableIterator(eventName); 537 | 538 | iterator.next().then(result => { 539 | // tslint:disable-next-line:no-unused-expression 540 | expect(result).to.exist; 541 | // tslint:disable-next-line:no-unused-expression 542 | expect(result.value).to.exist; 543 | expect(result.value.test).to.equal('word'); 544 | // tslint:disable-next-line:no-unused-expression 545 | expect(result.done).to.be.false; 546 | }); 547 | 548 | pubSub.publish(eventName, { test: 'word' }).then(() => { 549 | iterator.next().then(result => { 550 | // tslint:disable-next-line:no-unused-expression 551 | expect(result).to.exist; 552 | // tslint:disable-next-line:no-unused-expression 553 | expect(result.value).not.to.exist; 554 | // tslint:disable-next-line:no-unused-expression 555 | expect(result.done).to.be.true; 556 | done(); 557 | }); 558 | 559 | iterator.return(); 560 | pubSub.publish(eventName, { test: true }); 561 | }); 562 | }); 563 | 564 | }); 565 | --------------------------------------------------------------------------------