├── .npmignore ├── src ├── symbols.ts ├── index.ts ├── utils │ └── action-queue.ts ├── tokens.ts ├── plugin.ts ├── config.ts └── service.ts ├── tsconfig.spec.json ├── .prettierrc ├── .editorconfig ├── rollup.config.js ├── tests ├── exports.spec.ts ├── plugin.spec.ts └── service.spec.ts ├── karma.conf.ts ├── tsconfig.json ├── .gitignore ├── .github └── workflows │ ├── npm-build.yml │ └── npm-publish.yml ├── .eslintrc.js ├── LICENSE ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /src/symbols.ts: -------------------------------------------------------------------------------- 1 | import { SignalRService } from './service'; 2 | import type { InjectionKey } from 'vue'; 3 | 4 | /** The injection key for the SignalR service */ 5 | export const SignalRSymbol: InjectionKey = 6 | Symbol('SignalRService'); 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { VueSignalR, useSignalR } from './plugin'; 2 | export { SignalRService } from './service'; 3 | export { SignalRSymbol } from './symbols'; 4 | 5 | export type { SignalRConfig } from './config'; 6 | export type { HubCommandToken, HubEventToken } from './tokens'; 7 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jasmine", "node"] 5 | }, 6 | "include": [ 7 | "src/**/*.ts", 8 | "tests/**/*.spec.ts" 9 | ], 10 | "exclude": [ 11 | "./node_modules", 12 | "./dist" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": true, 4 | "arrowParens": "avoid", 5 | "semi": true, 6 | "trailingComma": "none", 7 | "singleQuote": true, 8 | "quoteProps": "as-needed", 9 | "endOfLine": "auto", 10 | "overrides": [ 11 | { 12 | "files": "README.md", 13 | "options": { 14 | "useTabs": false 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/action-queue.ts: -------------------------------------------------------------------------------- 1 | type Action = () => void | Promise; 2 | 3 | export class ActionQueue { 4 | private actions: Action[] = []; 5 | 6 | enqueue(action: Action): void { 7 | this.actions.push(action); 8 | } 9 | 10 | resolve(): void { 11 | while (this.actions.length) { 12 | const action = this.actions.shift() as Action; 13 | action.call(this); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | end_of_line = crlf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | indent_style = tab 13 | indent_size = unset 14 | 15 | [*.md] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.yml] 20 | indent_style = space 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /src/tokens.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The name of a command to send to the SignalR server 3 | * @typeParam TMessage - The type of the payload to send 4 | * @typeParam TResponse - The type of the response when using "signalr.invoke". 5 | */ 6 | export interface HubCommandToken extends String {} 7 | 8 | /** 9 | * The name of an event sent by the SignalR server 10 | * @typeParam TMessage - The type of the payload sent by the server 11 | */ 12 | export interface HubEventToken extends String {} 13 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-ts'; 2 | import pkg from './package.json'; 3 | 4 | export default { 5 | input: 'src/index.ts', 6 | output: [ 7 | { 8 | file: pkg.main, 9 | format: 'cjs' 10 | }, 11 | { 12 | file: pkg.module, 13 | format: 'es' 14 | } 15 | ], 16 | external: [ 17 | ...Object.keys(pkg.dependencies || {}), 18 | ...Object.keys(pkg.peerDependencies || {}) 19 | ], 20 | plugins: [ 21 | typescript({ 22 | typescript: require('typescript') 23 | }) 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /tests/exports.spec.ts: -------------------------------------------------------------------------------- 1 | import * as Exports from '@/index'; 2 | import { SignalRSymbol } from '@/symbols'; 3 | import { SignalRService } from '@/service'; 4 | import { VueSignalR, useSignalR } from '@/plugin'; 5 | 6 | describe('public exports', () => { 7 | it('should export public members', () => { 8 | expect(Exports.VueSignalR).toBe(VueSignalR); 9 | expect(Exports.useSignalR).toBe(useSignalR); 10 | expect(Exports.SignalRService).toBe(SignalRService); 11 | expect(Exports.SignalRSymbol).toBe(SignalRSymbol); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /karma.conf.ts: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | basePath: './', 4 | files: ['./src/**/*.ts', 'tests/**/*.spec.ts'], 5 | preprocessors: { 6 | '**/*.ts': 'karma-typescript' 7 | }, 8 | frameworks: ['jasmine', 'karma-typescript'], 9 | karmaTypescriptConfig: { 10 | compilerOptions: { 11 | module: 'commonjs' 12 | }, 13 | tsconfig: './tsconfig.spec.json' 14 | }, 15 | browsers: ['Chrome'], 16 | reporters: ['progress', 'coverage', 'karma-typescript'], 17 | customLaunchers: { 18 | ChromeHeadlessCI: { 19 | base: 'ChromeHeadless', 20 | flags: ['--no-sandbox'] 21 | } 22 | } 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "esnext", 5 | "strict": true, 6 | "declaration": true, 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "outDir": "dist", 15 | "baseUrl": "./", 16 | "paths": { 17 | "@/*": ["./src/*"] 18 | } 19 | }, 20 | "include": [ 21 | "src/index.ts", 22 | "tests/**/*.spec.ts" 23 | ], 24 | "exclude": [ 25 | "./node_modules", 26 | "./dist" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | .idea 40 | 41 | dist -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { SignalRConfig } from './config'; 2 | import { App, inject } from 'vue'; 3 | import { SignalRService } from './service'; 4 | import { SignalRSymbol } from './symbols'; 5 | import { HubConnectionBuilder } from '@microsoft/signalr'; 6 | 7 | /** The SignalR Plugin for Vue JS */ 8 | export const VueSignalR = { 9 | install(app: App, options: SignalRConfig): void { 10 | const service = new SignalRService(options, new HubConnectionBuilder()); 11 | 12 | app.provide(SignalRSymbol, service); 13 | 14 | service.init(); 15 | } 16 | }; 17 | 18 | /** Inject the SignalR service */ 19 | export function useSignalR(): SignalRService { 20 | const signalr = inject(SignalRSymbol); 21 | 22 | if (!signalr) { 23 | throw new Error('Failed to inject SignalR'); 24 | } 25 | 26 | return signalr; 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/npm-build.yml: -------------------------------------------------------------------------------- 1 | name: NPM Build 2 | on: 3 | pull_request: 4 | branches: [ main, release/* ] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout Repository 11 | uses: actions/checkout@v1 12 | 13 | - name: Setup NodeJS 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 16 17 | 18 | - name: Install Dependencies 19 | run: npm install 20 | 21 | - name: Install Peer Dependencies 22 | run: npm i vue@3.1.2 @microsoft/signalr@5.0.7 --no-save 23 | 24 | - name: Build Package 25 | run: npm run build 26 | 27 | - name: Run Tests 28 | run: npm test -- --single-run --browsers ChromeHeadlessCI 29 | 30 | - name: Run Linting 31 | run: npm run lint 32 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | env: { 5 | node: true 6 | }, 7 | plugins: ['@typescript-eslint', 'prettier'], 8 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 9 | parserOptions: { 10 | ecmaVersion: 2020 11 | }, 12 | rules: { 13 | 'prettier/prettier': 'error', 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | '@typescript-eslint/no-explicit-any': 'off', 17 | '@typescript-eslint/explicit-module-boundary-types': 'off', 18 | '@typescript-eslint/no-unused-vars': 'off', 19 | '@typescript-eslint/no-empty-interface': 'off' 20 | }, 21 | overrides: [ 22 | { 23 | files: [ 24 | '**/__tests__/*.{j,t}s?(x)', 25 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 26 | ], 27 | env: { 28 | mocha: true 29 | } 30 | } 31 | ] 32 | }; 33 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: NPM Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v1 13 | 14 | - name: Setup NodeJS 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 16 18 | 19 | - name: Install Dependencies 20 | run: npm ci 21 | 22 | - name: Install Peer Dependencies 23 | run: npm i vue@3.1.2 @microsoft/signalr@5.0.7 --no-save 24 | 25 | - name: Build Package 26 | run: npm run build 27 | 28 | - name: Run Tests 29 | run: npm test -- --single-run --browsers ChromeHeadlessCI 30 | 31 | - name: Run Linting 32 | run: npm run lint 33 | 34 | - name: Publish to NPM 35 | uses: JS-DevTools/npm-publish@v1 36 | with: 37 | token: ${{ secrets.NPM_TOKEN }} 38 | access: public 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Quangdao Nguyen 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@quangdao/vue-signalr", 3 | "private": false, 4 | "version": "1.0.1", 5 | "description": "SignalR plugin for Vue 3", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/quangdaon/vue-signalr.git" 9 | }, 10 | "main": "dist/index.js", 11 | "module": "dist/index.es.js", 12 | "types": "dist/index.d.ts", 13 | "scripts": { 14 | "build": "rollup -c", 15 | "test": "karma start", 16 | "lint": "eslint ./src/*" 17 | }, 18 | "keywords": [ 19 | "vue", 20 | "signalr", 21 | "websocket" 22 | ], 23 | "author": "Quangdao Nguyen", 24 | "license": "MIT", 25 | "peerDependencies": { 26 | "@microsoft/signalr": ">=5 <7", 27 | "vue": ">=3 <4" 28 | }, 29 | "devDependencies": { 30 | "@types/jasmine": "^3.8.2", 31 | "@types/node": "^16.7.4", 32 | "@typescript-eslint/eslint-plugin": "^5.7.0", 33 | "@typescript-eslint/parser": "^5.7.0", 34 | "eslint": "^8.5.0", 35 | "eslint-plugin-prettier": "^4.0.0", 36 | "jasmine-core": "^3.9.0", 37 | "karma": "^6.3.4", 38 | "karma-chrome-launcher": "^3.1.0", 39 | "karma-coverage": "^2.0.3", 40 | "karma-jasmine": "^4.0.1", 41 | "karma-typescript": "^5.5.2", 42 | "rollup": "^2.56.2", 43 | "rollup-plugin-ts": "^2.0.4", 44 | "tslib": "^2.3.1", 45 | "typescript": "^4.1.6" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HubConnectionBuilder, 3 | IHttpConnectionOptions 4 | } from '@microsoft/signalr'; 5 | 6 | export interface SignalRConfig { 7 | /** The address to your SignalR server */ 8 | url: string; 9 | 10 | /** Callback to trigger when the connection is lost */ 11 | disconnected?: () => void; 12 | 13 | /** Callback to trigger when the connection is reestablished */ 14 | reconnected?: () => void; 15 | 16 | /** 17 | * Function returning either the an access token to pass to every 18 | * command or a promise that returns the token 19 | * @example 20 | * accessTokenFactory: () => '' 21 | * @example 22 | * accessTokenFactory: authService.getAccessToken */ 23 | accessTokenFactory?: () => string | Promise; 24 | 25 | /** 26 | * Hook to modify the connection builder before the connection is built 27 | * @param builder The connection builder 28 | * @param options The connection builder options 29 | * @example 30 | * prebuild(builder: HubConnectionBuilder) { 31 | * builder.configureLogging(LogLevel.Information) 32 | * } 33 | */ 34 | prebuild?: ( 35 | builder: HubConnectionBuilder, 36 | options: IHttpConnectionOptions 37 | ) => void; 38 | 39 | /** 40 | * When true, the connection will automatically attempt to reconnect 41 | * @default false 42 | */ 43 | automaticReconnect?: boolean; 44 | 45 | /** 46 | * When true, events will automatically be unsubscribed when a component is destroyed 47 | * @default true 48 | */ 49 | automaticUnsubscribe?: boolean; 50 | } 51 | -------------------------------------------------------------------------------- /tests/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { SignalRSymbol } from '@/symbols'; 2 | import { useSignalR, VueSignalR } from '@/plugin'; 3 | import { SignalRConfig } from '@/config'; 4 | import * as Vue from 'vue'; 5 | import * as Services from '@/service'; 6 | 7 | describe('SignalR Vue Plugin', () => { 8 | let mockApp: jasmine.SpyObj; 9 | let mockOptions: SignalRConfig; 10 | let mockSignalRService: jasmine.SpyObj; 11 | 12 | beforeEach(() => { 13 | mockSignalRService = jasmine.createSpyObj(['init']); 14 | mockApp = jasmine.createSpyObj(['provide']); 15 | mockOptions = { 16 | url: 'test-url' 17 | }; 18 | 19 | spyOn(Services, 'SignalRService').and.returnValue(mockSignalRService); 20 | spyOn(Vue, 'inject').and.returnValue(mockSignalRService); 21 | }); 22 | 23 | it('should be installable', () => { 24 | expect(() => VueSignalR.install(mockApp, mockOptions)).not.toThrow(); 25 | }); 26 | 27 | it('should provide the service', () => { 28 | VueSignalR.install(mockApp, mockOptions); 29 | 30 | expect(mockApp.provide).toHaveBeenCalledOnceWith(SignalRSymbol, mockSignalRService); 31 | }); 32 | 33 | it('should start the service', () => { 34 | VueSignalR.install(mockApp, mockOptions); 35 | 36 | expect(mockSignalRService.init).toHaveBeenCalledTimes(1); 37 | }); 38 | 39 | it('should expose a composable', () => { 40 | const service = useSignalR(); 41 | expect(Vue.inject as any).toHaveBeenCalledOnceWith(SignalRSymbol); 42 | expect(service).toBe(mockSignalRService); 43 | }); 44 | 45 | it('should throw error if service is not injectable', () => { 46 | (Vue.inject as jasmine.Spy).and.returnValue(undefined); 47 | expect(() => useSignalR()).toThrowError('Failed to inject SignalR'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue SignalR Plugin 2 | 3 | [![NPM Install](https://nodei.co/npm/@quangdao/vue-signalr.png?mini=true)](https://www.npmjs.com/package/@quangdao/vue-signalr) 4 | 5 | SignalR plugin for Vue 3. 6 | 7 | ## Quick Start 8 | 9 | > For more detailed instructions, check out the wiki at . 10 | 11 | ### Installation 12 | 13 | This package is available on npm via: 14 | 15 | ``` 16 | npm install --save @quangdao/vue-signalr 17 | ``` 18 | 19 | To install in Vue 3: 20 | 21 | ```typescript 22 | import { createApp } from 'vue'; 23 | import { VueSignalR } from '@quangdao/vue-signalr'; 24 | import App from './App.vue'; 25 | 26 | createApp(App) 27 | .use(VueSignalR, { url: 'http://localhost:5000/signalr' }) 28 | .mount('#app'); 29 | ``` 30 | 31 | ### Usage 32 | 33 | This plugin provides a composable function to inject the SignalR service. The service exposes a few methods from the SignalR connection to support your app. 34 | 35 | ```typescript 36 | import { inject } from 'vue'; 37 | import { useSignalR } from '@quangdao/vue-signalr'; 38 | 39 | interface MyObject { 40 | prop: string; 41 | } 42 | 43 | // Optional Tokens 44 | // Learn More: https://github.com/quangdaon/vue-signalr/wiki/03.-Usage#tokens 45 | const SendMessage: HubCommandToken = 'SendMessage'; 46 | const MessageReceived: HubEventToken = 'MessageReceived'; 47 | 48 | export default { 49 | setup() { 50 | // Inject the service 51 | const signalr = useSignalR(); 52 | 53 | // Listen to the "MessageReceived" event 54 | signalr.on(MessageReceived, () => doSomething()); 55 | 56 | // Send a "SendMessage" payload to the hub 57 | signalr.send(SendMessage, { prop: 'Hello world!' }); 58 | 59 | // Invoke "SendMessage" and wait for a response 60 | signalr.invoke(SendMessage, { prop: 'Hello world!' }) 61 | .then(() => doSomething()) 62 | } 63 | }; 64 | ``` 65 | -------------------------------------------------------------------------------- /src/service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HubConnection, 3 | HubConnectionBuilder, 4 | IHttpConnectionOptions 5 | } from '@microsoft/signalr'; 6 | import { onBeforeUnmount, Ref, ref } from 'vue'; 7 | import { ActionQueue } from './utils/action-queue'; 8 | import type { SignalRConfig } from './config'; 9 | import type { HubEventToken, HubCommandToken } from './tokens'; 10 | 11 | /** 12 | * A service to integrate SignalR with VueJS 13 | */ 14 | export class SignalRService { 15 | /** The current SignalR connection object */ 16 | public readonly connection: HubConnection; 17 | 18 | private options: SignalRConfig; 19 | private initiated = false; 20 | private connected = ref(false); 21 | 22 | private invokeQueue = new ActionQueue(); 23 | private successQueue = new ActionQueue(); 24 | 25 | constructor(options: SignalRConfig, connectionBuilder: HubConnectionBuilder) { 26 | this.options = { 27 | automaticUnsubscribe: true, 28 | ...options 29 | }; 30 | 31 | const connection = this.buildConnection(connectionBuilder); 32 | this.configureConnection(connection); 33 | this.connection = connection; 34 | } 35 | 36 | /** Start the connection; called automatically when the plugin is registered */ 37 | async init(): Promise { 38 | try { 39 | await this.connection.start(); 40 | 41 | this.initiated = true; 42 | this.connected.value = true; 43 | 44 | this.invokeQueue.resolve(); 45 | this.successQueue.resolve(); 46 | } catch { 47 | this.fail(); 48 | } 49 | } 50 | 51 | /** Set a callback to trigger when a connection to the hub is successfully established */ 52 | connectionSuccess(callback: () => void): void { 53 | if (this.initiated) { 54 | callback(); 55 | } else { 56 | this.successQueue.enqueue(callback); 57 | } 58 | } 59 | 60 | /** 61 | * Send a command to the SignalR hub 62 | * @param target The name or token of the command to send to 63 | * @param message The payload to send to the command 64 | * @returns a promise the resolves with the event returns a value 65 | */ 66 | invoke( 67 | target: HubCommandToken, 68 | message?: TMessage 69 | ): Promise { 70 | const invoke = () => 71 | message 72 | ? this.connection.invoke(target as string, message) 73 | : this.connection.invoke(target as string); 74 | 75 | return new Promise((res, rej) => { 76 | if (this.initiated) { 77 | invoke().then(res).catch(rej); 78 | } else { 79 | this.invokeQueue.enqueue(() => invoke().then(res).catch(rej)); 80 | } 81 | }); 82 | } 83 | 84 | /** 85 | * Send a command to the SignalR hub without awaiting a response 86 | * @param target The name or token of the command to send to 87 | * @param message The payload to send to the command 88 | */ 89 | send(target: HubCommandToken, message?: T): void { 90 | const send = () => 91 | message 92 | ? this.connection.send(target as string, message) 93 | : this.connection.send(target as string); 94 | 95 | if (this.initiated) { 96 | send(); 97 | } else { 98 | this.invokeQueue.enqueue(send); 99 | } 100 | } 101 | 102 | /** 103 | * Subscribe to an event on the hub 104 | * @param target The name or token of the event to listen to 105 | * @param callback The callback to trigger with the hub sends the event 106 | * @param autoUnsubscribe Override options.automaticUnsubscribe config 107 | */ 108 | on( 109 | target: HubEventToken, 110 | callback: (arg: T) => void, 111 | autoUnsubscribe = this.options.automaticUnsubscribe 112 | ): void { 113 | this.connection.on(target as string, callback); 114 | if (autoUnsubscribe) onBeforeUnmount(() => this.off(target, callback)); 115 | } 116 | 117 | /** 118 | * Unsubscribe from an event on the hub 119 | * @param target The name or token of the event to unsubscribe from 120 | * @param callback The specific callback to unsubscribe. If none is provided, all listeners on the target will be unsubscribed 121 | */ 122 | off(target: HubEventToken, callback?: (arg: T) => void): void { 123 | if (callback) { 124 | this.connection.off(target as string, callback); 125 | } else { 126 | this.connection.off(target as string); 127 | } 128 | } 129 | 130 | /** Get a reactive connection status */ 131 | getConnectionStatus(): Ref { 132 | return this.connected; 133 | } 134 | 135 | private buildConnection(builder: HubConnectionBuilder) { 136 | const options = this.options; 137 | const connOptions: IHttpConnectionOptions = {}; 138 | 139 | if (options.accessTokenFactory) { 140 | connOptions.accessTokenFactory = options.accessTokenFactory; 141 | } 142 | 143 | if (options.prebuild) options.prebuild(builder, connOptions); 144 | 145 | builder.withUrl(options.url, connOptions); 146 | 147 | if (options.automaticReconnect) builder.withAutomaticReconnect(); 148 | 149 | return builder.build(); 150 | } 151 | 152 | private configureConnection(connection: HubConnection) { 153 | connection.onreconnected(() => this.reconnect()); 154 | connection.onreconnecting(() => this.fail()); 155 | connection.onclose(() => this.fail()); 156 | } 157 | 158 | private reconnect() { 159 | this.connected.value = true; 160 | this.options.reconnected?.call(null); 161 | } 162 | 163 | private fail() { 164 | this.connected.value = false; 165 | this.options.disconnected?.call(null); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tests/service.spec.ts: -------------------------------------------------------------------------------- 1 | import { SignalRConfig } from '@/config'; 2 | import { 3 | HubConnection, 4 | HubConnectionBuilder, 5 | IHttpConnectionOptions, 6 | LogLevel 7 | } from '@microsoft/signalr'; 8 | import { SignalRService } from '@/service'; 9 | 10 | import * as Vue from 'vue'; 11 | 12 | describe('SignalRService', () => { 13 | let mockOptions: SignalRConfig; 14 | let mockBuilder: jasmine.SpyObj; 15 | let mockConnection: jasmine.SpyObj; 16 | let onBeforeUnmountSpy: jasmine.Spy; 17 | 18 | beforeEach(() => { 19 | mockOptions = { url: 'fake-url' }; 20 | mockConnection = jasmine.createSpyObj([ 21 | 'onreconnected', 22 | 'onreconnecting', 23 | 'onclose', 24 | 'start', 25 | 'invoke', 26 | 'send', 27 | 'on', 28 | 'off' 29 | ]); 30 | mockConnection.start.and.returnValue(Promise.resolve()); 31 | mockConnection.invoke.and.returnValue(new Promise(res => res)); 32 | 33 | mockBuilder = jasmine.createSpyObj([ 34 | 'withUrl', 35 | 'withAutomaticReconnect', 36 | 'configureLogging', 37 | 'build' 38 | ]); 39 | mockBuilder.withUrl.and.returnValue(mockBuilder); 40 | mockBuilder.build.and.returnValue(mockConnection); 41 | 42 | onBeforeUnmountSpy = spyOn(Vue, 'onBeforeUnmount'); 43 | }); 44 | 45 | it('should be created', () => { 46 | const service = new SignalRService(mockOptions, mockBuilder); 47 | expect(service).toBeTruthy(); 48 | }); 49 | 50 | describe('configuration', () => { 51 | it('should connect to URL from configuration', () => { 52 | new SignalRService(mockOptions, mockBuilder); 53 | expect(mockBuilder.withUrl as any).toHaveBeenCalledOnceWith( 54 | 'fake-url', 55 | jasmine.anything() 56 | ); 57 | }); 58 | 59 | it('should connect to URL from configuration', () => { 60 | new SignalRService(mockOptions, mockBuilder); 61 | expect(mockBuilder.withUrl as any).toHaveBeenCalledOnceWith( 62 | 'fake-url', 63 | jasmine.anything() 64 | ); 65 | }); 66 | 67 | it('should not enable automatic reconnections by default', () => { 68 | new SignalRService(mockOptions, mockBuilder); 69 | expect(mockBuilder.withAutomaticReconnect).not.toHaveBeenCalled(); 70 | }); 71 | 72 | it('should enable automatic reconnections', () => { 73 | mockOptions.automaticReconnect = true; 74 | new SignalRService(mockOptions, mockBuilder); 75 | expect(mockBuilder.withAutomaticReconnect).toHaveBeenCalledTimes(1); 76 | }); 77 | 78 | it('should pass accessTokenFactory to builder options', () => { 79 | const factory = () => ''; 80 | mockOptions.accessTokenFactory = factory; 81 | new SignalRService(mockOptions, mockBuilder); 82 | expect(mockBuilder.withUrl).toHaveBeenCalledOnceWith( 83 | jasmine.any(String), 84 | jasmine.objectContaining({ 85 | accessTokenFactory: factory 86 | }) 87 | ); 88 | }); 89 | 90 | it('should call disconnect callback on close', () => { 91 | const disconnectSpy = jasmine.createSpy(); 92 | mockOptions.disconnected = disconnectSpy; 93 | mockConnection.onclose.and.callFake(callback => { 94 | callback(); 95 | 96 | expect(disconnectSpy).toHaveBeenCalledTimes(1); 97 | }); 98 | }); 99 | 100 | it('should not enable automatic reconnections by default', () => { 101 | new SignalRService(mockOptions, mockBuilder); 102 | expect(mockBuilder.withAutomaticReconnect).not.toHaveBeenCalled(); 103 | }); 104 | 105 | it('should enable automatic reconnections', () => { 106 | mockOptions.automaticReconnect = true; 107 | new SignalRService(mockOptions, mockBuilder); 108 | expect(mockBuilder.withAutomaticReconnect).toHaveBeenCalledTimes(1); 109 | }); 110 | 111 | it('should allow hooking into the builder', () => { 112 | mockOptions.prebuild = ( 113 | builder: HubConnectionBuilder, 114 | _: IHttpConnectionOptions 115 | ) => { 116 | builder.configureLogging(LogLevel.Information); 117 | }; 118 | 119 | new SignalRService(mockOptions, mockBuilder); 120 | expect(mockBuilder.configureLogging).toHaveBeenCalledOnceWith( 121 | LogLevel.Information 122 | ); 123 | }); 124 | 125 | it('should allow configuration of the builder options', () => { 126 | mockOptions.prebuild = ( 127 | _: HubConnectionBuilder, 128 | options: IHttpConnectionOptions 129 | ) => { 130 | options.headers = { 131 | boop: 'beep' 132 | }; 133 | }; 134 | 135 | new SignalRService(mockOptions, mockBuilder); 136 | expect(mockBuilder.withUrl).toHaveBeenCalledOnceWith( 137 | jasmine.any(String), 138 | jasmine.objectContaining({ 139 | headers: { 140 | boop: 'beep' 141 | } 142 | }) 143 | ); 144 | }); 145 | 146 | it('should call disconnect callback on close', () => { 147 | const disconnectSpy = jasmine.createSpy(); 148 | mockOptions.disconnected = disconnectSpy; 149 | mockConnection.onclose.and.callFake(callback => { 150 | callback(); 151 | 152 | expect(disconnectSpy).toHaveBeenCalledTimes(1); 153 | }); 154 | 155 | new SignalRService(mockOptions, mockBuilder); 156 | 157 | expect(mockConnection.onclose).toHaveBeenCalledTimes(1); 158 | }); 159 | 160 | it('should call fail silently on close if no disconnect callback', () => { 161 | mockOptions.disconnected = undefined; 162 | mockConnection.onclose.and.callFake(callback => { 163 | callback(); 164 | }); 165 | 166 | expect(() => new SignalRService(mockOptions, mockBuilder)).not.toThrow(); 167 | }); 168 | 169 | it('should set status to false on close', () => { 170 | const closeSpy = jasmine.createSpy(); 171 | 172 | mockConnection.onclose.and.callFake(callback => { 173 | closeSpy.and.callFake(() => { 174 | callback(); 175 | expect(status.value).toBeFalse(); 176 | }); 177 | }); 178 | 179 | const service = new SignalRService(mockOptions, mockBuilder); 180 | const status = service.getConnectionStatus(); 181 | 182 | status.value = true; 183 | closeSpy(); 184 | }); 185 | 186 | it('should call disconnect callback on reconnecting', () => { 187 | const disconnectSpy = jasmine.createSpy(); 188 | mockOptions.disconnected = disconnectSpy; 189 | mockConnection.onreconnecting.and.callFake(callback => { 190 | callback(); 191 | 192 | expect(disconnectSpy).toHaveBeenCalledTimes(1); 193 | }); 194 | 195 | new SignalRService(mockOptions, mockBuilder); 196 | 197 | expect(mockConnection.onreconnecting).toHaveBeenCalledTimes(1); 198 | }); 199 | 200 | it('should set status to false on reconnecting', () => { 201 | const reconnectSpy = jasmine.createSpy(); 202 | 203 | mockConnection.onreconnecting.and.callFake(callback => { 204 | reconnectSpy.and.callFake(() => { 205 | callback(); 206 | expect(status.value).toBeFalse(); 207 | }); 208 | }); 209 | 210 | const service = new SignalRService(mockOptions, mockBuilder); 211 | const status = service.getConnectionStatus(); 212 | 213 | status.value = true; 214 | reconnectSpy(); 215 | }); 216 | 217 | it('should call reconnect callback on reconnect', () => { 218 | const reconnectSpy = jasmine.createSpy(); 219 | mockOptions.reconnected = reconnectSpy; 220 | mockConnection.onreconnected.and.callFake(callback => { 221 | callback(); 222 | 223 | expect(reconnectSpy).toHaveBeenCalledTimes(1); 224 | }); 225 | 226 | new SignalRService(mockOptions, mockBuilder); 227 | 228 | expect(mockConnection.onreconnected).toHaveBeenCalledTimes(1); 229 | }); 230 | 231 | it('should set status to true on reconnect', () => { 232 | const reconnectSpy = jasmine.createSpy(); 233 | 234 | mockConnection.onreconnected.and.callFake(callback => { 235 | reconnectSpy.and.callFake(() => { 236 | callback(); 237 | expect(status.value).toBeTrue(); 238 | }); 239 | }); 240 | 241 | const service = new SignalRService(mockOptions, mockBuilder); 242 | const status = service.getConnectionStatus(); 243 | 244 | status.value = false; 245 | reconnectSpy(); 246 | }); 247 | }); 248 | 249 | describe('init', () => { 250 | it('should start the connection', () => { 251 | const service = new SignalRService(mockOptions, mockBuilder); 252 | service.init(); 253 | 254 | expect(mockConnection.start).toHaveBeenCalledTimes(1); 255 | }); 256 | 257 | it('should call disconnect callback when connection fails', done => { 258 | const disconnectSpy = jasmine.createSpy(); 259 | mockOptions.disconnected = disconnectSpy; 260 | mockConnection.start.and.returnValue(Promise.reject()); 261 | 262 | const service = new SignalRService(mockOptions, mockBuilder); 263 | service.init(); 264 | 265 | setTimeout(() => { 266 | expect(disconnectSpy).toHaveBeenCalledTimes(1); 267 | done(); 268 | }); 269 | }); 270 | 271 | it('should set connection status to true', done => { 272 | const service = new SignalRService(mockOptions, mockBuilder); 273 | const connected = service.getConnectionStatus(); 274 | 275 | expect(connected.value).toBeFalse(); 276 | service.init(); 277 | 278 | setTimeout(() => { 279 | expect(connected.value).toBeTrue(); 280 | done(); 281 | }); 282 | }); 283 | }); 284 | 285 | describe('connectionSuccess', () => { 286 | let callback: jasmine.Spy; 287 | let service: SignalRService; 288 | 289 | beforeEach(() => { 290 | callback = jasmine.createSpy(); 291 | service = new SignalRService(mockOptions, mockBuilder); 292 | }); 293 | 294 | it('should call callback immediately if connected', done => { 295 | service.init(); 296 | 297 | setTimeout(() => { 298 | service.connectionSuccess(callback); 299 | expect(callback).toHaveBeenCalledTimes(1); 300 | done(); 301 | }); 302 | }); 303 | 304 | it('should call callback after connection', done => { 305 | service.connectionSuccess(callback); 306 | expect(callback).not.toHaveBeenCalled(); 307 | 308 | service.init(); 309 | 310 | setTimeout(() => { 311 | expect(callback).toHaveBeenCalledTimes(1); 312 | done(); 313 | }); 314 | }); 315 | }); 316 | 317 | describe('invoke', () => { 318 | const message = `Hey ho, let's go!`; 319 | let service: SignalRService; 320 | 321 | beforeEach(() => { 322 | service = new SignalRService(mockOptions, mockBuilder); 323 | }); 324 | 325 | it('should invoke immediately if connected', done => { 326 | service.init(); 327 | 328 | setTimeout(() => { 329 | service.invoke('Command', message); 330 | expect(mockConnection.invoke).toHaveBeenCalledOnceWith( 331 | 'Command', 332 | message 333 | ); 334 | done(); 335 | }); 336 | }); 337 | 338 | it('should wait to invoke until after a successful connection', done => { 339 | service.invoke('Command', message); 340 | expect(mockConnection.invoke).not.toHaveBeenCalled(); 341 | 342 | service.init(); 343 | setTimeout(() => { 344 | expect(mockConnection.invoke).toHaveBeenCalledOnceWith( 345 | 'Command', 346 | message 347 | ); 348 | done(); 349 | }); 350 | }); 351 | 352 | it('should invoke without a message', done => { 353 | service.init(); 354 | 355 | setTimeout(() => { 356 | service.invoke('Command'); 357 | expect(mockConnection.invoke).toHaveBeenCalledOnceWith('Command'); 358 | expect(mockConnection.invoke).not.toHaveBeenCalledWith( 359 | 'Command', 360 | jasmine.anything 361 | ); 362 | done(); 363 | }); 364 | }); 365 | }); 366 | 367 | describe('send', () => { 368 | const message = 'We are the champions, my friends'; 369 | let service: SignalRService; 370 | 371 | beforeEach(() => { 372 | service = new SignalRService(mockOptions, mockBuilder); 373 | }); 374 | 375 | it('should send immediately if connected', done => { 376 | service.init(); 377 | 378 | setTimeout(() => { 379 | service.send('Command', message); 380 | expect(mockConnection.send).toHaveBeenCalledOnceWith( 381 | 'Command', 382 | message 383 | ); 384 | done(); 385 | }); 386 | }); 387 | 388 | it('should wait to send until after a successful connection', done => { 389 | service.send('Command', message); 390 | expect(mockConnection.send).not.toHaveBeenCalled(); 391 | 392 | service.init(); 393 | setTimeout(() => { 394 | expect(mockConnection.send).toHaveBeenCalledOnceWith( 395 | 'Command', 396 | message 397 | ); 398 | done(); 399 | }); 400 | }); 401 | 402 | it('should send without a message', done => { 403 | service.init(); 404 | 405 | setTimeout(() => { 406 | service.send('Command'); 407 | expect(mockConnection.send).toHaveBeenCalledOnceWith('Command'); 408 | expect(mockConnection.send).not.toHaveBeenCalledWith( 409 | 'Command', 410 | jasmine.anything 411 | ); 412 | done(); 413 | }); 414 | }); 415 | }); 416 | 417 | describe('on', () => { 418 | let service: SignalRService; 419 | 420 | beforeEach(() => { 421 | service = new SignalRService(mockOptions, mockBuilder); 422 | }); 423 | 424 | it('should call connection on method', () => { 425 | const callback = () => {}; 426 | service.on('Method', callback); 427 | 428 | expect(mockConnection.on).toHaveBeenCalledOnceWith('Method', callback); 429 | }); 430 | 431 | it('should unsubscribe on onmount', () => { 432 | const callback = () => {}; 433 | onBeforeUnmountSpy.and.callFake(((hook: () => any) => { 434 | hook(); 435 | expect(mockConnection.off).toHaveBeenCalledOnceWith('Method', callback); 436 | }) as any); 437 | 438 | service.on('Method', callback); 439 | 440 | expect(onBeforeUnmountSpy).toHaveBeenCalledTimes(1); 441 | }); 442 | 443 | it('should be able to opt out of auto unsubscribe when default is on', () => { 444 | const callback = () => {}; 445 | mockOptions.automaticUnsubscribe = true; 446 | onBeforeUnmountSpy.and.callFake(((hook: () => any) => { 447 | hook(); 448 | expect(mockConnection.off).toHaveBeenCalledOnceWith('Method', callback); 449 | }) as any); 450 | 451 | service.on('Method', callback, false); 452 | 453 | expect(onBeforeUnmountSpy).not.toHaveBeenCalled(); 454 | }); 455 | 456 | it('should be able to disable auto unsubscribe', () => { 457 | const callback = () => {}; 458 | mockOptions.automaticUnsubscribe = false; 459 | service = new SignalRService(mockOptions, mockBuilder); 460 | onBeforeUnmountSpy.and.callFake(((hook: () => any) => { 461 | hook(); 462 | expect(mockConnection.off).toHaveBeenCalledOnceWith('Method', callback); 463 | }) as any); 464 | 465 | service.on('Method', callback); 466 | 467 | expect(onBeforeUnmountSpy).not.toHaveBeenCalled(); 468 | }); 469 | 470 | it('should be able to opt in to auto unsubscribe when default is off', () => { 471 | const callback = () => {}; 472 | mockOptions.automaticUnsubscribe = false; 473 | onBeforeUnmountSpy.and.callFake(((hook: () => any) => { 474 | hook(); 475 | expect(mockConnection.off).toHaveBeenCalledOnceWith('Method', callback); 476 | }) as any); 477 | 478 | service.on('Method', callback, true); 479 | 480 | expect(onBeforeUnmountSpy).toHaveBeenCalledTimes(1); 481 | }); 482 | }); 483 | 484 | describe('off', () => { 485 | let service: SignalRService; 486 | 487 | beforeEach(() => { 488 | service = new SignalRService(mockOptions, mockBuilder); 489 | }); 490 | 491 | it('should call connection off method', () => { 492 | const callback = () => {}; 493 | service.off('Method', callback); 494 | 495 | expect(mockConnection.off).toHaveBeenCalledOnceWith('Method', callback); 496 | }); 497 | 498 | it('should allow no callback', () => { 499 | service.off('Method'); 500 | 501 | expect(mockConnection.off as any).toHaveBeenCalledOnceWith('Method'); 502 | }); 503 | }); 504 | }); 505 | --------------------------------------------------------------------------------