├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── cache.ts ├── createConnection.ts ├── index.ts └── useSignalr.ts ├── test ├── createConnection.test.ts └── useSignalr.test.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | 4 | .cache 5 | 6 | # don't lint build output & coverage report 7 | dist 8 | coverage 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | require('@known-as-bmf/eslint-config-bmf/patch/modern-module-resolution'); 2 | 3 | module.exports = { 4 | root: true, 5 | extends: ['@known-as-bmf/eslint-config-bmf/web'], 6 | parserOptions: { tsconfigRootDir: __dirname }, 7 | }; 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize eol 2 | * text=auto 3 | 4 | # Don't allow people to merge changes to these generated files, because the result 5 | # may be invalid. You need to run "rush update" again. 6 | pnpm-lock.yaml merge=binary 7 | shrinkwrap.yaml merge=binary 8 | npm-shrinkwrap.json merge=binary 9 | yarn.lock merge=binary 10 | 11 | # Rush's JSON config files use JavaScript-style code comments. The rule below prevents pedantic 12 | # syntax highlighters such as GitHub's from highlighting these comments as errors. Your text editor 13 | # may also require a special configuration to allow comments in JSON. 14 | # 15 | # For more information, see this issue: https://github.com/microsoft/rushstack/issues/1088 16 | # 17 | *.json text=auto linguist-language=JSON-with-Comments 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [develop, feature/*] 9 | pull_request: 10 | branches: [develop] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | name: Build on node ${{ matrix.node-version }} and ${{ matrix.os }} 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [windows-latest, ubuntu-latest] 23 | node-version: [12.x, 14.x] 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | # - uses: actions/cache@v2 28 | # with: 29 | # path: 'node_modules' 30 | # key: ${{ runner.os }}-node_modules-${{ hashFiles('package-lock.json') }} 31 | - name: Use Node.js ${{ matrix.node-version }} 32 | uses: actions/setup-node@v1 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | - run: npm install 36 | - run: npm run build 37 | env: 38 | CI: true 39 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: 'CodeQL' 7 | 8 | on: 9 | push: 10 | branches: [develop, feature/*] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [develop] 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | # Override automatic language detection by changing the below list 24 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 25 | language: ['javascript'] 26 | # Learn more... 27 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | with: 33 | # We must fetch at least the immediate parents so that if this is 34 | # a pull request then we can checkout the head. 35 | fetch-depth: 2 36 | 37 | # If this run was triggered by a pull request event, then checkout 38 | # the head of the pull request instead of the merge commit. 39 | - run: git checkout HEAD^2 40 | if: ${{ github.event_name == 'pull_request' }} 41 | 42 | # Initializes the CodeQL tools for scanning. 43 | - name: Initialize CodeQL 44 | uses: github/codeql-action/init@v1 45 | with: 46 | languages: ${{ matrix.language }} 47 | # If you wish to specify custom queries, you can do so here or in a config file. 48 | # By default, queries listed here will override any specified in a config file. 49 | # Prefix the list here with "+" to use these queries and those in the config file. 50 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@v1 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: [develop, feature/*] 9 | pull_request: 10 | branches: [develop] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | test: 15 | name: Test on node ${{ matrix.node-version }} and ${{ matrix.os }} 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [windows-latest, ubuntu-latest] 23 | node-version: [12.x, 14.x] 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | # - uses: actions/cache@v2 28 | # with: 29 | # path: 'node_modules' 30 | # key: ${{ runner.os }}-node_modules-${{ hashFiles('package-lock.json') }} 31 | - name: Use Node.js ${{ matrix.node-version }} 32 | uses: actions/setup-node@v1 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | - run: npm install 36 | - run: npm run test:coverage 37 | - name: Upload coverage to Codecov 38 | uses: codecov/codecov-action@v1 39 | 40 | env: 41 | CI: true 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | dist 9 | coverage 10 | .vscode 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 known-as-bmf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This hook is designed to be a proxy to the main [HubConnection](https://docs.microsoft.com/fr-fr/javascript/api/@aspnet/signalr/hubconnection?view=signalr-js-latest) capabilities. 2 | 3 | - [invoke](https://docs.microsoft.com/fr-fr/javascript/api/@aspnet/signalr/hubconnection?view=signalr-js-latest#invoke) 4 | - [send](https://docs.microsoft.com/fr-fr/javascript/api/@aspnet/signalr/hubconnection?view=signalr-js-latest#send) 5 | - [on](https://docs.microsoft.com/fr-fr/javascript/api/@aspnet/signalr/hubconnection?view=signalr-js-latest#on) / [off](https://docs.microsoft.com/fr-fr/javascript/api/@aspnet/signalr/hubconnection?view=signalr-js-latest#off) 6 | 7 | [![Build Status](https://travis-ci.org/known-as-bmf/react-signalr.svg?branch=master)](https://travis-ci.org/known-as-bmf/react-signalr) 8 | [![Known Vulnerabilities](https://snyk.io//test/github/known-as-bmf/react-signalr/badge.svg?targetFile=package.json)](https://snyk.io//test/github/known-as-bmf/react-signalr?targetFile=package.json) 9 | 10 | ## Installation 11 | 12 | `npm install --save @known-as-bmf/react-signalr` 13 | 14 | You also need react (>= 16.8) and rxjs (>= 6) installed in your project. 15 | 16 | ## Usage 17 | 18 | ```ts 19 | const signalrEndpoint = 'https://...'; 20 | 21 | const MyComponent = () => { 22 | const [value, set] = useState(); 23 | 24 | const { send, on } = useSignalr(signalrEndpoint); 25 | 26 | useEffect(() => { 27 | const sub = on('myMethod').pipe(debounceTime(200)).subscribe(set); 28 | 29 | return () => sub.unsubscribe(); 30 | }, [on]); 31 | 32 | const notify = useCallback(() => { 33 | send('remoteMethod', { foo: 'bar' }); 34 | }, []); 35 | }; 36 | ``` 37 | 38 | Connections are cached, it means that if you open a connection to an url, further calls to `useSignalr` with the same url will use the same connection. 39 | 40 | When the last hook using a specific connection is unmounted, this connection is closed. 41 | 42 | ## API 43 | 44 | ### useSignalr 45 | 46 | ```ts 47 | /** 48 | * Hook used to interact with a signalr connection. 49 | * Parameter changes (`hubUrl`, `options`) are not taken into account and will not rerender. 50 | * @param hubUrl - The URL of the signalr hub endpoint to connect to. 51 | * @param options - Options object to pass to connection builder. 52 | * @returns An object containing methods to interact with the hub connection. 53 | */ 54 | function useSignalr( 55 | hubUrl: string, 56 | options?: IHttpConnectionOptions 57 | ): UseSignalrHookResult; 58 | ``` 59 | 60 | ```ts 61 | interface UseSignalrHookResult { 62 | /** 63 | * Proxy to `HubConnection.invoke`. 64 | * 65 | * @typeparam TResponse - The expected response type. 66 | * @param methodName - The name of the server method to invoke. 67 | * @param arg - The argument used to invoke the server method. 68 | * 69 | * @returns A promise that resolves what `HubConnection.invoke` would have resolved. 70 | * 71 | * @see https://docs.microsoft.com/fr-fr/javascript/api/%40aspnet/signalr/hubconnection?view=signalr-js-latest#invoke 72 | */ 73 | invoke: InvokeFunction; 74 | /** 75 | * Utility method used to subscribe to realtime events (`HubConnection.on`, `HubConnection.off`). 76 | * 77 | * @typeparam TMessage - The expected message type. 78 | * @param methodName - The name of the server method to subscribe to. 79 | * 80 | * @returns An observable that emits every time a realtime message is recieved. 81 | * 82 | * @see https://docs.microsoft.com/fr-fr/javascript/api/%40aspnet/signalr/hubconnection?view=signalr-js-latest#on 83 | * @see https://docs.microsoft.com/fr-fr/javascript/api/%40aspnet/signalr/hubconnection?view=signalr-js-latest#off 84 | */ 85 | on: OnFunction; 86 | /** 87 | * Proxy to `HubConnection.send` 88 | * 89 | * @param methodName - The name of the server method to invoke. 90 | * @param arg - The argument used to invoke the server method. 91 | * 92 | * @returns A promise that resolves when `HubConnection.send` would have resolved. 93 | * 94 | * @see https://docs.microsoft.com/fr-fr/javascript/api/%40aspnet/signalr/hubconnection?view=signalr-js-latest#send 95 | */ 96 | send: SendFunction; 97 | } 98 | ``` 99 | 100 | ### SendFunction 101 | 102 | ```ts 103 | type SendFunction = (methodName: string, arg?: unknown) => Promise; 104 | ``` 105 | 106 | ### InvokeFunction 107 | 108 | ```ts 109 | type InvokeFunction = ( 110 | methodName: string, 111 | arg?: unknown 112 | ) => Promise; 113 | ``` 114 | 115 | ### OnFunction 116 | 117 | ```ts 118 | type OnFunction = ( 119 | methodName: string 120 | ) => Observable; 121 | ``` 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@known-as-bmf/react-signalr", 3 | "version": "1.0.6", 4 | "author": "Julien Avert", 5 | "description": "Reack hook to wrap a connection to a signalr hub.", 6 | "keywords": [ 7 | "javascript", 8 | "react", 9 | "react hook", 10 | "signalr", 11 | "rxjs", 12 | "websocket" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/known-as-bmf/react-signalr.git" 17 | }, 18 | "homepage": "https://github.com/known-as-bmf/react-signalr", 19 | "bugs": "https://github.com/known-as-bmf/react-signalr/issues", 20 | "license": "MIT", 21 | "main": "dist/index.cjs.js", 22 | "module": "dist/index.es.js", 23 | "typings": "dist/index.d.ts", 24 | "files": [ 25 | "src", 26 | "dist" 27 | ], 28 | "scripts": { 29 | "start": "tsdx watch", 30 | "build": "tscli build", 31 | "test": "tscli test", 32 | "test:watch": "tscli test --watch", 33 | "test:coverage": "tscli test --coverage", 34 | "lint": "tscli lint", 35 | "prepublishOnly": "tscli build" 36 | }, 37 | "peerDependencies": { 38 | "react": ">=16.8.0", 39 | "rxjs": ">=6.6.0" 40 | }, 41 | "devDependencies": { 42 | "@known-as-bmf/eslint-config-bmf": "1.0.7", 43 | "@known-as-bmf/tscli": "0.1.9", 44 | "@testing-library/react": "11.2.6", 45 | "@testing-library/react-hooks": "5.1.1", 46 | "@types/jest": "26.0.22", 47 | "@types/react": "17.0.3", 48 | "@types/react-dom": "17.0.3", 49 | "eslint": "7.24.0", 50 | "husky": "4.3.8", 51 | "prettier": "2.2.1", 52 | "pretty-quick": "3.1.0", 53 | "react": "17.0.2", 54 | "react-dom": "17.0.2", 55 | "react-test-renderer": "17.0.2", 56 | "rxjs": "6.6.7", 57 | "tslib": "2.2.0", 58 | "typescript": "4.2.4" 59 | }, 60 | "dependencies": { 61 | "@known-as-bmf/store": "3.0.7", 62 | "@microsoft/signalr": "5.0.5" 63 | }, 64 | "husky": { 65 | "hooks": { 66 | "pre-commit": "tscli lint --fix && pretty-quick --staged" 67 | } 68 | }, 69 | "prettier": { 70 | "printWidth": 80, 71 | "semi": true, 72 | "singleQuote": true, 73 | "trailingComma": "es5", 74 | "arrowParens": "avoid", 75 | "endOfLine": "auto" 76 | }, 77 | "publishConfig": { 78 | "access": "public" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { HubConnection } from '@microsoft/signalr'; 2 | import { Observable } from 'rxjs'; 3 | import { of, swap, deref, Store } from '@known-as-bmf/store'; 4 | 5 | interface ConnectionCacheState { 6 | [hubUrl: string]: Observable; 7 | } 8 | 9 | const connectionCacheStore: Store = of< 10 | ConnectionCacheState 11 | >({}); 12 | 13 | export const cache = (hubUrl: string, entry: Observable): void => 14 | swap(connectionCacheStore, state => { 15 | state[hubUrl] = entry; 16 | return state; 17 | }); 18 | 19 | export const lookup = (hubUrl: string): Observable => { 20 | const { [hubUrl]: entry } = deref(connectionCacheStore); 21 | return entry; 22 | }; 23 | 24 | export const invalidate = (hubUrl: string): void => 25 | swap(connectionCacheStore, state => { 26 | delete state[hubUrl]; 27 | return state; 28 | }); 29 | -------------------------------------------------------------------------------- /src/createConnection.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HubConnectionBuilder, 3 | IHttpConnectionOptions, 4 | HubConnection, 5 | } from '@microsoft/signalr'; 6 | 7 | export type HubConnectionBuilderDelegate = ( 8 | builder: HubConnectionBuilder 9 | ) => HubConnectionBuilder; 10 | 11 | /** 12 | * Creates a signalr hub connection. 13 | * @param url - The URL of the signalr hub endpoint to connect to. 14 | * @param options - Options object to pass to connection builder. 15 | * @param delegate - A delegate to further customize the HubConnection. 16 | */ 17 | export const createConnection = ( 18 | url: string, 19 | options: IHttpConnectionOptions = {}, 20 | delegate: HubConnectionBuilderDelegate = b => b 21 | ): HubConnection => 22 | delegate(new HubConnectionBuilder()).withUrl(url, options).build(); 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useSignalr } from './useSignalr'; 2 | -------------------------------------------------------------------------------- /src/useSignalr.ts: -------------------------------------------------------------------------------- 1 | import { fromEventPattern, Observable } from 'rxjs'; 2 | import { HubConnection, IHttpConnectionOptions } from '@microsoft/signalr'; 3 | import { shareReplay, switchMap, share, take } from 'rxjs/operators'; 4 | import { useCallback, useEffect, useMemo } from 'react'; 5 | 6 | import { 7 | createConnection, 8 | HubConnectionBuilderDelegate, 9 | } from './createConnection'; 10 | import { lookup, invalidate, cache } from './cache'; 11 | 12 | type SendFunction = (methodName: string, arg?: unknown) => Promise; 13 | type InvokeFunction = ( 14 | methodName: string, 15 | arg?: unknown 16 | ) => Promise; 17 | type OnFunction = ( 18 | methodName: string 19 | ) => Observable; 20 | 21 | interface UseSignalrHookResult { 22 | /** 23 | * Proxy to `HubConnection.invoke`. 24 | * 25 | * @typeparam TResponse - The expected response type. 26 | * @param methodName - The name of the server method to invoke. 27 | * @param arg - The argument used to invoke the server method. If no arg is passed or the value passed is undefined, nothing will be sent to the SignalR endpoint. 28 | * 29 | * @returns A promise that resolves what `HubConnection.invoke` would have resolved. 30 | * 31 | * @see https://docs.microsoft.com/fr-fr/javascript/api/%40aspnet/signalr/hubconnection?view=signalr-js-latest#invoke 32 | */ 33 | invoke: InvokeFunction; 34 | /** 35 | * Utility method used to subscribe to realtime events (`HubConnection.on`, `HubConnection.off`). 36 | * 37 | * @typeparam TMessage - The expected message type. 38 | * @param methodName - The name of the server method to subscribe to. 39 | * 40 | * @returns An observable that emits every time a realtime message is recieved. 41 | * 42 | * @see https://docs.microsoft.com/fr-fr/javascript/api/%40aspnet/signalr/hubconnection?view=signalr-js-latest#on 43 | * @see https://docs.microsoft.com/fr-fr/javascript/api/%40aspnet/signalr/hubconnection?view=signalr-js-latest#off 44 | */ 45 | on: OnFunction; 46 | /** 47 | * Proxy to `HubConnection.send` 48 | * 49 | * @param methodName - The name of the server method to invoke. 50 | * @param arg - The argument used to invoke the server method. If no arg is passed or the value passed is undefined, nothing will be sent to the SignalR endpoint. 51 | * 52 | * @returns A promise that resolves when `HubConnection.send` would have resolved. 53 | * 54 | * @see https://docs.microsoft.com/fr-fr/javascript/api/%40aspnet/signalr/hubconnection?view=signalr-js-latest#send 55 | */ 56 | send: SendFunction; 57 | } 58 | 59 | function getOrSetupConnection( 60 | hubUrl: string, 61 | options?: IHttpConnectionOptions, 62 | delegate?: HubConnectionBuilderDelegate 63 | ): Observable { 64 | // find if a connection is already cached for this hub 65 | let connection$ = lookup(hubUrl); 66 | 67 | if (!connection$) { 68 | // if no connection is established, create one and wrap it in an shared replay observable 69 | connection$ = new Observable(observer => { 70 | const connection = createConnection(hubUrl, options, delegate); 71 | 72 | // when the connection closes 73 | connection.onclose(() => { 74 | // remove the connection from the cache 75 | invalidate(hubUrl); 76 | // close the observable (trigger the teardown) 77 | observer.complete(); 78 | }); 79 | 80 | // start the connection and emit to the observable when the connection is ready 81 | void connection.start().then(() => { 82 | observer.next(connection); 83 | }); 84 | 85 | // teardown logic will be executed when there is no subscribers left (close the connection) 86 | return () => { 87 | void connection.stop(); 88 | }; 89 | }).pipe( 90 | // everyone subscribing will get the same connection 91 | // refCount is used to complete the observable when there is no subscribers left 92 | shareReplay({ refCount: true, bufferSize: 1 }) 93 | ); 94 | 95 | // add the connection to the cache 96 | cache(hubUrl, connection$); 97 | } 98 | 99 | return connection$; 100 | } 101 | 102 | /** 103 | * Hook used to interact with a signalr connection. 104 | * Parameter changes (`hubUrl`, `options`) are not taken into account and will not rerender. 105 | * 106 | * @param hubUrl - The URL of the signalr hub endpoint to connect to. 107 | * @param options - Options object to pass to connection builder. 108 | * 109 | * @returns An object containing methods to interact with the hub connection. 110 | */ 111 | export function useSignalr( 112 | hubUrl: string, 113 | options?: IHttpConnectionOptions, 114 | delegate?: HubConnectionBuilderDelegate 115 | ): UseSignalrHookResult { 116 | // ignore hubUrl, options & delegate changes, todo: useRef, useState ? 117 | const connection$ = useMemo( 118 | () => getOrSetupConnection(hubUrl, options, delegate), 119 | [] 120 | ); 121 | 122 | useEffect(() => { 123 | // used to maintain 1 active subscription while the hook is rendered 124 | const subscription = connection$.subscribe(); // todo: handle on complete (unexpected connection stop) ? 125 | 126 | return () => subscription.unsubscribe(); 127 | }, [connection$]); 128 | 129 | const send = useCallback( 130 | (methodName: string, arg?: unknown) => { 131 | return connection$ 132 | .pipe( 133 | // only take the current value of the observable 134 | take(1), 135 | // use the connection 136 | switchMap(connection => { 137 | if (arg === undefined) { 138 | // no argument provided 139 | return connection.send(methodName); 140 | } else { 141 | return connection.send(methodName, arg); 142 | } 143 | }) 144 | ) 145 | .toPromise(); 146 | }, 147 | [connection$] 148 | ); 149 | 150 | const invoke = useCallback( 151 | (methodName: string, arg?: unknown) => { 152 | return connection$ 153 | .pipe( 154 | // only take the current value of the observable 155 | take(1), 156 | // use the connection 157 | switchMap(connection => { 158 | if (arg === undefined) { 159 | // no argument provided 160 | return connection.invoke(methodName); 161 | } else { 162 | return connection.invoke(methodName, arg); 163 | } 164 | }) 165 | ) 166 | .toPromise(); 167 | }, 168 | [connection$] 169 | ); 170 | 171 | const on = useCallback( 172 | (methodName: string) => { 173 | return connection$ 174 | .pipe( 175 | // only take the current value of the observable 176 | take(1), 177 | // use the connection 178 | switchMap(connection => 179 | // create an observable from the server events 180 | fromEventPattern( 181 | (handler: (...args: unknown[]) => void) => 182 | connection.on(methodName, handler), 183 | (handler: (...args: unknown[]) => void) => 184 | connection.off(methodName, handler) 185 | ) 186 | ) 187 | ) 188 | .pipe(share()); 189 | }, 190 | [connection$] 191 | ); 192 | 193 | return { invoke, on, send }; 194 | } 195 | -------------------------------------------------------------------------------- /test/createConnection.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('@microsoft/signalr'); 2 | 3 | import * as signalr from '@microsoft/signalr'; 4 | 5 | import { createConnection } from '../src/createConnection'; 6 | 7 | const HubConnectionBuilderMock = (signalr.HubConnectionBuilder as unknown) as jest.MockedClass< 8 | typeof signalr.HubConnectionBuilder 9 | >; 10 | 11 | describe('createConnection', () => { 12 | let build: jest.Mock; 13 | let withUrl: jest.Mock< 14 | signalr.HubConnectionBuilder, 15 | [string, signalr.HttpTransportType | signalr.IHttpConnectionOptions] 16 | >; 17 | let configureLogging: jest.Mock< 18 | signalr.HubConnectionBuilder, 19 | [signalr.LogLevel | string | signalr.ILogger] 20 | >; 21 | 22 | beforeEach(() => { 23 | build = jest.fn(); 24 | withUrl = jest.fn().mockReturnThis(); 25 | configureLogging = jest.fn().mockReturnThis(); 26 | 27 | HubConnectionBuilderMock.mockImplementation(() => { 28 | return ({ 29 | build, 30 | withUrl, 31 | configureLogging, 32 | } as unknown) as signalr.HubConnectionBuilder; 33 | }); 34 | }); 35 | 36 | afterEach(() => { 37 | build.mockReset(); 38 | withUrl.mockReset(); 39 | configureLogging.mockReset(); 40 | HubConnectionBuilderMock.mockReset(); 41 | }); 42 | 43 | it('should be defined and a function', () => { 44 | expect(createConnection).toBeDefined(); 45 | expect(createConnection).toBeInstanceOf(Function); 46 | }); 47 | 48 | it('should call HubConnectionBuilder withUrl - no options', () => { 49 | createConnection('url'); 50 | 51 | expect(HubConnectionBuilderMock).toHaveBeenCalledTimes(1); 52 | expect(withUrl).toHaveBeenCalledTimes(1); 53 | expect(withUrl).toHaveBeenCalledWith('url', {}); 54 | expect(configureLogging).not.toHaveBeenCalled(); 55 | }); 56 | 57 | it('should call HubConnectionBuilder withUrl build - no options', () => { 58 | createConnection('url'); 59 | 60 | expect(HubConnectionBuilderMock).toHaveBeenCalledTimes(1); 61 | expect(withUrl).toHaveBeenCalledTimes(1); 62 | expect(withUrl).toHaveBeenCalledWith('url', {}); 63 | expect(build).toHaveBeenCalledTimes(1); 64 | expect(configureLogging).not.toHaveBeenCalled(); 65 | }); 66 | 67 | it('should call HubConnectionBuilder withUrl - options', () => { 68 | const options: signalr.IHttpConnectionOptions = {}; 69 | createConnection('url', options); 70 | 71 | expect(HubConnectionBuilderMock).toHaveBeenCalledTimes(1); 72 | expect(withUrl).toHaveBeenCalledTimes(1); 73 | expect(withUrl).toHaveBeenCalledWith('url', options); 74 | expect(configureLogging).not.toHaveBeenCalled(); 75 | }); 76 | 77 | it('should call HubConnectionBuilder withUrl build - options', () => { 78 | const options: signalr.IHttpConnectionOptions = {}; 79 | createConnection('url', options); 80 | 81 | expect(HubConnectionBuilderMock).toHaveBeenCalledTimes(1); 82 | expect(withUrl).toHaveBeenCalledTimes(1); 83 | expect(withUrl).toHaveBeenCalledWith('url', options); 84 | expect(build).toHaveBeenCalledTimes(1); 85 | expect(configureLogging).not.toHaveBeenCalled(); 86 | }); 87 | 88 | it('should use the provided HubConnectionBuilder delegate', () => { 89 | const options: signalr.IHttpConnectionOptions = {}; 90 | const delegate = jest.fn((builder: signalr.HubConnectionBuilder) => 91 | builder.configureLogging(signalr.LogLevel.Error) 92 | ); 93 | 94 | createConnection('url', options, delegate); 95 | 96 | expect(HubConnectionBuilderMock).toHaveBeenCalledTimes(1); 97 | expect(delegate).toHaveBeenCalledTimes(1); 98 | expect(configureLogging).toHaveBeenCalledTimes(1); 99 | expect(configureLogging).toHaveBeenCalledWith(signalr.LogLevel.Error); 100 | expect(withUrl).toHaveBeenCalledTimes(1); 101 | expect(withUrl).toHaveBeenCalledWith('url', options); 102 | expect(build).toHaveBeenCalledTimes(1); 103 | }); 104 | 105 | it('should override withUrl when using a HubConnectionBuilder delegate', () => { 106 | const options: signalr.IHttpConnectionOptions = {}; 107 | const delegate = jest.fn((builder: signalr.HubConnectionBuilder) => 108 | builder.configureLogging(signalr.LogLevel.Error).withUrl('dummy') 109 | ); 110 | 111 | createConnection('url', options, delegate); 112 | 113 | expect(HubConnectionBuilderMock).toHaveBeenCalledTimes(1); 114 | expect(delegate).toHaveBeenCalledTimes(1); 115 | expect(configureLogging).toHaveBeenCalledTimes(1); 116 | expect(configureLogging).toHaveBeenCalledWith(signalr.LogLevel.Error); 117 | expect(withUrl).toHaveBeenCalledTimes(2); 118 | expect(withUrl).toHaveBeenLastCalledWith('url', options); 119 | expect(build).toHaveBeenCalledTimes(1); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/useSignalr.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('../src/createConnection'); 2 | 3 | import { HubConnection, IHttpConnectionOptions } from '@microsoft/signalr'; 4 | import { renderHook, act } from '@testing-library/react-hooks'; 5 | import { Subscription } from 'rxjs'; 6 | 7 | import { useSignalr } from '../src'; 8 | import * as createConnection from '../src/createConnection'; 9 | 10 | const createConnectionMock = createConnection.createConnection as jest.Mock< 11 | HubConnection, 12 | [string, IHttpConnectionOptions | undefined] 13 | >; 14 | 15 | // we have to use delays for certain async stuff... TODO: find a way to remove this hack. 16 | // const delay = (duration: number = 0) => new Promise(res => setTimeout(res, duration)); 17 | 18 | describe('useSignalr', () => { 19 | let start: jest.Mock, []>; 20 | let stop: jest.Mock, []>; 21 | let onclose: jest.Mock void]>; 22 | 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | let on: jest.Mock void]>; 25 | let off: jest.Mock; 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | let send: jest.Mock, [string, ...any[]]>; 28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 29 | let invoke: jest.Mock, [string, ...any[]]>; 30 | 31 | beforeEach(() => { 32 | start = jest.fn(async () => { 33 | /** noop */ 34 | }); 35 | stop = jest.fn(async () => { 36 | /** noop */ 37 | }); 38 | onclose = jest.fn(); 39 | 40 | on = jest.fn(); 41 | off = jest.fn(); 42 | send = jest.fn(async (_, ...__) => { 43 | /** noop */ 44 | }); 45 | invoke = jest.fn(async (_, ...__) => { 46 | /** noop */ 47 | }); 48 | 49 | createConnectionMock.mockReturnValue(({ 50 | start, 51 | onclose, 52 | stop, 53 | on, 54 | off, 55 | send, 56 | invoke, 57 | } as unknown) as HubConnection); 58 | }); 59 | 60 | afterEach(() => { 61 | createConnectionMock.mockReset(); 62 | 63 | start.mockReset(); 64 | stop.mockReset(); 65 | onclose.mockReset(); 66 | 67 | on.mockReset(); 68 | off.mockReset(); 69 | send.mockReset(); 70 | invoke.mockReset(); 71 | }); 72 | 73 | it('should be a function', () => { 74 | expect(useSignalr).toBeDefined(); 75 | expect(useSignalr).toBeInstanceOf(Function); 76 | }); 77 | 78 | describe('renders without crashing', () => { 79 | it('1 param', () => { 80 | const { result } = renderHook(() => useSignalr('url1')); 81 | 82 | expect(createConnectionMock).toHaveBeenCalledTimes(1); 83 | expect(createConnectionMock).toHaveBeenCalledWith( 84 | 'url1', 85 | undefined, 86 | undefined 87 | ); 88 | expect(result.current.on).toBeDefined(); 89 | expect(result.current.on).toBeInstanceOf(Function); 90 | expect(result.current.send).toBeDefined(); 91 | expect(result.current.send).toBeInstanceOf(Function); 92 | expect(result.current.invoke).toBeDefined(); 93 | expect(result.current.invoke).toBeInstanceOf(Function); 94 | }); 95 | 96 | it('2 params', () => { 97 | const options = {}; 98 | const { result } = renderHook(() => useSignalr('url2', options)); 99 | 100 | expect(createConnectionMock).toHaveBeenCalledTimes(1); 101 | expect(createConnectionMock).toHaveBeenCalledWith( 102 | 'url2', 103 | options, 104 | undefined 105 | ); 106 | expect(result.current.on).toBeDefined(); 107 | expect(result.current.on).toBeInstanceOf(Function); 108 | expect(result.current.send).toBeDefined(); 109 | expect(result.current.send).toBeInstanceOf(Function); 110 | expect(result.current.invoke).toBeDefined(); 111 | expect(result.current.invoke).toBeInstanceOf(Function); 112 | }); 113 | }); 114 | 115 | describe('connection management', () => { 116 | it('should start the connection', () => { 117 | const options = {}; 118 | 119 | renderHook(() => useSignalr('url2', options)); 120 | 121 | expect(start).toHaveBeenCalledTimes(1); 122 | }); 123 | 124 | it('should create a single connection', () => { 125 | const options = {}; 126 | 127 | renderHook(() => useSignalr('url2', options)); 128 | renderHook(() => useSignalr('url2', options)); 129 | renderHook(() => useSignalr('url2', options)); 130 | 131 | expect(createConnectionMock).toHaveBeenCalledTimes(1); 132 | expect(createConnectionMock).toHaveBeenCalledWith( 133 | 'url2', 134 | options, 135 | undefined 136 | ); 137 | }); 138 | 139 | it('should stop the connection on unmount', () => { 140 | const options = {}; 141 | 142 | const { unmount } = renderHook(() => useSignalr('url2', options)); 143 | 144 | unmount(); 145 | 146 | expect(stop).toHaveBeenCalledTimes(1); 147 | }); 148 | 149 | it('should register a onclose callback', () => { 150 | const options = {}; 151 | let teardownCallback: () => void = () => { 152 | /** noop */ 153 | }; 154 | 155 | onclose.mockImplementation((fn: () => void) => { 156 | teardownCallback = fn; 157 | }); 158 | 159 | renderHook(() => useSignalr('url2', options)); 160 | 161 | expect(onclose).toHaveBeenCalledTimes(1); 162 | expect(onclose).toHaveBeenCalledWith(expect.any(Function)); 163 | 164 | teardownCallback(); 165 | }); 166 | }); 167 | 168 | describe('send', () => { 169 | it('should call the HubConnection.send method with no arg', async () => { 170 | const options = {}; 171 | 172 | const { result } = renderHook(() => useSignalr('url2', options)); 173 | 174 | await act(() => result.current.send('test')); 175 | 176 | expect(send).toHaveBeenCalledTimes(1); 177 | expect(send).toHaveBeenCalledWith('test'); 178 | }); 179 | 180 | it('should call the HubConnection.send method with an arg', async () => { 181 | const options = {}; 182 | 183 | const { result } = renderHook(() => useSignalr('url2', options)); 184 | 185 | await act(() => result.current.send('test', 'arg')); 186 | 187 | expect(send).toHaveBeenCalledTimes(1); 188 | expect(send).toHaveBeenCalledWith('test', 'arg'); 189 | }); 190 | }); 191 | 192 | describe('invoke', () => { 193 | it('should call the HubConnection.invoke method with no arg', async () => { 194 | const options = {}; 195 | 196 | const { result } = renderHook(() => useSignalr('url2', options)); 197 | 198 | await act(() => result.current.invoke('test')); 199 | 200 | expect(invoke).toHaveBeenCalledTimes(1); 201 | expect(invoke).toHaveBeenCalledWith('test'); 202 | }); 203 | 204 | it('should call the HubConnection.invoke method with an arg', async () => { 205 | const options = {}; 206 | 207 | const { result } = renderHook(() => useSignalr('url2', options)); 208 | 209 | await act(() => result.current.invoke('test', 'arg')); 210 | 211 | expect(invoke).toHaveBeenCalledTimes(1); 212 | expect(invoke).toHaveBeenCalledWith('test', 'arg'); 213 | }); 214 | }); 215 | 216 | describe('on', () => { 217 | it('should do nothing if not subscribed', () => { 218 | const options = {}; 219 | 220 | const { result } = renderHook(() => useSignalr('url2', options)); 221 | 222 | result.current.on('test'); 223 | 224 | expect(on).not.toHaveBeenCalled(); 225 | }); 226 | 227 | it('should call HubConnection.on when subscribing', async () => { 228 | const options = {}; 229 | 230 | const { result } = renderHook(() => useSignalr('url2', options)); 231 | 232 | const obs$ = result.current.on('test'); 233 | await act(() => { 234 | obs$.subscribe(); 235 | return Promise.resolve(); 236 | }); 237 | 238 | expect(on).toHaveBeenCalledTimes(1); 239 | expect(on).toHaveBeenCalledWith('test', expect.any(Function)); 240 | }); 241 | 242 | it('should call HubConnection.off when unsubscribing', async () => { 243 | const options = {}; 244 | let subscription: Subscription; 245 | 246 | const { result } = renderHook(() => useSignalr('url2', options)); 247 | 248 | const obs$ = result.current.on('test'); 249 | await act(() => { 250 | subscription = obs$.subscribe(); 251 | return Promise.resolve(); 252 | }); 253 | 254 | await act(() => { 255 | subscription.unsubscribe(); 256 | return Promise.resolve(); 257 | }); 258 | 259 | expect(off).toHaveBeenCalledTimes(1); 260 | expect(off).toHaveBeenCalledWith('test', expect.any(Function)); 261 | }); 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------