├── .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 |

2 |
3 |
4 |
5 |
6 |
7 |
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 |
--------------------------------------------------------------------------------