├── .gitignore ├── examples ├── micro │ ├── .gitignore │ ├── node_modules │ │ ├── fanout-graphql-tools │ │ └── .gitignore │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── src │ │ └── index.ts ├── apollo-server-express │ ├── .gitignore │ ├── node_modules │ │ ├── fanout-graphql-tools │ │ └── .gitignore │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ ├── src │ │ └── index.ts │ └── package-lock.json └── http-request-listener-api │ ├── .gitignore │ ├── node_modules │ ├── fanout-graphql-tools │ └── .gitignore │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── src │ └── index.ts ├── .vscode ├── settings.json └── launch.json ├── src ├── test │ ├── unit.ts │ └── cli.ts ├── @types │ └── killable │ │ └── index.d.ts ├── subscriptions-transport-ws-over-http │ ├── PubSubSubscriptionStorage.ts │ ├── WebSocketOverHttpPubSubMixin.ts │ ├── GraphqlWsOverWebSocketOverHttpRequestListener.ts │ ├── GraphqlWsGripChannelNamers.ts │ ├── GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller.ts │ ├── GraphqlWsOverWebSocketOverHttpStorageCleaner.ts │ ├── EpcpSubscriptionPublisher.ts │ ├── WebSocketOverHttpGraphqlContext.ts │ ├── SubscriptionStoragePubSubMixin.ts │ ├── GraphqlWebSocketOverHttpConnectionListener.ts │ ├── GraphqlWsOverWebSocketOverHttpExpressMiddlewareTest.test.ts │ └── GraphqlWsOverWebSocketOverHttpExpressMiddleware.ts ├── testing-tools │ ├── itemsFromLinkObservable.ts │ ├── ChangingValue.ts │ ├── WebSocketApolloClient.ts │ └── withListeningServer.ts ├── index.ts ├── graphql-ws │ ├── AcceptAllGraphqlSubscriptionsMessageHandler.ts │ ├── GraphqlQueryTools.ts │ └── GraphqlQueryToolsTest.test.ts ├── simple-table │ └── SimpleTable.ts ├── simple-graphql-api │ └── SimpleGraphqlApi.ts ├── websocket-over-http-express │ ├── WebSocketOverHttpConnectionListener.ts │ └── WebSocketOverHttpExpress.ts └── subscriptions-transport-apollo │ └── ApolloSubscriptionServerOptions.ts ├── tslint.json ├── tsconfig.json ├── .travis.yml ├── COPYING ├── docs └── getGripChannel.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | -------------------------------------------------------------------------------- /examples/micro/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /examples/apollo-server-express/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /examples/http-request-listener-api/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /examples/micro/node_modules/fanout-graphql-tools: -------------------------------------------------------------------------------- 1 | ../../../ -------------------------------------------------------------------------------- /examples/apollo-server-express/node_modules/fanout-graphql-tools: -------------------------------------------------------------------------------- 1 | ../../../ -------------------------------------------------------------------------------- /examples/http-request-listener-api/node_modules/fanout-graphql-tools: -------------------------------------------------------------------------------- 1 | ../../../ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /examples/micro/node_modules/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # But not these files... 5 | !.gitignore 6 | !fanout-graphql-tools 7 | 8 | -------------------------------------------------------------------------------- /examples/apollo-server-express/node_modules/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # But not these files... 5 | !.gitignore 6 | !fanout-graphql-tools 7 | 8 | -------------------------------------------------------------------------------- /examples/http-request-listener-api/node_modules/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # But not these files... 5 | !.gitignore 6 | !fanout-graphql-tools 7 | 8 | -------------------------------------------------------------------------------- /src/test/unit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Run unit tests 3 | */ 4 | 5 | import { cli } from "./cli"; 6 | 7 | const main = async () => { 8 | return cli(); 9 | }; 10 | 11 | if (require.main === module) { 12 | main().catch(e => { 13 | throw e; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/@types/killable/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "killable" { 2 | import { Server } from "http"; 3 | type KillableServerType = Server & { 4 | /** kill the http server, closing all connections */ 5 | kill: (errback: (error?: Error) => void) => void; 6 | }; 7 | // tslint:disable-next-line:completed-docs 8 | function killable(server: Server): KillableServerType; 9 | namespace killable { 10 | export type KillableServer = KillableServerType; 11 | } 12 | export = killable; 13 | } 14 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/PubSubSubscriptionStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Objects for storing info about apollo PubSubEngine subscriptions. 3 | */ 4 | 5 | export interface IStoredPubSubSubscription { 6 | /** websocket-over-http connection.id */ 7 | connectionId: string; 8 | /** date the subscription was stored. As [ISO_8601](http://en.wikipedia.org/wiki/ISO_8601) UTC string */ 9 | createdAt: string; 10 | /** graphql-ws start message for this subscription */ 11 | graphqlWsStartMessage: string; 12 | /** the PubSubEngine triggerName that was subscribed to (e.g. `PubSubEngine#asyncIterator([EVENT_NAME])`) */ 13 | triggerName: string; 14 | /** unique identifier for the subscription */ 15 | id: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/testing-tools/itemsFromLinkObservable.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "apollo-link"; 2 | 3 | /** 4 | * Given an observable, subscribe to it and return the subscription as well as an array that will be pushed to whenever an item is sent to subscription. 5 | */ 6 | export const itemsFromLinkObservable = ( 7 | observable: Observable, 8 | ): { 9 | /** Array of items that have come from the subscription */ 10 | items: T[]; 11 | /** Subscription that can be unsubscribed to */ 12 | subscription: ZenObservable.Subscription; 13 | } => { 14 | const items: T[] = []; 15 | const subscription = observable.subscribe({ 16 | next(item) { 17 | items.push(item); 18 | }, 19 | }); 20 | return { items, subscription }; 21 | }; 22 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-prettier" 6 | ], 7 | "jsRules": {}, 8 | "rules": { 9 | "completed-docs": [true, { 10 | "classes": true, 11 | "functions": { 12 | "visibilities": ["exported"] 13 | }, 14 | "methods": true, 15 | "properties": true, 16 | "variables": { 17 | "visibilities": ["exported"] 18 | } 19 | }], 20 | "jsdoc-format": [true], 21 | "no-console": [false], 22 | "no-redundant-jsdoc": true 23 | }, 24 | "rulesDirectory": [], 25 | "linterOptions": { 26 | "exclude": [ 27 | "node_modules/**" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/testing-tools/ChangingValue.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | /** Create functions that represent a changing value and events about how it changes */ 4 | export const ChangingValue = (): [ 5 | (v: T) => void, 6 | () => Promise, 7 | () => Promise, 8 | ] => { 9 | let value: T | undefined; 10 | let valueIsSet = false; 11 | const emitter = new EventEmitter(); 12 | const setValue = (valueIn: T) => { 13 | value = valueIn; 14 | valueIsSet = true; 15 | emitter.emit("value", value); 16 | }; 17 | const getNextValue = async (): Promise => { 18 | return new Promise((resolve, reject) => { 19 | emitter.on("value", resolve); 20 | }); 21 | }; 22 | const getValue = async (): Promise => { 23 | if (valueIsSet) { 24 | return value as T; 25 | } 26 | return getNextValue(); 27 | }; 28 | return [setValue, getValue, getNextValue]; 29 | }; 30 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "ts-node Current File", 9 | "type": "node", 10 | "request": "launch", 11 | "args": ["${relativeFile}"], 12 | "runtimeArgs": [ 13 | "--nolazy", 14 | "--preserve-symlinks", 15 | "--preserve-symlinks-main", 16 | "-r", "ts-node/register" 17 | ], 18 | "cwd": "${workspaceRoot}", 19 | "protocol": "inspector", 20 | "internalConsoleOptions": "openOnSessionStart", 21 | "outputCapture": "std", 22 | "sourceMaps": true, 23 | "env": { 24 | "LOG_LEVEL": "debug", 25 | "TEST_TIMEOUT_MS": "60000", 26 | "PUSHPIN_PROXY_URL": "http://localhost:7999", 27 | } 28 | }, 29 | ] 30 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "allowSyntheticDefaultImports": true, 5 | "outDir": "dist", 6 | "target": "es5", 7 | "lib": [ 8 | "es6", 9 | "dom", 10 | "es2015.symbol", 11 | "es2015.symbol.wellknown", 12 | "esnext", 13 | "esnext.asynciterable" 14 | ], 15 | "module": "commonjs", 16 | "moduleResolution": "node", 17 | "sourceMap": true, 18 | "experimentalDecorators": true, 19 | "pretty": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitAny": true, 22 | "noImplicitReturns": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "strict": true, 25 | "strictNullChecks": true, 26 | "typeRoots": ["./src/@types", "./node_modules/@types"], 27 | "downlevelIteration": true, 28 | "baseUrl": ".", 29 | "paths": { 30 | "*": ["./src/@types/*"] 31 | } 32 | }, 33 | "include": ["src/**/*"], 34 | "exclude": ["node_modules/**"] 35 | } 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | deploy: 5 | provider: npm 6 | # unless skip_cleanup=true, node_modules will be cleaned up before publish, causing npm prepublish scripts that use them to fail 7 | skip_cleanup: true 8 | email: bengoering@gmail.com 9 | api_key: 10 | secure: H6ydRT03L8NhXPjW//kzMIuD9KWqK/hMIRwT8Vp+xPWxeMQAMz4MH+SjeCkOuui0ldWqjAjfjxAh6k79qi40YciaWvCuve7pL3xoHeaeeuC60GltDjuTa0E51XXqy853LePH5JF8CEqu8BhQvX1c3sqIn/qXQ54sHHN/J+1uDoa6ZXs9m5Jd8VBA9gLPCvwcR3E98qtsQ1N9gSOpqg20TvY55SxiG07RLye6oYQOoq6r3BtEmuA2FupqFzs/VHjra1npHSuLZeds7ky0pHDNcuY4x0ZWs0gOEBtNvPatZZBOTQMSlWbmCYdjMqTYuUIOeiaP90WjFTsls4xeBq9Ixm6JpbIvZTwU68s21/uFj6slP1m1G338xTDv5r9QfSjnA3u7i2STPgQd/ogDnyIhSSJn6c8lrziIhGmo1UE2J1xlAIb4nnPKusSKboSWv2wA/U4K19/o8pnnV9QMtSpdwt49h5z/6ZI7OJcntj4hcWyrdbg9qp3poPnpgjX5/oYyNV3gyM/E88p7r92N9UWBX5LJIbUx1lirOqAl6vRN5djrzbGrQa6VmxBjKrzfbEzZgtxSKe4gNDMd4iTYLpkpc5DE2AvI23Uf/rTaTWjLRvTsYhuTsNLfW/0kRszN32lOk4jhJuJE28MPNrfwcgcsW065S/d2e91UrxNBJGiDrZM= 11 | on: 12 | tags: true 13 | repo: fanout/fanout-graphql-tools 14 | -------------------------------------------------------------------------------- /examples/micro/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "allowSyntheticDefaultImports": true, 5 | "outDir": "dist", 6 | "target": "es5", 7 | "lib": [ 8 | "es6", 9 | "dom", 10 | "es2015.symbol", 11 | "es2015.symbol.wellknown", 12 | "esnext", 13 | "esnext.asynciterable" 14 | ], 15 | "module": "commonjs", 16 | "moduleResolution": "node", 17 | "sourceMap": true, 18 | "experimentalDecorators": true, 19 | "pretty": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitAny": true, 22 | "noImplicitReturns": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "rootDir": "../../", 25 | "strict": true, 26 | "strictNullChecks": true, 27 | "typeRoots": ["./src/@types", "./node_modules/@types"], 28 | "downlevelIteration": true, 29 | "baseUrl": ".", 30 | "paths": { 31 | "*": ["./src/@types/*"] 32 | } 33 | }, 34 | "include": ["src/**/*", "../../src/**/*"], 35 | "exclude": ["node_modules/**"] 36 | } 37 | -------------------------------------------------------------------------------- /examples/apollo-server-express/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "allowSyntheticDefaultImports": true, 5 | "outDir": "dist", 6 | "target": "es5", 7 | "lib": [ 8 | "es6", 9 | "dom", 10 | "es2015.symbol", 11 | "es2015.symbol.wellknown", 12 | "esnext", 13 | "esnext.asynciterable" 14 | ], 15 | "module": "commonjs", 16 | "moduleResolution": "node", 17 | "sourceMap": true, 18 | "experimentalDecorators": true, 19 | "pretty": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitAny": true, 22 | "noImplicitReturns": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "rootDir": "../../", 25 | "strict": true, 26 | "strictNullChecks": true, 27 | "typeRoots": ["./src/@types", "./node_modules/@types"], 28 | "downlevelIteration": true, 29 | "baseUrl": ".", 30 | "paths": { 31 | "*": ["./src/@types/*"] 32 | } 33 | }, 34 | "include": ["src/**/*", "../../src/**/*"], 35 | "exclude": ["node_modules/**"] 36 | } 37 | -------------------------------------------------------------------------------- /examples/http-request-listener-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "allowSyntheticDefaultImports": true, 5 | "outDir": "dist", 6 | "target": "es5", 7 | "lib": [ 8 | "es6", 9 | "dom", 10 | "es2015.symbol", 11 | "es2015.symbol.wellknown", 12 | "esnext", 13 | "esnext.asynciterable" 14 | ], 15 | "module": "commonjs", 16 | "moduleResolution": "node", 17 | "sourceMap": true, 18 | "experimentalDecorators": true, 19 | "pretty": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitAny": true, 22 | "noImplicitReturns": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "rootDir": "../../", 25 | "strict": true, 26 | "strictNullChecks": true, 27 | "typeRoots": ["./src/@types", "./node_modules/@types"], 28 | "downlevelIteration": true, 29 | "baseUrl": ".", 30 | "paths": { 31 | "*": ["./src/@types/*"] 32 | } 33 | }, 34 | "include": ["src/**/*", "../../src/**/*"], 35 | "exclude": ["node_modules/**"] 36 | } 37 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (C) 2019 Fanout, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /examples/apollo-server-express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fanout-graphql-tools-example-apollo-server-express", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "bundledDependencies": [ 7 | "fanout-graphql-tools" 8 | ], 9 | "scripts": { 10 | "build": "npm run tsc", 11 | "check": "npm run prettier:check && npm run tsc:check && npm run tslint", 12 | "check-and-build": "npm run check && npm run build", 13 | "prettier": "../../node_modules/.bin/prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --write", 14 | "prettier:check": "../../node_modules/.bin/prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --check", 15 | "start": "ts-node src", 16 | "test": "ts-node src/test/unit && npm run check", 17 | "tsc": "../../node_modules/.bin/tsc -p tsconfig.json", 18 | "tsc:check": "npm run tsc -- --noEmit", 19 | "tslint": "../../node_modules/.bin/tslint -c ../../tslint.json 'src/**/*.ts'" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "dependencies": {}, 24 | "devDependencies": { 25 | "ts-node": "^8.3.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/micro/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fanout-graphql-tools-example-micro", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "bundledDependencies": [ 7 | "fanout-graphql-tools" 8 | ], 9 | "scripts": { 10 | "build": "npm run tsc", 11 | "check": "npm run prettier:check && npm run tsc:check && npm run tslint", 12 | "check-and-build": "npm run check && npm run build", 13 | "prettier": "../../node_modules/.bin/prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --write", 14 | "prettier:check": "../../node_modules/.bin/prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --check", 15 | "start": "ts-node src", 16 | "test": "ts-node src/test/unit && npm run check", 17 | "tsc": "../../node_modules/.bin/tsc -p tsconfig.json", 18 | "tsc:check": "npm run tsc -- --noEmit", 19 | "tslint": "../../node_modules/.bin/tslint -c ../../tslint.json 'src/**/*.ts'" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "dependencies": { 24 | "@types/micro": "^7.3.3", 25 | "apollo-server-micro": "^2.6.9", 26 | "micro": "^9.3.4" 27 | }, 28 | "devDependencies": { 29 | "ts-node": "^8.3.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/http-request-listener-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fanout-graphql-tools-example-http-request-listener-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "bundledDependencies": [ 7 | "fanout-graphql-tools" 8 | ], 9 | "scripts": { 10 | "build": "npm run tsc", 11 | "check": "npm run prettier:check && npm run tsc:check && npm run tslint", 12 | "check-and-build": "npm run check && npm run build", 13 | "prettier": "../../node_modules/.bin/prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --write", 14 | "prettier:check": "../../node_modules/.bin/prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --check", 15 | "start": "ts-node src", 16 | "test": "ts-node src/test/unit && npm run check", 17 | "tsc": "../../node_modules/.bin/tsc -p tsconfig.json", 18 | "tsc:check": "npm run tsc -- --noEmit", 19 | "tslint": "../../node_modules/.bin/tslint -c ../../tslint.json 'src/**/*.ts'" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "dependencies": { 24 | "apollo-server-micro": "^2.6.9", 25 | "micro": "^9.3.4" 26 | }, 27 | "devDependencies": { 28 | "ts-node": "^8.3.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** fanout-graphql-tools index - export all the things from submodules */ 2 | 3 | export * from "./graphql-ws/AcceptAllGraphqlSubscriptionsMessageHandler"; 4 | export { 5 | default as AcceptAllGraphqlSubscriptionsMessageHandler, 6 | } from "./graphql-ws/AcceptAllGraphqlSubscriptionsMessageHandler"; 7 | export * from "./graphql-ws/GraphqlQueryTools"; 8 | 9 | export * from "./simple-table/SimpleTable"; 10 | 11 | export * from "./subscriptions-transport-apollo/ApolloSubscriptionServerOptions"; 12 | 13 | export * from "./subscriptions-transport-ws-over-http/GraphqlWebSocketOverHttpConnectionListener"; 14 | export * from "./subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpExpressMiddleware"; 15 | export * from "./subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpRequestListener"; 16 | export * from "./subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpStorageCleaner"; 17 | export * from "./subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller"; 18 | export * from "./subscriptions-transport-ws-over-http/PubSubSubscriptionStorage"; 19 | export * from "./subscriptions-transport-ws-over-http/WebSocketOverHttpPubSubMixin"; 20 | export * from "./subscriptions-transport-ws-over-http/WebSocketOverHttpGraphqlContext"; 21 | -------------------------------------------------------------------------------- /examples/apollo-server-express/README.md: -------------------------------------------------------------------------------- 1 | # fanout-graphql-tools-example-apollo-server-express 2 | 3 | Example of how to use fanout-graphql-tools with apollo-server-express and express. 4 | 5 | ## Testing locally 6 | 7 | 1. [Install pushpin using these instructions](https://pushpin.org/docs/install/) 8 | 9 | 2. Configure your `/etc/pushpin/routes` file to contain: 10 | ``` 11 | *,debug localhost:57410,over_http 12 | ``` 13 | 3. 14 | In this directory, 15 | ``` 16 | npm install 17 | npm start 18 | ``` 19 | 20 | This will run the example GraphQL Server, listening on port 57410 21 | 22 | 4. Access the example in your web browser *through pushpin* using http://localhost:7999/graphql 23 | 24 | 5. 25 | Using the GraphiQL Playground UI that should render here, create the following subscription: 26 | ```graphql 27 | subscription { 28 | postAdded { 29 | author, 30 | comment, 31 | } 32 | } 33 | ``` 34 | 35 | 6. 36 | Open another tab in the GraphiQL Playground, and send the following mutation: 37 | 38 | ```graphql 39 | mutation { 40 | addPost(comment:"hi", author:"you") { 41 | author, 42 | comment, 43 | } 44 | } 45 | ``` 46 | 47 | 7. Switch back to the GraphiQL Playground tab for the subscription, and on the right side you should see the result of your mutation. 48 | -------------------------------------------------------------------------------- /src/graphql-ws/AcceptAllGraphqlSubscriptionsMessageHandler.ts: -------------------------------------------------------------------------------- 1 | export interface IGraphqlSubscriptionsMessageHandlerOptions { 2 | /** Called with graphql-ws start event. Return messages to respond with */ 3 | onStart?(startEvent: object): void | string; 4 | /** Called with graphql-ws stop event. */ 5 | onStop?(stopEvent: object): void; 6 | } 7 | 8 | /** 9 | * WebSocket Message Handler that does a minimal handshake of graphql-ws. 10 | * It will allow all incoming connections, but won't actually send anything to the subscriber. 11 | * It's intended that messages will be published to subscribers out of band. 12 | */ 13 | export default (opts: IGraphqlSubscriptionsMessageHandlerOptions = {}) => ( 14 | message: string, 15 | ): string | void => { 16 | const graphqlWsEvent = JSON.parse(message); 17 | switch (graphqlWsEvent.type) { 18 | case "connection_init": 19 | return JSON.stringify({ type: "connection_ack" }); 20 | break; 21 | case "start": 22 | if (opts.onStart) { 23 | return opts.onStart(graphqlWsEvent); 24 | } 25 | break; 26 | case "stop": 27 | if (opts.onStop) { 28 | opts.onStop(graphqlWsEvent); 29 | } 30 | return JSON.stringify({ 31 | id: graphqlWsEvent.id, 32 | payload: null, 33 | type: "complete", 34 | }); 35 | break; 36 | default: 37 | console.log("Unexpected graphql-ws event type", graphqlWsEvent); 38 | throw new Error(`Unexpected graphql-ws event type ${graphqlWsEvent}`); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /examples/micro/README.md: -------------------------------------------------------------------------------- 1 | # fanout-graphql-tools-example-micro 2 | 3 | Example of how to use fanout-graphql-tools with apollo-server-micro and micro. This also serves as an example of how to use fanout-graphql-tools with any web framework that returns an `http.Server`. 4 | 5 | fanout-graphql-tools exports a `GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller` constructor that can mutate any `http.Server` to add support for handling WebSocket-Over-HTTP requests. 6 | 7 | ## Testing locally 8 | 9 | 1. [Install pushpin using these instructions](https://pushpin.org/docs/install/) 10 | 11 | 2. Configure your `/etc/pushpin/routes` file to contain: 12 | ``` 13 | *,debug localhost:57410,over_http 14 | ``` 15 | 3. 16 | In this directory, 17 | ``` 18 | npm install 19 | npm start 20 | ``` 21 | 22 | This will run the example GraphQL Server, listening on port 57410 23 | 24 | 4. Access the example in your web browser *through pushpin* using http://localhost:7999/graphql 25 | 26 | 5. 27 | Using the GraphiQL Playground UI that should render here, create the following subscription: 28 | ```graphql 29 | subscription { 30 | postAdded { 31 | author, 32 | comment, 33 | } 34 | } 35 | ``` 36 | 37 | 6. 38 | Open another tab in the GraphiQL Playground, and send the following mutation: 39 | 40 | ```graphql 41 | mutation { 42 | addPost(comment:"hi", author:"you") { 43 | author, 44 | comment, 45 | } 46 | } 47 | ``` 48 | 49 | 7. Switch back to the GraphiQL Playground tab for the subscription, and on the right side you should see the result of your mutation. 50 | -------------------------------------------------------------------------------- /src/test/cli.ts: -------------------------------------------------------------------------------- 1 | import { TestOutputStream, TestRunner, TestSet } from "alsatian"; 2 | import { join } from "path"; 3 | import { Duplex } from "stream"; 4 | import { TapBark } from "tap-bark"; 5 | 6 | type TestCLI = (filename?: string) => Promise; 7 | 8 | /** 9 | * Test CLI 10 | * If passed a filename, will run test in that file. 11 | * Otherwise, run tests in all files. 12 | */ 13 | export const cli: TestCLI = async (filename?: string): Promise => { 14 | process.on("unhandledRejection", error => { 15 | console.error(error); 16 | throw error; 17 | }); 18 | await main(filename); 19 | }; 20 | 21 | /** 22 | * Run all tests 23 | */ 24 | async function main(filename?: string): Promise { 25 | // Setup the alsatian test runner 26 | const testRunner: TestRunner = new TestRunner(); 27 | const tapStream: TestOutputStream = testRunner.outputStream; 28 | const testSet: TestSet = TestSet.create(); 29 | const files: string = 30 | filename || join(__dirname, "../**/*.test.{ts,tsx,js,jsx}"); 31 | testSet.addTestsFromFiles(files); 32 | 33 | // This will output a human readable report to the console. 34 | // TapBark has bad types or something. That's why these type casts are here. (tslint no-any catches it) 35 | const bark = TapBark.create(); 36 | const barkTransform = bark.getPipeable(); 37 | tapStream.pipe(barkTransform).pipe(process.stdout); 38 | 39 | // Runs the tests 40 | const timeout = process.env.TEST_TIMEOUT_MS 41 | ? parseInt(process.env.TEST_TIMEOUT_MS, 10) 42 | : 60 * 1000; 43 | await testRunner.run(testSet, timeout); 44 | } 45 | 46 | if (require.main === module) { 47 | main().catch(error => { 48 | throw error; 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/testing-tools/WebSocketApolloClient.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCache } from "apollo-cache-inmemory"; 2 | import { ApolloClient } from "apollo-client"; 3 | import { split } from "apollo-link"; 4 | import { createHttpLink } from "apollo-link-http"; 5 | import { WebSocketLink } from "apollo-link-ws"; 6 | import { getMainDefinition } from "apollo-utilities"; 7 | import fetch from "cross-fetch"; 8 | import * as WebSocket from "ws"; 9 | 10 | /** Info about what URLs ApolloClient should connect to */ 11 | export interface IApolloServerUrlInfo { 12 | /** path to make subscriptions connections to */ 13 | subscriptionsUrl: string; 14 | /** http path for graphql query/mutation endpoint */ 15 | url: string; 16 | } 17 | 18 | const WebSocketApolloClient = ({ 19 | url, 20 | subscriptionsUrl, 21 | }: IApolloServerUrlInfo) => { 22 | const httpLink = createHttpLink({ 23 | fetch: async (input, init) => { 24 | const response = await fetch(input, init); 25 | return response; 26 | }, 27 | uri: url, 28 | }); 29 | const wsLink = new WebSocketLink({ 30 | options: { 31 | reconnect: true, 32 | timeout: 999999999, 33 | }, 34 | uri: subscriptionsUrl, 35 | webSocketImpl: WebSocket, 36 | }); 37 | const link = split( 38 | // split based on operation type 39 | ({ query }) => { 40 | const definition = getMainDefinition(query); 41 | return ( 42 | definition.kind === "OperationDefinition" && 43 | definition.operation === "subscription" 44 | ); 45 | }, 46 | wsLink, 47 | httpLink, 48 | ); 49 | const apolloClient = new ApolloClient({ 50 | cache: new InMemoryCache(), 51 | link, 52 | }); 53 | return apolloClient; 54 | }; 55 | 56 | export default WebSocketApolloClient; 57 | -------------------------------------------------------------------------------- /examples/http-request-listener-api/README.md: -------------------------------------------------------------------------------- 1 | # fanout-graphql-tools-example-http-request-listener-api 2 | 3 | Example of how to use fanout-graphql-tools with an http.RequestListener (in this case, from micro). 4 | 5 | Some node.js web libraries help you build an http.RequestListener function that can be passed to `require('http').createServer(requestListener)` to create an http.Server. 6 | 7 | fanout-graphql-tools exports a `GraphqlWsOverWebSocketOverHttpRequestListener` constructor that wraps another RequestListener, adding support for WebSocket-Over-HTTP in the returned, new, RequestListener. 8 | 9 | ## Testing locally 10 | 11 | 1. [Install pushpin using these instructions](https://pushpin.org/docs/install/) 12 | 13 | 2. Configure your `/etc/pushpin/routes` file to contain: 14 | ``` 15 | *,debug localhost:57410,over_http 16 | ``` 17 | 3. 18 | In this directory, 19 | ``` 20 | npm install 21 | npm start 22 | ``` 23 | 24 | This will run the example GraphQL Server, listening on port 57410 25 | 26 | 4. Access the example in your web browser *through pushpin* using http://localhost:7999/graphql 27 | 28 | 5. 29 | Using the GraphiQL Playground UI that should render here, create the following subscription: 30 | ```graphql 31 | subscription { 32 | postAdded { 33 | author, 34 | comment, 35 | } 36 | } 37 | ``` 38 | 39 | 6. 40 | Open another tab in the GraphiQL Playground, and send the following mutation: 41 | 42 | ```graphql 43 | mutation { 44 | addPost(comment:"hi", author:"you") { 45 | author, 46 | comment, 47 | } 48 | } 49 | ``` 50 | 51 | 7. Switch back to the GraphiQL Playground tab for the subscription, and on the right side you should see the result of your mutation. 52 | -------------------------------------------------------------------------------- /src/graphql-ws/GraphqlQueryTools.ts: -------------------------------------------------------------------------------- 1 | import { getMainDefinition } from "apollo-utilities"; 2 | import { ValueNode } from "graphql"; 3 | import gql from "graphql-tag"; 4 | 5 | /** Given a graphql query and a argument name, return the value of that argument in the query */ 6 | export const getQueryArgumentValue = ( 7 | query: string, 8 | argumentName: string, 9 | ): ValueNode => { 10 | const parsedQuery = gql` 11 | ${query} 12 | `; 13 | const mainDefinition = getMainDefinition(parsedQuery); 14 | const selectionSet = mainDefinition.selectionSet; 15 | const primarySelection = selectionSet.selections[0]; 16 | if (primarySelection.kind !== "Field") { 17 | throw new Error( 18 | `query first selection must be of kind Field, but got ${primarySelection.kind}`, 19 | ); 20 | } 21 | const argument = (primarySelection.arguments || []).find( 22 | a => argumentName === a.name.value, 23 | ); 24 | if (!argument) { 25 | throw new Error( 26 | `Could not find argument named ${argumentName} after parsing query`, 27 | ); 28 | } 29 | const argumentValueNode = argument.value; 30 | return argumentValueNode; 31 | }; 32 | 33 | /** 34 | * Given a ValueNode and some variables from a query, interpolate the variables and return a final value. 35 | * The provided variables will only e used if ValueNode.kind === "Variable". 36 | * Otherwise this just returns the .value of the ValueNode. 37 | */ 38 | export const interpolateValueNodeWithVariables = ( 39 | valueNode: ValueNode, 40 | variables: any, 41 | ) => { 42 | switch (valueNode.kind) { 43 | case "Variable": 44 | return variables[valueNode.name.value]; 45 | case "NullValue": 46 | return null; 47 | case "ListValue": 48 | case "ObjectValue": 49 | throw new Error(`Unsupported ValueNode type ${valueNode.kind}`); 50 | default: 51 | return valueNode.value; 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/graphql-ws/GraphqlQueryToolsTest.test.ts: -------------------------------------------------------------------------------- 1 | import { AsyncTest, Expect, TestCase, TestFixture } from "alsatian"; 2 | import { cli } from "../test/cli"; 3 | import { 4 | getQueryArgumentValue, 5 | interpolateValueNodeWithVariables, 6 | } from "./GraphqlQueryTools"; 7 | 8 | /** Test ./GraphqlQueryTools */ 9 | @TestFixture() 10 | export class GraphqlQueryToolsTest { 11 | /** Test getQueryArgumentValue can get the value of a variable from a query string + variables */ 12 | @TestCase({ 13 | query: ` 14 | subscription { 15 | noteAddedToChannel(channel: "#general") { 16 | content 17 | channel 18 | id 19 | } 20 | } 21 | `, 22 | variables: {}, 23 | }) 24 | @TestCase({ 25 | query: ` 26 | subscription NoteAddedToChannel($channelSubscriptionArg: String!) { 27 | noteAddedToChannel(channel: $channelSubscriptionArg) { 28 | content 29 | id 30 | __typename 31 | } 32 | } 33 | `, 34 | variables: { 35 | channelSubscriptionArg: "#general", 36 | }, 37 | }) 38 | @AsyncTest() 39 | public async testGetQueryArgumentValueAndInterpolateVariables({ 40 | query, 41 | variables, 42 | }: { 43 | /** graphql query */ 44 | query: string; 45 | /** variables that should be provided to query (or empty object) */ 46 | variables: object | {}; 47 | }) { 48 | const argumentName = "channel"; 49 | const expectedArgumentValue = "#general"; 50 | const argumentValueNode = getQueryArgumentValue(query, argumentName); 51 | const actualArgumentValue = interpolateValueNodeWithVariables( 52 | argumentValueNode, 53 | variables, 54 | ); 55 | Expect(actualArgumentValue).toEqual(expectedArgumentValue); 56 | } 57 | } 58 | 59 | if (require.main === module) { 60 | cli(__filename).catch((error: Error) => { 61 | throw error; 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/WebSocketOverHttpPubSubMixin.ts: -------------------------------------------------------------------------------- 1 | import { PubSubEngine } from "apollo-server"; 2 | import { 3 | ISubscriptionTestGraphqlContext, 4 | PublishToStoredSubscriptionsPubSubMixin, 5 | SubscriptionStoragePubSubMixin, 6 | } from "./SubscriptionStoragePubSubMixin"; 7 | import { 8 | IContextForPublishingWithEpcp, 9 | IWebSocketOverHttpGraphqlSubscriptionContext, 10 | } from "./WebSocketOverHttpGraphqlContext"; 11 | 12 | enum FanoutGraphqlToolsContextKeys { 13 | subscriptionTest = "subscriptionTest", 14 | webSocketOverHttp = "webSocketOverHttp", 15 | epcpPublishing = "epcpPublishing", 16 | } 17 | 18 | /** 19 | * Given graphql resolver context, return a PubSub mixin that will do what is needed 20 | * to enable subscriptions over ws-over-http 21 | */ 22 | export const WebSocketOverHttpPubSubMixin = ( 23 | context: 24 | | IWebSocketOverHttpGraphqlSubscriptionContext 25 | | IContextForPublishingWithEpcp 26 | | ISubscriptionTestGraphqlContext, 27 | ) => (pubsubIn: PubSubEngine): PubSubEngine => { 28 | let pubsub = pubsubIn; 29 | if ("subscriptionTest" in context) { 30 | pubsub = context.subscriptionTest.pubsub; 31 | } 32 | if ("webSocketOverHttp" in context && context.webSocketOverHttp) { 33 | pubsub = SubscriptionStoragePubSubMixin(context.webSocketOverHttp)(pubsub); 34 | } 35 | if ("epcpPublishing" in context && context.epcpPublishing) { 36 | pubsub = PublishToStoredSubscriptionsPubSubMixin(context.epcpPublishing)( 37 | pubsub, 38 | ); 39 | } 40 | if ( 41 | Object.keys(context).filter(contextKey => 42 | Object.keys(FanoutGraphqlToolsContextKeys).includes(contextKey), 43 | ).length === 0 44 | ) { 45 | console.warn( 46 | "WebSocketOverHttpPubSubMixin found no FanoutGraphqlToolsContextKeys in GraphQL Context. Did you remember to use WebSocketOverHttpContextFunction when constructing ApolloServer()?", 47 | ); 48 | } 49 | return pubsub; 50 | }; 51 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpRequestListener.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { GraphQLSchema } from "graphql"; 3 | import * as http from "http"; 4 | import { ISimpleTable } from "../simple-table/SimpleTable"; 5 | import { GraphqlWsGripChannelNamer } from "./GraphqlWsGripChannelNamers"; 6 | import GraphqlWsOverWebSocketOverHttpExpressMiddleware, { 7 | IStoredConnection, 8 | } from "./GraphqlWsOverWebSocketOverHttpExpressMiddleware"; 9 | import { IStoredPubSubSubscription } from "./PubSubSubscriptionStorage"; 10 | 11 | interface IGraphqlWsOverWebSocketOverHttpRequestListenerOptions { 12 | /** table to store information about each ws-over-http connection */ 13 | connectionStorage: ISimpleTable; 14 | /** table to store PubSub subscription info in */ 15 | pubSubSubscriptionStorage: ISimpleTable; 16 | /** GraphQL Schema including resolvers */ 17 | schema: GraphQLSchema; 18 | /** Given a graphql-ws GQL_START message, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates */ 19 | getGripChannel?: GraphqlWsGripChannelNamer; 20 | } 21 | 22 | /** 23 | * GraphqlWsOverWebSocketOverHttpRequestListener. 24 | * Given an http RequestListener, return a new one that will respond to incoming WebSocket-Over-Http requests that are graphql-ws 25 | * Subscriptions and accept the subscriptions. 26 | */ 27 | export const GraphqlWsOverWebSocketOverHttpRequestListener = ( 28 | originalRequestListener: http.RequestListener, 29 | options: IGraphqlWsOverWebSocketOverHttpRequestListenerOptions, 30 | ): http.RequestListener => (req, res) => { 31 | const handleWebSocketOverHttpRequestHandler: http.RequestListener = express() 32 | .use(GraphqlWsOverWebSocketOverHttpExpressMiddleware(options)) 33 | .use((expressRequest, expressResponse) => { 34 | // It wasn't handled by GraphqlWsOverWebSocketOverHttpExpressMiddleware 35 | originalRequestListener(req, res); 36 | }); 37 | handleWebSocketOverHttpRequestHandler(req, res); 38 | }; 39 | -------------------------------------------------------------------------------- /docs/getGripChannel.md: -------------------------------------------------------------------------------- 1 | # Configuring GRIP Channel Naming with `getGripChannel` 2 | 3 | Several functions in this package support a `getGripChannel` option that allows a developer to configure what [GRIP](https://pushpin.org/docs/protocols/grip/) Channel Name is used for each graphql-ws subscription. 4 | 5 | The value of this option should be a function that matches the [`GraphqlWsGripChannelNamer` type](https://github.com/fanout/fanout-graphql-tools/blob/master/src/subscriptions-transport-ws-over-http/GraphqlWsGripChannelNamers.ts#L7). At the time of this writing, that means the `getGripChannel` function will be passed a [WebSocket-Over-HTTP](https://pushpin.org/docs/protocols/websocket-over-http/) Connection-Id string as well as the [graphql-ws start message](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md#gql_start) that includes a GraphQL query, variables, and operation name. The `getGripChannel` function should use this information to return a single string, which will be used as the GRIP Channel name. 6 | 7 | If a custom `GraphqlWsGripChannelNamer` is not provided as `options.getGripChannel`, most modules in this library will use [DefaultGripChannelNamer](https://github.com/fanout/fanout-graphql-tools/blob/master/src/subscriptions-transport-ws-over-http/GraphqlWsGripChannelNamers.ts#L41), which is a GraphqlWsGripChannelNamer that simply returns the [WebSocket-Over-HTTP](https://pushpin.org/docs/protocols/websocket-over-http/) Connection-Id. With this configuration, there will always be one Grip-Channel per WebSocket client. 8 | 9 | ## Why would I configure tihs? 10 | 11 | * Add prefixes to all your Grip-Channel names when you have many separate applications behind the same GRIP Proxy and want to ensure they never accidentally use the same channel. 12 | * For some APIs where all clients are receiving the same messages, you may be able to make a GraphqlWsGripChannelNamer such that many clients share one or a few Grip-Channels, which can lessen the load on your GRIP Proxy and sometimes speed up message publish performance (because there are less channels to publish to). 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fanout-graphql-tools", 3 | "version": "0.2.0", 4 | "description": "", 5 | "main": "dist/src", 6 | "types": "dist/src/index.d.ts", 7 | "author": "", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/fanout/fanout-graphql-tools.git" 11 | }, 12 | "license": "ISC", 13 | "prettier": { 14 | "trailingComma": "all" 15 | }, 16 | "scripts": { 17 | "build": "npm run tsc", 18 | "check": "npm run prettier:check && npm run tsc:check && npm run tslint", 19 | "check-and-build": "npm run check && npm run build", 20 | "prettier": "prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --write", 21 | "prettier:check": "prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --check", 22 | "prepublishOnly": "npm run check-and-build", 23 | "preversion": "npm run check-and-build", 24 | "start": "ts-node src/FanoutGraphqlExpressServer", 25 | "test": "ts-node src/test/unit && npm run check", 26 | "tsc": "tsc -p tsconfig.json", 27 | "tsc:check": "npm run tsc -- --noEmit", 28 | "tslint": "tslint -c tslint.json 'src/**/*.ts'" 29 | }, 30 | "dependencies": { 31 | "@types/core-js": "^2.5.0", 32 | "@types/graphql": "^14.2.0", 33 | "@types/node": "latest", 34 | "@types/node-fetch": "^2.3.2", 35 | "@types/uuid": "^3.4.4", 36 | "apollo-cache-inmemory": "^1.5.1", 37 | "apollo-client": "^2.5.1", 38 | "apollo-link-http": "^1.5.14", 39 | "apollo-link-ws": "^1.0.17", 40 | "apollo-server": "^2.4.8", 41 | "aws-serverless-express": "^3.3.6", 42 | "body-parser": "^1.18.3", 43 | "core-js": "^3.1.3", 44 | "cross-fetch": "^3.0.2", 45 | "graphql": "^14.3.1", 46 | "grip": "^1.3.0", 47 | "killable": "^1.0.1", 48 | "node-fetch": "^2.3.0", 49 | "tap-bark": "^1.0.0", 50 | "ts-dedent": "^1.0.0", 51 | "uuid": "^3.3.2" 52 | }, 53 | "devDependencies": { 54 | "alsatian": "^2.4.0", 55 | "prettier": "^1.17.1", 56 | "ts-node": "^8.2.0", 57 | "tslint": "^5.17.0", 58 | "tslint-config-prettier": "^1.18.0", 59 | "typescript": "^3.5.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/GraphqlWsGripChannelNamers.ts: -------------------------------------------------------------------------------- 1 | import * as querystring from "querystring"; 2 | import { 3 | getSubscriptionOperationFieldName, 4 | IGraphqlWsStartMessage, 5 | } from "./GraphqlWebSocketOverHttpConnectionListener"; 6 | 7 | export type GraphqlWsGripChannelNamer = (context: { 8 | /** connection ID */ 9 | connectionId: string; 10 | /** grpahql-ws START message for the GraphQL Subscription. This includes the graphql query, operationId, variables */ 11 | graphqlWsStartMessage: IGraphqlWsStartMessage; 12 | }) => string; 13 | 14 | /** 15 | * A function that will return the Grip-Channel to use for the provided IGraphqlWsStartMessage. 16 | * This will only work for GraphQL APIs with subscriptions that dont have arguments. 17 | * The Grip-Channel will be based on the subscription field name + the graphqlWsStartMessage operation id 18 | */ 19 | export const gripChannelForSubscriptionWithoutArguments: GraphqlWsGripChannelNamer = ({ 20 | graphqlWsStartMessage, 21 | }): string => { 22 | const subscriptionFieldName = getSubscriptionOperationFieldName( 23 | graphqlWsStartMessage.payload, 24 | ); 25 | const gripChannel = `${subscriptionFieldName}?${querystring.stringify( 26 | sorted({ 27 | "subscription.operation.id": graphqlWsStartMessage.id, 28 | }), 29 | )}`; 30 | return gripChannel; 31 | }; 32 | 33 | /** GraphqlWsGripChannelNamer that avoids separate clients sharing a single GripChannel */ 34 | export const NeverShareGripChannelNamer = (): GraphqlWsGripChannelNamer => ({ 35 | connectionId, 36 | }) => { 37 | return connectionId; 38 | }; 39 | 40 | /** Create the default getGripChannel that should be used by other fanout-graphql-tools */ 41 | export const DefaultGripChannelNamer = (): GraphqlWsGripChannelNamer => 42 | NeverShareGripChannelNamer(); 43 | 44 | /** 45 | * given an object, return the same, ensuring that the object keys were inserted in alphabetical order 46 | * https://github.com/nodejs/node/issues/6594#issuecomment-217120402 47 | */ 48 | function sorted(o: any) { 49 | const p = Object.create(null); 50 | for (const k of Object.keys(o).sort()) { 51 | p[k] = o[k]; 52 | } 53 | return p; 54 | } 55 | -------------------------------------------------------------------------------- /src/testing-tools/withListeningServer.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import * as killable from "killable"; 3 | import { AddressInfo } from "net"; 4 | import * as url from "url"; 5 | 6 | interface IListeningServerInfo { 7 | /** url at which the server can be reached */ 8 | url: string; 9 | /** host of server */ 10 | hostname: string; 11 | /** port of server */ 12 | port: number; 13 | } 14 | 15 | const hostOfAddressInfo = (address: AddressInfo): string => { 16 | const host = 17 | address.address === "" || address.address === "::" 18 | ? "localhost" 19 | : address.address; 20 | return host; 21 | }; 22 | 23 | const urlOfServerAddress = (address: AddressInfo): string => { 24 | return url.format({ 25 | hostname: hostOfAddressInfo(address), 26 | port: address.port, 27 | protocol: "http", 28 | }); 29 | }; 30 | 31 | /** 32 | * Given an httpServer and port, have the server listen on the port 33 | * then call the provided doWorkWithServer function with info about the running server. 34 | * After work is done, kill the running server to clean up. 35 | */ 36 | export const withListeningServer = ( 37 | httpServer: http.Server, 38 | port: string | number = 0, 39 | ) => async ( 40 | doWorkWithServer: (serverInfo: IListeningServerInfo) => Promise, 41 | ) => { 42 | const { kill } = killable(httpServer); 43 | // listen 44 | await new Promise((resolve, reject) => { 45 | httpServer.on("listening", resolve); 46 | httpServer.on("error", error => { 47 | reject(error); 48 | }); 49 | try { 50 | httpServer.listen(port); 51 | } catch (error) { 52 | reject(error); 53 | } 54 | }); 55 | const address = httpServer.address(); 56 | if (typeof address === "string" || !address) { 57 | throw new Error(`Can't determine URL from address ${address}`); 58 | } 59 | await doWorkWithServer({ 60 | hostname: hostOfAddressInfo(address), 61 | port: address.port, 62 | url: urlOfServerAddress(address), 63 | }); 64 | await new Promise((resolve, reject) => { 65 | try { 66 | kill((error: Error | undefined) => { 67 | reject(error); 68 | }); 69 | } catch (error) { 70 | reject(error); 71 | } 72 | }); 73 | return; 74 | }; 75 | -------------------------------------------------------------------------------- /examples/apollo-server-express/src/index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer, makeExecutableSchema } from "apollo-server-express"; 2 | import * as express from "express"; 3 | import { IStoredConnection, IStoredPubSubSubscription } from "fanout-graphql-tools"; 4 | import { GraphqlWsOverWebSocketOverHttpExpressMiddleware } from "fanout-graphql-tools"; 5 | import { MapSimpleTable } from "fanout-graphql-tools"; 6 | import { WebSocketOverHttpContextFunction } from "fanout-graphql-tools"; 7 | import * as http from "http"; 8 | import { SimpleGraphqlApi } from "../../../src/simple-graphql-api/SimpleGraphqlApi"; 9 | 10 | /** 11 | * WebSocket-Over-HTTP Support requires storage to keep track of ws-over-http connections and subscriptions. 12 | * The Storage objects match an ISimpleTable interface that is a subset of the @pulumi/cloud Table interface. MapSimpleTable is an in-memory implementation, but you can use @pulumi/cloud implementations in production, e.g. to use DyanmoDB. 13 | */ 14 | const connectionStorage = MapSimpleTable(); 15 | const pubSubSubscriptionStorage = MapSimpleTable(); 16 | 17 | const { typeDefs, resolvers } = SimpleGraphqlApi(); 18 | const schema = makeExecutableSchema({ typeDefs, resolvers }); 19 | const apolloServer = new ApolloServer({ 20 | context: WebSocketOverHttpContextFunction({ 21 | grip: { 22 | url: process.env.GRIP_URL || "http://localhost:5561", 23 | }, 24 | pubSubSubscriptionStorage, 25 | schema, 26 | }), 27 | schema, 28 | }); 29 | 30 | const PORT = process.env.PORT || 57410; 31 | const app = express().use( 32 | // This is what you need to support WebSocket-Over-Http Subscribes 33 | GraphqlWsOverWebSocketOverHttpExpressMiddleware({ 34 | connectionStorage, 35 | pubSubSubscriptionStorage, 36 | schema, 37 | }), 38 | ); 39 | 40 | apolloServer.applyMiddleware({ app }); 41 | 42 | const httpServer = http.createServer(app); 43 | apolloServer.installSubscriptionHandlers(httpServer); 44 | 45 | // ⚠️ Pay attention to the fact that we are calling `listen` on the http server variable, and not on `app`. 46 | httpServer.listen(PORT, () => { 47 | console.log( 48 | `🚀 Server ready at http://localhost:${PORT}${apolloServer.graphqlPath}`, 49 | ); 50 | console.log( 51 | `🚀 Subscriptions ready at ws://localhost:${PORT}${apolloServer.subscriptionsPath}`, 52 | ); 53 | }); 54 | -------------------------------------------------------------------------------- /examples/micro/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API Demo of adding WebSocketOverHttp support patching any http.Server 3 | * Almost all node.js web libraries support creating one of these from the underlying Application object. 4 | * In this example, we use zeit/micro, but you can do something similar with koa, express, raw node http, etc. 5 | */ 6 | 7 | import { ApolloServer, makeExecutableSchema } from "apollo-server-micro"; 8 | import { 9 | GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller, 10 | IStoredConnection, 11 | IStoredPubSubSubscription, 12 | WebSocketOverHttpContextFunction, 13 | } from "fanout-graphql-tools"; 14 | import { MapSimpleTable } from "fanout-graphql-tools"; 15 | import * as http from "http"; 16 | import micro from "micro"; 17 | import { SimpleGraphqlApi } from "../../../src/simple-graphql-api/SimpleGraphqlApi"; 18 | 19 | /** 20 | * WebSocket-Over-HTTP Support requires storage to keep track of ws-over-http connections and subscriptions. 21 | * The Storage objects match an ISimpleTable interface that is a subset of the @pulumi/cloud Table interface. MapSimpleTable is an in-memory implementation, but you can use @pulumi/cloud implementations in production, e.g. to use DyanmoDB. 22 | */ 23 | const connectionStorage = MapSimpleTable(); 24 | const pubSubSubscriptionStorage = MapSimpleTable(); 25 | 26 | const schema = makeExecutableSchema(SimpleGraphqlApi()); 27 | 28 | const apolloServer = new ApolloServer({ 29 | context: WebSocketOverHttpContextFunction({ 30 | grip: { 31 | // Get this from your Fanout Cloud console, which looks like https://api.fanout.io/realm/{realm-id}?iss={realm-id}&key=base64:{realm-key} 32 | // or use this localhost for your own pushpin.org default installation 33 | url: process.env.GRIP_URL || "http://localhost:5561", 34 | }, 35 | pubSubSubscriptionStorage, 36 | schema, 37 | }), 38 | schema, 39 | }); 40 | 41 | // Note: In micro 9.3.5 this will return an http.RequestListener instead (after https://github.com/zeit/micro/pull/399) 42 | // Provide it to http.createServer to create an http.Server 43 | const httpServer: http.Server = micro(apolloServer.createHandler()); 44 | 45 | // Patch the http.Server to handle WebSocket-Over-Http requests that come from Fanout Cloud 46 | GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller({ 47 | connectionStorage, 48 | pubSubSubscriptionStorage, 49 | schema, 50 | })(httpServer); 51 | 52 | const port = process.env.PORT || 57410; 53 | httpServer.listen(port, () => { 54 | console.log(`Server is now running on http://localhost:${port}${apolloServer.graphqlPath}`); 55 | }); 56 | -------------------------------------------------------------------------------- /examples/http-request-listener-api/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API Demo of adding WebSocketOverHttp support patching any http.Server 3 | * Almost all node.js web libraries support creating one of these from the underlying Application object. 4 | * In this example, we use zeit/micro, but you can do something similar with koa, express, raw node http, etc. 5 | */ 6 | 7 | import { ApolloServer, makeExecutableSchema } from "apollo-server-micro"; 8 | import { 9 | GraphqlWsOverWebSocketOverHttpRequestListener, 10 | IStoredConnection, 11 | IStoredPubSubSubscription, 12 | WebSocketOverHttpContextFunction, 13 | } from "fanout-graphql-tools"; 14 | import { MapSimpleTable } from "fanout-graphql-tools"; 15 | import * as http from "http"; 16 | import { run as microRun } from "micro"; 17 | import { SimpleGraphqlApi } from "../../../src/simple-graphql-api/SimpleGraphqlApi"; 18 | 19 | /** 20 | * WebSocket-Over-HTTP Support requires storage to keep track of ws-over-http connections and subscriptions. 21 | * The Storage objects match an ISimpleTable interface that is a subset of the @pulumi/cloud Table interface. MapSimpleTable is an in-memory implementation, but you can use @pulumi/cloud implementations in production, e.g. to use DyanmoDB. 22 | */ 23 | const connectionStorage = MapSimpleTable(); 24 | const pubSubSubscriptionStorage = MapSimpleTable(); 25 | 26 | const schema = makeExecutableSchema(SimpleGraphqlApi()); 27 | 28 | const apolloServer = new ApolloServer({ 29 | context: WebSocketOverHttpContextFunction({ 30 | grip: { 31 | // Get this from your Fanout Cloud console, which looks like https://api.fanout.io/realm/{realm-id}?iss={realm-id}&key=base64:{realm-key} 32 | // or use this localhost for your own pushpin.org default installation 33 | url: process.env.GRIP_URL || "http://localhost:5561", 34 | }, 35 | pubSubSubscriptionStorage, 36 | schema, 37 | }), 38 | schema, 39 | }); 40 | 41 | // In micro 9.3.5, the default export of micro(handler) will return an http.RequestListener (after https://github.com/zeit/micro/pull/399). 42 | // As of this authoring, only 9.3.4 is out, which returns an http.Server. So we manually build the RequestListner here. 43 | // After 9.3.5, the following will work: 44 | // import micro from "micro" 45 | // const microRequestListener = micro(apolloServer.createHandler()) 46 | const microRequestListener: http.RequestListener = (req, res) => 47 | microRun(req, res, apolloServer.createHandler()); 48 | 49 | const httpServer = http.createServer( 50 | GraphqlWsOverWebSocketOverHttpRequestListener(microRequestListener, { 51 | connectionStorage, 52 | pubSubSubscriptionStorage, 53 | schema, 54 | }), 55 | ); 56 | 57 | const port = process.env.PORT || 57410; 58 | httpServer.listen(port, () => { 59 | console.log(`Server is now running on http://localhost:${port}${apolloServer.graphqlPath}`); 60 | }); 61 | -------------------------------------------------------------------------------- /src/simple-table/SimpleTable.ts: -------------------------------------------------------------------------------- 1 | interface IQueryById { 2 | /** id of Entity to get */ 3 | id: string; 4 | } 5 | 6 | /** 7 | * A simple interface for a 'table' of data. 8 | * Meant to be able to be shared across in-memory implementations and @pulumi/cloud.Table. 9 | * As much as possible, it tries to be a subset of @pulumi/cloud.Table 10 | */ 11 | export interface ISimpleTable { 12 | /** delete a single entity by id */ 13 | delete(query: IQueryById): Promise; 14 | /** Get a single entity by id */ 15 | get(query: IQueryById): Promise; 16 | /** Add a new entity */ 17 | insert(e: Entity): Promise; 18 | /** Get all entities */ 19 | scan(): Promise; 20 | /** Scan through batches of entities, passing them to callback */ 21 | scan(callback: (items: Entity[]) => Promise): Promise; 22 | /** 23 | * Updates a documents in the table. 24 | * If no item matches query exists, a new one should be created. 25 | * 26 | * @param query An object with the primary key ("id" by default) assigned 27 | * the value to lookup. 28 | * @param updates An object with all document properties that should be 29 | * updated. 30 | * @returns A promise for the success or failure of the update. 31 | */ 32 | update(query: IQueryById, updates: Partial): Promise; 33 | } 34 | 35 | interface IHasId { 36 | /** id of object */ 37 | id: string; 38 | } 39 | 40 | /** Implementation of ISimpleTable that stores data in-memory in a Map */ 41 | export const MapSimpleTable = ( 42 | map = new Map(), 43 | ): ISimpleTable => { 44 | type ScanCallback = (entities: Entity[]) => Promise; 45 | // tslint:disable:completed-docs 46 | async function scan(): Promise; 47 | async function scan(callback: ScanCallback): Promise; 48 | async function scan(callback?: ScanCallback) { 49 | if (callback) { 50 | for (const e of map.values()) { 51 | await callback([e]); 52 | } 53 | return; 54 | } else { 55 | const values = Array.from(map.values()); 56 | return values; 57 | } 58 | } 59 | // tslint:enable:completed-docs 60 | return { 61 | async delete(query: IQueryById) { 62 | map.delete(query.id); 63 | }, 64 | async get(query: IQueryById) { 65 | const got = map.get(query.id); 66 | if (!got) { 67 | throw new Error(`Entity not found for id=${query.id}`); 68 | } 69 | return got; 70 | }, 71 | async insert(entity: Entity) { 72 | map.set(entity.id, entity); 73 | }, 74 | scan, 75 | async update(query: IQueryById, updates) { 76 | const got = map.get(query.id) || {}; 77 | const updated = { ...query, ...got, ...updates }; 78 | // Type assertion is bad but required to emulated untyped nature of the pulumi/cloud aws table implementation 79 | map.set(query.id, updated as Entity); 80 | }, 81 | }; 82 | }; 83 | 84 | /** Return items in an ISimpleTable that match the provided filter function */ 85 | export const filterTable = async ( 86 | table: ISimpleTable, 87 | itemFilter: (item: ItemType) => boolean, 88 | ): Promise => { 89 | const filteredItems: ItemType[] = []; 90 | await table.scan(async items => { 91 | filteredItems.push(...items.filter(itemFilter)); 92 | return true; 93 | }); 94 | return filteredItems; 95 | }; 96 | -------------------------------------------------------------------------------- /examples/apollo-server-express/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fanout-graphql-tools-example-apollo-server-express", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "arg": { 8 | "version": "4.1.0", 9 | "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", 10 | "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", 11 | "dev": true 12 | }, 13 | "buffer-from": { 14 | "version": "1.1.1", 15 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", 16 | "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", 17 | "dev": true 18 | }, 19 | "diff": { 20 | "version": "4.0.1", 21 | "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.1.tgz", 22 | "integrity": "sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==", 23 | "dev": true 24 | }, 25 | "fanout-graphql-tools": { 26 | "version": "0.1.1", 27 | "requires": { 28 | "@types/core-js": "^2.5.0", 29 | "@types/graphql": "^14.2.0", 30 | "@types/node": "^12.6.2", 31 | "@types/node-fetch": "^2.3.2", 32 | "@types/uuid": "^3.4.4", 33 | "apollo-cache-inmemory": "^1.5.1", 34 | "apollo-client": "^2.5.1", 35 | "apollo-link-http": "^1.5.14", 36 | "apollo-link-ws": "^1.0.17", 37 | "apollo-server": "^2.4.8", 38 | "aws-serverless-express": "^3.3.6", 39 | "body-parser": "^1.18.3", 40 | "core-js": "^3.1.3", 41 | "cross-fetch": "^3.0.2", 42 | "graphql": "^14.3.1", 43 | "grip": "^1.3.0", 44 | "killable": "^1.0.1", 45 | "node-fetch": "^2.3.0", 46 | "tap-bark": "^1.0.0", 47 | "ts-dedent": "^1.0.0", 48 | "uuid": "^3.3.2" 49 | } 50 | }, 51 | "make-error": { 52 | "version": "1.3.5", 53 | "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.5.tgz", 54 | "integrity": "sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==", 55 | "dev": true 56 | }, 57 | "source-map": { 58 | "version": "0.6.1", 59 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 60 | "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 61 | "dev": true 62 | }, 63 | "source-map-support": { 64 | "version": "0.5.12", 65 | "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.12.tgz", 66 | "integrity": "sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ==", 67 | "dev": true, 68 | "requires": { 69 | "buffer-from": "^1.0.0", 70 | "source-map": "^0.6.0" 71 | } 72 | }, 73 | "ts-node": { 74 | "version": "8.3.0", 75 | "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.3.0.tgz", 76 | "integrity": "sha512-dyNS/RqyVTDcmNM4NIBAeDMpsAdaQ+ojdf0GOLqE6nwJOgzEkdRNzJywhDfwnuvB10oa6NLVG1rUJQCpRN7qoQ==", 77 | "dev": true, 78 | "requires": { 79 | "arg": "^4.1.0", 80 | "diff": "^4.0.1", 81 | "make-error": "^1.1.1", 82 | "source-map-support": "^0.5.6", 83 | "yn": "^3.0.0" 84 | } 85 | }, 86 | "yn": { 87 | "version": "3.1.0", 88 | "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.0.tgz", 89 | "integrity": "sha512-kKfnnYkbTfrAdd0xICNFw7Atm8nKpLcLv9AZGEt+kczL/WQVai4e2V6ZN8U/O+iI6WrNuJjNNOyu4zfhl9D3Hg==", 90 | "dev": true 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { GraphQLSchema } from "graphql"; 3 | import * as http from "http"; 4 | import { ISimpleTable } from "../simple-table/SimpleTable"; 5 | import { IGraphqlWsStartMessage } from "./GraphqlWebSocketOverHttpConnectionListener"; 6 | import { GraphqlWsGripChannelNamer } from "./GraphqlWsGripChannelNamers"; 7 | import GraphqlWsOverWebSocketOverHttpExpressMiddleware, { 8 | IStoredConnection, 9 | } from "./GraphqlWsOverWebSocketOverHttpExpressMiddleware"; 10 | import { IStoredPubSubSubscription } from "./PubSubSubscriptionStorage"; 11 | 12 | interface IGraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstallerOptions { 13 | /** table to store information about each ws-over-http connection */ 14 | connectionStorage: ISimpleTable; 15 | /** table to store PubSub subscription info in */ 16 | pubSubSubscriptionStorage: ISimpleTable; 17 | /** GraphQL Schema including resolvers */ 18 | schema: GraphQLSchema; 19 | /** Given a graphql-ws GQL_START message, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates */ 20 | getGripChannel?: GraphqlWsGripChannelNamer; 21 | } 22 | 23 | /** 24 | * Create a function that will patch an http.Server instance such that it responds to incoming graphql-ws over WebSocket-Over-Http requests in a way that will allow all GraphQL Subscriptions to initiate. 25 | * If the incoming request is not of this specific kind, it will be handled however the http.Server normally would. 26 | */ 27 | export const GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller = ( 28 | options: IGraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstallerOptions, 29 | ) => (httpServer: http.Server) => { 30 | interceptRequests(httpServer, (request, response, next) => { 31 | const handleWebSocketOverHttpRequestHandler: http.RequestListener = express() 32 | .use(GraphqlWsOverWebSocketOverHttpExpressMiddleware(options)) 33 | .use((expressRequest, expressResponse) => { 34 | // It wasn't handled by GraphqlWsOverWebSocketOverHttpExpressMiddleware 35 | next(); 36 | }); 37 | handleWebSocketOverHttpRequestHandler(request, response); 38 | }); 39 | }; 40 | 41 | type AnyFunction = (...args: any[]) => any; 42 | 43 | /** NodeJS.EventEmitter properties that do exist but are not documented and aren't on the TypeScript types */ 44 | interface IEventEmitterPrivates { 45 | /** Internal state holding refs to all listeners */ 46 | _events: Record; 47 | } 48 | /** Use declaration merigng to add IEventEmitterPrivates to NodeJs.EventEmitters like http.Server used below */ 49 | declare module "events" { 50 | // EventEmitter 51 | // tslint:disable-next-line:interface-name no-empty-interface 52 | interface EventEmitter extends IEventEmitterPrivates {} 53 | } 54 | 55 | type RequestInterceptor = ( 56 | request: http.IncomingMessage, 57 | response: http.ServerResponse, 58 | next: () => void, 59 | ) => void; 60 | 61 | /** Patch an httpServer to pass all incoming requests through an interceptor before doing what it would normally do */ 62 | function interceptRequests( 63 | httpServer: http.Server, 64 | intercept: RequestInterceptor, 65 | ) { 66 | const originalRequestListeners = httpServer._events.request; 67 | httpServer._events.request = ( 68 | request: http.IncomingMessage, 69 | response: http.ServerResponse, 70 | ) => { 71 | intercept(request, response, () => { 72 | const listeners = originalRequestListeners 73 | ? Array.isArray(originalRequestListeners) 74 | ? originalRequestListeners 75 | : [originalRequestListeners] 76 | : []; 77 | listeners.forEach(listener => listener(request, response)); 78 | }); 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /src/simple-graphql-api/SimpleGraphqlApi.ts: -------------------------------------------------------------------------------- 1 | import { PubSub, PubSubEngine } from "apollo-server"; 2 | import gql from "graphql-tag"; 3 | import { IWebSocketOverHttpGraphqlSubscriptionContext } from "../subscriptions-transport-ws-over-http/WebSocketOverHttpGraphqlContext"; 4 | import { IContextForPublishingWithEpcp } from "../subscriptions-transport-ws-over-http/WebSocketOverHttpGraphqlContext"; 5 | import { WebSocketOverHttpPubSubMixin } from "../subscriptions-transport-ws-over-http/WebSocketOverHttpPubSubMixin"; 6 | 7 | interface IPost { 8 | /** post author */ 9 | author: string; 10 | /** post body text */ 11 | comment: string; 12 | } 13 | 14 | interface IPostController { 15 | /** add a post */ 16 | addPost(post: IPost): IPost; 17 | /** get all posts */ 18 | posts(): IPost[]; 19 | } 20 | 21 | /** Constructor for a PostController that stores post in memory */ 22 | const PostController = (): IPostController => { 23 | const postStorage: IPost[] = []; 24 | const addPost = (post: IPost): IPost => { 25 | postStorage.push(post); 26 | return post; 27 | }; 28 | const posts = (): IPost[] => { 29 | return postStorage.slice(); 30 | }; 31 | return { 32 | addPost, 33 | posts, 34 | }; 35 | }; 36 | 37 | enum SimpleGraphqlApiPubSubTopic { 38 | POST_ADDED = "POST_ADDED", 39 | } 40 | 41 | /** Common Subscription queries that can be passed to ApolloClient.subscribe() */ 42 | export const SimpleGraphqlApiSubscriptions = { 43 | postAdded() { 44 | return { 45 | query: gql` 46 | subscription { 47 | postAdded { 48 | author 49 | comment 50 | } 51 | } 52 | `, 53 | variables: {}, 54 | }; 55 | }, 56 | }; 57 | 58 | /** Factories for common mutations that can be passed to apolloClient.mutate() */ 59 | export const SimpleGraphqlApiMutations = { 60 | addPost(post: IPost) { 61 | return { 62 | mutation: gql` 63 | mutation AddPost($author: String!, $comment: String!) { 64 | addPost(author: $author, comment: $comment) { 65 | author 66 | comment 67 | } 68 | } 69 | `, 70 | variables: post, 71 | }; 72 | }, 73 | }; 74 | 75 | interface ISimpleGraphqlApiOptions { 76 | /** controller that will handle storing/retrieving posts */ 77 | postController?: IPostController; 78 | /** pubsub implementation that will be used by the gql schema resolvers */ 79 | pubsub?: PubSubEngine; 80 | } 81 | 82 | /** 83 | * A GraphQL API from the apollo-server subscriptions docs: https://www.apollographql.com/docs/apollo-server/features/subscriptions/ 84 | */ 85 | export const SimpleGraphqlApi = ({ 86 | pubsub = new PubSub(), 87 | postController = PostController(), 88 | }: ISimpleGraphqlApiOptions = {}) => { 89 | const typeDefs = gql` 90 | type Subscription { 91 | postAdded: Post 92 | } 93 | 94 | type Query { 95 | posts: [Post] 96 | } 97 | 98 | type Mutation { 99 | addPost(author: String, comment: String): Post 100 | } 101 | 102 | type Post { 103 | author: String 104 | comment: String 105 | } 106 | `; 107 | const resolvers = { 108 | Mutation: { 109 | async addPost( 110 | root: any, 111 | args: any, 112 | context: IContextForPublishingWithEpcp, 113 | ) { 114 | await WebSocketOverHttpPubSubMixin(context)(pubsub).publish( 115 | SimpleGraphqlApiPubSubTopic.POST_ADDED, 116 | { 117 | postAdded: args, 118 | }, 119 | ); 120 | return postController.addPost(args); 121 | }, 122 | }, 123 | Query: { 124 | posts(root: any, args: any, context: any) { 125 | return postController.posts(); 126 | }, 127 | }, 128 | Subscription: { 129 | postAdded: { 130 | // Additional event labels can be passed to asyncIterator creation 131 | subscribe( 132 | rootValue: any, 133 | args: object, 134 | context: any | IWebSocketOverHttpGraphqlSubscriptionContext, 135 | ) { 136 | return WebSocketOverHttpPubSubMixin(context)(pubsub).asyncIterator([ 137 | SimpleGraphqlApiPubSubTopic.POST_ADDED, 138 | ]); 139 | }, 140 | }, 141 | }, 142 | }; 143 | return { 144 | resolvers, 145 | typeDefs, 146 | }; 147 | }; 148 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpStorageCleaner.ts: -------------------------------------------------------------------------------- 1 | import { ISimpleTable } from "../simple-table/SimpleTable"; 2 | import { IStoredConnection } from "./GraphqlWsOverWebSocketOverHttpExpressMiddleware"; 3 | import { IStoredPubSubSubscription } from "./PubSubSubscriptionStorage"; 4 | 5 | export interface IGraphqlWsOverWebSocketOverHttpStorageCleanerOptions { 6 | /** table to store information about each ws-over-http connection */ 7 | connectionStorage: ISimpleTable; 8 | /** table to store PubSub subscription info in */ 9 | pubSubSubscriptionStorage: ISimpleTable; 10 | } 11 | 12 | /** 13 | * Object that knows how to clean up old records in storage created by other subscriptions-transport-ws-over-http logic. 14 | * ConnectionStoringConnectionListener will try to keep aa `connection.expiresAt` field up to date to detect when a connection should 'timeout' and be treated as gone forever. 15 | * This Cleaner is a function that, when called, will delete all the connections whose expiresAt is in the past, and for each of those connections, any corresponding subscriptions. 16 | */ 17 | export const GraphqlWsOverWebSocketOverHttpStorageCleaner = ( 18 | options: IGraphqlWsOverWebSocketOverHttpStorageCleanerOptions, 19 | ) => { 20 | /** 21 | * @param now {Date|undefined} Time to consider now when comparing against connection.expiresAt. If not present, will use now. But pass a value to, for example, simulate a future datetime 22 | */ 23 | return async (now?: Date) => { 24 | await options.connectionStorage.scan( 25 | async (connections: IStoredConnection[]) => { 26 | await Promise.all( 27 | connections.map(async connection => { 28 | const parsedExpiresAt = Date.parse(connection.expiresAt); 29 | if (isNaN(parsedExpiresAt)) { 30 | console.log( 31 | `Failed to parse connection.expiresAt value as date: id=${connection.id} expiresAt=${connection.expiresAt}`, 32 | ); 33 | return; 34 | } 35 | const expiresAtDate = new Date(parsedExpiresAt); 36 | if ((now || new Date()) < expiresAtDate) { 37 | // connection hasn't expired yet 38 | return; 39 | } 40 | // connection has expired. 41 | await cleanupStorageAfterConnection({ 42 | connection, 43 | ...options, 44 | }); 45 | }), 46 | ); 47 | return true; 48 | }, 49 | ); 50 | }; 51 | }; 52 | 53 | /** 54 | * Cleanup the stored rows associated with a connection when it is no longer needed. 55 | * i.e. delete the connection record, but also any subscription rows created on that connection. 56 | */ 57 | export const cleanupStorageAfterConnection = async (options: { 58 | /** table to store information about each ws-over-http connection */ 59 | connectionStorage: ISimpleTable; 60 | /** table to store info about each PubSub subscription created by graphql subscription resolvers */ 61 | pubSubSubscriptionStorage: ISimpleTable; 62 | /** connection to cleanup */ 63 | connection: { 64 | /** connection id of connection to cleanup after */ 65 | id: string; 66 | }; 67 | }) => { 68 | const { connection } = options; 69 | // Delete all subscriptions for connection 70 | await deletePubSubSubscriptionsForConnection( 71 | options.pubSubSubscriptionStorage, 72 | connection, 73 | ); 74 | // and delete the connection itself 75 | await options.connectionStorage.delete({ id: connection.id }); 76 | }; 77 | 78 | /** 79 | * Delete all the stored PubSub subscriptions from pubSubSubscriptionStorage that were created for the provided connectionId 80 | */ 81 | async function deletePubSubSubscriptionsForConnection( 82 | pubSubSubscriptionStorage: ISimpleTable, 83 | connectionQuery: { 84 | /** Connection ID whose corresponding subscriptions should be deleted */ 85 | id: string; 86 | }, 87 | ) { 88 | await pubSubSubscriptionStorage.scan(async pubSubSubscriptions => { 89 | await Promise.all( 90 | pubSubSubscriptions.map(async pubSubSubscription => { 91 | if (pubSubSubscription.connectionId === connectionQuery.id) { 92 | // This pubSubSubscription is for the provided connection.id. Delete it. 93 | await pubSubSubscriptionStorage.delete({ id: pubSubSubscription.id }); 94 | } 95 | }), 96 | ); 97 | return true; 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/EpcpSubscriptionPublisher.ts: -------------------------------------------------------------------------------- 1 | import * as grip from "grip"; 2 | import * as pubcontrol from "pubcontrol"; 3 | import { parseGraphqlWsStartMessage } from "./GraphqlWebSocketOverHttpConnectionListener"; 4 | import { GraphqlWsGripChannelNamer } from "./GraphqlWsGripChannelNamers"; 5 | import { IStoredPubSubSubscription } from "./PubSubSubscriptionStorage"; 6 | 7 | export type ISubscriptionPublisher = ( 8 | subscription: IStoredPubSubSubscription, 9 | messages: any[], 10 | ) => Promise; 11 | 12 | /** Publishes messages to provided connectionId via EPCP */ 13 | export const EpcpSubscriptionPublisher = (options: { 14 | /** grip options */ 15 | grip: { 16 | /** Grip Control URL */ 17 | url: string; 18 | /** Given a graphql-ws GQL_START message, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates */ 19 | getGripChannel: GraphqlWsGripChannelNamer; 20 | }; 21 | }): ISubscriptionPublisher => async (subscription, messages) => { 22 | const gripPubControl = new grip.GripPubControl( 23 | grip.parseGripUri(options.grip.url), 24 | ); 25 | const subscriptionStartMessage = parseGraphqlWsStartMessage( 26 | subscription.graphqlWsStartMessage, 27 | ); 28 | const gripChannelName = options.grip.getGripChannel({ 29 | connectionId: subscription.connectionId, 30 | graphqlWsStartMessage: subscriptionStartMessage, 31 | }); 32 | for (const message of messages) { 33 | const dataMessage = { 34 | id: subscriptionStartMessage.id, 35 | payload: message, 36 | type: "data", 37 | }; 38 | const dataMessageString = JSON.stringify(dataMessage); 39 | await new Promise((resolve, reject) => { 40 | gripPubControl.publish( 41 | gripChannelName, 42 | new pubcontrol.Item(new grip.WebSocketMessageFormat(dataMessageString)), 43 | (success, error, context) => { 44 | console.log( 45 | `gripPubControl callback channel=${gripChannelName} success=${success} error=${error} context=${context} message=${dataMessageString}`, 46 | ); 47 | if (success) { 48 | return resolve(context); 49 | } 50 | return reject(error); 51 | }, 52 | ); 53 | }); 54 | } 55 | }; 56 | 57 | /** 58 | * Filter an AsyncIterable of stored PubSub subscriptions to only return 59 | * one subscription per unique value of applying getGripChannel 60 | */ 61 | export const UniqueGripChannelNameSubscriptionFilterer = (options: { 62 | /** Given a graphql-ws GQL_START message, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates */ 63 | getGripChannel: GraphqlWsGripChannelNamer; 64 | }) => { 65 | async function* filterSubscriptionsForUniqueGripChannel( 66 | subscriptions: AsyncIterable, 67 | ): AsyncIterable { 68 | const seenGripChannels = new Set(); 69 | for await (const s of subscriptions) { 70 | const gripChannel = options.getGripChannel({ 71 | connectionId: s.connectionId, 72 | graphqlWsStartMessage: parseGraphqlWsStartMessage( 73 | s.graphqlWsStartMessage, 74 | ), 75 | }); 76 | if (!seenGripChannels.has(gripChannel)) { 77 | yield s; 78 | } 79 | seenGripChannels.add(gripChannel); 80 | } 81 | } 82 | return filterSubscriptionsForUniqueGripChannel; 83 | }; 84 | 85 | /** Create async filterer of IStoredSubscriptions that */ 86 | export const UniqueConnectionIdOperationIdPairSubscriptionFilterer = () => { 87 | async function* filterSubscriptions( 88 | subscriptions: AsyncIterable, 89 | ): AsyncIterable { 90 | const seenConnectionOperationPairIds = new Set(); 91 | for await (const s of subscriptions) { 92 | const connectionIdOperationIdPair = [ 93 | s.connectionId, 94 | parseGraphqlWsStartMessage(s.graphqlWsStartMessage).id, 95 | ]; 96 | const connectionOperationPairId = JSON.stringify( 97 | connectionIdOperationIdPair, 98 | ); 99 | if (!seenConnectionOperationPairIds.has(connectionOperationPairId)) { 100 | yield s; 101 | } 102 | seenConnectionOperationPairIds.add(seenConnectionOperationPairIds); 103 | } 104 | } 105 | return filterSubscriptions; 106 | }; 107 | 108 | export type IAsyncFilter = (items: AsyncIterable) => AsyncIterable; 109 | 110 | /** Generic IAsyncFilter that doesn't actually filter anything */ 111 | export const NoopAsyncFilterer = (): IAsyncFilter => { 112 | async function* filter(items: AsyncIterable) { 113 | for await (const item of items) { 114 | yield item; 115 | } 116 | } 117 | return filter; 118 | }; 119 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/WebSocketOverHttpGraphqlContext.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from "graphql"; 2 | import { ISimpleTable } from "../simple-table/SimpleTable"; 3 | import { 4 | EpcpSubscriptionPublisher, 5 | IAsyncFilter, 6 | NoopAsyncFilterer, 7 | UniqueConnectionIdOperationIdPairSubscriptionFilterer, 8 | } from "./EpcpSubscriptionPublisher"; 9 | import { IGraphqlWsStartMessage } from "./GraphqlWebSocketOverHttpConnectionListener"; 10 | import { 11 | DefaultGripChannelNamer, 12 | GraphqlWsGripChannelNamer, 13 | } from "./GraphqlWsGripChannelNamers"; 14 | import { IStoredPubSubSubscription } from "./PubSubSubscriptionStorage"; 15 | import { 16 | IPubSubEnginePublish, 17 | PubSubSubscriptionsForPublishFromStorageGetter, 18 | } from "./SubscriptionStoragePubSubMixin"; 19 | 20 | /** Interface for graphql server context when the request is coming via graphql-ws over websocket-over-http */ 21 | export interface IContextForPublishingWithEpcp { 22 | /** info about the webSocketOverHttp context */ 23 | epcpPublishing?: { 24 | /** graphql context */ 25 | graphql: { 26 | /** graphql Schema */ 27 | schema: GraphQLSchema; 28 | }; 29 | /** get the relevant pubSubSubscriptions for a PubSub publish (e.g. read from storage) */ 30 | getPubSubSubscriptionsForPublish( 31 | publish: IPubSubEnginePublish, 32 | ): AsyncIterable; 33 | /** publish to a connection */ 34 | publish( 35 | subscription: IStoredPubSubSubscription, 36 | messages: any[], 37 | ): Promise; 38 | }; 39 | } 40 | 41 | export interface IWebSocketOverHttpGraphqlSubscriptionContext { 42 | /** info about the webSocketOverHttp context */ 43 | webSocketOverHttp?: { 44 | /** websocket-over-http connection info */ 45 | connection: { 46 | /** connection id */ 47 | id: string; 48 | }; 49 | /** graphql context */ 50 | graphql: { 51 | /** graphql Schema */ 52 | schema: GraphQLSchema; 53 | }; 54 | /** graphql-ws context */ 55 | graphqlWs: { 56 | /** start message of this graphql-ws subscription */ 57 | startMessage: IGraphqlWsStartMessage; 58 | }; 59 | /** table to store PubSub subscription info in */ 60 | pubSubSubscriptionStorage: ISimpleTable; 61 | }; 62 | } 63 | 64 | interface IGraphqlWsGripChannelSharingStrategy { 65 | /** Given a graphql-ws GQL_START message, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates */ 66 | getGripChannel: GraphqlWsGripChannelNamer; 67 | /** given a PubSubEngine.publish from a mutation, return an AsyncFilterer that decides which relevant subscriptions can be deemed identical for the sake of simulating the resolvers and publishing results via EPCP */ 68 | getSubscriptionFilterForPublish( 69 | publish: IPubSubEnginePublish, 70 | ): IAsyncFilter; 71 | } 72 | 73 | /** ContextFunction that can be passed to ApolloServerOptions["context"] that will provide required context for WebSocket-Over-HTTP PubSub mixin */ 74 | export const WebSocketOverHttpContextFunction = (options: { 75 | /** graphql schema */ 76 | schema: GraphQLSchema; 77 | /** storage for pubSubScriptionStorage */ 78 | pubSubSubscriptionStorage: ISimpleTable; 79 | /** grip uri */ 80 | grip: { 81 | /** GRIP URI for EPCP Gateway */ 82 | url: string; 83 | /** Given a graphql-ws GQL_START message, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates */ 84 | getGripChannel?: GraphqlWsGripChannelNamer; 85 | /** strategy for sharing grip channels */ 86 | getSubscriptionFilterForPublish?( 87 | publish: IPubSubEnginePublish, 88 | ): IAsyncFilter; 89 | }; 90 | }) => { 91 | const channelSharingStrategy: IGraphqlWsGripChannelSharingStrategy = { 92 | getGripChannel: options.grip.getGripChannel || DefaultGripChannelNamer(), 93 | getSubscriptionFilterForPublish: ( 94 | publish: IPubSubEnginePublish, 95 | ): IAsyncFilter => 96 | options.grip && options.grip.getSubscriptionFilterForPublish 97 | ? options.grip.getSubscriptionFilterForPublish(publish) 98 | : NoopAsyncFilterer(), 99 | }; 100 | const context: IContextForPublishingWithEpcp = { 101 | epcpPublishing: { 102 | getPubSubSubscriptionsForPublish(publish) { 103 | const storedSubscriptions = PubSubSubscriptionsForPublishFromStorageGetter( 104 | options.pubSubSubscriptionStorage, 105 | )(publish); 106 | const filtered = channelSharingStrategy.getSubscriptionFilterForPublish( 107 | publish, 108 | )(storedSubscriptions); 109 | return filtered; 110 | }, 111 | graphql: { 112 | schema: options.schema, 113 | }, 114 | publish: EpcpSubscriptionPublisher({ 115 | grip: { 116 | ...options.grip, 117 | getGripChannel: channelSharingStrategy.getGripChannel, 118 | }, 119 | }), 120 | }, 121 | }; 122 | return context; 123 | }; 124 | -------------------------------------------------------------------------------- /src/websocket-over-http-express/WebSocketOverHttpConnectionListener.ts: -------------------------------------------------------------------------------- 1 | import { IncomingHttpHeaders } from "http"; 2 | 3 | export interface IConnectionListenerOnOpenResponse { 4 | /** response headers */ 5 | headers: Record; 6 | } 7 | 8 | export interface IConnectionListener { 9 | /** Called when connection is closed explicitly */ 10 | onClose?(closeCode: string): Promise; 11 | /** Called when connection is disconnected uncleanly */ 12 | onDisconnect?(): Promise; 13 | /** Called with each message on the socket. Should return promise of messages to issue in response */ 14 | onMessage?(message: string): Promise; 15 | /** Called when connection opens */ 16 | onOpen?(): Promise; 17 | /** called on each WebSocket HTTP Request */ 18 | onHttpRequest?(request: { 19 | /** HTTP request headers */ 20 | headers: IncomingHttpHeaders; 21 | }): Promise; 24 | }>; 25 | } 26 | 27 | const composeOnCloseHandlers = ( 28 | onCloses: Array, 29 | ): IConnectionListener["onClose"] => { 30 | return async (closeCode: string) => { 31 | for (const onClose of onCloses) { 32 | if (onClose) { 33 | await onClose(closeCode); 34 | } 35 | } 36 | }; 37 | }; 38 | 39 | /** Merge multiple HTTP header objects, but throw if this would require merging a single headerName */ 40 | const mergedHeaders = ( 41 | headersArray: Array>, 42 | ): Record => { 43 | const merged: Record = {}; 44 | for (const headers of headersArray) { 45 | for (const [headerName, headerValue] of Object.entries(headers)) { 46 | if (headerName in merged) { 47 | throw new Error( 48 | `Can't merge headers. Header ${headerName} already used.`, 49 | ); 50 | } 51 | merged[headerName] = headerValue; 52 | } 53 | } 54 | return merged; 55 | }; 56 | 57 | const composeOnOpenHandlers = ( 58 | onOpens: Array, 59 | ): IConnectionListener["onOpen"] => { 60 | return async () => { 61 | const reduceOnOpenResponse = ( 62 | mergedOnOpenResponse: undefined | IConnectionListenerOnOpenResponse, 63 | onOpenResponse: IConnectionListenerOnOpenResponse | void, 64 | ) => { 65 | if (!mergedOnOpenResponse) { 66 | return { headers: {} }; 67 | } 68 | if (!(onOpenResponse && onOpenResponse.headers)) { 69 | return mergedOnOpenResponse; 70 | } 71 | return { 72 | ...mergedOnOpenResponse, 73 | headers: mergedHeaders([ 74 | mergedOnOpenResponse.headers, 75 | onOpenResponse.headers, 76 | ]), 77 | }; 78 | }; 79 | let response: IConnectionListenerOnOpenResponse = { headers: {} }; 80 | for (const onOpen of onOpens) { 81 | if (onOpen) { 82 | response = reduceOnOpenResponse(response, await onOpen()); 83 | } 84 | } 85 | return response; 86 | }; 87 | }; 88 | 89 | type AsyncArglessVoidFunction = () => Promise; 90 | const composeAsyncArglessVoidFunctionsSequentially = ( 91 | functions: AsyncArglessVoidFunction[], 92 | ) => { 93 | return async () => { 94 | for (const f of functions) { 95 | await f(); 96 | } 97 | }; 98 | }; 99 | 100 | const composeOnHttpRequestHandlers = ( 101 | onHttpRequestHandlers: Array, 102 | ): IConnectionListener["onHttpRequest"] => { 103 | return async request => { 104 | const reduceResponse = ( 105 | mergedOnOpenResponse: undefined | IConnectionListenerOnOpenResponse, 106 | onOpenResponse: IConnectionListenerOnOpenResponse | void, 107 | ) => { 108 | if (!mergedOnOpenResponse) { 109 | return { headers: {} }; 110 | } 111 | if (!(onOpenResponse && onOpenResponse.headers)) { 112 | return mergedOnOpenResponse; 113 | } 114 | return { 115 | ...mergedOnOpenResponse, 116 | headers: mergedHeaders([ 117 | mergedOnOpenResponse.headers, 118 | onOpenResponse.headers, 119 | ]), 120 | }; 121 | }; 122 | let response = { headers: {} }; 123 | for (const onHttpRequest of onHttpRequestHandlers) { 124 | if (onHttpRequest) { 125 | response = reduceResponse(response, await onHttpRequest(request)); 126 | } 127 | } 128 | return response; 129 | }; 130 | }; 131 | 132 | type IMessageListener = IConnectionListener["onMessage"]; 133 | /** 134 | * Compose multiple message handlers into a single one. 135 | * The resulting composition will call each of the input handlers in order and merge their responses. 136 | */ 137 | export const composeMessageHandlers = ( 138 | handlers: IMessageListener[], 139 | ): IMessageListener => { 140 | const composedMessageHandler = async (message: string) => { 141 | const responses = []; 142 | for (const handler of handlers) { 143 | if (handler) { 144 | responses.push(await handler(message)); 145 | } 146 | } 147 | return responses.filter(Boolean).join("\n"); 148 | }; 149 | return composedMessageHandler; 150 | }; 151 | 152 | /** Compose multiple ws-over-http ConnectionListeners into one */ 153 | export const ComposedConnectionListener = ( 154 | connectionListeners: IConnectionListener[], 155 | ): IConnectionListener => { 156 | const onClose = composeOnCloseHandlers( 157 | connectionListeners.map(c => c.onClose), 158 | ); 159 | const onDisconnect = composeAsyncArglessVoidFunctionsSequentially( 160 | connectionListeners.map( 161 | c => 162 | c.onDisconnect || 163 | (async () => { 164 | return; 165 | }), 166 | ), 167 | ); 168 | const onMessage = composeMessageHandlers( 169 | connectionListeners.map(c => c.onMessage), 170 | ); 171 | const onOpen = composeOnOpenHandlers(connectionListeners.map(c => c.onOpen)); 172 | const onHttpRequest = composeOnHttpRequestHandlers( 173 | connectionListeners.map(c => c.onHttpRequest), 174 | ); 175 | return { 176 | onClose, 177 | onDisconnect, 178 | onHttpRequest, 179 | onMessage, 180 | onOpen, 181 | }; 182 | }; 183 | -------------------------------------------------------------------------------- /src/websocket-over-http-express/WebSocketOverHttpExpress.ts: -------------------------------------------------------------------------------- 1 | import * as bodyParser from "body-parser"; 2 | import * as express from "express"; 3 | import * as grip from "grip"; 4 | import { IConnectionListener } from "./WebSocketOverHttpConnectionListener"; 5 | 6 | export interface IWebSocketOverHTTPConnectionInfo { 7 | /** Connection-ID from Pushpin */ 8 | id: string; 9 | /** WebSocketContext for this connection. Can be used to issue grip control messages */ 10 | webSocketContext: grip.WebSocketContext; 11 | /** Sec-WebSocket-Protocol */ 12 | protocol?: string; 13 | } 14 | 15 | type AsyncRequestHandler = ( 16 | req: express.Request, 17 | res: express.Response, 18 | next: express.NextFunction, 19 | ) => Promise; 20 | 21 | const AsyncExpress = ( 22 | handleRequestAsync: AsyncRequestHandler, 23 | ): express.RequestHandler => { 24 | return (req, res, next) => { 25 | Promise.resolve(handleRequestAsync(req, res, next)).catch(next); 26 | }; 27 | }; 28 | 29 | interface IWebSocketOverHttpExpressOptions { 30 | /** GRIP control message prefix. see https://pushpin.org/docs/protocols/grip/ */ 31 | gripPrefix?: string; 32 | /** look up a listener for the given connection */ 33 | getConnectionListener( 34 | info: IWebSocketOverHTTPConnectionInfo, 35 | ): IConnectionListener; 36 | } 37 | 38 | /** 39 | * Express App that does WebSocket-Over-HTTP when getting requests from Pushpin 40 | */ 41 | export default ( 42 | options: IWebSocketOverHttpExpressOptions, 43 | ): express.RequestHandler => { 44 | const app = express() 45 | .use(bodyParser.raw({ type: "application/websocket-events" })) 46 | .use( 47 | AsyncExpress(async (req, res, next) => { 48 | if ( 49 | !( 50 | req.headers["grip-sig"] && 51 | req.headers["content-type"] === "application/websocket-events" 52 | ) 53 | ) { 54 | return next(); 55 | } 56 | // ok it's a Websocket-Over-Http connection https://pushpin.org/docs/protocols/websocket-over-http/ 57 | const events = grip.decodeWebSocketEvents(req.body); 58 | const connectionId = req.get("connection-id"); 59 | const meta = {}; // TODO: get from req.headers that start with 'meta-'? Not sure why? https://github.com/fanout/node-faas-grip/blob/746e10ea90305d05e736ce6390ac9f536ecb061f/lib/faas-grip.js#L168 60 | const gripWebSocketContext = new grip.WebSocketContext( 61 | connectionId, 62 | meta, 63 | events, 64 | options.gripPrefix, 65 | ); 66 | if (!events.length) { 67 | // No events. This may be a ws-over-http KeepAlive request. 68 | console.debug( 69 | "WebSocketOverHttpExpress got WebSocket-Over-Http request with zero events. May be keepalive.", 70 | new Date(Date.now()), 71 | ); 72 | } 73 | if (!connectionId) { 74 | throw new Error(`Expected connection-id header but none is present`); 75 | } 76 | const connectionListener = options.getConnectionListener({ 77 | id: connectionId, 78 | protocol: req.get("sec-websocket-protocol"), 79 | webSocketContext: gripWebSocketContext, 80 | }); 81 | const setHeaders = ( 82 | response: express.Response, 83 | headers: Record, 84 | ) => { 85 | for (const [header, value] of Object.entries(headers)) { 86 | res.setHeader(header, value); 87 | } 88 | }; 89 | const eventsOut: grip.WebSocketEvent[] = []; 90 | for (const event of events) { 91 | switch (event.getType()) { 92 | case "CLOSE": // "Close message with 16-bit close code." 93 | const closeCode = (event.getContent() || "").toString(); 94 | if (connectionListener.onClose) { 95 | await connectionListener.onClose(closeCode); 96 | } 97 | eventsOut.push(new grip.WebSocketEvent("CLOSE", closeCode)); 98 | break; 99 | case "DISCONNECT": // "Indicates connection closed uncleanly or does not exist." 100 | if (connectionListener.onDisconnect) { 101 | await connectionListener.onDisconnect(); 102 | } 103 | eventsOut.push(new grip.WebSocketEvent("DISCONNECT")); 104 | break; 105 | case "OPEN": 106 | if (connectionListener.onOpen) { 107 | const onOpenResponse = await connectionListener.onOpen(); 108 | if (onOpenResponse && onOpenResponse.headers) { 109 | setHeaders(res, onOpenResponse.headers); 110 | } 111 | } 112 | eventsOut.push(new grip.WebSocketEvent("OPEN")); 113 | break; 114 | case "TEXT": 115 | const content = event.getContent(); 116 | if (!content) { 117 | break; 118 | } 119 | const message = content.toString(); 120 | if (connectionListener.onMessage) { 121 | const response = await connectionListener.onMessage(message); 122 | if (response) { 123 | eventsOut.push(new grip.WebSocketEvent("TEXT", response)); 124 | } 125 | } 126 | break; 127 | default: 128 | throw new Error(`Unexpected event type ${event.getType()}`); 129 | // assertNever(event) 130 | } 131 | } 132 | if (typeof connectionListener.onHttpRequest === "function") { 133 | const onHttpRequestResponse = await connectionListener.onHttpRequest({ 134 | headers: req.headers, 135 | }); 136 | if (onHttpRequestResponse && onHttpRequestResponse.headers) { 137 | setHeaders(res, onHttpRequestResponse.headers); 138 | } 139 | } 140 | res.status(200); 141 | res.setHeader("content-type", "application/websocket-events"); 142 | res.setHeader("sec-websocket-extensions", 'grip; message-prefix=""'); 143 | res.write( 144 | grip.encodeWebSocketEvents([...gripWebSocketContext.outEvents]), 145 | ); 146 | res.write(grip.encodeWebSocketEvents([...eventsOut])); 147 | res.end(); 148 | }), 149 | ); 150 | return app; 151 | }; 152 | -------------------------------------------------------------------------------- /src/subscriptions-transport-apollo/ApolloSubscriptionServerOptions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic tools/interfaces for GraphQL SubscriptionServers. 3 | * This can be used to use implementations other than the subscriptions-transport-ws that is tightly coupled with apollo-server-core. 4 | * Much of the underlying types were pulled from ApolloServerBase and subscriptions-transport-ws, but with any mention of WebSockets removed or not required. 5 | */ 6 | import { 7 | ApolloServerBase, 8 | Context, 9 | formatApolloErrors, 10 | SubscriptionServerOptions, 11 | } from "apollo-server-core"; 12 | import { Config as ApolloServerConfig } from "apollo-server-core"; 13 | import { 14 | DocumentNode, 15 | execute, 16 | ExecutionResult, 17 | GraphQLFieldResolver, 18 | GraphQLSchema, 19 | subscribe, 20 | ValidationContext, 21 | } from "graphql"; 22 | import * as http from "http"; 23 | 24 | type AnyFunction = (...args: any[]) => any; 25 | 26 | export type ExecuteFunction = ( 27 | schema: GraphQLSchema, 28 | document: DocumentNode, 29 | rootValue?: any, 30 | contextValue?: any, 31 | variableValues?: { 32 | [key: string]: any; 33 | }, 34 | operationName?: string, 35 | fieldResolver?: GraphQLFieldResolver, 36 | ) => 37 | | ExecutionResult 38 | | Promise 39 | | AsyncIterator; 40 | export type SubscribeFunction = ( 41 | schema: GraphQLSchema, 42 | document: DocumentNode, 43 | rootValue?: any, 44 | contextValue?: any, 45 | variableValues?: { 46 | [key: string]: any; 47 | }, 48 | operationName?: string, 49 | fieldResolver?: GraphQLFieldResolver, 50 | subscribeFieldResolver?: GraphQLFieldResolver, 51 | ) => 52 | | AsyncIterator 53 | | Promise | ExecutionResult>; 54 | 55 | /** 56 | * Copied from subscriptions-transport-ws 57 | */ 58 | // tslint:disable:completed-docs 59 | export interface ISubscriptionServerOptions { 60 | rootValue?: any; 61 | schema?: GraphQLSchema; 62 | execute?: ExecuteFunction; 63 | subscribe?: SubscribeFunction; 64 | validationRules?: 65 | | Array<(context: ValidationContext) => any> 66 | | ReadonlyArray; 67 | onOperation?: AnyFunction; 68 | onOperationComplete?: AnyFunction; 69 | onConnect?: AnyFunction; 70 | onDisconnect?: AnyFunction; 71 | keepAlive?: number; 72 | } 73 | // tslint:enable:completed-docs 74 | 75 | export interface ISubscriptionServer { 76 | /** Shut down the server */ 77 | close(): void; 78 | } 79 | 80 | /** 81 | * Create SubscriptionServerOptions from ApolloServer Config. 82 | * This is pulled from constructor in ApolloServerBase. 83 | */ 84 | export const createSubscriptionServerOptions = ( 85 | subscriptions: ApolloServerConfig["subscriptions"], 86 | /** apolloServer.graphqlPath */ 87 | graphqlPath: ApolloServerBase["graphqlPath"], 88 | ): SubscriptionServerOptions => { 89 | if (subscriptions === true || typeof subscriptions === "undefined") { 90 | return { 91 | path: graphqlPath, 92 | }; 93 | } else if (typeof subscriptions === "string") { 94 | return { path: subscriptions }; 95 | } else { 96 | return { 97 | path: graphqlPath, 98 | ...subscriptions, 99 | }; 100 | } 101 | }; 102 | 103 | /** 104 | * Copied from subscription-transport-ws to remove dependency on that 105 | */ 106 | export interface ISubscriptionServerExecutionParams { 107 | // tslint:disable:completed-docs 108 | query: string | DocumentNode; 109 | variables: { 110 | [key: string]: any; 111 | }; 112 | operationName: string; 113 | context: TContext; 114 | formatResponse?: AnyFunction; 115 | formatError?: AnyFunction; 116 | callback?: AnyFunction; 117 | schema?: GraphQLSchema; 118 | } 119 | // tslint:enable:completed-docs 120 | 121 | interface ISubscriptionServerInstallationTarget { 122 | /** Path at which the SubscriptionServer should watch for new connections */ 123 | path: string; 124 | /** HTTP Server to modify to accept subscriptions */ 125 | server: http.Server; 126 | } 127 | 128 | /** e.g. subscriptions-transport-ws SubscriptionServer.create */ 129 | export type SubscriptionServerCreator = ( 130 | opts: ISubscriptionServerOptions, 131 | installTo: ISubscriptionServerInstallationTarget, 132 | ) => ISubscriptionServer; 133 | 134 | /** 135 | * Create SubscriptionServerOptions from ApolloServer Config. 136 | * This is pulled from constructor in ApolloServerBase. 137 | */ 138 | export const createApolloSubscriptionsOptions = ( 139 | subscriptions: ApolloServerConfig["subscriptions"], 140 | /** apolloServer.graphqlPath */ 141 | graphqlPath: ApolloServerBase["graphqlPath"], 142 | ): SubscriptionServerOptions => { 143 | if (subscriptions === true || typeof subscriptions === "undefined") { 144 | return { 145 | path: graphqlPath, 146 | }; 147 | } else if (typeof subscriptions === "string") { 148 | return { path: subscriptions }; 149 | } else { 150 | return { 151 | path: graphqlPath, 152 | ...subscriptions, 153 | }; 154 | } 155 | }; 156 | 157 | /** 158 | * Install handlers to the provided httpServer such that it can handle GraphQL Subscriptions using subscriptions-transport-ws 159 | */ 160 | export const ApolloSubscriptionServerOptions = ( 161 | apolloServer: ApolloServerBase, 162 | apolloConfig: ApolloServerConfig, 163 | schema: GraphQLSchema, 164 | ): ISubscriptionServerOptions => { 165 | const { 166 | onDisconnect, 167 | onConnect, 168 | keepAlive, 169 | } = createApolloSubscriptionsOptions( 170 | apolloConfig.subscriptions, 171 | apolloServer.graphqlPath, 172 | ); 173 | 174 | const apolloSubscriptionServerOptions: ISubscriptionServerOptions = { 175 | execute, 176 | keepAlive, 177 | onConnect: onConnect 178 | ? onConnect 179 | : (connectionParams: object) => ({ ...connectionParams }), 180 | onDisconnect, 181 | onOperation: async ( 182 | message: { 183 | /** operation message payload */ 184 | payload: any; 185 | }, 186 | connection: ISubscriptionServerExecutionParams, 187 | ) => { 188 | connection.formatResponse = (value: ExecutionResult) => { 189 | return { 190 | ...value, 191 | errors: 192 | value.errors && 193 | formatApolloErrors([...value.errors], { 194 | debug: apolloServer.requestOptions.debug, 195 | formatter: apolloServer.requestOptions.formatError, 196 | }), 197 | }; 198 | }; 199 | let context: Context = apolloConfig.context 200 | ? apolloConfig.context 201 | : { connection }; 202 | try { 203 | context = 204 | typeof apolloConfig.context === "function" 205 | ? await apolloConfig.context({ 206 | connection, 207 | payload: message.payload, 208 | }) 209 | : context; 210 | } catch (e) { 211 | throw formatApolloErrors([e], { 212 | debug: apolloServer.requestOptions.debug, 213 | formatter: apolloServer.requestOptions.formatError, 214 | })[0]; 215 | } 216 | 217 | return { ...connection, context }; 218 | }, 219 | schema, 220 | subscribe, 221 | }; 222 | 223 | return apolloSubscriptionServerOptions; 224 | }; 225 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/SubscriptionStoragePubSubMixin.ts: -------------------------------------------------------------------------------- 1 | import { PubSub, PubSubEngine } from "apollo-server"; 2 | import * as graphql from "graphql"; 3 | import { createAsyncIterator, isAsyncIterable } from "iterall"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | import { ISimpleTable } from "../simple-table/SimpleTable"; 6 | import { 7 | IGraphqlWsStartMessage, 8 | isGraphqlWsStartMessage, 9 | } from "./GraphqlWebSocketOverHttpConnectionListener"; 10 | import { IStoredPubSubSubscription } from "./PubSubSubscriptionStorage"; 11 | 12 | export interface IPubSubEnginePublish { 13 | /** publish trigger name as string */ 14 | triggerName: string; 15 | /** payload passed for this triggered publish */ 16 | payload: any; 17 | } 18 | 19 | export interface ISubscriptionTestPubSub { 20 | /** return an asyncIterator of the provided triggerName publishes */ 21 | asyncIterator: PubSubEngine["asyncIterator"]; 22 | } 23 | 24 | /** 25 | * Create a PubSub that can only be subscribed to, and will publish a finite set of events passed on construction 26 | * @param publishes Crea 27 | */ 28 | function PrePublishedPubSub(publishes: IPubSubEnginePublish[]): PubSubEngine { 29 | const asyncIterator = (triggerName: string | string[]) => { 30 | const triggerNames = Array.isArray(triggerName) 31 | ? triggerName 32 | : [triggerName]; 33 | const ai = createAsyncIterator( 34 | publishes 35 | .filter(p => triggerNames.includes(p.triggerName)) 36 | .map(p => p.payload), 37 | ); 38 | return ai; 39 | }; 40 | return Object.assign(new PubSub(), { 41 | asyncIterator, 42 | }); 43 | } 44 | 45 | export interface ISubscriptionTestGraphqlContext { 46 | /** subscription test context */ 47 | subscriptionTest: { 48 | /** pubsub to use with subscription resolver during subscription test */ 49 | pubsub: PubSubEngine; 50 | }; 51 | } 52 | 53 | /** Wrap a PubSubEngine so that calls to .subscribe are stored */ 54 | export const SubscriptionStoragePubSubMixin = (options: { 55 | /** websocket-over-http connection */ 56 | connection: { 57 | /** connection id */ 58 | id: string; 59 | }; 60 | /** graphql context */ 61 | graphql: { 62 | /** graphql schema */ 63 | schema: graphql.GraphQLSchema; 64 | }; 65 | /** graphql-ws context */ 66 | graphqlWs: { 67 | /** start message of this graphql-ws subscription */ 68 | startMessage: IGraphqlWsStartMessage; 69 | }; 70 | /** table to store PubSub subscription info in */ 71 | pubSubSubscriptionStorage: ISimpleTable; 72 | }) => (pubsub: PubSubEngine) => { 73 | // defer to pubsub.subscribe, but also store the subscription 74 | const subscribe: PubSubEngine["subscribe"] = async ( 75 | triggerName, 76 | onMessage, 77 | subscribeOptions, 78 | ): Promise => { 79 | const subscribeReturn = await pubsub.subscribe( 80 | triggerName, 81 | onMessage, 82 | subscribeOptions, 83 | ); 84 | await options.pubSubSubscriptionStorage.insert({ 85 | connectionId: options.connection.id, 86 | createdAt: new Date().toISOString(), 87 | graphqlWsStartMessage: JSON.stringify(options.graphqlWs.startMessage), 88 | id: uuidv4(), 89 | triggerName, 90 | }); 91 | return subscribeReturn; 92 | }; 93 | const pubSubWithStorage: PubSubEngine = Object.assign(Object.create(pubsub), { 94 | subscribe, 95 | }); 96 | return pubSubWithStorage; 97 | }; 98 | 99 | /** 100 | * Create a function that will retrive stored PubSubSubscriptions from storage via ISimpleTable interface. 101 | * The resulting function can be passed as options.getPubSubSubscriptionsForPublish to PublishToStoredSubscriptionsPubSubMixin 102 | */ 103 | export const PubSubSubscriptionsForPublishFromStorageGetter = ( 104 | pubSubSubscriptionStorage: ISimpleTable, 105 | ) => { 106 | const getPubSubSubscriptionsForPublishFromStorage = async function*( 107 | publish: IPubSubEnginePublish, 108 | ) { 109 | /** 110 | * @TODO this will use lots of memory when there are many subscriptions. 111 | * We should use the callback-version of .scan(cb), and find a way of casting tha to an AsyncIterator 112 | * This could help: https://stackoverflow.com/a/50865906 113 | */ 114 | const subscriptionsForTrigger = (await pubSubSubscriptionStorage.scan()).filter( 115 | storedSubscription => 116 | storedSubscription.triggerName === publish.triggerName, 117 | ); 118 | yield* subscriptionsForTrigger; 119 | }; 120 | return getPubSubSubscriptionsForPublishFromStorage; 121 | }; 122 | 123 | /** 124 | * PubSub mixin that will patch publish method to also publish to stored pubSubSubscriptions 125 | */ 126 | export const PublishToStoredSubscriptionsPubSubMixin = (options: { 127 | /** graphql context */ 128 | graphql: { 129 | /** graphql schema */ 130 | schema: graphql.GraphQLSchema; 131 | }; 132 | /** get the relevant pubSubSubscriptions for a PubSub publish (e.g. read from storage) */ 133 | getPubSubSubscriptionsForPublish( 134 | publish: IPubSubEnginePublish, 135 | ): AsyncIterable; 136 | /** publish to a connection */ 137 | publish( 138 | subscription: IStoredPubSubSubscription, 139 | messages: any[], 140 | ): Promise; 141 | }) => (pubsub: PubSubEngine): PubSubEngine => { 142 | // defer to pubsub.publish, but also publish to stored connections that were subscribing to triggerName 143 | const publish: PubSubEngine["publish"] = async ( 144 | triggerName: string, 145 | payload: any, 146 | ): Promise => { 147 | await pubsub.publish(triggerName, payload); 148 | /** 149 | * For each stored PubSub Subscription, if it was for this eventName, consider publishing to it 150 | */ 151 | for await (const storedSubscription of options.getPubSubSubscriptionsForPublish( 152 | { triggerName, payload }, 153 | )) { 154 | const subscriptionResult = await simulateStoredSubscription( 155 | storedSubscription, 156 | ); 157 | if (isAsyncIterable(subscriptionResult)) { 158 | const subscriptionResultItems = []; 159 | for await (const result of subscriptionResult) { 160 | subscriptionResultItems.push(result); 161 | } 162 | await options.publish(storedSubscription, subscriptionResultItems); 163 | } 164 | } 165 | /** for the stored subscription, simulate this publish for it, and if there are subscription results, pass them to options.publish */ 166 | async function simulateStoredSubscription( 167 | storedSubscription: IStoredPubSubSubscription, 168 | ) { 169 | // This storedSubscription was listening for this triggerName. 170 | // That means it's listening for it but may still do further filtering. 171 | const fakePubSub = PrePublishedPubSub([{ triggerName, payload }]); 172 | const storedSubscriptionGraphqlWsStartMessage = JSON.parse( 173 | storedSubscription.graphqlWsStartMessage, 174 | ); 175 | if (!isGraphqlWsStartMessage(storedSubscriptionGraphqlWsStartMessage)) { 176 | throw new Error( 177 | `couldn't parse storedSubscription graphql-ws start message`, 178 | ); 179 | } 180 | const operation = storedSubscriptionGraphqlWsStartMessage.payload; 181 | const contextValue: ISubscriptionTestGraphqlContext = { 182 | subscriptionTest: { 183 | pubsub: fakePubSub, 184 | }, 185 | }; 186 | const subscriptionResult = await graphql.subscribe({ 187 | contextValue, 188 | document: graphql.parse(operation.query), 189 | operationName: operation.operationName, 190 | schema: options.graphql.schema, 191 | variableValues: operation.variables, 192 | }); 193 | return subscriptionResult; 194 | } 195 | }; 196 | const pubSubWithPatchedPublish: PubSubEngine = Object.assign( 197 | Object.create(pubsub), 198 | { 199 | publish, 200 | }, 201 | ); 202 | return pubSubWithPatchedPublish; 203 | }; 204 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/GraphqlWebSocketOverHttpConnectionListener.ts: -------------------------------------------------------------------------------- 1 | import { getMainDefinition } from "apollo-utilities"; 2 | import gql from "graphql-tag"; 3 | import { IConnectionListener } from "../websocket-over-http-express/WebSocketOverHttpConnectionListener"; 4 | import { IWebSocketOverHTTPConnectionInfo } from "../websocket-over-http-express/WebSocketOverHttpExpress"; 5 | 6 | /** 7 | * Given a subscription IGraphqlWsStartEventPayload, return the name of the subscription field. 8 | * This is useful to get an identifier for a subscription query as long as the query has no arguments. 9 | * It does not take query variables/arguments into account. 10 | */ 11 | export const getSubscriptionOperationFieldName = ( 12 | graphqlWsEventPayload: IGraphqlWsStartEventPayload, 13 | ): string => { 14 | const query = gql` 15 | ${graphqlWsEventPayload.query} 16 | `; 17 | const mainDefinition = getMainDefinition(query); 18 | if (mainDefinition.kind === "FragmentDefinition") { 19 | throw new Error( 20 | `Did not expect subscription mainDefinition to be FragmentDefinition`, 21 | ); 22 | } 23 | const selections = mainDefinition.selectionSet.selections; 24 | const selection = selections[0]; 25 | if (!selection) { 26 | throw new Error("could not parse selection from graphqlWsEvent"); 27 | } 28 | if (selection.kind !== "Field") { 29 | throw new Error(`could not get selection from graphqlWsEvent`); 30 | } 31 | const selectedFieldName = selection.name.value; 32 | const gripChannel = selectedFieldName; 33 | return gripChannel; 34 | }; 35 | 36 | /** interface for payload that comes up in a graphql-ws start event */ 37 | export interface IGraphqlWsStartEventPayload { 38 | /** graphql query operationName. Could be user-provided input */ 39 | operationName: string | null; 40 | /** GraphQL query */ 41 | query: string; 42 | /** Variables passed to GraphQL query */ 43 | variables: { [variable: string]: any }; 44 | } 45 | 46 | /** 47 | * https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md#gql_start 48 | */ 49 | export interface IGraphqlWsStartMessage { 50 | /** Subscription Operation ID */ 51 | id: string; 52 | /** Message payload including subscription query */ 53 | payload: IGraphqlWsStartEventPayload; 54 | /** Message type. Indicates that this is a start message */ 55 | type: "start"; 56 | } 57 | 58 | /** Return whether the provided value matches IGraphqlWsStartMessage */ 59 | export const isGraphqlWsStartMessage = ( 60 | o: any, 61 | ): o is IGraphqlWsStartMessage => { 62 | return ( 63 | typeof o === "object" && 64 | typeof o.id === "string" && 65 | o.type === "start" && 66 | typeof o.payload === "object" && 67 | typeof o.payload.query === "string" 68 | ); 69 | }; 70 | 71 | /** Given a JSON string that should be a graphql-ws start message, return a parsed object of it, or throw if inalid */ 72 | export const parseGraphqlWsStartMessage = ( 73 | jsonString: string, 74 | ): IGraphqlWsStartMessage => { 75 | const startMessage = JSON.parse(jsonString); 76 | if (!isGraphqlWsStartMessage(startMessage)) { 77 | throw new Error(`invalid graphql-ws start message: ${jsonString}`); 78 | } 79 | return startMessage; 80 | }; 81 | 82 | /** https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md#gql_stop */ 83 | export interface IGraphqlWsStopMessage { 84 | /** Subscription Operation ID */ 85 | id: string; 86 | /** Message type. Indicates that this is a start message */ 87 | type: "stop"; 88 | } 89 | 90 | /** Return whether the provided value matches IGraphqlWsStopMessage */ 91 | export const isGraphqlWsStopMessage = (o: any): o is IGraphqlWsStopMessage => { 92 | return typeof o === "object" && typeof o.id === "string" && o.type === "stop"; 93 | }; 94 | 95 | export interface IGetGripChannelByConnectionSelector { 96 | /** connection info */ 97 | connection: { 98 | /** connection id */ 99 | id: string; 100 | }; 101 | } 102 | 103 | export interface IGraphqlWebSocketOverHttpConnectionListenerOptions { 104 | /** Info about the WebSocket-Over-HTTP Connection */ 105 | connection: IWebSocketOverHTTPConnectionInfo; 106 | /** WebSocket-Over-HTTP options */ 107 | webSocketOverHttp?: { 108 | /** how often to ask ws-over-http gateway to make keepalive requests */ 109 | keepAliveIntervalSeconds?: number; 110 | }; 111 | /** Handle a websocket message and optionally return a response */ 112 | getMessageResponse(message: string): void | string | Promise; 113 | /** 114 | * Given a subscription operation, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates 115 | */ 116 | getGripChannels( 117 | connection: IWebSocketOverHTTPConnectionInfo, 118 | subscriptionOperation?: IGraphqlWsStartMessage | IGraphqlWsStopMessage, 119 | ): Promise; 120 | /** Cleanup after a connection has closed/disconnected, e.g. delete all stored subscriptions created by the connection */ 121 | cleanupConnection( 122 | connection: IWebSocketOverHTTPConnectionInfo, 123 | ): Promise; 124 | } 125 | 126 | /** 127 | * GraphqlWebSocketOverHttpConnectionListener 128 | * WebSocket-Over-HTTP Connection Listener that tries to mock out a basic graphql-ws. 129 | */ 130 | export default ( 131 | options: IGraphqlWebSocketOverHttpConnectionListenerOptions, 132 | ): IConnectionListener => { 133 | /** 134 | * Called to permanent end a connection, clean it up, and unsubscribe from all the connection's subscriptions. 135 | * It should be called after receiving WebSocket-Over-HTTP close and disconnect events 136 | */ 137 | const endConnection = async ( 138 | connection: IWebSocketOverHTTPConnectionInfo, 139 | ): Promise => { 140 | const gripChannelsForConnection = await options.getGripChannels( 141 | options.connection, 142 | ); 143 | for (const gripChannel of gripChannelsForConnection) { 144 | console.debug( 145 | `GraphqlWebSocketOverHttpConnectionListener unsubscribing from grip-channel ${gripChannel}`, 146 | ); 147 | options.connection.webSocketContext.unsubscribe(gripChannel); 148 | } 149 | await options.cleanupConnection(options.connection); 150 | }; 151 | return { 152 | async onClose() { 153 | console.debug("GraphqlWebSocketOverHttpConnectionListener onClose"); 154 | await endConnection(options.connection); 155 | }, 156 | async onDisconnect() { 157 | console.debug("GraphqlWebSocketOverHttpConnectionListener onDisconnect"); 158 | await endConnection(options.connection); 159 | }, 160 | async onMessage(message) { 161 | const graphqlWsEvent = JSON.parse(message); 162 | if (isGraphqlWsStartMessage(graphqlWsEvent)) { 163 | for (const gripChannel of await options.getGripChannels( 164 | options.connection, 165 | graphqlWsEvent, 166 | )) { 167 | console.debug( 168 | `GraphqlWebSocketOverHttpConnectionListener requesting grip subscribe to channel ${gripChannel}`, 169 | ); 170 | options.connection.webSocketContext.subscribe(gripChannel); 171 | } 172 | } else if (isGraphqlWsStopMessage(graphqlWsEvent)) { 173 | for (const gripChannel of await options.getGripChannels( 174 | options.connection, 175 | graphqlWsEvent, 176 | )) { 177 | console.debug( 178 | `GraphqlWebSocketOverHttpConnectionListener unsubscribing from grip-channel ${gripChannel}`, 179 | ); 180 | options.connection.webSocketContext.unsubscribe(gripChannel); 181 | } 182 | } 183 | return options.getMessageResponse(message); 184 | }, 185 | async onOpen() { 186 | const webSocketOverHttpOptions = options.webSocketOverHttp; 187 | const keepAliveIntervalSeconds = 188 | webSocketOverHttpOptions && 189 | webSocketOverHttpOptions.keepAliveIntervalSeconds; 190 | const headers: Record = { 191 | ...(options.connection.protocol 192 | ? { "sec-websocket-protocol": options.connection.protocol } 193 | : {}), 194 | ...(keepAliveIntervalSeconds && keepAliveIntervalSeconds < Infinity 195 | ? { "Keep-Alive-Interval": String(keepAliveIntervalSeconds) } 196 | : {}), 197 | }; 198 | return { headers }; 199 | }, 200 | }; 201 | }; 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fanout-graphql-tools 2 | 3 | Modules that help make GraphQL servers that work with [Fanout Cloud](https://fanout.io/cloud/). 4 | 5 | See [fanout/apollo-serverless-demo](https://github.com/fanout/apollo-serverless-demo) for an example project that uses this to power a GraphQL API server with GraphQL Subscriptions on AWS Lambda. 6 | 7 | Fanout Cloud can act as a reverse proxy between your users' web browsers and your GraphQL API, holding open long-running WebSocket connections so your server (or function-as-a-service) doesn't have to. Instead, Fanout Cloud makes simple regular HTTP Requests to your application using the [WebSocket-Over-HTTP Protocol](https://pushpin.org/docs/protocols/websocket-over-http/). The tools in this library allow your GraphQL API server to serve the GraphQL Subscriptions protocol ([`graphql-ws`](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md)) over WebSocket-Over-HTTP. 8 | 9 | ## Usage 10 | 11 | ### With apollo-server 12 | 13 | Let's say you already have a project that uses apollo-server to make a GraphQL API with subscriptions. Follow these steps to make it work with Fanout. 14 | 15 | 0. Make some decisions about your data stores. These tools require a persistent place to store data of two types: GraphQL PubSub Subscriptions as well as WebSocket-Over-HTTP Connections. You must provide storage objects that implement the [ISimpleTable](./src/simple-table/SimpleTable.ts) interface. 16 | * You can make your own and store data wherever you want. 17 | * This interface is a subset of the [`@pulumi/cloud.Table`](https://www.pulumi.com/docs/reference/pkg/nodejs/pulumi/cloud/#Table) interface, so you can use those too. Pulumi has implementations for [AWS DyanmoDB](https://github.com/pulumi/pulumi-cloud/blob/master/aws/table.ts) as well as [Azure Table Storage](https://github.com/pulumi/pulumi-cloud/blob/master/azure/table.ts). 18 | * If you want to use another data store not listed here and need help, file an issue to let us know. 19 | * When developing, you can use `MapSimpleTable`, which stores data in-memory in a `Map` object. But the data won't be very persistent. 20 | 21 | Here's an example of creating these storage objects using `MapSimpleTable`. 22 | ```typescript 23 | import { MapSimpleTable, IStoredPubSubSubscription, IStoredConnection } from "fanout-graphql-tools" 24 | 25 | const connectionStorage = MapSimpleTable() 26 | const pubSubSubscriptionStorage = MapSimpleTable() 27 | ``` 28 | 29 | 1. Use `WebSocketOverHttpContextFunction` when constructing `ApolloServer`. This adds some properties to your GraphQL Context that can later be used in your GraphQL Resolvers. 30 | 31 | ```typescript 32 | import { WebSocketOverHttpContextFunction } from "fanout-graphql-tools" 33 | // you may get ApolloServer from elsewhere, e.g. apollo-server-express 34 | import { ApolloServer } from "apollo-server" 35 | import { makeExecutableSchema } from "graphql-tools"; 36 | import MyGraphqlApi from "./my-graphql-api" 37 | 38 | // these depend on your specific API, e.g. https://github.com/apollographql/apollo-server#installation-standalone 39 | const { typeDefs, resolvers } = MyGraphqlApi() 40 | const schema = makeExecutableSchema({ typeDefs, resolvers }) 41 | 42 | const apolloServer = ApolloServer({ 43 | context: WebSocketOverHttpContextFunction({ 44 | grip: { 45 | // Get this from your Fanout Cloud console, which looks like https://api.fanout.io/realm/{realm-id}?iss={realm-id}&key=base64:{realm-key} 46 | // or use this localhost for your own pushpin.org default installation 47 | url: process.env.GRIP_URL || "http://localhost:5561", 48 | }, 49 | pubSubSubscriptionStorage, 50 | schema, 51 | }), 52 | schema, 53 | }) 54 | ``` 55 | 56 | You can see a full example of this [here](./examples/apollo-server-express/) 57 | 58 | 2. 59 | In your GraphQL Resolvers, wrap all usages of `pubsub` with `WebSocketOverHttpPubSubMixin(context)(pubsub)`. 60 | 61 | Every `ApolloServer` has to be created with some [GraphQL Resolvers](https://www.apollographql.com/docs/graphql-tools/resolvers/). To power GraphQL Subscriptions, these resolvers make use of a [`PubSubEngine`](https://www.apollographql.com/docs/apollo-server/features/subscriptions/#subscriptions-example). In mutation resolvers, you call `pubsub.publish(triggerName, payload)`. In your subscription resolvers, you call `pubsub.asyncIterator(triggerName)`. 62 | 63 | Here's a before/after example 64 | 65 | * Before (example from the [official Apollo docs on subscriptions](https://www.apollographql.com/docs/apollo-server/features/subscriptions/#subscriptions-example).) 66 | ```typescript 67 | const resolvers = { 68 | Subscription: { 69 | postAdded: { 70 | // Additional event labels can be passed to asyncIterator creation 71 | subscribe: () => pubsub.asyncIterator([POST_ADDED]), 72 | }, 73 | }, 74 | Mutation: { 75 | addPost(root: any, args: any, context: any) { 76 | pubsub.publish(POST_ADDED, { postAdded: args }); 77 | return postController.addPost(args); 78 | }, 79 | }, 80 | }; 81 | ``` 82 | * After wrapping pubsubs with `WebSocketOverHttpPubSubMixin(context)(pubsub)` 83 | ```typescript 84 | import { WebSocketOverHttpPubSubMixin } from "fanout-graphql-tools" 85 | const resolvers = { 86 | Subscription: { 87 | postAdded: { 88 | // Additional event labels can be passed to asyncIterator creation 89 | subscribe: (source, args, context) => WebSocketOverHttpPubSubMixin(context)(pubsub).asyncIterator([POST_ADDED]), 90 | }, 91 | }, 92 | Mutation: { 93 | addPost(root: any, args: any, context: any) { 94 | WebSocketOverHttpPubSubMixin(context)(pubsub).publish(POST_ADDED, { postAdded: args }); 95 | return postController.addPost(args); 96 | }, 97 | }, 98 | }; 99 | ``` 100 | 101 | You can see a full example of this in [SimpleGraphqlApi](./src/simple-graphql-api/SimpleGraphqlApi.ts) 102 | 103 | 3. Add WebSocket-Over-HTTP handling to the http server that serves your GraphQL App. The way to do this depends on how you make an HTTP Server. 104 | * apollo-server-express 105 | 106 | Many projects use `ApolloServer` along with the [Express](https://expressjs.com/) web framework. There is an official apollo-server integration called [apollo-server-express](https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-express). You can add WebSocket-Over-HTTP handling to your epxress app with `GraphqlWsOverWebSocketOverHttpExpressMiddleware`. 107 | 108 | ```typescript 109 | import * as express from "express" 110 | import { GraphqlWsOverWebSocketOverHttpExpressMiddleware } from "fanout-graphql-tools" 111 | import { makeExecutableSchema } from "graphql-tools"; 112 | import MyGraphqlApi from "./my-graphql-api" 113 | 114 | // these depend on your specific API, e.g. https://github.com/apollographql/apollo-server#installation-standalone 115 | const { typeDefs, resolvers } = MyGraphqlApi() 116 | const schema = makeExecutableSchema({ typeDefs, resolvers }) 117 | const app = express() 118 | .use(GraphqlWsOverWebSocketOverHttpExpressMiddleware({ 119 | // we created these earlier, remember? 120 | connectionStorage, 121 | pubSubSubscriptionStorage, 122 | schema, 123 | })) 124 | 125 | // later, do `ApolloServer(/*...*/).applyMiddleware({ app }) 126 | ``` 127 | 128 | You can see a full example of this [here](./examples/apollo-server-express/) 129 | 130 | * 131 | Other web frameworks 132 | 133 | Not everyone uses express. That's fine. We still want to work with your project. Many node.js web frameworks ultimately end up using the `http` module from the standard library behind the scenes. If your web framework gives you a reference to an underlying `http.Server` instance, you can use `GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller` to install WebSocket-Over-HTTP handling to it. 134 | 135 | ```typescript 136 | import * as http from "http" 137 | import { GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller } from "fanout-graphql-tools" 138 | import { makeExecutableSchema } from "graphql-tools"; 139 | import MyGraphqlApi from "./my-graphql-api" 140 | 141 | // these depend on your specific API, e.g. https://github.com/apollographql/apollo-server#installation-standalone 142 | const { typeDefs, resolvers } = MyGraphqlApi() 143 | const schema = makeExecutableSchema({ typeDefs, resolvers }) 144 | 145 | // However you get here, e.g. with https://github.com/zeit/micro 146 | const httpServer: http.Server = http.createServer(requestListener) 147 | 148 | GraphqlWsOverWebSocketOverHttpSubscriptionHandlerInstaller({ 149 | connectionStorage, 150 | pubSubSubscriptionStorage, 151 | schema, 152 | })(httpServer); 153 | 154 | ``` 155 | 156 | Take a look at [the micro example](./examples/micro) for a working example of this with an http.Server created from [micro](https://github.com/zeit/micro) and [apollo-server-micro](https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-micro). 157 | 158 | * Have a question about this part? File an issue and we can help out and add to the docs. 159 | 160 | Those are the steps for using fanout-graphql-tools. See [apollo-serverless-demo](https://github.com/fanout/apollo-serverless-demo) for a fully functional app, running in AWS Lambda and storing data in DynamoDB. 161 | 162 | ## Development 163 | 164 | ### Releasing New Versions 165 | 166 | Release a new version of this package by pushing a git tag with a name that is a semver version like "v0.0.2". 167 | Make sure you also update the `package.json` to have the same version. 168 | 169 | The best way to do this is using [`npm version `](https://docs.npmjs.com/cli/version), which will update `package.json`, then create a git commit, then create a git tag pointing to that git commit. You should run this in the master branch. 170 | 171 | After that you can push the commit and new tags using `git push --follow-tags`. 172 | 173 | ``` 174 | npm version minor 175 | git push --follow-tags 176 | ``` 177 | 178 | [Travis](https://travis-ci.org/fanout/fanout-graphql-tools) is configured to test and publish all git tags to [npm](https://www.npmjs.com/package/fanout-graphql-tools). You don't need to run `npm publish` locally. 179 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpExpressMiddlewareTest.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncTest, 3 | Expect, 4 | FocusTest, 5 | IgnoreTest, 6 | TestFixture, 7 | } from "alsatian"; 8 | import { NormalizedCacheObject } from "apollo-cache-inmemory"; 9 | import ApolloClient from "apollo-client"; 10 | import { ApolloServer } from "apollo-server-express"; 11 | import { 12 | buildSchemaFromTypeDefinitions, 13 | IResolvers, 14 | makeExecutableSchema, 15 | PubSub, 16 | PubSubEngine, 17 | } from "apollo-server-express"; 18 | import * as express from "express"; 19 | import { DocumentNode } from "graphql"; 20 | import * as http from "http"; 21 | import * as urlModule from "url"; 22 | import { 23 | SimpleGraphqlApi, 24 | SimpleGraphqlApiMutations, 25 | SimpleGraphqlApiSubscriptions, 26 | } from "../simple-graphql-api/SimpleGraphqlApi"; 27 | import { ISimpleTable, MapSimpleTable } from "../simple-table/SimpleTable"; 28 | import { cli } from "../test/cli"; 29 | import { ChangingValue } from "../testing-tools/ChangingValue"; 30 | import { itemsFromLinkObservable } from "../testing-tools/itemsFromLinkObservable"; 31 | import WebSocketApolloClient from "../testing-tools/WebSocketApolloClient"; 32 | import { withListeningServer } from "../testing-tools/withListeningServer"; 33 | import { GraphqlWsGripChannelNamer } from "./GraphqlWsGripChannelNamers"; 34 | import GraphqlWsOverWebSocketOverHttpExpressMiddleware, { 35 | IStoredConnection, 36 | } from "./GraphqlWsOverWebSocketOverHttpExpressMiddleware"; 37 | import { GraphqlWsOverWebSocketOverHttpStorageCleaner } from "./GraphqlWsOverWebSocketOverHttpStorageCleaner"; 38 | import { IStoredPubSubSubscription } from "./PubSubSubscriptionStorage"; 39 | import { WebSocketOverHttpContextFunction } from "./WebSocketOverHttpGraphqlContext"; 40 | 41 | interface ISubscriptionsListener { 42 | /** called on subscription start */ 43 | onConnect: (...args: any[]) => void; 44 | } 45 | 46 | interface IGraphqlHttpAppOptions { 47 | /** table to store information about each ws-over-http connection */ 48 | connectionStorage: ISimpleTable; 49 | /** configure graphql API */ 50 | graphql: { 51 | /** GraphQL API typeDefs */ 52 | typeDefs: DocumentNode; 53 | /** get resolvers for GraphQL API */ 54 | getResolvers(options: { 55 | /** PubSubEngine to use in resolvers */ 56 | pubsub: PubSubEngine; 57 | }): IResolvers; 58 | }; 59 | /** table that will store information about PubSubEngine subscriptions */ 60 | pubSubSubscriptionStorage: ISimpleTable; 61 | /** Object that will be called base on subscription connect/disconnect */ 62 | subscriptionListener?: ISubscriptionsListener; 63 | /** configure WebSocket-Over-Http */ 64 | webSocketOverHttp?: { 65 | /** Given a graphql-ws GQL_START message, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates */ 66 | getGripChannel?: GraphqlWsGripChannelNamer; 67 | }; 68 | } 69 | 70 | const WsOverHttpGraphqlHttpApp = (options: IGraphqlHttpAppOptions) => { 71 | const { subscriptionListener } = options; 72 | const pubsub = new PubSub(); 73 | const resolvers = options.graphql.getResolvers({ pubsub }); 74 | const schema = makeExecutableSchema({ 75 | resolvers, 76 | typeDefs: options.graphql.typeDefs, 77 | }); 78 | const expressApplication = express().use( 79 | GraphqlWsOverWebSocketOverHttpExpressMiddleware({ 80 | connectionStorage: options.connectionStorage, 81 | getGripChannel: 82 | options.webSocketOverHttp && options.webSocketOverHttp.getGripChannel, 83 | onSubscriptionStart: 84 | subscriptionListener && subscriptionListener.onConnect, 85 | pubSubSubscriptionStorage: options.pubSubSubscriptionStorage, 86 | schema, 87 | }), 88 | ); 89 | const graphqlPath = "/"; 90 | const subscriptionsPath = "/"; 91 | const apolloServer = new ApolloServer({ 92 | context: WebSocketOverHttpContextFunction({ 93 | grip: { 94 | getGripChannel: 95 | options.webSocketOverHttp && options.webSocketOverHttp.getGripChannel, 96 | url: process.env.GRIP_URL || "http://localhost:5561", 97 | }, 98 | pubSubSubscriptionStorage: options.pubSubSubscriptionStorage, 99 | schema, 100 | }), 101 | resolvers, 102 | subscriptions: { 103 | onConnect: subscriptionListener && subscriptionListener.onConnect, 104 | path: subscriptionsPath, 105 | }, 106 | typeDefs: options.graphql.typeDefs, 107 | }); 108 | apolloServer.applyMiddleware({ 109 | app: expressApplication, 110 | path: graphqlPath, 111 | }); 112 | const httpServer = http.createServer(expressApplication); 113 | apolloServer.installSubscriptionHandlers(httpServer); 114 | return { apolloServer, graphqlPath, httpServer, subscriptionsPath }; 115 | }; 116 | 117 | /** Given a base URL and a Path, return a new URL with that path on the baseUrl (existing path on baseUrl is ignored) */ 118 | const urlWithPath = (baseUrl: string, pathname: string): string => { 119 | const parsedBaseUrl = urlModule.parse(baseUrl); 120 | const newUrl = urlModule.format({ ...parsedBaseUrl, pathname }); 121 | return newUrl; 122 | }; 123 | 124 | /** Test ./GraphqlWsOverWebSocketOverHttpExpressMiddleware */ 125 | @TestFixture() 126 | export class GraphqlWsOverWebSocketOverHttpExpressMiddlewareTest { 127 | /** test we can make a server and connect to it */ 128 | @AsyncTest() 129 | public async testSimpleGraphqlServerWithApolloClient() { 130 | const [ 131 | onSubscriptionConnection, 132 | _, 133 | latestSubscriptionChanged, 134 | ] = ChangingValue(); 135 | const pubSubSubscriptionStorage = MapSimpleTable< 136 | IStoredPubSubSubscription 137 | >(); 138 | const graphqlSchema = buildSchemaFromTypeDefinitions( 139 | SimpleGraphqlApi().typeDefs, 140 | ); 141 | const app = WsOverHttpGraphqlHttpApp({ 142 | connectionStorage: MapSimpleTable(), 143 | graphql: { 144 | getResolvers: ({ pubsub }) => SimpleGraphqlApi({ pubsub }).resolvers, 145 | typeDefs: SimpleGraphqlApi().typeDefs, 146 | }, 147 | pubSubSubscriptionStorage, 148 | subscriptionListener: { onConnect: onSubscriptionConnection }, 149 | }); 150 | await withListeningServer(app.httpServer, 0)(async ({ url }) => { 151 | const urls = { 152 | subscriptionsUrl: urlWithPath(url, app.subscriptionsPath), 153 | url: urlWithPath(url, app.graphqlPath), 154 | }; 155 | const apolloClient = WebSocketApolloClient(urls); 156 | const { items, subscription } = itemsFromLinkObservable( 157 | apolloClient.subscribe(SimpleGraphqlApiSubscriptions.postAdded()), 158 | ); 159 | await latestSubscriptionChanged(); 160 | const postToAdd = { 161 | author: "me", 162 | comment: "first!", 163 | }; 164 | const mutationResult = await apolloClient.mutate( 165 | SimpleGraphqlApiMutations.addPost(postToAdd), 166 | ); 167 | Expect(mutationResult.data.addPost.comment).toEqual(postToAdd.comment); 168 | Expect(items.length).toEqual(1); 169 | }); 170 | return; 171 | } 172 | /** 173 | * test we can make a server and connect to it through pushpin. 174 | * This requires that pushpin be running and have /etc/pushpin/routes configured to route traffic to serverPort, e.g. "*,debug localhost:57410,over_http". 175 | * If pushpin is running, the default value of PUSHPIN_PROXY_URL=http://localhost:7999 176 | */ 177 | @DecorateIf( 178 | () => !Boolean(process.env.PUSHPIN_PROXY_URL), 179 | IgnoreTest("process.env.PUSHPIN_PROXY_URL is not defined"), 180 | ) 181 | @AsyncTest() 182 | public async testSimpleGraphqlServerWithApolloClientThroughPushpin( 183 | serverPort = 57410, 184 | pushpinProxyUrl = process.env.PUSHPIN_PROXY_URL, 185 | pushpinGripUrl = "http://localhost:5561", 186 | ) { 187 | if (!pushpinProxyUrl) { 188 | throw new Error(`pushpinProxyUrl is required`); 189 | } 190 | const [ 191 | onSubscriptionConnection, 192 | _, 193 | latestSubscriptionChanged, 194 | ] = ChangingValue(); 195 | const connectionStorage = MapSimpleTable(); 196 | const pubSubSubscriptionStorage = MapSimpleTable< 197 | IStoredPubSubSubscription 198 | >(); 199 | const app = WsOverHttpGraphqlHttpApp({ 200 | connectionStorage, 201 | graphql: { 202 | getResolvers: ({ pubsub }) => SimpleGraphqlApi({ pubsub }).resolvers, 203 | typeDefs: SimpleGraphqlApi().typeDefs, 204 | }, 205 | pubSubSubscriptionStorage, 206 | subscriptionListener: { onConnect: onSubscriptionConnection }, 207 | }); 208 | await withListeningServer(app.httpServer, serverPort)(async ({ url }) => { 209 | const urls = { 210 | subscriptionsUrl: urlWithPath(pushpinProxyUrl, app.subscriptionsPath), 211 | url: urlWithPath(pushpinProxyUrl, app.graphqlPath), 212 | }; 213 | const createApolloClient = () => WebSocketApolloClient(urls); 214 | const apolloClient = createApolloClient(); 215 | const subscriptionListeners = await (async () => { 216 | const listeners = []; 217 | // create these one at a time so we can wait for latestSubscriptionChanged after each subscription attempt 218 | for (const i of new Array(2).fill(0)) { 219 | const listener = itemsFromLinkObservable( 220 | apolloClient.subscribe(SimpleGraphqlApiSubscriptions.postAdded()), 221 | ); 222 | await latestSubscriptionChanged(); 223 | listeners.push(listener); 224 | } 225 | return listeners; 226 | })(); 227 | // Check that the subscription resulted in storing info about the subscription and also the graphql-ws connection it was sent over 228 | const storedConnectionsAfterSubscription = await connectionStorage.scan(); 229 | Expect(storedConnectionsAfterSubscription.length).toEqual(1); 230 | const storedPubSubSubscriptionsAfterSubscription = await pubSubSubscriptionStorage.scan(); 231 | Expect(storedPubSubSubscriptionsAfterSubscription.length).toEqual( 232 | subscriptionListeners.length, 233 | ); 234 | 235 | // Now let's make a mutation that should result in a message coming from the subscription 236 | const postToAdd = { 237 | author: "me", 238 | comment: "first!", 239 | }; 240 | const mutationResult = await apolloClient.mutate( 241 | SimpleGraphqlApiMutations.addPost(postToAdd), 242 | ); 243 | Expect(mutationResult.data.addPost.comment).toEqual(postToAdd.comment); 244 | await timer(500); 245 | Expect(subscriptionListeners[0].items.length).toEqual(1); 246 | Expect(subscriptionListeners[1].items.length).toEqual(1); 247 | const firstPostAddedMessage = subscriptionListeners[0].items[0]; 248 | Expect(firstPostAddedMessage.data.postAdded.comment).toEqual( 249 | postToAdd.comment, 250 | ); 251 | 252 | // Now we want to make sure it's possible to clean up records from storage once they have expired due to inactivity 253 | const cleanUpStorage = GraphqlWsOverWebSocketOverHttpStorageCleaner({ 254 | connectionStorage, 255 | pubSubSubscriptionStorage, 256 | }); 257 | // first try a cleanup right now. Right after creating the connection and subscription. It should not result in any deleted rows because it's too soon. They haven't expired yet. 258 | const afterEarlyCleanup = { 259 | connections: await connectionStorage.scan(), 260 | pubSubSubscriptions: await pubSubSubscriptionStorage.scan(), 261 | }; 262 | Expect(afterEarlyCleanup.pubSubSubscriptions.length).toEqual( 263 | subscriptionListeners.length, 264 | ); 265 | Expect(afterEarlyCleanup.connections.length).toEqual(1); 266 | 267 | // Five minutes from now - At this point they should be expired 268 | const simulateCleanupAtDate = (() => { 269 | return new Date( 270 | Date.parse(afterEarlyCleanup.connections[0].expiresAt) + 1000, 271 | ); 272 | })(); 273 | await cleanUpStorage(simulateCleanupAtDate); 274 | const afterCleanup = { 275 | connections: await connectionStorage.scan(), 276 | pubSubSubscriptions: await pubSubSubscriptionStorage.scan(), 277 | }; 278 | Expect(afterCleanup.pubSubSubscriptions.length).toEqual(0); 279 | Expect(afterCleanup.connections.length).toEqual(0); 280 | 281 | await testMultipleGraphqlSubscriptions( 282 | createApolloClient, 283 | latestSubscriptionChanged, 284 | ); 285 | }); 286 | return; 287 | } 288 | } 289 | 290 | /** 291 | * There was a bug that would manifest like so: 292 | * * create a graphql-ws subscription (through pushpin/GRIP) using N>=2 clients. Identical subscriptions (e.g. two tabs in graphiql playground) 293 | * * send a mutation that should result in a message on the subscriptions 294 | * * You expect each client to receive 1 message from the mutation, but actually they will receive N. 295 | * This was because, while they were all independent clients/subscriptions, they all end up reusing the same Grip-Channel. So we only need to publish one message via EPCP to that Grip-Channel, not N messages, one per subscription. 296 | * This tests that this bug is fixed. 297 | */ 298 | async function testMultipleGraphqlSubscriptions( 299 | // It's assumed this is properly configured to talk to a listening graphql API through pushpin that is running the 'SimpleGraphqlAPI' typeDefs/resolvers 300 | createApolloClient: () => ApolloClient, 301 | // call this to get a promise for when a new subscription is fully handled by the server 302 | latestSubscriptionChanged: () => Promise, 303 | ): Promise { 304 | // we need separate apollo clients because we want the graphql-ws start messages that come in to be exactly equivalent, including the operationId. 305 | // If we use the same client, it auto-increments the operationId and the bug would not happen. 306 | const apolloClient1 = createApolloClient(); 307 | const firstSubscriptionListener = itemsFromLinkObservable( 308 | apolloClient1.subscribe(SimpleGraphqlApiSubscriptions.postAdded()), 309 | ); 310 | await latestSubscriptionChanged(); 311 | 312 | const apolloClient2 = createApolloClient(); 313 | const secondSubscriptionListener = itemsFromLinkObservable( 314 | apolloClient2.subscribe(SimpleGraphqlApiSubscriptions.postAdded()), 315 | ); 316 | await latestSubscriptionChanged(); 317 | 318 | await apolloClient1.mutate( 319 | SimpleGraphqlApiMutations.addPost({ author: "me", comment: "hello" }), 320 | ); 321 | await timer(500); 322 | Expect(firstSubscriptionListener.items.length).toEqual(1); 323 | Expect(secondSubscriptionListener.items.length).toEqual(1); 324 | } 325 | 326 | if (require.main === module) { 327 | cli(__filename).catch((error: Error) => { 328 | throw error; 329 | }); 330 | } 331 | 332 | type Decorator = ( 333 | target: object, 334 | propertyKey: string, 335 | descriptor?: TypedPropertyDescriptor, 336 | ) => void; 337 | /** Conditionally apply a decorator */ 338 | function DecorateIf(test: () => boolean, decorator: Decorator): Decorator { 339 | if (test()) { 340 | return decorator; 341 | } 342 | return () => { 343 | return; 344 | }; 345 | } 346 | 347 | /** return promise that resolves after some milliseconds */ 348 | export function timer(ms: number) { 349 | return new Promise(resolve => setTimeout(resolve, ms)); 350 | } 351 | -------------------------------------------------------------------------------- /src/subscriptions-transport-ws-over-http/GraphqlWsOverWebSocketOverHttpExpressMiddleware.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import * as express from "express"; 3 | import * as graphql from "graphql"; 4 | import { WebSocketEvent } from "grip"; 5 | import { default as AcceptAllGraphqlSubscriptionsMessageHandler } from "../graphql-ws/AcceptAllGraphqlSubscriptionsMessageHandler"; 6 | import { filterTable, ISimpleTable } from "../simple-table/SimpleTable"; 7 | import { 8 | ComposedConnectionListener, 9 | composeMessageHandlers, 10 | IConnectionListener, 11 | } from "../websocket-over-http-express/WebSocketOverHttpConnectionListener"; 12 | import WebSocketOverHttpExpress, { 13 | IWebSocketOverHTTPConnectionInfo, 14 | } from "../websocket-over-http-express/WebSocketOverHttpExpress"; 15 | import GraphqlWebSocketOverHttpConnectionListener, { 16 | IGraphqlWsStartMessage, 17 | IGraphqlWsStopMessage, 18 | isGraphqlWsStartMessage, 19 | isGraphqlWsStopMessage, 20 | parseGraphqlWsStartMessage, 21 | } from "./GraphqlWebSocketOverHttpConnectionListener"; 22 | import { 23 | DefaultGripChannelNamer, 24 | GraphqlWsGripChannelNamer, 25 | } from "./GraphqlWsGripChannelNamers"; 26 | import { cleanupStorageAfterConnection } from "./GraphqlWsOverWebSocketOverHttpStorageCleaner"; 27 | import { IStoredPubSubSubscription } from "./PubSubSubscriptionStorage"; 28 | import { IWebSocketOverHttpGraphqlSubscriptionContext } from "./WebSocketOverHttpGraphqlContext"; 29 | 30 | /** WebSocket Message Handler that calls a callback on graphql-ws start message */ 31 | const GraphqlWsStartMessageHandler = ( 32 | onMessage: (startMessage: IGraphqlWsStartMessage) => Promise, 33 | ) => async (message: string) => { 34 | const graphqlWsEvent = JSON.parse(message); 35 | if (!isGraphqlWsStartMessage(graphqlWsEvent)) { 36 | return; 37 | } 38 | return onMessage(graphqlWsEvent); 39 | }; 40 | 41 | /** WebSocket Message Handler that calls a callback on graphql-ws stop message */ 42 | const GraphqlWsStopMessageHandler = ( 43 | onMessage: (stopMessage: IGraphqlWsStopMessage) => Promise, 44 | ) => async (message: string) => { 45 | const graphqlWsEvent = JSON.parse(message); 46 | if (!isGraphqlWsStopMessage(graphqlWsEvent)) { 47 | return; 48 | } 49 | return onMessage(graphqlWsEvent); 50 | }; 51 | 52 | /** 53 | * WebSocket message handler that will watch for graphql-ws GQL_STOP events that stop subscriptions, 54 | * and remove corresponding pubSubSubscription records from pubSubSubscriptionStorage. 55 | */ 56 | const PubSubSubscriptionDeletingMessageHandler = (options: { 57 | /** WebSocket Connection Info */ 58 | connection: { 59 | /** Connection ID */ 60 | id: string; 61 | }; 62 | /** Table in which gql subscriptions are stored */ 63 | pubSubSubscriptionStorage: ISimpleTable; 64 | }) => async (message: string) => { 65 | const graphqlWsEvent = JSON.parse(message); 66 | if (!isGraphqlWsStopMessage(graphqlWsEvent)) { 67 | return; 68 | } 69 | const operationId = graphqlWsEvent.id; 70 | assert(operationId, "graphql-ws GQL_STOP message must have id"); 71 | const pubSubscriptionRowsForThisEvent = await filterTable( 72 | options.pubSubSubscriptionStorage, 73 | sub => { 74 | const subscriptionOperationStartMessage = JSON.parse( 75 | sub.graphqlWsStartMessage, 76 | ); 77 | if (!isGraphqlWsStartMessage(subscriptionOperationStartMessage)) { 78 | throw new Error( 79 | `invalid graphql-ws start message ${sub.graphqlWsStartMessage}`, 80 | ); 81 | } 82 | const subscriptionOperationId = subscriptionOperationStartMessage.id; 83 | return ( 84 | sub.connectionId === options.connection.id && 85 | subscriptionOperationId === graphqlWsEvent.id 86 | ); 87 | }, 88 | ); 89 | await Promise.all( 90 | pubSubscriptionRowsForThisEvent.map(sub => 91 | options.pubSubSubscriptionStorage.delete({ id: sub.id }), 92 | ), 93 | ); 94 | }; 95 | 96 | /** Message handler that will properly handle graphql-ws subscription operations 97 | * by calling `subscribe` export of `graphql` package 98 | */ 99 | const ExecuteGraphqlWsSubscriptionsMessageHandler = (options: { 100 | /** ws-over-http info */ 101 | webSocketOverHttp: { 102 | /** info aobut the ws-over-http connection */ 103 | connection: IWebSocketOverHTTPConnectionInfo; 104 | }; 105 | /** table to store PubSub subscription info in */ 106 | pubSubSubscriptionStorage: ISimpleTable; 107 | /** graphql resolver root value */ 108 | rootValue?: any; 109 | /** graphql schema to evaluate subscriptions against */ 110 | schema: graphql.GraphQLSchema; 111 | }) => async (message: string) => { 112 | const graphqlWsEvent = JSON.parse(message); 113 | const operation = graphqlWsEvent && graphqlWsEvent.payload; 114 | if (!(isGraphqlWsStartMessage(graphqlWsEvent) && operation)) { 115 | // not a graphql-ws subscription start. Do nothing 116 | return; 117 | } 118 | const queryDocument = graphql.parse(operation.query); 119 | // const validationErrors = graphql.validate(queryDocument) 120 | const operationAST = graphql.getOperationAST( 121 | queryDocument, 122 | operation.operationName || "", 123 | ); 124 | if (!(operationAST && operationAST.operation === "subscription")) { 125 | // not a subscription. do nothing 126 | return; 127 | } 128 | const contextValue: IWebSocketOverHttpGraphqlSubscriptionContext = { 129 | webSocketOverHttp: { 130 | connection: options.webSocketOverHttp.connection, 131 | graphql: { 132 | schema: options.schema, 133 | }, 134 | graphqlWs: { 135 | startMessage: graphqlWsEvent, 136 | }, 137 | pubSubSubscriptionStorage: options.pubSubSubscriptionStorage, 138 | }, 139 | }; 140 | const subscriptionAsyncIterator = await graphql.subscribe({ 141 | contextValue, 142 | document: queryDocument, 143 | operationName: operation.operationName, 144 | rootValue: options.rootValue, 145 | schema: options.schema, 146 | variableValues: operation.variables, 147 | }); 148 | if ("next" in subscriptionAsyncIterator) { 149 | // may need to call this to actually trigger underlying subscription resolver. 150 | // When underlying PubSub has SubscriptionStoragePubSubMixin, this will result in storing some info 151 | // about what PubSub event names are subscribed to. 152 | subscriptionAsyncIterator.next(); 153 | } 154 | if ( 155 | "return" in subscriptionAsyncIterator && 156 | subscriptionAsyncIterator.return 157 | ) { 158 | // but we don't want to keep listening on this terator. Subscription events will be broadcast to EPCP gateway 159 | // at time of mutation. 160 | subscriptionAsyncIterator.return(); 161 | } 162 | }; 163 | 164 | /** Interface for ws-over-http connections stored in the db */ 165 | export interface IStoredConnection { 166 | /** When the connection was createdAt (ISO_8601 string) */ 167 | createdAt: string; 168 | /** unique connection id */ 169 | id: string; 170 | /** datetime that this connection should timeout and be deleted (ISO_8601 string) */ 171 | expiresAt: string; 172 | } 173 | 174 | /** 175 | * ConnectionListener that will store Connection info. 176 | * On connection open, store a record in connectionStorage. 177 | * On every subsequent ws-over-http request, consider whether to update connection.expiresAt to push out the date at which it should be considered expired because of inactivity/timeout. 178 | * To minimize db reads/writes, store a Meta-Connection-Expiration-Delay-At value in the ws-over-http state to help decide when to update connection.expiresAt with a write to connectionStorage. 179 | */ 180 | const ConnectionStoringConnectionListener = (options: { 181 | /** info about the connection */ 182 | connection: IWebSocketOverHTTPConnectionInfo; 183 | /** table to store information about each ws-over-http connection */ 184 | connectionStorage: ISimpleTable; 185 | /** how often to ask ws-over-http gateway to make keepalive requests */ 186 | keepAliveIntervalSeconds: number; 187 | /** table to store PubSub subscription info in */ 188 | pubSubSubscriptionStorage: ISimpleTable; 189 | }): IConnectionListener => { 190 | // Return date of when we should consider the connection expired because of inactivity. 191 | // now + (2 * keepAliveIntervalSeconds) 192 | const getNextExpiresAt = (): Date => { 193 | const d = new Date(); 194 | d.setSeconds(d.getSeconds() + 2 * options.keepAliveIntervalSeconds); 195 | return d; 196 | }; 197 | // Return a date of when we should next delay connection expiration. 198 | // It's now + keepAliveIntervalSeconds 199 | const getNextExpirationDelayAt = (): Date => { 200 | const d = new Date(); 201 | d.setSeconds(d.getSeconds() + options.keepAliveIntervalSeconds); 202 | return d; 203 | }; 204 | const metaConnectionExpirationDelayAt = "Meta-Connection-Expiration-Delay-At"; 205 | /** Return HTTP response header key/value that will delay the connection expiration */ 206 | const delayExpirationDelayResponseHeaders = (): Record => { 207 | return { 208 | [`Set-${metaConnectionExpirationDelayAt}`]: getNextExpirationDelayAt().toISOString(), 209 | }; 210 | }; 211 | // cleanup after the connection once it is closed or disconnected 212 | const cleanupConnection = async () => { 213 | await cleanupStorageAfterConnection({ 214 | connection: { id: options.connection.id }, 215 | connectionStorage: options.connectionStorage, 216 | pubSubSubscriptionStorage: options.pubSubSubscriptionStorage, 217 | }); 218 | }; 219 | return { 220 | async onClose() { 221 | await cleanupConnection(); 222 | }, 223 | async onDisconnect() { 224 | await cleanupConnection(); 225 | }, 226 | /** On connection open, store the connection */ 227 | async onOpen() { 228 | await options.connectionStorage.insert({ 229 | createdAt: new Date().toISOString(), 230 | expiresAt: getNextExpiresAt().toISOString(), 231 | id: options.connection.id, 232 | }); 233 | return { 234 | headers: { 235 | ...delayExpirationDelayResponseHeaders(), 236 | }, 237 | }; 238 | }, 239 | /** On every WebSocket-Over-HTTP request, check if it's time to delay expiration of the connection and, if so, update the connection.expiresAt in connectionStorage */ 240 | async onHttpRequest(request) { 241 | const delayExpirationAtHeaderValue = 242 | request.headers[metaConnectionExpirationDelayAt.toLowerCase()]; 243 | const delayExpirationAtISOString = Array.isArray( 244 | delayExpirationAtHeaderValue, 245 | ) 246 | ? delayExpirationAtHeaderValue[0] 247 | : delayExpirationAtHeaderValue; 248 | if (!delayExpirationAtISOString) { 249 | // This is probably the connection open request, in which case we just created the connection. We don't need to refresh it 250 | return; 251 | } 252 | const delayExpirationAtDate = new Date( 253 | Date.parse(delayExpirationAtISOString), 254 | ); 255 | if (new Date() < delayExpirationAtDate) { 256 | // we don't need to delay expiration yet. 257 | return; 258 | } 259 | const storedConnection = await options.connectionStorage.get({ 260 | id: options.connection.id, 261 | }); 262 | if (!storedConnection) { 263 | console.warn( 264 | `Got WebSocket-Over-Http request with ${metaConnectionExpirationDelayAt}, but there is no corresponding stored connection. This should only happen if the connection has been deleted some other way. Returning DISCONNECT message to tell the gateway that this connection should be forgotten.`, 265 | ); 266 | options.connection.webSocketContext.outEvents.push( 267 | new WebSocketEvent("DISCONNECT"), 268 | ); 269 | return; 270 | } 271 | // update expiration date of connection in storage 272 | await options.connectionStorage.update( 273 | { id: options.connection.id }, 274 | { 275 | expiresAt: getNextExpiresAt().toISOString(), 276 | }, 277 | ); 278 | // And update the ws-over-http state management to push out the next time we need to delay expiration 279 | return { 280 | headers: { 281 | ...delayExpirationDelayResponseHeaders(), 282 | }, 283 | }; 284 | }, 285 | }; 286 | }; 287 | 288 | /** TypeScript helper for exhaustive switches https://www.typescriptlang.org/docs/handbook/advanced-types.html */ 289 | function assertNever(x: never): never { 290 | throw new Error("Unexpected object: " + x); 291 | } 292 | 293 | interface IGraphqlWsOverWebSocketOverHttpExpressMiddlewareOptions { 294 | /** table to store information about each ws-over-http connection */ 295 | connectionStorage: ISimpleTable; 296 | /** table to store PubSub subscription info in */ 297 | pubSubSubscriptionStorage: ISimpleTable; 298 | /** graphql schema */ 299 | schema: graphql.GraphQLSchema; 300 | /** WebSocket-Over-HTTP options */ 301 | webSocketOverHttp?: { 302 | /** how often to ask ws-over-http gateway to make keepalive requests */ 303 | keepAliveIntervalSeconds?: number; 304 | }; 305 | /** Given a graphql-ws GQL_START message, return a string that is the Grip-Channel that the GRIP server should subscribe to for updates */ 306 | getGripChannel?: GraphqlWsGripChannelNamer; 307 | /** Called when a new subscrpition connection is made */ 308 | onSubscriptionStart?(...args: any[]): any; 309 | /** Called when a subscription is stopped */ 310 | onSubscriptionStop?(...args: any[]): any; 311 | } 312 | 313 | /** 314 | * Create an Express Middleware that will accept graphql-ws connections that come in over WebSocket-Over-Http 315 | */ 316 | export const GraphqlWsOverWebSocketOverHttpExpressMiddleware = ( 317 | options: IGraphqlWsOverWebSocketOverHttpExpressMiddlewareOptions, 318 | ): express.RequestHandler => { 319 | const { connectionStorage } = options; 320 | const { keepAliveIntervalSeconds = 120 } = options.webSocketOverHttp || {}; 321 | const getGripChannelForStartMessage = 322 | options.getGripChannel || DefaultGripChannelNamer(); 323 | return WebSocketOverHttpExpress({ 324 | getConnectionListener(connection) { 325 | /** This connectionListener will respond to graphql-ws messages in a way that accepts all incoming subscriptions */ 326 | const graphqlWsConnectionListener = GraphqlWebSocketOverHttpConnectionListener( 327 | { 328 | async cleanupConnection(conn) { 329 | await cleanupStorageAfterConnection({ 330 | ...options, 331 | connection: conn, 332 | }); 333 | }, 334 | connection, 335 | getMessageResponse: AcceptAllGraphqlSubscriptionsMessageHandler(), 336 | webSocketOverHttp: { 337 | keepAliveIntervalSeconds, 338 | ...options.webSocketOverHttp, 339 | }, 340 | async getGripChannels( 341 | { id: connectionId }, 342 | channelSelector, 343 | ): Promise { 344 | const relevantSubscriptions = await (async (): Promise< 345 | Array<{ 346 | /** connection ID */ 347 | connectionId: string; 348 | /** grpahql-ws START message for the GraphQL Subscription. This includes the graphql query, operationId, variables */ 349 | graphqlWsStartMessage: IGraphqlWsStartMessage; 350 | }> 351 | > => { 352 | if (!channelSelector) { 353 | // look up by connectionId 354 | const subscriptionsForConnection = await filterTable( 355 | options.pubSubSubscriptionStorage, 356 | subscription => subscription.connectionId === connectionId, 357 | ); 358 | return await Promise.all( 359 | subscriptionsForConnection.map(s => { 360 | const startMessage = JSON.parse(s.graphqlWsStartMessage); 361 | return startMessage; 362 | }), 363 | ); 364 | } 365 | if (isGraphqlWsStartMessage(channelSelector)) { 366 | return [ 367 | { 368 | connectionId, 369 | graphqlWsStartMessage: channelSelector, 370 | }, 371 | ]; 372 | } 373 | if (isGraphqlWsStopMessage(channelSelector)) { 374 | const stopMessage: IGraphqlWsStopMessage = channelSelector; 375 | // Look up the graphql-ws start message corresponding to this stop message from the subscriptionStorage 376 | const storedSubscriptionsForStopMessage = await filterTable( 377 | options.pubSubSubscriptionStorage, 378 | s => { 379 | return ( 380 | parseGraphqlWsStartMessage(s.graphqlWsStartMessage).id === 381 | stopMessage.id && s.connectionId === connection.id 382 | ); 383 | }, 384 | ); 385 | return await Promise.all( 386 | storedSubscriptionsForStopMessage.map(s => { 387 | const graphqlWsStartMessage = parseGraphqlWsStartMessage( 388 | s.graphqlWsStartMessage, 389 | ); 390 | return { graphqlWsStartMessage, connectionId }; 391 | }), 392 | ); 393 | } 394 | assertNever(channelSelector); 395 | throw new Error( 396 | `Failed to retrieve gripChannels for channelSelector ${channelSelector}`, 397 | ); 398 | })(); 399 | const gripChannels = relevantSubscriptions.map( 400 | getGripChannelForStartMessage, 401 | ); 402 | return gripChannels; 403 | }, 404 | }, 405 | ); 406 | const subscriptionEventCallbacksConnectionListener: IConnectionListener = { 407 | onMessage: composeMessageHandlers([ 408 | // We want this at the end so we can rely on onSubscriptionStop being called after subscriptionStorage has been updated 409 | GraphqlWsStartMessageHandler(async () => { 410 | if (options.onSubscriptionStart) { 411 | options.onSubscriptionStart(); 412 | } 413 | }), 414 | // We want this at the end so we can rely on onSubscriptionStop being called after subscriptionStorage has been updated 415 | GraphqlWsStopMessageHandler(async () => { 416 | if (options.onSubscriptionStop) { 417 | options.onSubscriptionStop(); 418 | } 419 | }), 420 | ]), 421 | }; 422 | /** 423 | * Returned onMessage is going to be a composition of the above message handlers. 424 | * Note that storeSubscriptions happens at the beginning, and deleteSubscriptionsOnStop happens at the end. 425 | * This way any message handlers in the middle can count on the stored subscription being in storage. 426 | */ 427 | return ComposedConnectionListener([ 428 | ConnectionStoringConnectionListener({ 429 | connection, 430 | connectionStorage, 431 | keepAliveIntervalSeconds, 432 | pubSubSubscriptionStorage: options.pubSubSubscriptionStorage, 433 | }), 434 | { 435 | onMessage: ExecuteGraphqlWsSubscriptionsMessageHandler({ 436 | ...options, 437 | webSocketOverHttp: { connection }, 438 | }), 439 | }, 440 | graphqlWsConnectionListener, 441 | { 442 | onMessage: PubSubSubscriptionDeletingMessageHandler({ 443 | connection, 444 | pubSubSubscriptionStorage: options.pubSubSubscriptionStorage, 445 | }), 446 | }, 447 | subscriptionEventCallbacksConnectionListener, 448 | ]); 449 | }, 450 | }); 451 | }; 452 | 453 | export default GraphqlWsOverWebSocketOverHttpExpressMiddleware; 454 | --------------------------------------------------------------------------------