├── .eslintrc.js ├── CHANGELOG-ABLY.md ├── LICENSE.md ├── README.md ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── channel │ ├── ably-channel.ts │ ├── ably-presence-channel.ts │ ├── ably-private-channel.ts │ ├── ably │ │ ├── attach.ts │ │ ├── auth.ts │ │ ├── index.ts │ │ ├── token-request.ts │ │ └── utils.ts │ ├── channel.ts │ ├── index.ts │ ├── null-channel.ts │ ├── null-presence-channel.ts │ ├── null-private-channel.ts │ ├── presence-channel.ts │ ├── pusher-channel.ts │ ├── pusher-encrypted-private-channel.ts │ ├── pusher-presence-channel.ts │ ├── pusher-private-channel.ts │ ├── socketio-channel.ts │ ├── socketio-presence-channel.ts │ └── socketio-private-channel.ts ├── connector │ ├── ably-connector.ts │ ├── connector.ts │ ├── index.ts │ ├── null-connector.ts │ ├── pusher-connector.ts │ └── socketio-connector.ts ├── echo.ts ├── index.iife.ts └── util │ ├── event-formatter.ts │ └── index.ts ├── tsconfig.json └── typings ├── ably.ts └── index.d.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | parser: "@typescript-eslint/parser", 9 | plugins: ["@typescript-eslint"], 10 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 11 | rules: { 12 | "@typescript-eslint/ban-types": "off", 13 | "@typescript-eslint/no-explicit-any": "off", 14 | "prefer-const": "off", 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /CHANGELOG-ABLY.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v1.0.6](https://github.com/ably-forks/laravel-echo/tree/ably-echo-1.0.6) 4 | 5 | [Full Changelog](https://github.com/ably-forks/laravel-echo/compare/ably-echo-1.0.5...ably-echo-1.0.6) 6 | 7 | **Merged pull requests:** 8 | 9 | - \[ECO-4995\] Fix base64 url encoding/decoding [\#43](https://github.com/ably-forks/laravel-echo/pull/43) ([sacOO7](https://github.com/sacOO7)) 10 | - \[ECO-4977\] Fix broadcast to others [\#42](https://github.com/ably-forks/laravel-echo/pull/42) ([sacOO7](https://github.com/sacOO7)) 11 | 12 | ## [v1.0.5](https://github.com/ably-forks/laravel-echo/tree/ably-echo-1.0.5) 13 | 14 | [Full Changelog](https://github.com/ably-forks/laravel-echo/compare/ably-echo-1.0.4...ably-echo-1.0.5) 15 | 16 | **Closed issues:** 17 | 18 | - 401 Error on Subsequent Private/Public Channel Subscriptions [\#36](https://github.com/ably-forks/laravel-echo/issues/36) 19 | 20 | **Merged pull requests:** 21 | 22 | - Fix clientId mismatch on login [\#38](https://github.com/ably-forks/laravel-echo/pull/38) ([sacOO7](https://github.com/sacOO7)) 23 | 24 | ## [v1.0.4](https://github.com/ably-forks/laravel-echo/tree/ably-echo-1.0.4) 25 | 26 | [Full Changelog](https://github.com/ably-forks/laravel-echo/compare/ably-echo-1.0.3...ably-echo-1.0.4) 27 | 28 | **Closed issues:** 29 | 30 | - Update README to use ably-js version \< 2.0 [\#34](https://github.com/ably-forks/laravel-echo/issues/34) 31 | - customInternalAttach -\> authorize -\> errCallback undefined? [\#29](https://github.com/ably-forks/laravel-echo/issues/29) 32 | - Doesn't work with Laravel Sanctum [\#26](https://github.com/ably-forks/laravel-echo/issues/26) 33 | 34 | **Merged pull requests:** 35 | 36 | - Fix laravel echo version [\#33](https://github.com/ably-forks/laravel-echo/pull/33) ([sacOO7](https://github.com/sacOO7)) 37 | - Fix channel preattach errCallback null check [\#31](https://github.com/ably-forks/laravel-echo/pull/31) ([sacOO7](https://github.com/sacOO7)) 38 | - Added explicit section to work with laravel sanctum [\#27](https://github.com/ably-forks/laravel-echo/pull/27) ([sacOO7](https://github.com/sacOO7)) 39 | 40 | ## [v1.0.3](https://github.com/ably-forks/laravel-echo/tree/ably-echo-1.0.3) 41 | 42 | [Full Changelog](https://github.com/ably-forks/laravel-echo/compare/ably-echo-1.0.2...ably-echo-1.0.3) 43 | 44 | **Closed issues:** 45 | 46 | - Update/Sync project wrt origin [\#21](https://github.com/ably-forks/laravel-echo/issues/21) 47 | 48 | **Merged pull requests:** 49 | 50 | - Fix/sync fork [\#23](https://github.com/ably-forks/laravel-echo/pull/23) ([sacOO7](https://github.com/sacOO7)) 51 | - Update documentation [\#22](https://github.com/ably-forks/laravel-echo/pull/22) ([sacOO7](https://github.com/sacOO7)) 52 | - Update README ably clientOptions [\#20](https://github.com/ably-forks/laravel-echo/pull/20) ([sacOO7](https://github.com/sacOO7)) 53 | - Replace hosts with env. in clientOptions [\#19](https://github.com/ably-forks/laravel-echo/pull/19) ([sacOO7](https://github.com/sacOO7)) 54 | 55 | ## [v1.0.2](https://github.com/ably-forks/laravel-echo/tree/ably-echo-1.0.2) 56 | 57 | [Full Changelog](https://github.com/ably-forks/laravel-echo/compare/ably-echo-1.0.1...ably-echo-1.0.2) 58 | 59 | **Merged pull requests:** 60 | 61 | - Sync changes from upstream laravel echo [\#17](https://github.com/ably-forks/laravel-echo/pull/17) ([sacOO7](https://github.com/sacOO7)) 62 | - Fixed presence here method callback data set [\#16](https://github.com/ably-forks/laravel-echo/pull/16) ([sacOO7](https://github.com/sacOO7)) 63 | 64 | ## [v1.0.1](https://github.com/ably-forks/laravel-echo/tree/ably-echo-1.0.1) 65 | 66 | [Full Changelog](https://github.com/ably-forks/laravel-echo/compare/ably-echo-1.0.0...ably-echo-1.0.1) 67 | 68 | **Merged pull requests:** 69 | 70 | - Fix ably-echo import in README [\#11](https://github.com/ably-forks/laravel-echo/pull/11) ([sacOO7](https://github.com/sacOO7)) 71 | - Implement Ably-Agent header [\#9](https://github.com/ably-forks/laravel-echo/pull/9) ([sacOO7](https://github.com/sacOO7)) 72 | 73 | ## [v1.0.0](https://github.com/ably-forks/laravel-echo/tree/ably-echo-1.0.0) 74 | 75 | [Full Changelog](https://github.com/ably-forks/laravel-echo/compare/v1.11.7...ably-echo-1.0.0) 76 | 77 | **Merged pull requests:** 78 | 79 | - ably-js support for laravel-echo [\#2](https://github.com/ably-forks/laravel-echo/pull/2) ([sacOO7](https://github.com/sacOO7)) 80 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Logo Laravel Echo

2 | 3 |

4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 |

9 | 10 | ## Introduction 11 | 12 | In many modern web applications, WebSockets are used to implement realtime, live-updating user interfaces. When some data is updated on the server, a message is typically sent over a WebSocket connection to be handled by the client. This provides a more robust, efficient alternative to continually polling your application for changes. 13 | 14 | To assist you in building these types of applications, Laravel makes it easy to "broadcast" your events over a WebSocket connection. Broadcasting your Laravel events allows you to share the same event names between your server-side code and your client-side JavaScript application. 15 | 16 | Laravel Echo is a JavaScript library that makes it painless to subscribe to channels and listen for events broadcast by Laravel. You may install Echo via the NPM package manager. 17 | 18 | ## Official Documentation 19 | 20 | Documentation for Echo can be found on the [Laravel website](https://laravel.com/docs/broadcasting). 21 | 22 | ## Contributing 23 | 24 | Thank you for considering contributing to Echo! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 25 | 26 | ## Code of Conduct 27 | 28 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 29 | 30 | ## Security Vulnerabilities 31 | 32 | Please review [our security policy](https://github.com/laravel/echo/security/policy) on how to report security vulnerabilities. 33 | 34 | ## License 35 | 36 | Laravel Echo is open-sourced software licensed under the [MIT license](LICENSE.md). 37 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': 'ts-jest', 4 | }, 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ably/laravel-echo", 3 | "version": "1.0.6", 4 | "description": "Laravel Echo library for beautiful Ably integration", 5 | "keywords": [ 6 | "laravel", 7 | "ably" 8 | ], 9 | "homepage": "https://github.com/ably-forks/laravel-echo", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/ably-forks/laravel-echo" 13 | }, 14 | "license": "MIT", 15 | "author": { 16 | "name": "Taylor Otwell" 17 | }, 18 | "main": "dist/echo.common.js", 19 | "module": "dist/echo.js", 20 | "types": "dist/src/echo.d.ts", 21 | "scripts": { 22 | "build": "npm run compile && npm run declarations", 23 | "compile": "rollup -c", 24 | "declarations": "tsc --emitDeclarationOnly", 25 | "lint": "eslint --ext .js,.ts ./src ./tests", 26 | "prepublish": "npm run build", 27 | "use:ablyReadme": "move-cli 'README.md' 'base.README.md' && move-cli '.github/README.md' 'README.md'", 28 | "use:baseReadme": "move-cli 'README.md' '.github/README.md' && move-cli 'base.README.md' 'README.md'", 29 | "prepublishOnly": "run-s build use:ablyReadme", 30 | "postpublish": "npm run use:baseReadme", 31 | "release": "npm run test && standard-version && git push --follow-tags && npm publish", 32 | "test": "jest" 33 | }, 34 | "devDependencies": { 35 | "@babel/plugin-proposal-decorators": "^7.17.2", 36 | "@babel/plugin-proposal-export-namespace-from": "^7.16.7", 37 | "@babel/plugin-proposal-function-sent": "^7.16.7", 38 | "@babel/plugin-proposal-numeric-separator": "^7.16.7", 39 | "@babel/plugin-proposal-throw-expressions": "^7.16.7", 40 | "@babel/plugin-transform-object-assign": "^7.16.7", 41 | "@babel/preset-env": "^7.16.11", 42 | "@rollup/plugin-babel": "^5.3.1", 43 | "@types/jest": "^27.4.1", 44 | "@types/node": "^18.11.9", 45 | "@typescript-eslint/eslint-plugin": "^5.14.0", 46 | "@typescript-eslint/parser": "^5.14.0", 47 | "ably": "^1.2", 48 | "eslint": "^8.11.0", 49 | "jest": "^27.5.1", 50 | "jsonwebtoken": "^8.5.1", 51 | "move-cli": "^2.0.0", 52 | "npm-run-all": "^4.1.5", 53 | "rollup": "^2.70.1", 54 | "rollup-plugin-typescript2": "^0.31.2", 55 | "standard-version": "^9.3.2", 56 | "ts-jest": "^27.1.3", 57 | "tslib": "^2.3.1", 58 | "typescript": "^4.6.2", 59 | "wait-for-expect": "^3.0.2" 60 | }, 61 | "engines": { 62 | "node": ">=10" 63 | }, 64 | "peerDependencies": { 65 | "ably": "^1.2" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | 4 | const plugins = [ 5 | typescript(), 6 | babel({ 7 | babelHelpers: 'bundled', 8 | exclude: 'node_modules/**', 9 | extensions: ['.ts'], 10 | presets: ['@babel/preset-env'], 11 | plugins: [ 12 | ['@babel/plugin-proposal-decorators', { legacy: true }], 13 | '@babel/plugin-proposal-function-sent', 14 | '@babel/plugin-proposal-export-namespace-from', 15 | '@babel/plugin-proposal-numeric-separator', 16 | '@babel/plugin-proposal-throw-expressions', 17 | '@babel/plugin-transform-object-assign', 18 | ], 19 | }), 20 | ]; 21 | 22 | export default [ 23 | { 24 | input: './src/echo.ts', 25 | output: [ 26 | { file: './dist/echo.js', format: 'esm' }, 27 | { file: './dist/echo.common.js', format: 'cjs' }, 28 | ], 29 | plugins, 30 | }, 31 | { 32 | input: './src/index.iife.ts', 33 | output: [{ file: './dist/echo.iife.js', format: 'iife', name: 'Echo' }], 34 | plugins, 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /src/channel/ably-channel.ts: -------------------------------------------------------------------------------- 1 | import { AblyRealtime, AblyRealtimeChannel } from '../../typings/ably'; 2 | import { EventFormatter } from '../util'; 3 | import { Channel } from './channel'; 4 | 5 | /** 6 | * This class represents an Ably channel. 7 | */ 8 | export class AblyChannel extends Channel { 9 | /** 10 | * The Ably client instance. 11 | */ 12 | ably: AblyRealtime; 13 | 14 | /** 15 | * The name of the channel. 16 | */ 17 | name: string; 18 | 19 | /** 20 | * Channel options. 21 | */ 22 | options: any; 23 | 24 | /** 25 | * The event formatter. 26 | */ 27 | eventFormatter: EventFormatter; 28 | 29 | /** 30 | * The subscription of the channel. 31 | */ 32 | channel: AblyRealtimeChannel; 33 | 34 | /** 35 | * An array containing all registered subscribed listeners. 36 | */ 37 | subscribedListeners: Function[]; 38 | 39 | /** 40 | * An array containing all registered error listeners. 41 | */ 42 | errorListeners: Function[]; 43 | 44 | /** 45 | * Channel event subscribe callbacks, maps callback to modified implementation. 46 | */ 47 | callbacks: Map; 48 | 49 | /** 50 | * Create a new class instance. 51 | */ 52 | constructor(ably: any, name: string, options: any, autoSubscribe = true) { 53 | super(); 54 | 55 | this.name = name; 56 | this.ably = ably; 57 | this.options = options; 58 | this.eventFormatter = new EventFormatter(this.options.namespace); 59 | this.subscribedListeners = []; 60 | this.errorListeners = []; 61 | this.channel = ably.channels.get(name); 62 | this.callbacks = new Map(); 63 | 64 | if (autoSubscribe) { 65 | this.subscribe(); 66 | } 67 | } 68 | 69 | /** 70 | * Subscribe to an Ably channel. 71 | */ 72 | subscribe(): any { 73 | this.channel.on((stateChange) => { 74 | const { previous, current, reason } = stateChange; 75 | if (previous !== 'attached' && current == 'attached') { 76 | this.subscribedListeners.forEach((listener) => listener()); 77 | } else if (reason) { 78 | this._alertErrorListeners(stateChange); 79 | } 80 | }); 81 | this.channel.attach(this._alertErrorListeners); 82 | } 83 | 84 | /** 85 | * Unsubscribe from an Ably channel, unregister all callbacks and finally detach the channel 86 | */ 87 | unsubscribe(): void { 88 | this.channel.unsubscribe(); 89 | this.callbacks.clear(); 90 | this.unregisterError(); 91 | this.unregisterSubscribed(); 92 | this.channel.off(); 93 | this.channel.detach(); 94 | } 95 | 96 | /** 97 | * Listen for an event on the channel instance. 98 | */ 99 | listen(event: string, callback: Function): AblyChannel { 100 | this.callbacks.set(callback, ({ data, ...metaData }) => callback(data, metaData)); 101 | this.channel.subscribe(this.eventFormatter.format(event), this.callbacks.get(callback) as any); 102 | return this; 103 | } 104 | 105 | /** 106 | * Listen for all events on the channel instance. 107 | */ 108 | listenToAll(callback: Function): AblyChannel { 109 | this.callbacks.set(callback, ({ name, data, ...metaData }) => { 110 | let namespace = this.options.namespace.replace(/\./g, '\\'); 111 | 112 | let formattedEvent = name.startsWith(namespace) ? name.substring(namespace.length + 1) : '.' + name; 113 | 114 | callback(formattedEvent, data, metaData); 115 | }); 116 | this.channel.subscribe(this.callbacks.get(callback) as any); 117 | return this; 118 | } 119 | 120 | /** 121 | * Stop listening for an event on the channel instance. 122 | */ 123 | stopListening(event: string, callback?: Function): AblyChannel { 124 | if (callback) { 125 | this.channel.unsubscribe(this.eventFormatter.format(event), this.callbacks.get(callback) as any); 126 | this.callbacks.delete(callback); 127 | } else { 128 | this.channel.unsubscribe(this.eventFormatter.format(event)); 129 | } 130 | 131 | return this; 132 | } 133 | 134 | /** 135 | * Stop listening for all events on the channel instance. 136 | */ 137 | stopListeningToAll(callback?: Function): AblyChannel { 138 | if (callback) { 139 | this.channel.unsubscribe(this.callbacks.get(callback) as any); 140 | this.callbacks.delete(callback); 141 | } else { 142 | this.channel.unsubscribe(); 143 | } 144 | 145 | return this; 146 | } 147 | 148 | /** 149 | * Register a callback to be called anytime a subscription succeeds. 150 | */ 151 | subscribed(callback: Function): AblyChannel { 152 | this.subscribedListeners.push(callback); 153 | 154 | return this; 155 | } 156 | 157 | /** 158 | * Register a callback to be called anytime a subscription error occurs. 159 | */ 160 | error(callback: Function): AblyChannel { 161 | this.errorListeners.push(callback); 162 | 163 | return this; 164 | } 165 | 166 | /** 167 | * Unregisters given error callback from the listeners. 168 | * @param callback 169 | * @returns AblyChannel 170 | */ 171 | unregisterSubscribed(callback?: Function): AblyChannel { 172 | if (callback) { 173 | this.subscribedListeners = this.subscribedListeners.filter((s) => s != callback); 174 | } else { 175 | this.subscribedListeners = []; 176 | } 177 | 178 | return this; 179 | } 180 | 181 | /** 182 | * Unregisters given error callback from the listeners. 183 | * @param callback 184 | * @returns AblyChannel 185 | */ 186 | unregisterError(callback?: Function): AblyChannel { 187 | if (callback) { 188 | this.errorListeners = this.errorListeners.filter((e) => e != callback); 189 | } else { 190 | this.errorListeners = []; 191 | } 192 | 193 | return this; 194 | } 195 | 196 | _alertErrorListeners = (err: any) => { 197 | if (err) { 198 | this.errorListeners.forEach((listener) => listener(err)); 199 | } 200 | }; 201 | } 202 | -------------------------------------------------------------------------------- /src/channel/ably-presence-channel.ts: -------------------------------------------------------------------------------- 1 | import { AblyChannel } from './ably-channel'; 2 | import { AblyAuth } from './ably/auth'; 3 | import { PresenceChannel } from './presence-channel'; 4 | 5 | /** 6 | * This class represents an Ably presence channel. 7 | */ 8 | export class AblyPresenceChannel extends AblyChannel implements PresenceChannel { 9 | presenceData: any; 10 | 11 | constructor(ably: any, name: string, options: any, auth: AblyAuth) { 12 | super(ably, name, options, false); 13 | this.channel.on('failed', auth.onChannelFailed(this)); 14 | this.channel.on('attached', () => this.enter(this.presenceData, this._alertErrorListeners)); 15 | this.subscribe(); 16 | } 17 | 18 | unsubscribe(): void { 19 | this.leave(this.presenceData, this._alertErrorListeners); 20 | this.channel.presence.unsubscribe(); 21 | super.unsubscribe(); 22 | } 23 | 24 | /** 25 | * Register a callback to be called anytime the member list changes. 26 | */ 27 | here(callback: Function): AblyPresenceChannel { 28 | this.channel.presence.subscribe(['enter', 'update', 'leave'], () => 29 | this.channel.presence.get((err, members) => 30 | callback(members.map(({data}) => data), err) 31 | ) 32 | ); 33 | return this; 34 | } 35 | 36 | /** 37 | * Listen for someone joining the channel. 38 | */ 39 | joining(callback: Function): AblyPresenceChannel { 40 | this.channel.presence.subscribe(['enter', 'update'], ({ data, ...metaData }) => { 41 | callback(data, metaData); 42 | }); 43 | 44 | return this; 45 | } 46 | 47 | /** 48 | * Listen for someone leaving the channel. 49 | */ 50 | leaving(callback: Function): AblyPresenceChannel { 51 | this.channel.presence.subscribe('leave', ({ data, ...metaData }) => { 52 | callback(data, metaData); 53 | }); 54 | 55 | return this; 56 | } 57 | 58 | /** 59 | * Enter presence 60 | * @param data - Data to be published while entering the channel 61 | * @param callback - success/error callback (err) => {} 62 | * @returns AblyPresenceChannel 63 | */ 64 | enter(data: any, callback: Function): AblyPresenceChannel { 65 | this.channel.presence.enter(data, callback as any); 66 | 67 | return this; 68 | } 69 | 70 | /** 71 | * Leave presence 72 | * @param data - Data to be published while leaving the channel 73 | * @param callback - success/error callback (err) => {} 74 | * @returns AblyPresenceChannel 75 | */ 76 | leave(data: any, callback?: Function): AblyPresenceChannel { 77 | this.channel.presence.leave(data, callback as any); 78 | 79 | return this; 80 | } 81 | 82 | /** 83 | * Update presence 84 | * @param data - Update presence with data 85 | * @param callback - success/error callback (err) => {} 86 | * @returns AblyPresenceChannel 87 | */ 88 | update(data: any, callback: Function): AblyPresenceChannel { 89 | this.channel.presence.update(data, callback as any); 90 | 91 | return this; 92 | } 93 | 94 | /** 95 | * Send a whisper event to other clients in the channel. 96 | */ 97 | whisper(eventName: string, data: any, callback?: Function): AblyPresenceChannel { 98 | if (callback) { 99 | this.channel.publish(`client-${eventName}`, data, callback as any); 100 | } else { 101 | this.channel.publish(`client-${eventName}`, data); 102 | } 103 | return this; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/channel/ably-private-channel.ts: -------------------------------------------------------------------------------- 1 | import { AblyChannel } from './ably-channel'; 2 | import { AblyAuth } from './ably/auth'; 3 | 4 | export class AblyPrivateChannel extends AblyChannel { 5 | constructor(ably: any, name: string, options: any, auth: AblyAuth) { 6 | super(ably, name, options, false); 7 | this.channel.on('failed', auth.onChannelFailed(this)); 8 | this.subscribe(); 9 | } 10 | /** 11 | * Send a whisper event to other clients in the channel. 12 | */ 13 | whisper(eventName: string, data: any, callback?: Function): AblyPrivateChannel { 14 | if (callback) { 15 | this.channel.publish(`client-${eventName}`, data, callback as any); 16 | } else { 17 | this.channel.publish(`client-${eventName}`, data); 18 | } 19 | return this; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/channel/ably/attach.ts: -------------------------------------------------------------------------------- 1 | import { isNullOrUndefined } from './utils'; 2 | 3 | let channelAttachAuthorized = false; 4 | 5 | /** 6 | * Modifies existing channel attach with custom authz implementation 7 | */ 8 | export const beforeChannelAttach = (ablyClient, authorize: Function) => { 9 | const dummyRealtimeChannel = ablyClient.channels.get('dummy'); 10 | dummyRealtimeChannel.__proto__.authorizeChannel = authorize; 11 | if (channelAttachAuthorized) { 12 | return; 13 | } 14 | const internalAttach = dummyRealtimeChannel.__proto__._attach; 15 | if (isNullOrUndefined(internalAttach)) { 16 | console.warn('Failed to enable authorize for pre-attach, please check for right library version'); 17 | return; 18 | } 19 | function customInternalAttach(forceReattach, attachReason, errCallback) { 20 | if (this.authorizing) { 21 | return; 22 | } 23 | this.authorizing = true; 24 | const bindedInternalAttach = internalAttach.bind(this); 25 | 26 | this.authorizeChannel(this, (error) => { 27 | this.authorizing = false; 28 | if (error) { 29 | if (errCallback) { 30 | errCallback(error); 31 | } else { 32 | console.error(`channel ${this.name} auth error => ${error}`) 33 | } 34 | return; 35 | } else { 36 | bindedInternalAttach(forceReattach, attachReason, errCallback); 37 | } 38 | }); 39 | } 40 | dummyRealtimeChannel.__proto__._attach = customInternalAttach; 41 | channelAttachAuthorized = true; 42 | }; 43 | -------------------------------------------------------------------------------- /src/channel/ably/auth.ts: -------------------------------------------------------------------------------- 1 | import { beforeChannelAttach } from './attach'; 2 | import { toTokenDetails, parseJwt, fullUrl, httpRequestAsync } from './utils'; 3 | import { SequentialAuthTokenRequestExecuter } from './token-request'; 4 | import { AblyChannel } from '../ably-channel'; 5 | import { AblyConnector } from '../../connector/ably-connector'; 6 | import { AblyPresenceChannel } from '../ably-presence-channel'; 7 | import { AblyRealtime, AuthOptions, ChannelStateChange, ClientOptions, TokenDetails } from '../../../typings/ably'; 8 | 9 | export class AblyAuth { 10 | authEndpoint: string; 11 | authHeaders: any; 12 | authRequestExecuter: SequentialAuthTokenRequestExecuter; 13 | ablyConnector: AblyConnector; 14 | 15 | expiredAuthChannels = new Set(); 16 | setExpired = (channelName: string) => this.expiredAuthChannels.add(channelName); 17 | isExpired = (channelName: string) => this.expiredAuthChannels.has(channelName); 18 | removeExpired = (channelName: string) => this.expiredAuthChannels.delete(channelName); 19 | 20 | options: AuthOptions & Pick = { 21 | queryTime: true, 22 | useTokenAuth: true, 23 | authCallback: async (_, callback) => { 24 | try { 25 | const { token } = await this.authRequestExecuter.request(null); 26 | const tokenDetails = toTokenDetails(token); 27 | callback(null, tokenDetails); 28 | } catch (error) { 29 | callback(error, null); 30 | } 31 | }, 32 | echoMessages: false, // https://docs.ably.io/client-lib-development-guide/features/#TO3h 33 | }; 34 | 35 | requestToken = async (channelName: string, existingToken: string) => { 36 | let postData = JSON.stringify({ channel_name: channelName, token: existingToken }); 37 | let postOptions = { 38 | uri: this.authEndpoint, 39 | method: 'POST', 40 | headers: { 41 | Accept: 'application/json', 42 | 'Content-Type': 'application/json', 43 | 'Content-Length': postData.length, 44 | ...this.authHeaders, 45 | }, 46 | body: postData, 47 | }; 48 | return await httpRequestAsync(postOptions); 49 | }; 50 | 51 | constructor(ablyConnector: AblyConnector, options) { 52 | this.ablyConnector = ablyConnector; 53 | const { 54 | authEndpoint, 55 | auth: { headers }, 56 | token, 57 | requestTokenFn, 58 | } = options; 59 | this.authEndpoint = fullUrl(authEndpoint); 60 | this.authHeaders = headers; 61 | this.authRequestExecuter = new SequentialAuthTokenRequestExecuter(token, requestTokenFn ?? this.requestToken); 62 | } 63 | 64 | ablyClient = () => this.ablyConnector.ably as AblyRealtime | any; 65 | 66 | existingToken = () => this.ablyClient().auth.tokenDetails as TokenDetails; 67 | 68 | getChannel = name => this.ablyConnector.channels[name]; 69 | 70 | enableAuthorizeBeforeChannelAttach = () => { 71 | const ablyClient = this.ablyClient() 72 | ablyClient.auth.getTimestamp(this.options.queryTime, () => void 0); // generates serverTimeOffset in the background 73 | 74 | beforeChannelAttach(ablyClient, (realtimeChannel, errorCallback) => { 75 | const channelName = realtimeChannel.name; 76 | if (channelName.startsWith('public:')) { 77 | errorCallback(null); 78 | return; 79 | } 80 | 81 | // Use cached token if has channel capability and is not expired 82 | const tokenDetails = this.existingToken(); 83 | if (tokenDetails && !this.isExpired(channelName)) { 84 | const capability = parseJwt(tokenDetails.token).payload['x-ably-capability']; 85 | const tokenHasChannelCapability = capability.includes(`${channelName}"`); 86 | // checks with server time using offset, otherwise local time 87 | if (tokenHasChannelCapability && tokenDetails.expires > ablyClient.auth.getTimestampUsingOffset()) { 88 | errorCallback(null); 89 | return; 90 | } 91 | } 92 | 93 | // explicitly request token for given channel name 94 | this.authRequestExecuter 95 | .request(channelName) 96 | .then(({ token: jwtToken, info }) => { 97 | // get upgraded token with channel access 98 | const echoChannel = this.getChannel(channelName); 99 | this.setPresenceInfo(echoChannel, info); 100 | this.tryAuthorizeOnSameConnection( 101 | { ...this.options, token: toTokenDetails(jwtToken) }, 102 | (err, _tokenDetails) => { 103 | if (err) { 104 | errorCallback(err); 105 | } else { 106 | this.removeExpired(channelName); 107 | errorCallback(null); 108 | } 109 | } 110 | ); 111 | }) 112 | .catch((err) => errorCallback(err)); 113 | }); 114 | }; 115 | 116 | allowReconnectOnUserLogin = () => { 117 | const ablyConnection = this.ablyClient().connection 118 | 119 | const connectionFailedCallback = stateChange => { 120 | if (stateChange.reason.code == 40102) { // 40102 denotes mismatched clientId 121 | ablyConnection.off(connectionFailedCallback); 122 | console.warn("User login detected, re-connecting again!") 123 | this.onClientIdChanged(); 124 | } 125 | } 126 | ablyConnection.on('failed', connectionFailedCallback); 127 | } 128 | 129 | /** 130 | * This will be called when (guest)user logs in and new clientId is returned in the jwt token. 131 | * If client tries to authenticate with new clientId on same connection, ably server returns 132 | * error and connection goes into failed state. 133 | * See https://github.com/ably/laravel-broadcaster/issues/45 for more details. 134 | * There's a separate test case added for user login flow => ably-user-login.test.ts. 135 | */ 136 | onClientIdChanged = () => { 137 | this.ablyClient().connect(); 138 | for (const ablyChannel of Object.values(this.ablyConnector.channels)) { 139 | ablyChannel.channel.attach(ablyChannel._alertErrorListeners); 140 | } 141 | } 142 | 143 | tryAuthorizeOnSameConnection = (authOptions?: AuthOptions, callback?: (error, TokenDetails) => void) => { 144 | this.ablyClient().auth.authorize(null, authOptions, callback) 145 | } 146 | 147 | onChannelFailed = (echoAblyChannel: AblyChannel) => (stateChange: ChannelStateChange) => { 148 | // channel capability rejected https://help.ably.io/error/40160 149 | if (stateChange.reason?.code == 40160) { 150 | this.handleChannelAuthError(echoAblyChannel); 151 | } 152 | }; 153 | 154 | handleChannelAuthError = (echoAblyChannel: AblyChannel) => { 155 | if ((echoAblyChannel as any).skipAuth) { 156 | return; 157 | } 158 | const channelName = echoAblyChannel.name; 159 | // get upgraded token with channel access 160 | this.authRequestExecuter 161 | .request(channelName) 162 | .then(({ token: jwtToken, info }) => { 163 | this.setPresenceInfo(echoAblyChannel, info); 164 | this.tryAuthorizeOnSameConnection( 165 | { ...this.options, token: toTokenDetails(jwtToken) as any }, 166 | (err, _tokenDetails) => { 167 | if (err) { 168 | echoAblyChannel._alertErrorListeners(err); 169 | } else { 170 | (echoAblyChannel as any).skipAuth = true; 171 | echoAblyChannel.channel.once('attached', () => { 172 | (echoAblyChannel as any).skipAuth = false; 173 | }); 174 | echoAblyChannel.channel.attach(echoAblyChannel._alertErrorListeners); 175 | } 176 | } 177 | ); 178 | }) 179 | .catch((err) => echoAblyChannel._alertErrorListeners(err)); 180 | }; 181 | 182 | setPresenceInfo = (echoAblyChannel: AblyChannel, info: any) => { 183 | if (echoAblyChannel instanceof AblyPresenceChannel) { 184 | echoAblyChannel.presenceData = info; 185 | } 186 | }; 187 | } 188 | -------------------------------------------------------------------------------- /src/channel/ably/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | -------------------------------------------------------------------------------- /src/channel/ably/token-request.ts: -------------------------------------------------------------------------------- 1 | export class SequentialAuthTokenRequestExecuter { 2 | cachedToken: string; 3 | queue: TaskQueue; 4 | requestTokenFn: Function; 5 | 6 | constructor(token: string = null, requestTokenFn: Function) { 7 | this.cachedToken = token; 8 | this.requestTokenFn = requestTokenFn; 9 | this.queue = new TaskQueue(); 10 | } 11 | 12 | execute = (tokenRequestFn: Function): Promise<{ token: string; info: any }> => 13 | new Promise((resolve, reject) => { 14 | this.queue.run(async () => { 15 | try { 16 | const { token, info } = await tokenRequestFn(this.cachedToken); 17 | this.cachedToken = token; 18 | resolve({ token, info }); 19 | } catch (err) { 20 | reject(err); 21 | } 22 | }); 23 | }); 24 | 25 | request = (channelName: string): Promise<{ token: string; info: any }> => 26 | this.execute((token) => this.requestTokenFn(channelName, token)); 27 | } 28 | 29 | type Task = Function; 30 | class TaskQueue { 31 | total: Number; 32 | todo: Array; 33 | running: Array; 34 | count: Number; 35 | 36 | constructor(tasks: Array = [], concurrentCount = 1) { 37 | this.total = tasks.length; 38 | this.todo = tasks; 39 | this.running = []; 40 | this.count = concurrentCount; 41 | } 42 | 43 | canRunNext = () => this.running.length < this.count && this.todo.length; 44 | 45 | run = async (task: Task) => { 46 | if (task) { 47 | this.todo.push(task); 48 | } 49 | while (this.canRunNext()) { 50 | const currentTask = this.todo.shift(); 51 | this.running.push(currentTask); 52 | await currentTask(); 53 | this.running.shift(); 54 | } 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/channel/ably/utils.ts: -------------------------------------------------------------------------------- 1 | import { TokenDetails } from '../../../typings/ably'; 2 | 3 | export const isNullOrUndefined = (obj) => obj == null || obj === undefined; 4 | export const isEmptyString = (stringToCheck, ignoreSpaces = true) => 5 | (ignoreSpaces ? stringToCheck.trim() : stringToCheck) === ''; 6 | export const isNullOrUndefinedOrEmpty = (obj) => obj == null || obj === undefined || isEmptyString(obj); 7 | 8 | /** 9 | * @throws Exception if parsing error 10 | */ 11 | export const parseJwt = (jwtToken: string): { header: any; payload: any } => { 12 | // Get Token Header 13 | const base64HeaderUrl = jwtToken.split('.')[0]; 14 | const base64Header = base64HeaderUrl.replace('-', '+').replace('_', '/'); 15 | const header = JSON.parse(fromBase64UrlEncoded(base64Header)); 16 | // Get Token payload 17 | const base64Url = jwtToken.split('.')[1]; 18 | const base64 = base64Url.replace('-', '+').replace('_', '/'); 19 | const payload = JSON.parse(fromBase64UrlEncoded(base64)); 20 | return { header, payload }; 21 | }; 22 | 23 | // RSA4f - omitted `capability` property 24 | export const toTokenDetails = (jwtToken: string): TokenDetails | any => { 25 | const { payload } = parseJwt(jwtToken); 26 | return { 27 | clientId: payload['x-ably-clientId'], 28 | expires: payload.exp * 1000, // Convert Seconds to ms 29 | issued: payload.iat * 1000, 30 | token: jwtToken, 31 | }; 32 | }; 33 | 34 | const isBrowser = typeof window === 'object'; 35 | 36 | /** 37 | * Helper method to decode base64 url encoded string 38 | * https://stackoverflow.com/a/78178053 39 | * @param base64 base64 url encoded string 40 | * @returns decoded text string 41 | */ 42 | export const fromBase64UrlEncoded = (base64: string): string => { 43 | const base64Encoded = base64.replace(/-/g, '+').replace(/_/g, '/'); 44 | const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4)); 45 | const base64WithPadding = base64Encoded + padding; 46 | 47 | if (isBrowser) { 48 | return atob(base64WithPadding); 49 | } 50 | return Buffer.from(base64WithPadding, 'base64').toString(); 51 | }; 52 | 53 | /** 54 | * Helper method to encode text into base64 url encoded string 55 | * https://stackoverflow.com/a/78178053 56 | * @param base64 text 57 | * @returns base64 url encoded string 58 | */ 59 | export const toBase64UrlEncoded = (text: string): string => { 60 | let encoded = '' 61 | if (isBrowser) { 62 | encoded = btoa(text); 63 | } else { 64 | encoded = Buffer.from(text).toString('base64'); 65 | } 66 | return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 67 | }; 68 | 69 | const isAbsoluteUrl = (url: string) => (url && url.indexOf('http://') === 0) || url.indexOf('https://') === 0; 70 | 71 | export const fullUrl = (url: string) => { 72 | if (!isAbsoluteUrl(url) && typeof window != 'undefined') { 73 | const host = window?.location?.hostname; 74 | const port = window?.location?.port; 75 | const protocol = window?.location?.protocol.replace(':', ''); 76 | if (host && port && protocol) { 77 | return protocol + '://' + host + ':' + port + url; 78 | } 79 | } 80 | return url; 81 | }; 82 | 83 | let httpClient: any; 84 | function httpRequest(options, callback) { 85 | if (!httpClient) { 86 | httpClient = new Ably.Rest.Platform.Http(); 87 | } 88 | // Automatically set by browser 89 | if (isBrowser) { 90 | delete options.headers['Content-Length']; // XHR warning - Refused to set unsafe header "Content-Length" 91 | } else { 92 | options.method = options.method.toLowerCase(); 93 | } 94 | httpClient.doUri( 95 | options.method, 96 | null, 97 | options.uri, 98 | options.headers, 99 | options.body, 100 | options.paramsIfNoHeaders || {}, 101 | callback 102 | ); 103 | } 104 | 105 | export const httpRequestAsync = (options): Promise => { 106 | return new Promise((resolve, reject) => { 107 | httpRequest(options, function (err: any, res: any) { 108 | if (err) { 109 | reject(err); 110 | } else { 111 | if (typeof res === 'string') { 112 | resolve(JSON.parse(res)); 113 | } else if (!isBrowser && Buffer.isBuffer(res)) { 114 | try { 115 | resolve(JSON.parse(res.toString())); 116 | } catch (e) { 117 | resolve(res); 118 | } 119 | } else { 120 | resolve(res); 121 | } 122 | } 123 | }); 124 | }); 125 | }; 126 | -------------------------------------------------------------------------------- /src/channel/channel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This class represents a basic channel. 3 | */ 4 | export abstract class Channel { 5 | /** 6 | * The Echo options. 7 | */ 8 | options: any; 9 | 10 | /** 11 | * Listen for an event on the channel instance. 12 | */ 13 | abstract listen(event: string, callback: Function): Channel; 14 | 15 | /** 16 | * Listen for a whisper event on the channel instance. 17 | */ 18 | listenForWhisper(event: string, callback: Function): Channel { 19 | return this.listen('.client-' + event, callback); 20 | } 21 | 22 | /** 23 | * Listen for an event on the channel instance. 24 | */ 25 | notification(callback: Function): Channel { 26 | return this.listen('.Illuminate\\Notifications\\Events\\BroadcastNotificationCreated', callback); 27 | } 28 | 29 | /** 30 | * Stop listening to an event on the channel instance. 31 | */ 32 | abstract stopListening(event: string, callback?: Function): Channel; 33 | 34 | /** 35 | * Stop listening for a whisper event on the channel instance. 36 | */ 37 | stopListeningForWhisper(event: string, callback?: Function): Channel { 38 | return this.stopListening('.client-' + event, callback); 39 | } 40 | 41 | /** 42 | * Register a callback to be called anytime a subscription succeeds. 43 | */ 44 | abstract subscribed(callback: Function): Channel; 45 | 46 | /** 47 | * Register a callback to be called anytime an error occurs. 48 | */ 49 | abstract error(callback: Function): Channel; 50 | } 51 | -------------------------------------------------------------------------------- /src/channel/index.ts: -------------------------------------------------------------------------------- 1 | export * from './channel'; 2 | export * from './presence-channel'; 3 | export * from './ably-channel'; 4 | export * from './ably-private-channel'; 5 | export * from './ably-presence-channel'; 6 | export * from './ably'; 7 | export * from './pusher-channel'; 8 | export * from './pusher-private-channel'; 9 | export * from './pusher-encrypted-private-channel'; 10 | export * from './pusher-presence-channel'; 11 | export * from './socketio-channel'; 12 | export * from './socketio-private-channel'; 13 | export * from './socketio-presence-channel'; 14 | export * from './null-channel'; 15 | export * from './null-private-channel'; 16 | export * from './null-presence-channel'; 17 | -------------------------------------------------------------------------------- /src/channel/null-channel.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from './channel'; 2 | 3 | /** 4 | * This class represents a null channel. 5 | */ 6 | export class NullChannel extends Channel { 7 | /** 8 | * Subscribe to a channel. 9 | */ 10 | subscribe(): any { 11 | // 12 | } 13 | 14 | /** 15 | * Unsubscribe from a channel. 16 | */ 17 | unsubscribe(): void { 18 | // 19 | } 20 | 21 | /** 22 | * Listen for an event on the channel instance. 23 | */ 24 | listen(event: string, callback: Function): NullChannel { 25 | return this; 26 | } 27 | 28 | /** 29 | * Listen for all events on the channel instance. 30 | */ 31 | listenToAll(callback: Function): NullChannel { 32 | return this; 33 | } 34 | 35 | /** 36 | * Stop listening for an event on the channel instance. 37 | */ 38 | stopListening(event: string, callback?: Function): NullChannel { 39 | return this; 40 | } 41 | 42 | /** 43 | * Register a callback to be called anytime a subscription succeeds. 44 | */ 45 | subscribed(callback: Function): NullChannel { 46 | return this; 47 | } 48 | 49 | /** 50 | * Register a callback to be called anytime an error occurs. 51 | */ 52 | error(callback: Function): NullChannel { 53 | return this; 54 | } 55 | 56 | /** 57 | * Bind a channel to an event. 58 | */ 59 | on(event: string, callback: Function): NullChannel { 60 | return this; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/channel/null-presence-channel.ts: -------------------------------------------------------------------------------- 1 | import { NullChannel } from './null-channel'; 2 | import { PresenceChannel } from './presence-channel'; 3 | 4 | /** 5 | * This class represents a null presence channel. 6 | */ 7 | export class NullPresenceChannel extends NullChannel implements PresenceChannel { 8 | /** 9 | * Register a callback to be called anytime the member list changes. 10 | */ 11 | here(callback: Function): NullPresenceChannel { 12 | return this; 13 | } 14 | 15 | /** 16 | * Listen for someone joining the channel. 17 | */ 18 | joining(callback: Function): NullPresenceChannel { 19 | return this; 20 | } 21 | 22 | /** 23 | * Send a whisper event to other clients in the channel. 24 | */ 25 | whisper(eventName: string, data: any): NullPresenceChannel { 26 | return this; 27 | } 28 | 29 | /** 30 | * Listen for someone leaving the channel. 31 | */ 32 | leaving(callback: Function): NullPresenceChannel { 33 | return this; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/channel/null-private-channel.ts: -------------------------------------------------------------------------------- 1 | import { NullChannel } from './null-channel'; 2 | 3 | /** 4 | * This class represents a null private channel. 5 | */ 6 | export class NullPrivateChannel extends NullChannel { 7 | /** 8 | * Send a whisper event to other clients in the channel. 9 | */ 10 | whisper(eventName: string, data: any): NullPrivateChannel { 11 | return this; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/channel/presence-channel.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from './channel'; 2 | 3 | /** 4 | * This interface represents a presence channel. 5 | */ 6 | export interface PresenceChannel extends Channel { 7 | /** 8 | * Register a callback to be called anytime the member list changes. 9 | */ 10 | here(callback: Function): PresenceChannel; 11 | 12 | /** 13 | * Listen for someone joining the channel. 14 | */ 15 | joining(callback: Function): PresenceChannel; 16 | 17 | /** 18 | * Send a whisper event to other clients in the channel. 19 | */ 20 | whisper(eventName: string, data: any): PresenceChannel; 21 | 22 | /** 23 | * Listen for someone leaving the channel. 24 | */ 25 | leaving(callback: Function): PresenceChannel; 26 | } 27 | -------------------------------------------------------------------------------- /src/channel/pusher-channel.ts: -------------------------------------------------------------------------------- 1 | import { EventFormatter } from '../util'; 2 | import { Channel } from './channel'; 3 | 4 | /** 5 | * This class represents a Pusher channel. 6 | */ 7 | export class PusherChannel extends Channel { 8 | /** 9 | * The Pusher client instance. 10 | */ 11 | pusher: any; 12 | 13 | /** 14 | * The name of the channel. 15 | */ 16 | name: any; 17 | 18 | /** 19 | * Channel options. 20 | */ 21 | options: any; 22 | 23 | /** 24 | * The event formatter. 25 | */ 26 | eventFormatter: EventFormatter; 27 | 28 | /** 29 | * The subscription of the channel. 30 | */ 31 | subscription: any; 32 | 33 | /** 34 | * Create a new class instance. 35 | */ 36 | constructor(pusher: any, name: any, options: any) { 37 | super(); 38 | 39 | this.name = name; 40 | this.pusher = pusher; 41 | this.options = options; 42 | this.eventFormatter = new EventFormatter(this.options.namespace); 43 | 44 | this.subscribe(); 45 | } 46 | 47 | /** 48 | * Subscribe to a Pusher channel. 49 | */ 50 | subscribe(): any { 51 | this.subscription = this.pusher.subscribe(this.name); 52 | } 53 | 54 | /** 55 | * Unsubscribe from a Pusher channel. 56 | */ 57 | unsubscribe(): void { 58 | this.pusher.unsubscribe(this.name); 59 | } 60 | 61 | /** 62 | * Listen for an event on the channel instance. 63 | */ 64 | listen(event: string, callback: Function): PusherChannel { 65 | this.on(this.eventFormatter.format(event), callback); 66 | 67 | return this; 68 | } 69 | 70 | /** 71 | * Listen for all events on the channel instance. 72 | */ 73 | listenToAll(callback: Function): PusherChannel { 74 | this.subscription.bind_global((event, data) => { 75 | if (event.startsWith('pusher:')) { 76 | return; 77 | } 78 | 79 | let namespace = this.options.namespace.replace(/\./g, '\\'); 80 | 81 | let formattedEvent = event.startsWith(namespace) ? event.substring(namespace.length + 1) : '.' + event; 82 | 83 | callback(formattedEvent, data); 84 | }); 85 | 86 | return this; 87 | } 88 | 89 | /** 90 | * Stop listening for an event on the channel instance. 91 | */ 92 | stopListening(event: string, callback?: Function): PusherChannel { 93 | if (callback) { 94 | this.subscription.unbind(this.eventFormatter.format(event), callback); 95 | } else { 96 | this.subscription.unbind(this.eventFormatter.format(event)); 97 | } 98 | 99 | return this; 100 | } 101 | 102 | /** 103 | * Stop listening for all events on the channel instance. 104 | */ 105 | stopListeningToAll(callback?: Function): PusherChannel { 106 | if (callback) { 107 | this.subscription.unbind_global(callback); 108 | } else { 109 | this.subscription.unbind_global(); 110 | } 111 | 112 | return this; 113 | } 114 | 115 | /** 116 | * Register a callback to be called anytime a subscription succeeds. 117 | */ 118 | subscribed(callback: Function): PusherChannel { 119 | this.on('pusher:subscription_succeeded', () => { 120 | callback(); 121 | }); 122 | 123 | return this; 124 | } 125 | 126 | /** 127 | * Register a callback to be called anytime a subscription error occurs. 128 | */ 129 | error(callback: Function): PusherChannel { 130 | this.on('pusher:subscription_error', (status) => { 131 | callback(status); 132 | }); 133 | 134 | return this; 135 | } 136 | 137 | /** 138 | * Bind a channel to an event. 139 | */ 140 | on(event: string, callback: Function): PusherChannel { 141 | this.subscription.bind(event, callback); 142 | 143 | return this; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/channel/pusher-encrypted-private-channel.ts: -------------------------------------------------------------------------------- 1 | import { PusherChannel } from './pusher-channel'; 2 | 3 | /** 4 | * This class represents a Pusher private channel. 5 | */ 6 | export class PusherEncryptedPrivateChannel extends PusherChannel { 7 | /** 8 | * Send a whisper event to other clients in the channel. 9 | */ 10 | whisper(eventName: string, data: any): PusherEncryptedPrivateChannel { 11 | this.pusher.channels.channels[this.name].trigger(`client-${eventName}`, data); 12 | 13 | return this; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/channel/pusher-presence-channel.ts: -------------------------------------------------------------------------------- 1 | import { PusherChannel } from './pusher-channel'; 2 | import { PresenceChannel } from './presence-channel'; 3 | 4 | /** 5 | * This class represents a Pusher presence channel. 6 | */ 7 | export class PusherPresenceChannel extends PusherChannel implements PresenceChannel { 8 | /** 9 | * Register a callback to be called anytime the member list changes. 10 | */ 11 | here(callback: Function): PusherPresenceChannel { 12 | this.on('pusher:subscription_succeeded', (data) => { 13 | callback(Object.keys(data.members).map((k) => data.members[k])); 14 | }); 15 | 16 | return this; 17 | } 18 | 19 | /** 20 | * Listen for someone joining the channel. 21 | */ 22 | joining(callback: Function): PusherPresenceChannel { 23 | this.on('pusher:member_added', (member) => { 24 | callback(member.info); 25 | }); 26 | 27 | return this; 28 | } 29 | 30 | /** 31 | * Send a whisper event to other clients in the channel. 32 | */ 33 | whisper(eventName: string, data: any): PusherPresenceChannel { 34 | this.pusher.channels.channels[this.name].trigger(`client-${eventName}`, data); 35 | 36 | return this; 37 | } 38 | 39 | /** 40 | * Listen for someone leaving the channel. 41 | */ 42 | leaving(callback: Function): PusherPresenceChannel { 43 | this.on('pusher:member_removed', (member) => { 44 | callback(member.info); 45 | }); 46 | 47 | return this; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/channel/pusher-private-channel.ts: -------------------------------------------------------------------------------- 1 | import { PusherChannel } from './pusher-channel'; 2 | 3 | /** 4 | * This class represents a Pusher private channel. 5 | */ 6 | export class PusherPrivateChannel extends PusherChannel { 7 | /** 8 | * Send a whisper event to other clients in the channel. 9 | */ 10 | whisper(eventName: string, data: any): PusherPrivateChannel { 11 | this.pusher.channels.channels[this.name].trigger(`client-${eventName}`, data); 12 | 13 | return this; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/channel/socketio-channel.ts: -------------------------------------------------------------------------------- 1 | import { EventFormatter } from '../util'; 2 | import { Channel } from './channel'; 3 | 4 | /** 5 | * This class represents a Socket.io channel. 6 | */ 7 | export class SocketIoChannel extends Channel { 8 | /** 9 | * The Socket.io client instance. 10 | */ 11 | socket: any; 12 | 13 | /** 14 | * The name of the channel. 15 | */ 16 | name: any; 17 | 18 | /** 19 | * Channel options. 20 | */ 21 | options: any; 22 | 23 | /** 24 | * The event formatter. 25 | */ 26 | eventFormatter: EventFormatter; 27 | 28 | /** 29 | * The event callbacks applied to the socket. 30 | */ 31 | events: any = {}; 32 | 33 | /** 34 | * User supplied callbacks for events on this channel. 35 | */ 36 | private listeners: any = {}; 37 | 38 | /** 39 | * Create a new class instance. 40 | */ 41 | constructor(socket: any, name: string, options: any) { 42 | super(); 43 | 44 | this.name = name; 45 | this.socket = socket; 46 | this.options = options; 47 | this.eventFormatter = new EventFormatter(this.options.namespace); 48 | 49 | this.subscribe(); 50 | } 51 | 52 | /** 53 | * Subscribe to a Socket.io channel. 54 | */ 55 | subscribe(): void { 56 | this.socket.emit('subscribe', { 57 | channel: this.name, 58 | auth: this.options.auth || {}, 59 | }); 60 | } 61 | 62 | /** 63 | * Unsubscribe from channel and ubind event callbacks. 64 | */ 65 | unsubscribe(): void { 66 | this.unbind(); 67 | 68 | this.socket.emit('unsubscribe', { 69 | channel: this.name, 70 | auth: this.options.auth || {}, 71 | }); 72 | } 73 | 74 | /** 75 | * Listen for an event on the channel instance. 76 | */ 77 | listen(event: string, callback: Function): SocketIoChannel { 78 | this.on(this.eventFormatter.format(event), callback); 79 | 80 | return this; 81 | } 82 | 83 | /** 84 | * Stop listening for an event on the channel instance. 85 | */ 86 | stopListening(event: string, callback?: Function): SocketIoChannel { 87 | this.unbindEvent(this.eventFormatter.format(event), callback); 88 | 89 | return this; 90 | } 91 | 92 | /** 93 | * Register a callback to be called anytime a subscription succeeds. 94 | */ 95 | subscribed(callback: Function): SocketIoChannel { 96 | this.on('connect', (socket) => { 97 | callback(socket); 98 | }); 99 | 100 | return this; 101 | } 102 | 103 | /** 104 | * Register a callback to be called anytime an error occurs. 105 | */ 106 | error(callback: Function): SocketIoChannel { 107 | return this; 108 | } 109 | 110 | /** 111 | * Bind the channel's socket to an event and store the callback. 112 | */ 113 | on(event: string, callback: Function): SocketIoChannel { 114 | this.listeners[event] = this.listeners[event] || []; 115 | 116 | if (!this.events[event]) { 117 | this.events[event] = (channel, data) => { 118 | if (this.name === channel && this.listeners[event]) { 119 | this.listeners[event].forEach((cb) => cb(data)); 120 | } 121 | }; 122 | 123 | this.socket.on(event, this.events[event]); 124 | } 125 | 126 | this.listeners[event].push(callback); 127 | 128 | return this; 129 | } 130 | 131 | /** 132 | * Unbind the channel's socket from all stored event callbacks. 133 | */ 134 | unbind(): void { 135 | Object.keys(this.events).forEach((event) => { 136 | this.unbindEvent(event); 137 | }); 138 | } 139 | 140 | /** 141 | * Unbind the listeners for the given event. 142 | */ 143 | protected unbindEvent(event: string, callback?: Function): void { 144 | this.listeners[event] = this.listeners[event] || []; 145 | 146 | if (callback) { 147 | this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback); 148 | } 149 | 150 | if (!callback || this.listeners[event].length === 0) { 151 | if (this.events[event]) { 152 | this.socket.removeListener(event, this.events[event]); 153 | 154 | delete this.events[event]; 155 | } 156 | 157 | delete this.listeners[event]; 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/channel/socketio-presence-channel.ts: -------------------------------------------------------------------------------- 1 | import { PresenceChannel } from './presence-channel'; 2 | import { SocketIoPrivateChannel } from './socketio-private-channel'; 3 | 4 | /** 5 | * This class represents a Socket.io presence channel. 6 | */ 7 | export class SocketIoPresenceChannel extends SocketIoPrivateChannel implements PresenceChannel { 8 | /** 9 | * Register a callback to be called anytime the member list changes. 10 | */ 11 | here(callback: Function): SocketIoPresenceChannel { 12 | this.on('presence:subscribed', (members: any[]) => { 13 | callback(members.map((m) => m.user_info)); 14 | }); 15 | 16 | return this; 17 | } 18 | 19 | /** 20 | * Listen for someone joining the channel. 21 | */ 22 | joining(callback: Function): SocketIoPresenceChannel { 23 | this.on('presence:joining', (member) => callback(member.user_info)); 24 | 25 | return this; 26 | } 27 | 28 | /** 29 | * Send a whisper event to other clients in the channel. 30 | */ 31 | whisper(eventName: string, data: any): SocketIoPresenceChannel { 32 | this.socket.emit('client event', { 33 | channel: this.name, 34 | event: `client-${eventName}`, 35 | data: data, 36 | }); 37 | 38 | return this; 39 | } 40 | 41 | /** 42 | * Listen for someone leaving the channel. 43 | */ 44 | leaving(callback: Function): SocketIoPresenceChannel { 45 | this.on('presence:leaving', (member) => callback(member.user_info)); 46 | 47 | return this; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/channel/socketio-private-channel.ts: -------------------------------------------------------------------------------- 1 | import { SocketIoChannel } from './socketio-channel'; 2 | 3 | /** 4 | * This class represents a Socket.io private channel. 5 | */ 6 | export class SocketIoPrivateChannel extends SocketIoChannel { 7 | /** 8 | * Send a whisper event to other clients in the channel. 9 | */ 10 | whisper(eventName: string, data: any): SocketIoChannel { 11 | this.socket.emit('client event', { 12 | channel: this.name, 13 | event: `client-${eventName}`, 14 | data: data, 15 | }); 16 | 17 | return this; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/connector/ably-connector.ts: -------------------------------------------------------------------------------- 1 | import { Connector } from './connector'; 2 | 3 | import { AblyChannel, AblyPrivateChannel, AblyPresenceChannel, AblyAuth } from './../channel'; 4 | import { AblyRealtime, TokenDetails } from '../../typings/ably'; 5 | import { toBase64UrlEncoded } from '../channel/ably/utils'; 6 | 7 | /** 8 | * This class creates a connector to Ably. 9 | */ 10 | export class AblyConnector extends Connector { 11 | 12 | /** 13 | * The laravel-echo library version. 14 | */ 15 | static LIB_VERSION = '1.0.6'; 16 | 17 | /** 18 | * The Ably instance. 19 | */ 20 | ably: AblyRealtime; 21 | 22 | /** 23 | * All of the subscribed channel names. 24 | */ 25 | channels: Record = {}; 26 | 27 | /** 28 | * Auth instance containing all explicit channel authz ops. 29 | */ 30 | ablyAuth: AblyAuth; 31 | 32 | /** 33 | * Create a fresh Ably connection. 34 | */ 35 | connect(): void { 36 | if (typeof this.options.client !== 'undefined') { 37 | this.ably = this.options.client; 38 | } else { 39 | this.ablyAuth = new AblyAuth(this, this.options); 40 | if (!this.options.agents) { 41 | this.options.agents = {}; 42 | } 43 | this.options.agents['laravel-echo'] = AblyConnector.LIB_VERSION; 44 | this.ably = new Ably.Realtime({ ...this.ablyAuth.options, ...this.options }); 45 | this.ablyAuth.enableAuthorizeBeforeChannelAttach(); 46 | this.ablyAuth.allowReconnectOnUserLogin() 47 | } 48 | } 49 | 50 | /** 51 | * Listen for an event on a channel instance. 52 | */ 53 | listen(name: string, event: string, callback: Function): AblyChannel { 54 | return this.channel(name).listen(event, callback); 55 | } 56 | 57 | /** 58 | * Get a channel instance by name. 59 | */ 60 | channel(name: string): AblyChannel { 61 | const prefixedName = `public:${name}`; // adding public as a ably namespace prefix 62 | if (!this.channels[prefixedName]) { 63 | this.channels[prefixedName] = new AblyChannel(this.ably, prefixedName, this.options); 64 | } 65 | 66 | return this.channels[prefixedName]; 67 | } 68 | 69 | /** 70 | * Get a private channel instance by name. 71 | */ 72 | privateChannel(name: string): AblyPrivateChannel { 73 | const prefixedName = `private:${name}`; // adding private as a ably namespace prefix 74 | if (!this.channels[prefixedName]) { 75 | this.channels[prefixedName] = new AblyPrivateChannel(this.ably, prefixedName, this.options, this.ablyAuth); 76 | } 77 | 78 | return this.channels[prefixedName] as AblyPrivateChannel; 79 | } 80 | 81 | /** 82 | * Get a presence channel instance by name. 83 | */ 84 | presenceChannel(name: string): AblyPresenceChannel { 85 | const prefixedName = `presence:${name}`; // adding presence as a ably namespace prefix 86 | if (!this.channels[prefixedName]) { 87 | this.channels[prefixedName] = new AblyPresenceChannel(this.ably, prefixedName, this.options, this.ablyAuth); 88 | } 89 | 90 | return this.channels[prefixedName] as AblyPresenceChannel; 91 | } 92 | 93 | /** 94 | * Leave the given channel, as well as its private and presence variants. 95 | */ 96 | leave(name: string): void { 97 | let channels = [`public:${name}`, `private:${name}`, `presence:${name}`]; 98 | 99 | channels.forEach((name: string, index: number) => { 100 | this.leaveChannel(name); 101 | }); 102 | } 103 | 104 | /** 105 | * Leave the given channel. 106 | */ 107 | leaveChannel(name: string): void { 108 | if (name.indexOf('public:') !== 0 && name.indexOf('private:') !== 0 && name.indexOf('presence:') !== 0) { 109 | throw new Error( 110 | `Error leaving ${name}, name should be prefixed with either "public:", "private:" or "presence:"` 111 | ); 112 | } 113 | if (this.channels[name]) { 114 | this.channels[name].unsubscribe(); 115 | this.ablyAuth.setExpired(name); 116 | delete this.channels[name]; 117 | } 118 | } 119 | 120 | /** 121 | * Get the socket ID for the connection. 122 | * For ably, returns base64 url encoded json with keys {connectionKey, clientId} 123 | */ 124 | socketId(): string { 125 | let socketIdObject = { 126 | connectionKey : this.ably.connection.key, 127 | clientId : this.ably.auth.clientId ?? null, 128 | } 129 | return toBase64UrlEncoded(JSON.stringify(socketIdObject)); 130 | } 131 | 132 | /** 133 | * Disconnect Ably connection. 134 | */ 135 | disconnect(): void { 136 | this.ably.close(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/connector/connector.ts: -------------------------------------------------------------------------------- 1 | import { Channel, PresenceChannel } from './../channel'; 2 | 3 | export abstract class Connector { 4 | /** 5 | * Default connector options. 6 | */ 7 | private _defaultOptions: any = { 8 | auth: { 9 | headers: {}, 10 | }, 11 | authEndpoint: '/broadcasting/auth', 12 | userAuthentication: { 13 | endpoint: '/broadcasting/user-auth', 14 | headers: {}, 15 | }, 16 | broadcaster: 'pusher', 17 | csrfToken: null, 18 | bearerToken: null, 19 | host: null, 20 | key: null, 21 | namespace: 'App.Events', 22 | }; 23 | 24 | /** 25 | * Connector options. 26 | */ 27 | options: any; 28 | 29 | /** 30 | * Create a new class instance. 31 | */ 32 | constructor(options: any) { 33 | this.setOptions(options); 34 | this.connect(); 35 | } 36 | 37 | /** 38 | * Merge the custom options with the defaults. 39 | */ 40 | protected setOptions(options: any): any { 41 | this.options = Object.assign(this._defaultOptions, options); 42 | 43 | let token = this.csrfToken(); 44 | 45 | if (token) { 46 | this.options.auth.headers['X-CSRF-TOKEN'] = token; 47 | this.options.userAuthentication.headers['X-CSRF-TOKEN'] = token; 48 | } 49 | 50 | token = this.options.bearerToken; 51 | 52 | if (token) { 53 | this.options.auth.headers['Authorization'] = 'Bearer ' + token; 54 | this.options.userAuthentication.headers['Authorization'] = 'Bearer ' + token; 55 | } 56 | 57 | return options; 58 | } 59 | 60 | /** 61 | * Extract the CSRF token from the page. 62 | */ 63 | protected csrfToken(): null | string { 64 | let selector; 65 | 66 | if (typeof window !== 'undefined' && window['Laravel'] && window['Laravel'].csrfToken) { 67 | return window['Laravel'].csrfToken; 68 | } else if (this.options.csrfToken) { 69 | return this.options.csrfToken; 70 | } else if ( 71 | typeof document !== 'undefined' && 72 | typeof document.querySelector === 'function' && 73 | (selector = document.querySelector('meta[name="csrf-token"]')) 74 | ) { 75 | return selector.getAttribute('content'); 76 | } 77 | 78 | return null; 79 | } 80 | 81 | /** 82 | * Create a fresh connection. 83 | */ 84 | abstract connect(): void; 85 | 86 | /** 87 | * Get a channel instance by name. 88 | */ 89 | abstract channel(channel: string): Channel; 90 | 91 | /** 92 | * Get a private channel instance by name. 93 | */ 94 | abstract privateChannel(channel: string): Channel; 95 | 96 | /** 97 | * Get a presence channel instance by name. 98 | */ 99 | abstract presenceChannel(channel: string): PresenceChannel; 100 | 101 | /** 102 | * Leave the given channel, as well as its private and presence variants. 103 | */ 104 | abstract leave(channel: string): void; 105 | 106 | /** 107 | * Leave the given channel. 108 | */ 109 | abstract leaveChannel(channel: string): void; 110 | 111 | /** 112 | * Get the socket_id of the connection. 113 | */ 114 | abstract socketId(): string; 115 | 116 | /** 117 | * Disconnect from the Echo server. 118 | */ 119 | abstract disconnect(): void; 120 | } 121 | -------------------------------------------------------------------------------- /src/connector/index.ts: -------------------------------------------------------------------------------- 1 | export * from './connector'; 2 | export * from './pusher-connector'; 3 | export * from './socketio-connector'; 4 | export * from './null-connector'; 5 | export * from './ably-connector'; 6 | -------------------------------------------------------------------------------- /src/connector/null-connector.ts: -------------------------------------------------------------------------------- 1 | import { Connector } from './connector'; 2 | import { NullChannel, NullPrivateChannel, NullPresenceChannel, PresenceChannel } from './../channel'; 3 | 4 | /** 5 | * This class creates a null connector. 6 | */ 7 | export class NullConnector extends Connector { 8 | /** 9 | * All of the subscribed channel names. 10 | */ 11 | channels: any = {}; 12 | 13 | /** 14 | * Create a fresh connection. 15 | */ 16 | connect(): void { 17 | // 18 | } 19 | 20 | /** 21 | * Listen for an event on a channel instance. 22 | */ 23 | listen(name: string, event: string, callback: Function): NullChannel { 24 | return new NullChannel(); 25 | } 26 | 27 | /** 28 | * Get a channel instance by name. 29 | */ 30 | channel(name: string): NullChannel { 31 | return new NullChannel(); 32 | } 33 | 34 | /** 35 | * Get a private channel instance by name. 36 | */ 37 | privateChannel(name: string): NullPrivateChannel { 38 | return new NullPrivateChannel(); 39 | } 40 | 41 | /** 42 | * Get a private encrypted channel instance by name. 43 | */ 44 | encryptedPrivateChannel(name: string): NullPrivateChannel { 45 | return new NullPrivateChannel(); 46 | } 47 | 48 | /** 49 | * Get a presence channel instance by name. 50 | */ 51 | presenceChannel(name: string): PresenceChannel { 52 | return new NullPresenceChannel(); 53 | } 54 | 55 | /** 56 | * Leave the given channel, as well as its private and presence variants. 57 | */ 58 | leave(name: string): void { 59 | // 60 | } 61 | 62 | /** 63 | * Leave the given channel. 64 | */ 65 | leaveChannel(name: string): void { 66 | // 67 | } 68 | 69 | /** 70 | * Get the socket ID for the connection. 71 | */ 72 | socketId(): string { 73 | return 'fake-socket-id'; 74 | } 75 | 76 | /** 77 | * Disconnect the connection. 78 | */ 79 | disconnect(): void { 80 | // 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/connector/pusher-connector.ts: -------------------------------------------------------------------------------- 1 | import { Connector } from './connector'; 2 | import { 3 | PusherChannel, 4 | PusherPrivateChannel, 5 | PusherEncryptedPrivateChannel, 6 | PusherPresenceChannel, 7 | PresenceChannel, 8 | } from './../channel'; 9 | 10 | /** 11 | * This class creates a connector to Pusher. 12 | */ 13 | export class PusherConnector extends Connector { 14 | /** 15 | * The Pusher instance. 16 | */ 17 | pusher: any; 18 | 19 | /** 20 | * All of the subscribed channel names. 21 | */ 22 | channels: any = {}; 23 | 24 | /** 25 | * Create a fresh Pusher connection. 26 | */ 27 | connect(): void { 28 | if (typeof this.options.client !== 'undefined') { 29 | this.pusher = this.options.client; 30 | } else if (this.options.Pusher) { 31 | this.pusher = new this.options.Pusher(this.options.key, this.options); 32 | } else { 33 | this.pusher = new Pusher(this.options.key, this.options); 34 | } 35 | } 36 | 37 | /** 38 | * Sign in the user via Pusher user authentication (https://pusher.com/docs/channels/using_channels/user-authentication/). 39 | */ 40 | signin(): void { 41 | this.pusher.signin(); 42 | } 43 | 44 | /** 45 | * Listen for an event on a channel instance. 46 | */ 47 | listen(name: string, event: string, callback: Function): PusherChannel { 48 | return this.channel(name).listen(event, callback); 49 | } 50 | 51 | /** 52 | * Get a channel instance by name. 53 | */ 54 | channel(name: string): PusherChannel { 55 | if (!this.channels[name]) { 56 | this.channels[name] = new PusherChannel(this.pusher, name, this.options); 57 | } 58 | 59 | return this.channels[name]; 60 | } 61 | 62 | /** 63 | * Get a private channel instance by name. 64 | */ 65 | privateChannel(name: string): PusherChannel { 66 | if (!this.channels['private-' + name]) { 67 | this.channels['private-' + name] = new PusherPrivateChannel(this.pusher, 'private-' + name, this.options); 68 | } 69 | 70 | return this.channels['private-' + name]; 71 | } 72 | 73 | /** 74 | * Get a private encrypted channel instance by name. 75 | */ 76 | encryptedPrivateChannel(name: string): PusherChannel { 77 | if (!this.channels['private-encrypted-' + name]) { 78 | this.channels['private-encrypted-' + name] = new PusherEncryptedPrivateChannel( 79 | this.pusher, 80 | 'private-encrypted-' + name, 81 | this.options 82 | ); 83 | } 84 | 85 | return this.channels['private-encrypted-' + name]; 86 | } 87 | 88 | /** 89 | * Get a presence channel instance by name. 90 | */ 91 | presenceChannel(name: string): PresenceChannel { 92 | if (!this.channels['presence-' + name]) { 93 | this.channels['presence-' + name] = new PusherPresenceChannel( 94 | this.pusher, 95 | 'presence-' + name, 96 | this.options 97 | ); 98 | } 99 | 100 | return this.channels['presence-' + name]; 101 | } 102 | 103 | /** 104 | * Leave the given channel, as well as its private and presence variants. 105 | */ 106 | leave(name: string): void { 107 | let channels = [name, 'private-' + name, 'private-encrypted-' + name, 'presence-' + name]; 108 | 109 | channels.forEach((name: string, index: number) => { 110 | this.leaveChannel(name); 111 | }); 112 | } 113 | 114 | /** 115 | * Leave the given channel. 116 | */ 117 | leaveChannel(name: string): void { 118 | if (this.channels[name]) { 119 | this.channels[name].unsubscribe(); 120 | 121 | delete this.channels[name]; 122 | } 123 | } 124 | 125 | /** 126 | * Get the socket ID for the connection. 127 | */ 128 | socketId(): string { 129 | return this.pusher.connection.socket_id; 130 | } 131 | 132 | /** 133 | * Disconnect Pusher connection. 134 | */ 135 | disconnect(): void { 136 | this.pusher.disconnect(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/connector/socketio-connector.ts: -------------------------------------------------------------------------------- 1 | import { Connector } from './connector'; 2 | import { SocketIoChannel, SocketIoPrivateChannel, SocketIoPresenceChannel } from './../channel'; 3 | 4 | /** 5 | * This class creates a connnector to a Socket.io server. 6 | */ 7 | export class SocketIoConnector extends Connector { 8 | /** 9 | * The Socket.io connection instance. 10 | */ 11 | socket: any; 12 | 13 | /** 14 | * All of the subscribed channel names. 15 | */ 16 | channels: { [name: string]: SocketIoChannel } = {}; 17 | 18 | /** 19 | * Create a fresh Socket.io connection. 20 | */ 21 | connect(): void { 22 | let io = this.getSocketIO(); 23 | 24 | this.socket = io(this.options.host, this.options); 25 | 26 | this.socket.on('reconnect', () => { 27 | Object.values(this.channels).forEach((channel) => { 28 | channel.subscribe(); 29 | }); 30 | }); 31 | 32 | return this.socket; 33 | } 34 | 35 | /** 36 | * Get socket.io module from global scope or options. 37 | */ 38 | getSocketIO(): any { 39 | if (typeof this.options.client !== 'undefined') { 40 | return this.options.client; 41 | } 42 | 43 | if (typeof io !== 'undefined') { 44 | return io; 45 | } 46 | 47 | throw new Error('Socket.io client not found. Should be globally available or passed via options.client'); 48 | } 49 | 50 | /** 51 | * Listen for an event on a channel instance. 52 | */ 53 | listen(name: string, event: string, callback: Function): SocketIoChannel { 54 | return this.channel(name).listen(event, callback); 55 | } 56 | 57 | /** 58 | * Get a channel instance by name. 59 | */ 60 | channel(name: string): SocketIoChannel { 61 | if (!this.channels[name]) { 62 | this.channels[name] = new SocketIoChannel(this.socket, name, this.options); 63 | } 64 | 65 | return this.channels[name]; 66 | } 67 | 68 | /** 69 | * Get a private channel instance by name. 70 | */ 71 | privateChannel(name: string): SocketIoPrivateChannel { 72 | if (!this.channels['private-' + name]) { 73 | this.channels['private-' + name] = new SocketIoPrivateChannel(this.socket, 'private-' + name, this.options); 74 | } 75 | 76 | return this.channels['private-' + name] as SocketIoPrivateChannel; 77 | } 78 | 79 | /** 80 | * Get a presence channel instance by name. 81 | */ 82 | presenceChannel(name: string): SocketIoPresenceChannel { 83 | if (!this.channels['presence-' + name]) { 84 | this.channels['presence-' + name] = new SocketIoPresenceChannel( 85 | this.socket, 86 | 'presence-' + name, 87 | this.options 88 | ); 89 | } 90 | 91 | return this.channels['presence-' + name] as SocketIoPresenceChannel; 92 | } 93 | 94 | /** 95 | * Leave the given channel, as well as its private and presence variants. 96 | */ 97 | leave(name: string): void { 98 | let channels = [name, 'private-' + name, 'presence-' + name]; 99 | 100 | channels.forEach((name) => { 101 | this.leaveChannel(name); 102 | }); 103 | } 104 | 105 | /** 106 | * Leave the given channel. 107 | */ 108 | leaveChannel(name: string): void { 109 | if (this.channels[name]) { 110 | this.channels[name].unsubscribe(); 111 | 112 | delete this.channels[name]; 113 | } 114 | } 115 | 116 | /** 117 | * Get the socket ID for the connection. 118 | */ 119 | socketId(): string { 120 | return this.socket.id; 121 | } 122 | 123 | /** 124 | * Disconnect Socketio connection. 125 | */ 126 | disconnect(): void { 127 | this.socket.disconnect(); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/echo.ts: -------------------------------------------------------------------------------- 1 | import { Channel, PresenceChannel } from './channel'; 2 | import { AblyConnector, Connector, PusherConnector, SocketIoConnector, NullConnector } from './connector'; 3 | 4 | /** 5 | * This class is the primary API for interacting with broadcasting. 6 | */ 7 | export default class Echo { 8 | /** 9 | * The broadcasting connector. 10 | */ 11 | connector: any; 12 | 13 | /** 14 | * The Echo options. 15 | */ 16 | options: any; 17 | 18 | /** 19 | * Create a new class instance. 20 | */ 21 | constructor(options: any) { 22 | this.options = options; 23 | this.connect(); 24 | 25 | if (!this.options.withoutInterceptors) { 26 | this.registerInterceptors(); 27 | } 28 | } 29 | 30 | /** 31 | * Get a channel instance by name. 32 | */ 33 | channel(channel: string): Channel { 34 | return this.connector.channel(channel); 35 | } 36 | 37 | /** 38 | * Create a new connection. 39 | */ 40 | connect(): void { 41 | if (this.options.broadcaster == 'ably') { 42 | this.connector = new AblyConnector(this.options); 43 | } else if (this.options.broadcaster == 'reverb') { 44 | this.connector = new PusherConnector({ ...this.options, cluster: '' }); 45 | } else if (this.options.broadcaster == 'pusher') { 46 | this.connector = new PusherConnector(this.options); 47 | } else if (this.options.broadcaster == 'socket.io') { 48 | this.connector = new SocketIoConnector(this.options); 49 | } else if (this.options.broadcaster == 'null') { 50 | this.connector = new NullConnector(this.options); 51 | } else if (typeof this.options.broadcaster == 'function') { 52 | this.connector = new this.options.broadcaster(this.options); 53 | } else { 54 | throw new Error( 55 | `Broadcaster ${typeof this.options.broadcaster} ${this.options.broadcaster} is not supported.` 56 | ); 57 | } 58 | } 59 | 60 | /** 61 | * Disconnect from the Echo server. 62 | */ 63 | disconnect(): void { 64 | this.connector.disconnect(); 65 | } 66 | 67 | /** 68 | * Get a presence channel instance by name. 69 | */ 70 | join(channel: string): PresenceChannel { 71 | return this.connector.presenceChannel(channel); 72 | } 73 | 74 | /** 75 | * Leave the given channel, as well as its private and presence variants. 76 | */ 77 | leave(channel: string): void { 78 | this.connector.leave(channel); 79 | } 80 | 81 | /** 82 | * Leave the given channel. 83 | */ 84 | leaveChannel(channel: string): void { 85 | this.connector.leaveChannel(channel); 86 | } 87 | 88 | /** 89 | * Leave all channels. 90 | */ 91 | leaveAllChannels(): void { 92 | for (const channel in this.connector.channels) { 93 | this.leaveChannel(channel); 94 | } 95 | } 96 | 97 | /** 98 | * Listen for an event on a channel instance. 99 | */ 100 | listen(channel: string, event: string, callback: Function): Channel { 101 | return this.connector.listen(channel, event, callback); 102 | } 103 | 104 | /** 105 | * Get a private channel instance by name. 106 | */ 107 | private(channel: string): Channel { 108 | return this.connector.privateChannel(channel); 109 | } 110 | 111 | /** 112 | * Get a private encrypted channel instance by name. 113 | */ 114 | encryptedPrivate(channel: string): Channel { 115 | return this.connector.encryptedPrivateChannel(channel); 116 | } 117 | 118 | /** 119 | * Get the Socket ID for the connection. 120 | * For ably, returns base64 url encoded json with keys {connectionKey, clientId} 121 | */ 122 | socketId(): string { 123 | return this.connector.socketId(); 124 | } 125 | 126 | /** 127 | * Register 3rd party request interceptiors. These are used to automatically 128 | * send a connections socket id to a Laravel app with a X-Socket-Id header. 129 | */ 130 | registerInterceptors(): void { 131 | if (typeof Vue === 'function' && Vue.http) { 132 | this.registerVueRequestInterceptor(); 133 | } 134 | 135 | if (typeof axios === 'function') { 136 | this.registerAxiosRequestInterceptor(); 137 | } 138 | 139 | if (typeof jQuery === 'function') { 140 | this.registerjQueryAjaxSetup(); 141 | } 142 | 143 | if (typeof Turbo === 'object') { 144 | this.registerTurboRequestInterceptor(); 145 | } 146 | } 147 | 148 | /** 149 | * Register a Vue HTTP interceptor to add the X-Socket-ID header. 150 | */ 151 | registerVueRequestInterceptor(): void { 152 | Vue.http.interceptors.push((request, next) => { 153 | if (this.socketId()) { 154 | request.headers.set('X-Socket-ID', this.socketId()); 155 | } 156 | 157 | next(); 158 | }); 159 | } 160 | 161 | /** 162 | * Register an Axios HTTP interceptor to add the X-Socket-ID header. 163 | */ 164 | registerAxiosRequestInterceptor(): void { 165 | axios.interceptors.request.use((config) => { 166 | if (this.socketId()) { 167 | config.headers['X-Socket-Id'] = this.socketId(); 168 | } 169 | 170 | return config; 171 | }); 172 | } 173 | 174 | /** 175 | * Register jQuery AjaxPrefilter to add the X-Socket-ID header. 176 | */ 177 | registerjQueryAjaxSetup(): void { 178 | if (typeof jQuery.ajax != 'undefined') { 179 | jQuery.ajaxPrefilter((options, originalOptions, xhr) => { 180 | if (this.socketId()) { 181 | xhr.setRequestHeader('X-Socket-Id', this.socketId()); 182 | } 183 | }); 184 | } 185 | } 186 | 187 | /** 188 | * Register the Turbo Request interceptor to add the X-Socket-ID header. 189 | */ 190 | registerTurboRequestInterceptor(): void { 191 | document.addEventListener('turbo:before-fetch-request', (event: any) => { 192 | event.detail.fetchOptions.headers['X-Socket-Id'] = this.socketId(); 193 | }); 194 | } 195 | } 196 | 197 | /** 198 | * Export channel classes for TypeScript. 199 | */ 200 | export { Connector, Channel, PresenceChannel }; 201 | 202 | export { EventFormatter } from './util'; 203 | -------------------------------------------------------------------------------- /src/index.iife.ts: -------------------------------------------------------------------------------- 1 | export { default } from './echo'; 2 | -------------------------------------------------------------------------------- /src/util/event-formatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Event name formatter 3 | */ 4 | export class EventFormatter { 5 | /** 6 | * Create a new class instance. 7 | */ 8 | constructor(private namespace: string | boolean) { 9 | // 10 | } 11 | 12 | /** 13 | * Format the given event name. 14 | */ 15 | format(event: string): string { 16 | if (['.', '\\'].includes(event.charAt(0))) { 17 | return event.substring(1); 18 | } else if (this.namespace) { 19 | event = this.namespace + '.' + event; 20 | } 21 | 22 | return event.replace(/\./g, '\\'); 23 | } 24 | 25 | /** 26 | * Set the event namespace. 27 | */ 28 | setNamespace(value: string | boolean): void { 29 | this.namespace = value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from './event-formatter'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "outDir": "./dist", 7 | "sourceMap": false, 8 | "target": "es6", 9 | "isolatedModules": false, 10 | "esModuleInterop": true, 11 | "typeRoots": [ 12 | "node_modules/@types" 13 | ], 14 | "lib": [ 15 | "es2017", 16 | "dom" 17 | ] 18 | }, 19 | "include": [ 20 | "./typings/*.ts", 21 | "./src/*.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /typings/ably.ts: -------------------------------------------------------------------------------- 1 | import * as ably from 'ably'; 2 | export type AblyRealtime = ably.Types.RealtimeCallbacks; 3 | export type AblyRealtimeChannel = ably.Types.RealtimeChannelCallbacks; 4 | export type ChannelStateChange = ably.Types.ChannelStateChange; 5 | export type AuthOptions = ably.Types.AuthOptions; 6 | export type ClientOptions = ably.Types.ClientOptions; 7 | export type TokenDetails = ably.Types.TokenDetails; 8 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | // libs required to be set globally before initializing echo instance (currently used in tests) 2 | declare global { 3 | var Ably: any; 4 | var Pusher: any; 5 | var io: any; 6 | var Vue: any; 7 | var axios: any; 8 | var jQuery: any; 9 | var Turbo: any; 10 | } 11 | export {}; 12 | --------------------------------------------------------------------------------